graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: Down arrow plays the timeline in reverse
A per-direction transport pair: Up and Down each toggle play/pause in
their direction, and a press in the OTHER direction while playing
redirects without stopping. Reverse runs the frame counter down and
wraps start→end, mirroring the forward wrap; simnets re-solve from their
seed on every backward frame, exactly like scrubbing. The playbar button
stays the forward transport. Play toggles fire once per physical press
(key repeat would flicker them; the step keys keep repeating). get_state
reports the direction.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/app.rs | 31 ++++++++++++++++++++++++++++---
src/main.rs | 42 ++++++++++++++++++++++++++++++++++++++++++
src/playbar.rs | 20 ++++++++++++++++++--
src/shortcut.rs | 1 +
src/window.rs | 1 +
5 files changed, 90 insertions(+), 5 deletions(-)
diff --git a/src/app.rs b/src/app.rs
index c58c2e1..5ef009d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3177,6 +3177,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
register("next_context", "Ctrl+Tab", Action::NextContext);
register("previous_context", "Ctrl+Shift+Tab", Action::PrevContext);
register("play_pause", "Up", Action::PlayPause);
+ register("play_pause_reverse", "Down", Action::PlayPauseReverse);
register("frame_next", "Right", Action::FrameNext);
register("frame_prev", "Left", Action::FramePrev);
}
@@ -4243,9 +4244,27 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
);
self.sync_pane_focus();
}
+ // The two play toggles are per-direction: a press in the OTHER
+ // direction while playing redirects instead of pausing (Up while
+ // reverse-playing plays forward, and vice versa) — matching the
+ // usual up/down transport feel.
Action::PlayPause => {
let pb = self.slots.playbar.inner_mut();
- pb.playing = !pb.playing;
+ if pb.playing && !pb.reversed {
+ pb.playing = false;
+ } else {
+ pb.playing = true;
+ pb.reversed = false;
+ }
+ }
+ Action::PlayPauseReverse => {
+ let pb = self.slots.playbar.inner_mut();
+ if pb.playing && pb.reversed {
+ pb.playing = false;
+ } else {
+ pb.playing = true;
+ pb.reversed = true;
+ }
}
// Whole-frame stepping off the ROUNDED current frame: during
// playback the playhead sits between frames, and stepping from
@@ -5352,11 +5371,17 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
// path so the timeline answers from any pane ("all contexts").
// It sits AFTER the param pane's chance on purpose: a focused
// text/code field consumed the arrows above for its caret.
+ // Steps auto-repeat (holding Right scrubs); the play toggles
+ // fire once per physical press, or a held key would flicker
+ // play/pause at the repeat rate.
if event.state == ElementState::Pressed {
- if let Some(action @ (Action::PlayPause | Action::FrameNext | Action::FramePrev)) =
+ if let Some(action @ (Action::PlayPause | Action::PlayPauseReverse | Action::FrameNext | Action::FramePrev)) =
self.shortcut_manager.match_action(&self.modifiers, &event.logical_key)
{
- self.execute_action(action);
+ let is_toggle = matches!(action, Action::PlayPause | Action::PlayPauseReverse);
+ if !(is_toggle && event.repeat) {
+ self.execute_action(action);
+ }
return true;
}
}
diff --git a/src/main.rs b/src/main.rs
index 47ccf41..c1be6ba 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -652,6 +652,48 @@ mod tests {
assert_eq!(m.match_action(&plain, &Key::Named(NamedKey::ArrowRight)), Some(Action::FrameNext));
assert_eq!(m.match_action(&plain, &Key::Named(NamedKey::ArrowLeft)), Some(Action::FramePrev));
assert_eq!(m.match_action(&ctrl, &Key::Named(NamedKey::ArrowUp)), None);
+ m.register("Down", Action::PlayPauseReverse).unwrap();
+ assert_eq!(m.match_action(&plain, &Key::Named(NamedKey::ArrowDown)), Some(Action::PlayPauseReverse));
+ }
+
+ /// The two play toggles are per-direction: same-direction press pauses,
+ /// other-direction press redirects without stopping — and the reverse
+ /// tick runs the frame counter down, wrapping start→end.
+ #[test]
+ fn test_reverse_playback_semantics_and_wrap() {
+ let mut state = State::new(false);
+
+ state.execute_action(Action::PlayPauseReverse);
+ {
+ let pb = state.slots.playbar.inner();
+ assert!(pb.playing && pb.reversed, "Down from stopped plays in reverse");
+ }
+ state.execute_action(Action::PlayPause);
+ {
+ let pb = state.slots.playbar.inner();
+ assert!(pb.playing && !pb.reversed, "Up while reversed redirects forward, not pause");
+ }
+ state.execute_action(Action::PlayPauseReverse);
+ {
+ let pb = state.slots.playbar.inner();
+ assert!(pb.playing && pb.reversed, "Down while forward redirects to reverse");
+ }
+ state.execute_action(Action::PlayPauseReverse);
+ assert!(!state.slots.playbar.inner().playing, "Down while reversed pauses");
+
+ {
+ let pb = state.slots.playbar.inner_mut();
+ pb.playing = true;
+ pb.reversed = 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 };
+ let moved =
+ cce_ui::widget::Input::tick(state.slots.playbar.inner_mut(), 0.1, rect);
+ assert!(moved, "reverse playback advances the frame");
+ let f = state.slots.playbar.inner().current_frame;
+ assert!(f > 200.0, "running off the start wraps to the end, got {f}");
+ assert!(state.slots.playbar.inner().playing, "the wrap does not stop playback");
}
#[test]
diff --git a/src/playbar.rs b/src/playbar.rs
index bfceaa6..60655c5 100644
--- a/src/playbar.rs
+++ b/src/playbar.rs
@@ -13,6 +13,10 @@ use cce_ui::widget::*;
#[derive(Debug, Clone)]
pub struct Playbar {
pub playing: bool,
+ /// Playback direction while `playing`: reverse runs the frame counter
+ /// down and wraps start→end. Simnets re-solve from their seed on every
+ /// backward frame (steps are not invertible), exactly like scrubbing.
+ pub reversed: bool,
pub current_frame: f32,
pub start_frame: f32,
pub end_frame: f32,
@@ -30,6 +34,7 @@ impl Playbar {
pub fn new() -> Adapted<Playbar> {
Adapted::new(Self {
playing: false,
+ reversed: false,
current_frame: 1.0,
start_frame: 1.0,
end_frame: 240.0,
@@ -232,7 +237,13 @@ impl Input for Playbar {
ElementState::Pressed => {
let b = self.button_rect(rect);
if *x >= b.x && *x <= b.x + b.width && *y >= b.y && *y <= b.y + b.height {
+ // 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;
+ }
return true;
}
let t = self.track_rect(rect);
@@ -263,10 +274,15 @@ impl Input for Playbar {
if !self.playing {
return false;
}
- self.current_frame += dt * self.fps;
+ let dir = if self.reversed { -1.0 } else { 1.0 };
+ 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 {
- let range = (self.end_frame - self.start_frame).max(1.0);
self.current_frame = self.start_frame + (self.current_frame - self.start_frame) % range;
+ } 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;
}
true
}
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 364fc98..485f335 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -17,6 +17,7 @@ pub enum Action {
NextContext,
PrevContext,
PlayPause,
+ PlayPauseReverse,
FrameNext,
FramePrev,
}
diff --git a/src/window.rs b/src/window.rs
index ac28626..1b0711e 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -630,6 +630,7 @@ impl State {
v["playbar"] = serde_json::json!({
"frame": pb.current_frame.round() as i64,
"playing": pb.playing,
+ "reversed": pb.reversed,
"start_frame": pb.start_frame.round() as i64,
"end_frame": pb.end_frame.round() as i64,
});