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/menus.rs b/crates/ui-egui/src/menus.rs index de9f0ef4..6cb70b82 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. @@ -1524,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..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); @@ -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/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/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/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 233e1384..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); @@ -2403,6 +2450,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/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_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"); +} 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"); +} 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"); +} 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(); 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.