From e008085a93a2795bfc31f4f27c94f80ff3be23b5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:34:42 +0700 Subject: [PATCH] Spacebar: a tap previews on release, held it is the Hand tool (#227) Spacebar started or stopped the preview on its press, so holding it to pan the Composition panel (Space+drag already panned there) also toggled playback. As in After Effects, plain Spacebar now runs its shortcut when it is released, and only if no mouse button went down while it was held and the press didn't go to a text field; key auto-repeat toggles nothing more. Shift+Spacebar and the numpad preview shortcuts still run on the press. Holding Spacebar over the Timeline's ruler and time graph is the Hand tool there too: dragging scrolls the time graph (when zoomed in, never before the comp's start or past its end) instead of moving bars, keys or markers. Test: spacebar_taps_preview_and_held_spacebar_pans_the_viewer, spacebar_drag_scrolls_the_time_graph. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 37 +++++++++++--- crates/ui-egui/src/panels/mod.rs | 6 +++ crates/ui-egui/src/panels/timeline.rs | 16 +++++++ crates/ui-egui/src/panels/viewer.rs | 2 +- crates/ui-egui/tests/ui_timeline_zoom.rs | 37 ++++++++++++++ crates/ui-egui/tests/ui_viewer.rs | 61 ++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index de9f0ef46..4bdab23fe 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1396,6 +1396,12 @@ fn mods_match(want: egui::Modifiers, got: egui::Modifiers) -> bool { want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.command && cfg!(not(target_os = "macos"))) } +/// Set (egui temp data) from a plain Spacebar press until its release while a tap would still +/// run the Spacebar shortcut. +fn space_tap_id() -> egui::Id { + egui::Id::new("spacebar-tap") +} + /// Dispatch keyboard shortcuts (skipped while typing in a text field). pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { // Dialog cancellation owns Escape even when a text field has keyboard focus. @@ -1420,12 +1426,17 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { } return; } + // A mouse button down while Spacebar is held belongs to the Hand tool: its release won't + // preview. + if ctx.input(|i| i.pointer.any_down()) || ctx.egui_wants_keyboard_input() { + ctx.data_mut(|d| d.remove::(space_tap_id())); + } if ctx.egui_wants_keyboard_input() { return; } // Text editing in the viewer takes the clipboard events itself. let clipboard = app.session.state.text_edit.is_none(); - let events: Vec<(egui::Key, egui::Modifiers)> = ctx.input(|i| { + let events: Vec<(egui::Key, egui::Modifiers, bool)> = ctx.input(|i| { // The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of // key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut, // Paste and their variants (Ctrl+Alt+C…) run. @@ -1436,11 +1447,12 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { egui::Event::Key { key, pressed: true, modifiers, repeat, .. } if !*repeat || matches!(key, egui::Key::PageUp | egui::Key::PageDown | egui::Key::ArrowLeft | egui::Key::ArrowRight) => { - Some((*key, *modifiers)) + Some((*key, *modifiers, true)) } - egui::Event::Copy if clipboard => Some((egui::Key::C, held)), - egui::Event::Cut if clipboard => Some((egui::Key::X, held)), - egui::Event::Paste(_) if clipboard => Some((egui::Key::V, held)), + egui::Event::Key { key: egui::Key::Space, pressed: false, .. } => Some((egui::Key::Space, egui::Modifiers::NONE, false)), + egui::Event::Copy if clipboard => Some((egui::Key::C, held, true)), + egui::Event::Cut if clipboard => Some((egui::Key::X, held, true)), + egui::Event::Paste(_) if clipboard => Some((egui::Key::V, held, true)), _ => None, }) .collect() @@ -1449,7 +1461,20 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { return; } let binds = bindings(&app.session); - for (key, mods) in events { + for (key, mods, pressed) in events { + // Plain Spacebar runs its shortcut (Play Current Preview) on the release: held, it is + // the Hand tool (After Effects), and a drag with it must not start or stop playback. + if key == egui::Key::Space && (!pressed || !mods.any()) { + if pressed { + if app.dialog.is_none() { + ctx.data_mut(|d| d.insert_temp(space_tap_id(), true)); + } + continue; + } + if !ctx.data_mut(|d| d.remove_temp::(space_tap_id())).unwrap_or(false) { + continue; + } + } // Escape closes dialogs. if key == egui::Key::Escape && app.dialog.is_some() { // Escape while recording a shortcut cancels the recording, not the editor. diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index b6cae1a05..361a123a2 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -83,6 +83,12 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String { effectcraft_engine::commands::time::display_time(session, comp, t) } +/// Spacebar held outside a text field: the Hand tool (drags pan the viewer and scroll the +/// Timeline). +pub(crate) fn space_hand(ctx: &egui::Context) -> bool { + ctx.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input() +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) { match p { PanelKind::Composition => viewers::show(app, ui, 0, rect), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 233e13841..f06136341 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2403,6 +2403,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mrows = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)); super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows); + // Spacebar held: the Hand tool. Dragging the ruler or the time graph scrolls it in time when + // zoomed in (registered last, so it takes the drag from the bars, keys and markers there). + if super::space_hand(&ctx) { + let area = Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(rect.max.x, rows_rect.max.y)); + let hand = ui.interact(area, egui::Id::new("tl-hand"), Sense::drag()); + app.auto.add("timeline.hand", area, "Hand (hold Spacebar)"); + if hand.hovered() || hand.dragged() { + ctx.set_cursor_icon(if hand.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + if hand.dragged() && app.ui.timeline.pps.is_some() { + let start = app.ui.timeline.start; + let last = (comp.duration.seconds() - (tm.t(graph_x1) - tm.start)).max(start).max(0.0); + app.ui.timeline.start = (start - hand.drag_delta().x as f64 / pps).clamp(0.0, last); + } + } + // Drop targets: effects from Effects & Presets go on the layer under the pointer. Footage and // comps from the Project panel and files from the Media Browser go where they are dropped: a // line shows the place in the stack, and over the time graph a marker shows the In point too diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f3ba15629..9894672be 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -991,7 +991,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.ui.viewer.interacting = resp.dragged(); let mods = ui.input(|i| i.modifiers); - let space_pan = ui.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input(); + let space_pan = super::space_hand(&ctx); // Pen tool: each press places a vertex (dragging pulls Bezier tangents); pressing on the // first vertex closes the path. if app.ui.tool == Tool::Pen diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 460375a59..c8bbd72b0 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -124,3 +124,40 @@ fn semicolon_toggles_frame_level_and_the_whole_comp() { effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFrameToggle", json!({})).unwrap(); assert_eq!(h.state().ui.timeline.pps, None); } + +fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool) { + h.event(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +/// #227: with Spacebar held, a drag over the time graph scrolls it in time (the Hand tool) +/// instead of moving the layer bar under the pointer, and the release doesn't preview. +#[test] +fn spacebar_drag_scrolls_the_time_graph() { + let (mut h, layer) = harness(); + // (Opening the comp showed all of it.) + h.state_mut().ui.timeline.pps = Some(200.0); + h.state_mut().ui.timeline.start = 10.0; + h.run_steps(2); + let in_point = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers[0].in_point.seconds(); + let from = pos2(rect(&h, "timeline.ruler").center().x, over_bar(&h, layer).y); + // 400 px to the left at 200 px/s: 2 s later. + space(&mut h, true); + drag(&mut h, from, from - vec2(400.0, 0.0)); + space(&mut h, false); + let (a, _) = visible(&h); + assert!((a - 12.0).abs() < 0.01, "starts at {a}"); + assert_eq!(in_point(&h), 0.0, "the bar didn't move"); + assert!(!h.state().playback.playing, "a Spacebar drag doesn't preview"); + // To the right, at most back to the comp's start. + space(&mut h, true); + for _ in 0..6 { + drag(&mut h, from, from + vec2(500.0, 0.0)); + } + space(&mut h, false); + assert_eq!(visible(&h).0, 0.0); + assert_eq!(in_point(&h), 0.0); + // Without Spacebar the same drag moves the bar. + drag(&mut h, from, from - vec2(400.0, 0.0)); + assert!(in_point(&h) < -1.0, "{}", in_point(&h)); +} diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index b20145093..842c134f9 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -761,6 +761,67 @@ fn middle_and_hand_drags_pan_the_viewer_and_the_pan_stays_after_release() { assert!((end[0] - after[0] + 50.0).abs() < 1.0 && (end[1] - after[1] + 30.0).abs() < 1.0, "{after:?} → {end:?}"); } +/// A Spacebar press or release (`repeat`: the keyboard's auto-repeat while it is held). +fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool, repeat: bool) { + h.input_mut().events.push(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat, modifiers: Default::default() }); + h.step(); +} + +/// #227: a Spacebar tap starts and stops the preview when it is released; held, Spacebar is the +/// Hand tool, so a drag pans the viewer and neither starts nor stops the preview. Auto-repeat +/// while it is held toggles nothing more, and a space typed in a text field doesn't preview. +#[test] +fn spacebar_taps_preview_and_held_spacebar_pans_the_viewer() { + let mut h = harness(); + let playing = |h: &Harness<'_, EffectcraftApp>| h.state().playback.playing; + let tap = |h: &mut Harness<'_, EffectcraftApp>| { + space(h, true, false); + space(h, false, false); + }; + space(&mut h, true, false); + assert!(!playing(&h), "the press alone doesn't play"); + space(&mut h, false, false); + assert!(playing(&h), "the release does"); + tap(&mut h); + assert!(!playing(&h), "another tap stops"); + // Held with auto-repeat: one toggle, on the release. + space(&mut h, true, false); + for _ in 0..5 { + space(&mut h, true, true); + } + assert!(!playing(&h)); + space(&mut h, false, false); + assert!(playing(&h)); + tap(&mut h); + + // Held and dragged: pans the viewer (the Box under the pointer stays), and plays nothing. + let box_pos = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().value.clone(); + let (pos, before) = (box_pos(&h), h.state().ui.viewer.pan); + let c = rect(&h, "viewer.comp").center(); + space(&mut h, true, false); + drag_with(&mut h, egui::PointerButton::Primary, c, c + vec2(80.0, 40.0)); + space(&mut h, false, false); + let after = h.state().ui.viewer.pan; + assert!((after[0] - before[0] - 80.0).abs() < 1.0 && (after[1] - before[1] - 40.0).abs() < 1.0, "{before:?} → {after:?}"); + assert_eq!(box_pos(&h), pos); + assert!(!playing(&h), "a Spacebar drag doesn't start the preview"); + // Nor stop one. + tap(&mut h); + let c = rect(&h, "viewer.comp").center(); + space(&mut h, true, false); + drag_with(&mut h, egui::PointerButton::Primary, c, c - vec2(30.0, 0.0)); + space(&mut h, false, false); + assert!(playing(&h), "a Spacebar drag doesn't stop the preview"); + tap(&mut h); + assert!(!playing(&h)); + + // In a text field Spacebar types. + let search = rect(&h, "timeline.search").center(); + click(&mut h, search); + tap(&mut h); + assert!(!playing(&h), "a space typed in the Timeline search doesn't preview"); +} + #[test] fn timeline_rows_drag_to_reorder_layers() { let mut h = harness();