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();