git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitd54af50071eeff5664378f9c115e82e446d5c6fc
parentb7e9c2c700
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 19:12
feat: Repeat Playback setting — the playbar wraps or stops at the end

`toggle_playbar_repeat`, a palette switch in the Playbar context, unbound.
On (the default, and the only behaviour until now) playback wraps at the
range's end as before; off, it lands ON the last frame and stops, in
either direction, so a simulation's final state is what stays on screen.
A play press on a timeline stopped at its far end restarts from the near
one (`Playbar::begin`, shared by the button and the Up/Down chords).
Persisted top-level in state.kdl as `playbar_repeat`, beside `gpu`: how
the transport behaves is not a property of a scene. `get_state` reports
it in the playbar block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md       |  7 +++++++
 src/app.rs      | 24 ++++++++++++++++++++--
 src/command.rs  |  4 ++++
 src/dialog.rs   |  1 +
 src/main.rs     | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/playbar.rs  | 44 +++++++++++++++++++++++++++++++++++----
 src/shortcut.rs |  2 ++
 src/window.rs   |  1 +
 8 files changed, 141 insertions(+), 6 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 464e904..fdbb7cd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -372,6 +372,13 @@ render block — that block rides the project file, and which GPU a machine
 has is not a property of a scene. Verified under the session's pin: unset
 opens the Iris Xe, `discrete` the RTX 4080.
 
+**`playbar_repeat`** (top-level too, since 2026-09-28) is whether playback
+wraps at the end of the frame range or stops on the last frame — the
+`toggle_playbar_repeat` command, a switch in the palette, unbound. The one
+copy is `Playbar::repeat`; with it off a play press on a timeline stopped
+at its far end restarts from the near one (`Playbar::begin`, which the
+button and the Up/Down chords share).
+
 `DesignSettings` (viewport/graph display state the app rewrites itself:
 colors, grid sizes, show flags) persists to `state.kdl` — deliberately NOT
 `config.kdl`, which is the user-authored toolkit-config override slot that
diff --git a/src/app.rs b/src/app.rs
index 1fba694..a457fd1 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1442,6 +1442,12 @@ pub struct DesignSettings {
     /// of a scene.
     #[serde(default = "default_gpu")]
     pub gpu: String,
+    /// Whether playback wraps at the end of the frame range (the default)
+    /// or stops on the last frame — `toggle_playbar_repeat`. Top-level like
+    /// `gpu`: how the transport behaves is how you like to work, not a
+    /// property of a scene, so it does not ride the project file.
+    #[serde(default = "default_true")]
+    pub playbar_repeat: bool,
 }
 
 impl Default for DesignSettings {
@@ -1451,10 +1457,15 @@ impl Default for DesignSettings {
             render: RenderSettings::default(),
             default_project: None,
             gpu: default_gpu(),
+            playbar_repeat: true,
         }
     }
 }
 
+fn default_true() -> bool {
+    true
+}
+
 /// The GPU setting's options. "integrated" is cce-ui's own default
 /// (`CCE_VK_DEVICE` unset); "discrete" asks for the dedicated card.
 pub const GPU_CHOICES: &[&str] = &["integrated", "discrete"];
@@ -2483,6 +2494,7 @@ impl State {
             render: display.render,
             default_project: self.default_project_setting.clone(),
             gpu: self.gpu_preference.clone(),
+            playbar_repeat: self.slots.playbar.inner().repeat,
         };
         settings.save();
         self.last_design_mod_time = {
@@ -5888,6 +5900,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             dialog: crate::dialog::Dialog::new(),
         });
 
+        slots.playbar.inner_mut().repeat = settings.playbar_repeat;
         if let Some(viewport) = slots.viewport.as_any_mut().downcast_mut::<Viewport3D>() {
             viewport.show_grid = settings.viewport.show_grid_enabled;
             viewport.show_origin = settings.viewport.show_origin_enabled;
@@ -7950,10 +7963,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 if pb.playing {
                     pb.playing = false;
                 } else {
-                    pb.playing = true;
-                    pb.reversed = action == Action::PlayPauseReverse;
+                    pb.begin(action == Action::PlayPauseReverse);
                 }
             }
+            // Repeat is a setting, not a transport press: the timeline keeps
+            // doing whatever it is doing, and the next run-off honours it.
+            Action::TogglePlaybarRepeat => {
+                let pb = self.slots.playbar.inner_mut();
+                pb.repeat = !pb.repeat;
+                settings_changed = true;
+            }
             // Whole-frame stepping off the ROUNDED current frame: during
             // playback the playhead sits between frames, and stepping from
             // the fractional value would land off the frame grid. The scene
@@ -9776,6 +9795,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                          let settings = DesignSettings::load();
                          self.default_project_setting = settings.default_project.clone();
                          self.gpu_preference = settings.gpu.clone();
+                         self.slots.playbar.inner_mut().repeat = settings.playbar_repeat;
                          self.square_viewport = settings.viewport.square;
                          self.grid_thickness = settings.viewport.grid_thickness;
                          self.viewport_mut().show_grid = settings.viewport.show_grid_enabled;
diff --git a/src/command.rs b/src/command.rs
index 6b93e51..00a80c7 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -213,6 +213,10 @@ pub const COMMANDS: &[Command] = &[
     // the way a transport's stop-to-start does — one press whatever the
     // timeline is doing.
     Command { id: "frame_start", label: "Go To Start Frame", context: Context::Playbar, run: Run::Key(Action::FrameStart), default_chord: Some("Ctrl+Up") },
+    // Repeat: wrap at the end of the range (on) or stop on the last frame
+    // (off). A setting rather than a transport key, so it ships unbound and
+    // carries a switch in the palette; persisted in state.kdl.
+    Command { id: "toggle_playbar_repeat", label: "Repeat Playback", context: Context::Playbar, run: Run::Key(Action::TogglePlaybarRepeat), default_chord: None },
 ];
 
 pub fn by_id(id: &str) -> Option<&'static Command> {
diff --git a/src/dialog.rs b/src/dialog.rs
index 17236e3..9e314ee 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -1620,6 +1620,7 @@ impl State {
             "show_viewport_pane" => self.show_viewport,
             "show_parameters_pane" => self.show_parameters,
             "show_playbar_pane" => self.show_playbar,
+            "toggle_playbar_repeat" => self.slots.playbar.inner().repeat,
             "toggle_snap" => self.viewer_tool.as_ref()?.snap.is_some(),
             _ => return None,
         })
diff --git a/src/main.rs b/src/main.rs
index e8ecf28..9ed5780 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2474,6 +2474,7 @@ mod tests {
             let pb = state.slots.playbar.inner_mut();
             pb.playing = true;
             pb.reversed = true;
+            pb.repeat = true;
             pb.current_frame = 1.5;
         }
         let rect = cce_ui::scene::layout::Rect { x: 0.0, y: 0.0, width: 100.0, height: 30.0 };
@@ -12736,4 +12737,67 @@ mod tests {
             assert_eq!(state.last_sim_frame, state.sim_frame(), "and the scene was built for that frame, not the last one");
         }
     }
+
+    /// Repeat off: playback stops ON the last frame instead of wrapping,
+    /// in either direction, and a play press on a timeline stopped at its
+    /// far end restarts from the near one. The setting is a palette toggle
+    /// persisted top-level in state.kdl, read back by a fresh State.
+    #[test]
+    fn repeat_off_stops_playback_at_the_end_and_persists() {
+        use cce_ui::widget::Input;
+        let rect = cce_ui::scene::layout::Rect { x: 0.0, y: 0.0, width: 100.0, height: 30.0 };
+        let mut state = State::new(false);
+        assert_eq!(state.command_toggle_state("toggle_playbar_repeat"), Some(true), "repeat is on by default");
+        assert!(state.run_command("toggle_playbar_repeat"));
+        assert_eq!(state.command_toggle_state("toggle_playbar_repeat"), Some(false));
+        // Persisted: the toggle wrote state.kdl, and a fresh State reads it.
+        // Checked at once, since the suite's tests share the (redirected)
+        // file and another's save could follow.
+        let kdl = fs::read_to_string(DesignSettings::file_path()).expect("state.kdl was written");
+        assert!(!DesignSettings::from_kdl_str(&kdl).playbar_repeat, "{kdl}");
+        assert!(!State::new(false).slots.playbar.inner().repeat, "a new State seeds the playbar from the setting");
+
+        // Forward: run off the end, land on it, stop.
+        {
+            let pb = state.slots.playbar.inner_mut();
+            pb.current_frame = pb.end_frame - 0.5;
+            pb.begin(false);
+        }
+        assert!(Input::tick(state.slots.playbar.inner_mut(), 0.1, rect));
+        {
+            let pb = state.slots.playbar.inner();
+            assert_eq!(pb.current_frame, pb.end_frame, "stopped on the last frame");
+            assert!(!pb.playing, "and playback ended");
+        }
+        // Play again from the end: restarts from the start frame.
+        state.execute_action(Action::PlayPause);
+        {
+            let pb = state.slots.playbar.inner();
+            assert!(pb.playing && !pb.reversed);
+            assert_eq!(pb.current_frame, pb.start_frame, "a play press at the far end rewinds");
+        }
+        // Reverse: run off the start, stop there; Down restarts from the end.
+        {
+            let pb = state.slots.playbar.inner_mut();
+            pb.playing = false;
+            pb.current_frame = pb.start_frame + 0.5;
+            pb.begin(true);
+        }
+        assert!(Input::tick(state.slots.playbar.inner_mut(), 0.1, rect));
+        {
+            let pb = state.slots.playbar.inner();
+            assert_eq!(pb.current_frame, pb.start_frame);
+            assert!(!pb.playing);
+        }
+        state.execute_action(Action::PlayPauseReverse);
+        {
+            let pb = state.slots.playbar.inner();
+            assert!(pb.playing && pb.reversed);
+            assert_eq!(pb.current_frame, pb.end_frame);
+        }
+
+        // Back on, and the file follows.
+        assert!(state.run_command("toggle_playbar_repeat"));
+        assert!(DesignSettings::from_kdl_str(&fs::read_to_string(DesignSettings::file_path()).unwrap()).playbar_repeat);
+    }
 }
diff --git a/src/playbar.rs b/src/playbar.rs
index d5b5165..90fd708 100644
--- a/src/playbar.rs
+++ b/src/playbar.rs
@@ -21,6 +21,12 @@ pub struct Playbar {
     pub start_frame: f32,
     pub end_frame: f32,
     pub fps: f32,
+    /// Whether playback wraps at the range's end (on, the default and the
+    /// only behaviour until 2026-09-28) or stops there. Persisted in
+    /// state.kdl (`playbar_repeat`) and flipped by `toggle_playbar_repeat`;
+    /// this field is the one copy, read by the dialog's switch and
+    /// `save_settings`.
+    pub repeat: bool,
     dragging: bool,
 }
 
@@ -39,10 +45,27 @@ impl Playbar {
             start_frame: 1.0,
             end_frame: 240.0,
             fps: 24.0,
+            repeat: true,
             dragging: false,
         })
     }
 
+    /// Start playing in a direction. With Repeat off a timeline stopped at
+    /// its far end has nowhere to go, so the press restarts it from the
+    /// near one, the way a transport's play does after a stop-at-end; with
+    /// Repeat on the next tick wraps anyway and the frame is left alone.
+    pub fn begin(&mut self, reversed: bool) {
+        self.playing = true;
+        self.reversed = reversed;
+        if !self.repeat {
+            if !reversed && self.current_frame >= self.end_frame {
+                self.current_frame = self.start_frame;
+            } else if reversed && self.current_frame <= self.start_frame {
+                self.current_frame = self.end_frame;
+            }
+        }
+    }
+
     fn button_rect(&self, rect: Rect) -> Rect {
         let s = (rect.height - 2.0 * PAD).max(12.0).min(BTN_W);
         Rect { x: rect.x + PAD, y: rect.y + (rect.height - s) * 0.5, width: s, height: s }
@@ -225,9 +248,10 @@ impl Input for Playbar {
                         // The button is the FORWARD transport: playing (either
                         // direction) pauses; paused starts forward. Reverse is
                         // the Down-arrow chord's domain.
-                        self.playing = !self.playing;
                         if self.playing {
-                            self.reversed = false;
+                            self.playing = false;
+                        } else {
+                            self.begin(false);
                         }
                         return true;
                     }
@@ -263,11 +287,23 @@ impl Input for Playbar {
         self.current_frame += dir * dt * self.fps;
         let range = (self.end_frame - self.start_frame).max(1.0);
         if self.current_frame > self.end_frame {
-            self.current_frame = self.start_frame + (self.current_frame - self.start_frame) % range;
+            if self.repeat {
+                self.current_frame = self.start_frame + (self.current_frame - self.start_frame) % range;
+            } else {
+                // Repeat off: land ON the last frame and stop there, so the
+                // final state of a simulation is what stays on screen.
+                self.current_frame = self.end_frame;
+                self.playing = false;
+            }
         } else if self.current_frame < self.start_frame {
             // The reverse wrap, mirroring the forward one: run off the start,
             // come back in from the end.
-            self.current_frame = self.end_frame - (self.start_frame - self.current_frame) % range;
+            if self.repeat {
+                self.current_frame = self.end_frame - (self.start_frame - self.current_frame) % range;
+            } else {
+                self.current_frame = self.start_frame;
+                self.playing = false;
+            }
         }
         true
     }
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 020674b..32cf004 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -14,6 +14,8 @@ pub enum Action {
     ToggleSmoothShading,
     /// See through a translucent fill to what it occludes.
     ToggleShowOccluded,
+    /// Whether playback wraps at the end of the frame range or stops there.
+    TogglePlaybarRepeat,
     /// The three point overlays on the visible scene — markers, index
     /// numbers, normal whiskers. Global display settings reached from the
     /// command palette; they were per-node `meta` child preferences until
diff --git a/src/window.rs b/src/window.rs
index 1221efd..dc3fa2d 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -640,6 +640,7 @@ impl State {
                 "frame": pb.current_frame.round() as i64,
                 "playing": pb.playing,
                 "reversed": pb.reversed,
+                "repeat": pb.repeat,
                 "start_frame": pb.start_frame.round() as i64,
                 "end_frame": pb.end_frame.round() as i64,
             });