graphic design tool
git clone https://git.lucas.co/cce-designer.git
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,
});