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

commitf0523fa17aef55d2abbd774b1ad14835708588ab
parentc4112265ee
authorLucas Galante <[email protected]>
date2026-08-26 12:03
feat: arrow-key playbar transport in every context

Up toggles play/pause, Left/Right step one frame (clamped, off the
rounded frame so stepping mid-playback lands on the grid). Registered
as rebindable chords (play_pause/frame_next/frame_prev) and dispatched
ahead of every remaining key path — any pane, any context — but after
the param pane's chance, so a focused text/code field keeps its caret
keys. The network grid cursor is hjkl-only now (alt+hjkl still nudges
nodes); arrows no longer move it.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/app.rs      | 44 ++++++++++++++++++++++++++++++++------------
 src/main.rs     | 18 ++++++++++++++++++
 src/shortcut.rs |  3 +++
 3 files changed, 53 insertions(+), 12 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index b9ef71d..73b8951 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3166,6 +3166,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             register("save_document_as", "Ctrl+Shift+s", Action::SaveAs);
             register("next_context", "Ctrl+Tab", Action::NextContext);
             register("previous_context", "Ctrl+Shift+Tab", Action::PrevContext);
+            register("play_pause", "Up", Action::PlayPause);
+            register("frame_next", "Right", Action::FrameNext);
+            register("frame_prev", "Left", Action::FramePrev);
         }
 
         let mut state = Self {
@@ -4226,6 +4229,19 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 );
                 self.sync_pane_focus();
             }
+            Action::PlayPause => {
+                let pb = self.slots.playbar.inner_mut();
+                pb.playing = !pb.playing;
+            }
+            // 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
+            // rebuild follows from tick_frame's last_sim_frame diff.
+            Action::FrameNext | Action::FramePrev => {
+                let step = if action == Action::FrameNext { 1.0 } else { -1.0 };
+                let pb = self.slots.playbar.inner_mut();
+                pb.current_frame = (pb.current_frame.round() + step).clamp(pb.start_frame, pb.end_frame);
+            }
         }
         if settings_changed {
             self.save_settings();
@@ -5313,6 +5329,19 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     return true;
                 }
 
+                // Playbar transport dispatches ahead of every remaining key
+                // 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.
+                if event.state == ElementState::Pressed {
+                    if let Some(action @ (Action::PlayPause | Action::FrameNext | Action::FramePrev)) =
+                        self.shortcut_manager.match_action(&self.modifiers, &event.logical_key)
+                    {
+                        self.execute_action(action);
+                        return true;
+                    }
+                }
+
                 if event.state == ElementState::Pressed && event.logical_key == Key::Named(NamedKey::Escape) {
                     self.graph_mut().cancel_connecting();
                     return true;
@@ -5325,19 +5354,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
 
                         let mut delta = None;
 
+                        // Grid-cursor movement is hjkl-only: the arrows belong
+                        // to the playbar transport (dispatched above), in every
+                        // pane and context.
                         match &event.logical_key {
-                            Key::Named(NamedKey::ArrowUp) => {
-                                delta = Some((0, -1));
-                            }
-                            Key::Named(NamedKey::ArrowDown) => {
-                                delta = Some((0, 1));
-                            }
-                            Key::Named(NamedKey::ArrowLeft) => {
-                                delta = Some((-1, 0));
-                            }
-                            Key::Named(NamedKey::ArrowRight) => {
-                                delta = Some((1, 0));
-                            }
                             Key::Character(s) => {
                                 match s.as_str() {
                                     "k" | "K" if is_plain_key || (is_alt_key && self.focused_pane == LEFT_MENUBAR_IDX) => {
diff --git a/src/main.rs b/src/main.rs
index 5ae26cc..65ebbdc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -594,6 +594,24 @@ mod tests {
         assert_eq!(m.match_action(&ctrl_shift, &lower), Some(Action::SaveAs));
     }
 
+    /// The playbar transport chords: plain arrows drive the timeline (Up =
+    /// play/pause, Left/Right = step), and a held modifier must NOT match —
+    /// modified arrows stay free for other bindings.
+    #[test]
+    fn test_playbar_transport_keys() {
+        use cce_ui::widget::{Key, NamedKey};
+        let mut m = ShortcutManager::new();
+        m.register("Up", Action::PlayPause).unwrap();
+        m.register("Right", Action::FrameNext).unwrap();
+        m.register("Left", Action::FramePrev).unwrap();
+        let plain = crate::app::ModifiersState::default();
+        let ctrl = crate::app::ModifiersState { ctrl: true, ..Default::default() };
+        assert_eq!(m.match_action(&plain, &Key::Named(NamedKey::ArrowUp)), Some(Action::PlayPause));
+        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);
+    }
+
     #[test]
     fn test_load_default_project() {
         let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("default_project.json");
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 55bc248..364fc98 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -16,6 +16,9 @@ pub enum Action {
     SaveAs,
     NextContext,
     PrevContext,
+    PlayPause,
+    FrameNext,
+    FramePrev,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]