From e008085a93a2795bfc31f4f27c94f80ff3be23b5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:34:42 +0700 Subject: [PATCH 1/5] 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 de9f0ef4..4bdab23f 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 b6cae1a0..361a123a 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 233e1384..f0613634 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 f3ba1562..9894672b 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 460375a5..c8bbd72b 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 b2014509..842c134f 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(); From fab6dedd7cb7f723756ee87421897612f276b6de Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:35:11 +0700 Subject: [PATCH 2/5] Effect Controls: right-click shows the Effect menu (#227) Right-clicking the Effect Controls panel did nothing outside an effect's header. As in After Effects, a right-click anywhere a control has no menu of its own (the empty area, property names, buttons such as Reset) now opens the Effect menu: Effect Controls, Last Effect, Remove All, Manage Effects and every effect category, applying the chosen effect to the selected layers. It is the menu bar's own Effect menu tree and drawing code; an effect header keeps its own menu. Test: right_click_in_effect_controls_shows_the_effect_menu. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 12 ++++ crates/ui-egui/src/panels/effect_controls.rs | 10 +++ crates/ui-egui/tests/ui_effect_controls.rs | 66 ++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4bdab23f..6cb70b82 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1549,6 +1549,18 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { } } +/// The entries of top-level menu `name` (the Effect menu is the Effect Controls panel's context +/// menu); returns the chosen command and its params. +pub(crate) fn menu_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, name: &str) -> Option<(String, Value)> { + let mut clicked = None; + if let Some(MenuNode::Submenu { children, .. }) = + effectcraft_engine::menus::menu_bar().iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == name)) + { + menu_nodes(app, ui, children, &mut clicked); + } + clicked.map(|(id, params)| (id, if params.is_null() { json!({}) } else { params })) +} + fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], clicked: &mut Option<(String, Value)>) { for n in nodes { match n { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index c579e8e9..27c034e8 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1063,6 +1063,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.data_mut(|d| d.remove::(drag_id)); } } + // Right-click anywhere a control has no menu of its own: the Effect menu, which applies to + // the selected layers (After Effects). Drawn after the rows, whose menus open first. + let menu_id = bg.id.with("effect-menu"); + let open = ui.input(|i| i.pointer.secondary_clicked()) + && ui.rect_contains_pointer(body) + && (!egui::Popup::is_any_open(&ctx) || egui::Popup::is_id_open(&ctx, menu_id)); + egui::Popup::context_menu(&bg).id(menu_id).open_memory(open.then_some(egui::SetOpenCommand::Bool(true))).show(|ui| { + ui.set_min_width(200.0); + actions.extend(crate::menus::menu_contents(app, ui, "Effect")); + }); ui.set_clip_rect(panel_clip); let content_h = y + scroll - body.min.y; scroll = scroll.min((content_h - body.height()).max(0.0)); diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index 63fc4954..a31e9315 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -9,6 +9,7 @@ use effectcraft_ui_egui::EffectcraftApp; use effectcraft_ui_egui::dock::PanelKind; use effectcraft_ui_egui::state::FxPick; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use serde_json::json; struct Ids { @@ -269,3 +270,68 @@ fn angle_revolutions_scrub_and_take_typing() { type_into(&mut h, &format!("properties.prop.{rot}.revolutions"), "1"); assert_eq!(rotation(&h), 360.0); } + +fn right_click(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2) { + h.event(egui::Event::PointerMoved(pos)); + h.step(); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.step(); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +fn hover(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2) { + h.event(egui::Event::PointerMoved(pos)); + h.run_steps(3); +} + +/// #227: right-clicking Effect Controls where no control has a menu of its own shows the +/// Effect menu (After Effects), which applies an effect to the selected layers; an effect's +/// header keeps its own menu. +#[test] +fn right_click_in_effect_controls_shows_the_effect_menu() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "A", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"name": "B", "color": "#804060"})).unwrap()["layer"].as_u64().unwrap(); + let fill = s.execute("effect.apply", json!({"layers": [b], "effect": "Fill"})).unwrap()["effects"][0].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(b), LayerId(a)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(3); + let category = " Blur & Sharpen ⏵"; + // An effect's header: its own menu. + let header = rect_of(&h, &format!("effectControls.effect.{fill}")).center(); + right_click(&mut h, header); + assert!(h.query_by_label("Duplicate").is_some(), "the effect's own menu"); + assert!(h.query_by_label(category).is_none()); + h.key_press(egui::Key::Escape); + h.run_steps(2); + // A control without a menu (the header's Reset): the Effect menu. + let reset = rect_of(&h, &format!("effectControls.effect.{fill}.reset")).center(); + right_click(&mut h, reset); + assert!(h.query_by_label(category).is_some()); + assert_eq!(layer(&h.state().session, b).effects().map(|f| f.groups().count()), Some(1), "Reset didn't run"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.query_by_label(category).is_none()); + // The empty area below it: the Effect menu. + let panel = rect_of(&h, "panel.EffectControls"); + right_click(&mut h, egui::pos2(panel.center().x, panel.max.y - 30.0)); + let cat = h.query_by_label(category).expect("the Effect menu's categories").rect(); + assert!(h.query_by_label_contains("Remove All").is_some(), "the whole Effect menu"); + hover(&mut h, cat.center()); + let at = h.query_by_label(" Gaussian Blur").expect("Blur & Sharpen ▸ Gaussian Blur").rect().center(); + hover(&mut h, egui::pos2(at.x, cat.center().y)); + for k in 1..=10 { + hover(&mut h, egui::pos2(at.x, cat.center().y + (at.y - cat.center().y) * k as f32 / 10.0)); + } + click(&mut h, at); + let names = |h: &Harness<'_, EffectcraftApp>, l: u64| -> Vec { + layer(&h.state().session, l).effects().map(|f| f.groups().map(|g| g.name.clone()).collect()).unwrap_or_default() + }; + assert_eq!(names(&h, b), ["Fill", "Gaussian Blur"]); + assert_eq!(names(&h, a), ["Gaussian Blur"], "every selected layer gets it"); + assert!(h.query_by_label(category).is_none(), "the menu closed"); +} From 6b95b7a182fd64b58cb6991e702d853c9daaaf62 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:35:35 +0700 Subject: [PATCH 3/5] Project panel: items dropped on New Composition make a comp (#227) Dragging Project items onto the Create a new Composition button at the bottom of the Project panel did nothing. As in After Effects, the button now takes the drop and runs File > New Comp from Selection with the dragged items (the selection when the dragged item is in it): one item becomes a comp of its size, frame rate and duration holding it, and several open the New Composition from Selection dialog. The button is outlined while items are dragged over it. Test: project_items_dropped_on_new_comp_make_a_composition. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/project.rs | 20 +++++++++++++++++-- crates/ui-egui/tests/ui_drag_drop.rs | 29 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index c14b8131..d46b6508 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -2,8 +2,9 @@ //! search, item list with columns (Name, Label, then the visible optional columns: Type, Size, //! Media Duration, Frame Rate, File Path, Comment — shown or hidden from the header's context //! menu), folders (drag items into and out of them), renaming (Enter, or double-click the name), -//! the label colour picker, and the bottom bar (interpret, new folder, new comp, bit depth, -//! delete). Edits are `project.*` engine commands, so they are undoable. +//! the label colour picker, and the bottom bar (interpret, new folder, new comp: click, or drop +//! items on it for a comp from them, bit depth, delete). Edits are engine commands, so they are +//! undoable. use std::sync::{Arc, Mutex}; @@ -699,6 +700,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => app.ui.status = "Interpret Footage: select a footage item".into(), } } + // Items dropped on Create a new Composition: New Comp from Selection with them (After + // Effects; several items ask how in its dialog). + if id == "newComp" + && let Some(DragPayload::Item(iid)) = egui::DragAndDrop::payload::(&ctx).as_deref() + && ui.rect_contains_pointer(r) + { + p.rect_stroke(r, 4.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + if ctx.input(|i| i.pointer.any_released()) { + if !app.session.state.project_selection.contains(&ItemId(*iid)) { + app.session.state.project_selection = vec![ItemId(*iid)]; + } + actions.push(("file.newCompFromSelection".into(), json!({}))); + egui::DragAndDrop::clear_payload(&ctx); + } + } x += 26.0; } let depth = app.session.project.settings.bit_depth.label(); diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 0fcdcd19..b05a235a 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -136,3 +136,32 @@ fn effects_dropped_on_the_viewer_go_on_the_layer_under_the_pointer() { let effects = |id: u64| comp.layer(effectcraft_engine::project::LayerId(id)).unwrap().effects().map_or(0, |fx| fx.groups().count()); assert_eq!((effects(top), effects(middle)), (1, 0), "on Top, which is under the pointer"); } + +/// #227: Project items dropped on Create a new Composition (the Project panel's footer) make a +/// composition from them, as File ▸ New Comp from Selection does; several items ask how in its +/// dialog. +#[test] +fn project_items_dropped_on_new_comp_make_a_composition() { + let (mut h, clip) = harness(); + let comps = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.comps().count(); + let before = comps(&h); + let item = rect(&h, &format!("project.item.{clip}.name")).center(); + let button = rect(&h, "project.newComp").center(); + drag(&mut h, item, button, Modifiers::NONE); + assert_eq!(comps(&h), before + 1); + let comp = h.state().session.active_comp().unwrap(); + assert_eq!((comp.width, comp.height, comp.duration.seconds()), (160, 90, 1.0), "the item's settings"); + assert_eq!(stack(&h), ["Clip"], "holding the item"); + assert_ne!(h.state().session.active_comp_id().map(|c| c.0), Some(clip), "a new comp, open"); + assert!(h.state().dialog.is_none()); + + // Two selected items, one dragged: New Composition from Selection asks how. + let main = h.state().session.project.items.values().find(|i| i.name == "Main").unwrap().id; + h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip), main]; + h.run_steps(2); + let item = rect(&h, &format!("project.item.{clip}.name")).center(); + drag(&mut h, item, button, Modifiers::NONE); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + assert!(h.state().auto.find("form.field.single").is_some(), "the New Composition from Selection dialog"); + assert_eq!(comps(&h), before + 1, "nothing made yet"); +} From c4c28682eb81f406549631dcbc32d6055ef3d5aa Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:36:12 +0700 Subject: [PATCH 4/5] Timeline: dragging over layer switches sets them all (#227) Each layer switch toggled on its own click only, so hiding or locking many layers took a click per layer. As in After Effects, pressing a switch in the A/V Features or Switches column now sets it at once, and dragging up or down gives the same switch of every layer the pointer passes the state the first one got, whatever their own state was (rows passed in a quick drag included). The whole drag is one undo step: layer.setSwitch takes a `merge` key like other gesture edits. A single click still changes only that layer, so clicking the eye of one selected layer doesn't change the others (Layer > Switches still sets all selected layers). Test: dragging_over_layer_switches_sets_them_all. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 5 +- crates/ui-egui/src/panels/effect_controls.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 67 +++++++++++-- crates/ui-egui/src/widgets.rs | 10 +- crates/ui-egui/tests/ui_timeline_switches.rs | 98 ++++++++++++++++++++ 5 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 crates/ui-egui/tests/ui_timeline_switches.rs diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 5881b329..ca34760e 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -404,7 +404,8 @@ fn set_switch(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string(); let v = b_p(p, "value"); let label = format!("Layer Switch ({name})"); - let r = s.edit(&label, None, |proj, _| { + // `merge`: a switch dragged over several layers in the Timeline is one undo step. + let r = s.edit(&label, merge_p(p), |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut last = false; // Toggle relative to the first layer so a multi-selection ends up consistent. @@ -1344,7 +1345,7 @@ pub fn specs() -> Vec { "Layer Switch", [], None, - "{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?}", + "{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?}", has_layers, set_switch ), diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 27c034e8..bbd6ce47 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -946,7 +946,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let selected = app.session.state.selected_props.iter().any(|(l, u)| *l == layer.id && *u == g.uid); bp.rect_filled(r, 0.0, if selected { Color32::from_rgb(0x2f, 0x3a, 0x52) } else { Color32::from_rgb(0x2a, 0x2a, 0x2a) }); let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0)); - if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), None).clicked() { + if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), Sense::click()).clicked() { actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid}))); } app.auto.add(&format!("effectControls.effect.{}.fx", g.uid), fxr, &g.name); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index f0613634..341decdd 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -862,6 +862,56 @@ struct KeyDrag { fn key_drag_id() -> egui::Id { egui::Id::new("tl-key-drag") } + +/// A layer switch pressed and dragged over other layers: the switch, the state the press gave +/// it, and the undo step the drag folds into. +#[derive(Clone, Debug)] +struct SwitchDrag { + switch: &'static str, + value: bool, + merge: String, +} + +fn switch_drag_id() -> egui::Id { + egui::Id::new("tl-switch-drag") +} + +/// A layer's switch (A/V Features and Switches columns) in `row`. Pressing it sets it, and +/// dragging on gives the same switch of every layer the pointer passes the same new state, in +/// one undo step (After Effects). +#[allow(clippy::too_many_arguments)] +fn layer_switch( + ui: &mut egui::Ui, + t: &Tokens, + row: Rect, + br: Rect, + icon: Icon, + on: bool, + id: egui::Id, + layer: u64, + name: &'static str, + actions: &mut Vec<(String, serde_json::Value)>, +) { + let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag()); + let set = |actions: &mut Vec<(String, serde_json::Value)>, value: bool, merge: &str| { + actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "value": value, "merge": merge}))) + }; + if resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) { + let merge = format!("tl-switch-{}", ui.input(|i| i.time)); + set(actions, !on, &merge); + ui.data_mut(|d| d.insert_temp(switch_drag_id(), SwitchDrag { switch: name, value: !on, merge })); + } else if let Some(d) = ui.data(|d| d.get_temp::(switch_drag_id())).filter(|d| d.switch == name && d.value != on) + // The heights the pointer moved over since the last frame, so a quick drag skips no row. + && let Some((a, b)) = ui.input(|i| i.pointer.interact_pos().map(|p| (p.y - i.pointer.delta().y, p.y))) + && a.min(b) < row.max.y + && a.max(b) >= row.min.y + { + set(actions, d.value, &d.merge); + } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) { + // Keyboard or accessibility activation (a pointer press was handled above). + actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name}))); + } +} /// Set on the frame the reorder drag is released. /// Where layers dropped at height `py` land among the visible layer rows: above the first row /// whose middle is below it (its index in `layer_rows`), else below the last row. Returns that @@ -923,6 +973,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.line_segment([pos2(graph_x0 - 1.0, top), pos2(graph_x0 - 1.0, rect.max.y)], Stroke::new(1.0, t.app_bg)); let mut actions: Vec<(String, serde_json::Value)> = Vec::new(); + if !ui.input(|i| i.pointer.primary_down()) { + ctx.data_mut(|d| d.remove::(switch_drag_id())); + } // ---- header (left): time display, search, switches. let tc = crate::panels::timecode(&app.session, &comp, time); let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0)); @@ -1339,11 +1392,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } lp.rect_filled(br.shrink(0.5), 1.0, SWITCH_BOX); - let resp = widgets::icon_toggle(ui, br, icon, on, &t, egui::Id::new(("av", layer.id.0, i)), None); + layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, &mut actions); app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name); - if resp.clicked() { - actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": name}))); - } } // Label swatch. if vis.label { @@ -1484,11 +1534,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0)); lp.rect_filled(br.shrink(1.0), 1.0, SWITCH_BOX); - let resp = widgets::icon_toggle(ui, br, icon, on, &t, egui::Id::new(("sw", layer.id.0, i)), None); + layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, &mut actions); app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name); - if resp.clicked() { - actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": name}))); - } } // Modes. if app.ui.timeline.show_modes && layer.source.is_av() { @@ -1715,7 +1762,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } if let Some(en) = fx { let fr_ = Rect::from_center_size(pos2(cw.switches + SW * 3.0 + SW / 2.0, cy), vec2(16.0, 16.0)); - if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::new(("gfx", uid)), None).clicked() { + if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::new(("gfx", uid)), Sense::click()).clicked() { actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": uid}))); } app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name); @@ -1724,7 +1771,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Layer style eye switch in the A/V column, like AE. let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0)); lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); - if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::new(("geye", uid)), None).clicked() { + if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::new(("geye", uid)), Sense::click()).clicked() { actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid}))); } app.auto.add(&format!("timeline.group.{uid}.eye"), er, name); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c737953f..b92e87ae 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -148,15 +148,15 @@ pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> R resp } -/// A flat icon toggle inside a rect. -pub fn icon_toggle(ui: &mut Ui, rect: Rect, icon: Icon, on: bool, t: &Tokens, id: egui::Id, on_color: Option) -> Response { - let resp = ui.interact(rect, id, Sense::click()); +/// A flat icon toggle inside a rect (`sense`: click, or click and drag for a switch dragged +/// over several rows). +pub fn icon_toggle(ui: &mut Ui, rect: Rect, icon: Icon, on: bool, t: &Tokens, id: egui::Id, sense: Sense) -> Response { + let resp = ui.interact(rect, id, sense); if resp.hovered() { ui.painter().rect_filled(rect, 2.0, t.hover); } - let col = if on { on_color.unwrap_or(t.icon) } else { t.text_faint.gamma_multiply(0.7) }; if on || resp.hovered() { - icons::paint(ui.painter(), rect.shrink(rect.width() * 0.18), icon, if !on { t.text_faint } else { col }); + icons::paint(ui.painter(), rect.shrink(rect.width() * 0.18), icon, if on { t.icon } else { t.text_faint }); } resp } diff --git a/crates/ui-egui/tests/ui_timeline_switches.rs b/crates/ui-egui/tests/ui_timeline_switches.rs new file mode 100644 index 00000000..501d2096 --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_switches.rs @@ -0,0 +1,98 @@ +//! Timeline layer switches with real pointer input (egui_kittest): pressing a switch and +//! dragging over other layers gives them all the state the first one got, in one undo step, as +//! in After Effects (#227). + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom. +fn harness() -> (Harness<'static, EffectcraftApp>, Vec) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let mut ids: Vec = ["D", "C", "B", "A"] + .iter() + .map(|name| s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap()) + .collect(); + ids.reverse(); + s.execute("edit.deselectAll", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + (h, ids) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +/// Press on `from`, move to `to` in `steps`, release there. +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=steps { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Each layer's switch, top to bottom. +fn states(h: &Harness<'_, EffectcraftApp>, ids: &[u64], f: fn(&effectcraft_engine::project::Layer) -> bool) -> Vec { + let comp = h.state().session.active_comp().unwrap(); + ids.iter().map(|id| f(comp.layer(LayerId(*id)).unwrap())).collect() +} + +fn video(l: &effectcraft_engine::project::Layer) -> bool { + l.switches.video +} + +fn motion_blur(l: &effectcraft_engine::project::Layer) -> bool { + l.switches.motion_blur +} + +#[test] +fn dragging_over_layer_switches_sets_them_all() { + let (mut h, ids) = harness(); + let eye = |h: &Harness<'_, EffectcraftApp>, i: usize| rect(h, &format!("timeline.layer.{}.video", ids[i])).center(); + let steps = h.state().session.history.undo.len(); + + // From A's eye down to D's: all hidden, one undo step. + let (a, d) = (eye(&h, 0), eye(&h, 3)); + drag(&mut h, a, d, 10); + assert_eq!(states(&h, &ids, video), [false; 4]); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(states(&h, &ids, video), [true; 4]); + + // Every layer gets the first one's new state, whatever its own: B hidden, then from C (shown + // → hidden) up to A, quickly (two moves): B stays hidden, A is hidden, D untouched. + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [ids[1]], "switch": "video", "value": false})).unwrap(); + h.run_steps(2); + let (c, a) = (eye(&h, 2), eye(&h, 0)); + drag(&mut h, c, a, 2); + assert_eq!(states(&h, &ids, video), [false, false, false, true]); + // From B (hidden → shown) down to D: B, C and D shown. + let (b, d) = (eye(&h, 1), eye(&h, 3)); + drag(&mut h, b, d + vec2(0.0, 40.0), 6); + assert_eq!(states(&h, &ids, video), [false, true, true, true]); + + // A click still toggles only that layer. + let c = eye(&h, 2); + drag(&mut h, c, c, 1); + assert_eq!(states(&h, &ids, video), [false, true, false, true]); + + // The Switches column works the same (Motion Blur, from D up to B). + let mb = |h: &Harness<'_, EffectcraftApp>, i: usize| rect(h, &format!("timeline.layer.{}.switch.motionBlur", ids[i])).center(); + let (d, b) = (mb(&h, 3), mb(&h, 1)); + drag(&mut h, d, b, 8); + assert_eq!(states(&h, &ids, motion_blur), [false, true, true, true]); + assert_eq!(states(&h, &ids, video), [false, true, false, true], "other switches untouched"); +} From 764eee409bd50a6bc245ae3673f538379836c3f3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:36:35 +0700 Subject: [PATCH 5/5] Gaps: regression evidence for the #227 interface fixes Co-Authored-By: Claude Opus 5.5 --- docs/gaps.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/gaps.md b/docs/gaps.md index 119d38fa..7379645d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -98,6 +98,12 @@ The most important missing piece: it turns every other estimate here into a meas and Change Renderer, #206; deselecting below the layers, Alt+Shift+P and the toolbar's stroke, #205; linked Mask Feather, #203), `dragging_in_the_empty_area_box_selects_items` and `selecting_a_mask_selects_its_points` (#203), and `input_keys_ignore_later_effects` (#212). +- Regression evidence for the interface items of [#227](https://github.com/storytold/effectcraft/issues/227), + checked headlessly on Windows: `spacebar_taps_preview_and_held_spacebar_pans_the_viewer` and + `spacebar_drag_scrolls_the_time_graph` (Spacebar previews on its release, held it is the Hand + tool), `right_click_in_effect_controls_shows_the_effect_menu`, + `project_items_dropped_on_new_comp_make_a_composition` and + `dragging_over_layer_switches_sets_them_all`. - Done when: no open bug blocks a basic workflow (import, arrange, animate, preview, render) on any of the three desktop platforms, and every bug users report gets triaged within a day.