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