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

commit2d8237dc784b156eef5b5be0a5933d5128cce8bc
parent1165c52fb9
authorLucas Galante <[email protected]>
date2026-08-26 15:07
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,
             });