diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8a5068e8f..c2ecb838d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -6,7 +6,7 @@ use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, La use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, has_comp, has_layers, layers_or, layers_p, match_path_of, selected_leaf_props, str_p}; +use super::{CommandSpec, has_comp, has_layers, layers_or, layers_p, match_path_of, merge_p, selected_leaf_props, str_p}; use crate::{EngineError, KeyRef, LinkClip, Result, Session, cmd}; fn can_undo(s: &Session) -> std::result::Result<(), String> { @@ -145,7 +145,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { Some(layer) => layers_or(s, p, &[layer])?, None => layers_p(s, p)?, }; - let new = s.edit("Duplicate", None, |proj, st| { + // `merge` folds the copy and the drag that follows it (Alt-drag in the viewer) into one undo step. + let new = s.edit("Duplicate", merge_p(p), |proj, st| { let mut next = proj.next_id; let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; let mut created = vec![]; @@ -1007,7 +1008,7 @@ pub fn specs() -> Vec { "Duplicate", ["Edit"], Some("Cmd+D"), - "{layers?} (without layers: during a text edit the edited layer, else the selected effects, shape items or text animators, else the selected layers)", + "{layers?, merge?} (without layers: during a text edit the edited layer, else the selected effects, shape items or text animators, else the selected layers)", layers_or_text_edit, duplicate ), diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 3da62d82b..5664602a7 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1090,6 +1090,45 @@ fn middle_pan(app: &mut EffectcraftApp, ui: &egui::Ui, resp: &egui::Response, v: true } +/// One layer of a Move: its id, start Position and the Position's per-comp-axis weights. +type MoveLayer = (LayerId, [f64; 3], [f64; 3], [f64; 3]); + +/// The merge key of the drag in progress: every step it writes folds into one undo step. +fn drag_merge_key(ui: &egui::Ui) -> String { + format!("viewer-drag-{}", ui.data(|d| d.get_temp::(egui::Id::unique("viewer-drag-n")).unwrap_or(0))) +} + +/// Duplicate the layers of an Alt-drag (`edit.duplicate`, selecting the copies) and return the move +/// entries re-pointed at the copies. If the duplicate fails the originals are dragged as they were. +fn alt_duplicate(app: &mut EffectcraftApp, ui: &egui::Ui, layers: Vec) -> Vec { + let ids: Vec = layers.iter().map(|l| l.0.0).collect(); + // A key left over from the previous drag would fold this copy into that drag's undo step. + app.session.history.merge_key = None; + let made = app.session.execute("edit.duplicate", json!({"layers": ids, "merge": drag_merge_key(ui)})); + let Ok(copies) = made.map(|v| serde_json::from_value::>(v).unwrap_or_default()) else { return layers }; + layers.into_iter().zip(copies).map(|((_, p0, ax, ay), copy)| (LayerId(copy), p0, ax, ay)).collect() +} + +/// For each layer of a Move, whether one of its ancestors is in the Move as well: it follows that +/// parent, so its own Position must not get the drag too. The copies `alt_duplicate` makes keep +/// this order and are parented to each other the same way. +fn led_by_a_moved_ancestor(comp: &Comp, layers: &[MoveLayer]) -> Vec { + let moved: std::collections::HashSet = layers.iter().map(|l| l.0).collect(); + let led = |id: LayerId| { + let mut up = comp.layer(id).and_then(|l| l.parent); + // (bounded: a parent cycle in a broken file must not hang the viewer) + for _ in 0..comp.layers.len() { + let Some(p) = up else { return false }; + if moved.contains(&p) { + return true; + } + up = comp.layer(p).and_then(|l| l.parent); + } + false + }; + layers.iter().map(|l| led(l.0)).collect() +} + fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) { let comp_rect = v.comp_rect; let zoom = v.zoom; @@ -1261,7 +1300,12 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, // that axis only, without snapping. if let Some((lid, _, ax, ay)) = axis_at(&axis_hits, press) { let p0 = comp.layer(lid).and_then(|l| Some(ectx.v3(l, l.transform()?, "position", [0.0; 3]))); - p0.map(|p0| Gesture::Move { layers: vec![(lid, p0, ax, ay)], start: cpt, snap_src: None }) + p0.map(|p0| { + // Alt duplicates the layer and moves the copy along the axis. + let layers = vec![(lid, p0, ax, ay)]; + let layers = if mods.alt { alt_duplicate(app, ui, layers) } else { layers }; + Gesture::Move { layers, start: cpt, snap_src: None } + }) } else if let Some((lid, hi, ..)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(press) < HANDLE + 2.0).cloned() { let layer = comp.layer(lid).cloned(); layer.and_then(|layer| { @@ -1287,7 +1331,7 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, }) }) } else if let Some(l) = pick(app, ectx, cpt, mods.shift) { - let layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])> = app + let layers: Vec = app .session .state .selected_layers @@ -1309,6 +1353,13 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, da.total_cmp(&db) }) }); + // A layer whose parent (or grandparent…) is dragged too rides along with it: + // moving it as well would add the drag twice. It is still duplicated. + let led = led_by_a_moved_ancestor(comp, &layers); + // Alt-drag duplicates the dragged layers and moves the copies; the copy and the + // drag are one undo step. + let layers = if mods.alt && !layers.is_empty() { alt_duplicate(app, ui, layers) } else { layers }; + let layers: Vec = layers.into_iter().zip(led).filter(|(_, led)| !led).map(|(l, _)| l).collect(); (!layers.is_empty()).then_some(Gesture::Move { layers, start: map.to_comp(press), snap_src }) } else { // With path vertices on screen the marquee selects vertices (layers stay @@ -1386,7 +1437,7 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) { let map = edge_pan(app, &ctx, &g, map, pos); let cpt = map.to_comp(pos); - let merge = format!("viewer-drag-{}", ui.data(|d| d.get_temp::(egui::Id::unique("viewer-drag-n")).unwrap_or(0))); + let merge = drag_merge_key(ui); match g { Gesture::Pan { start_pan } => { // No total drag on the release frame: keep the pan reached so far (it used to diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 67a394059..ea6a310d1 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -9,6 +9,7 @@ mod ui_3d; mod ui_accessibility; mod ui_accessibility_audit; mod ui_agent_audit; +mod ui_alt_drag_duplicate; mod ui_angles; mod ui_audio_scrub; mod ui_azerty_shortcuts; diff --git a/crates/ui-egui/tests/it/ui_alt_drag_duplicate.rs b/crates/ui-egui/tests/it/ui_alt_drag_duplicate.rs new file mode 100644 index 000000000..b7fa93301 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_alt_drag_duplicate.rs @@ -0,0 +1,154 @@ +//! Alt/Option-drag in the Composition viewer (#2968) duplicates the dragged layers and moves the +//! copies, copy and move together one undo step. A plain drag still moves the layer itself. + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::panels::viewer::comp_to_screen; +use egui::{Event, Modifiers, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// A 640×360 comp with two solids, A (left) and B (right), both selected. +fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "A", "color": "#e04020", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"name": "B", "color": "#2040e0", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [150.0, 180.0]})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [450.0, 180.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(4); + (h, a, b) +} + +fn at(h: &Harness<'_, EffectcraftApp>, comp: [f32; 2]) -> Pos2 { + comp_to_screen(&h.ctx, comp).unwrap() +} + +fn zoom(h: &Harness<'_, EffectcraftApp>) -> f32 { + (at(h, [100.0, 0.0]).x - at(h, [0.0, 0.0]).x) / 100.0 +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + for i in 1..=12 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 12.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::default())); + h.run_steps(3); +} + +fn pos(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> [f64; 2] { + let v = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/position").unwrap().value.as_vec2(); + [v[0], v[1]] +} + +fn count(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.active_comp().unwrap().layers.len() +} + +#[test] +fn alt_drag_duplicates_the_selected_layers_and_moves_the_copies() { + let (mut h, a, b) = harness(); + let undo = h.state().session.history.undo.len(); + let z = zoom(&h); + let from = at(&h, [150.0, 180.0]); + drag(&mut h, from, from + vec2(0.0, 60.0) * z, Modifiers::ALT); + assert_eq!(count(&h), 4, "both selected layers were copied"); + assert_eq!(pos(&h, LayerId(a)), [150.0, 180.0], "the originals stay"); + assert_eq!(pos(&h, LayerId(b)), [450.0, 180.0]); + let sel = h.state().session.state.selected_layers.clone(); + assert_eq!(sel.len(), 2, "the copies are selected"); + let mut moved: Vec<[f64; 2]> = sel.iter().map(|l| pos(&h, *l)).collect(); + moved.sort_by(|p, q| p[0].total_cmp(&q[0])); + for (got, want) in moved.iter().zip([[150.0, 240.0], [450.0, 240.0]]) { + assert!((got[0] - want[0]).abs() < 1.5 && (got[1] - want[1]).abs() < 1.5, "copy at {got:?}, wanted {want:?}"); + } + assert_eq!(h.state().session.history.undo.len(), undo + 1, "copy and drag are one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(count(&h), 2, "one undo removes the copies"); + assert_eq!(pos(&h, LayerId(a)), [150.0, 180.0]); +} + +#[test] +fn a_plain_drag_moves_the_layers_without_copying() { + let (mut h, a, _) = harness(); + let z = zoom(&h); + let from = at(&h, [150.0, 180.0]); + drag(&mut h, from, from + vec2(0.0, 60.0) * z, Modifiers::NONE); + assert_eq!(count(&h), 2); + let p = pos(&h, LayerId(a)); + assert!((p[1] - 240.0).abs() < 1.5, "the layer moved: {p:?}"); +} + +/// A second Alt-drag right after the first is its own undo step: its copy doesn't fold into the +/// first drag's step. +#[test] +fn a_second_alt_drag_is_its_own_undo_step() { + let (mut h, _, _) = harness(); + let z = zoom(&h); + let from = at(&h, [150.0, 180.0]); + drag(&mut h, from, from + vec2(0.0, 60.0) * z, Modifiers::ALT); + let undo = h.state().session.history.undo.len(); + let from = at(&h, [150.0, 240.0]); + drag(&mut h, from, from + vec2(0.0, 40.0) * z, Modifiers::ALT); + assert_eq!(count(&h), 6); + assert_eq!(h.state().session.history.undo.len(), undo + 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(count(&h), 4, "one undo removes only the second copies"); +} + +/// A selected child under a selected parent follows the parent: its own Position doesn't get the +/// drag a second time, with or without Alt (the child's copy is parented to the parent's copy). +#[test] +fn a_selected_child_keeps_its_place_under_its_dragged_parent() { + for modifiers in [Modifiers::NONE, Modifiers::ALT] { + let (mut h, a, b) = harness(); + h.state_mut().session.execute("layer.setParent", json!({"layers": [b], "parent": a})).unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + h.run_steps(2); + let local = pos(&h, LayerId(b)); + let z = zoom(&h); + let from = at(&h, [150.0, 180.0]); + drag(&mut h, from, from + vec2(0.0, 60.0) * z, modifiers); + let comp = h.state().session.active_comp().unwrap(); + let sel = h.state().session.state.selected_layers.clone(); + let parent = *sel.iter().find(|l| comp.layer(**l).unwrap().parent.is_none()).unwrap(); + let child = *sel.iter().find(|l| comp.layer(**l).unwrap().parent == Some(parent)).expect("the child follows the moved parent"); + let p = pos(&h, parent); + assert!((p[1] - 240.0).abs() < 1.5, "{modifiers:?}: the parent moved: {p:?}"); + let c = pos(&h, child); + assert!((c[0] - local[0]).abs() < 1e-6 && (c[1] - local[1]).abs() < 1e-6, "{modifiers:?}: child at {c:?}, was {local:?} under its parent"); + } +} + +/// Alt-dragging a 3D layer's gizmo arrow copies the layer and moves the copy along that axis. +#[test] +fn alt_dragging_a_gizmo_arrow_moves_a_copy() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layer": solid, "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.select", json!({"layers": [solid]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(6); + let p0 = pos(&h, LayerId(solid)); + let [x, y, w, hh] = h.state().auto.find(&format!("viewer.gizmo.{solid}.x")).expect("x arrow registered").rect; + let tip = Pos2::new(x + w / 2.0, y + hh / 2.0); + drag(&mut h, tip, tip + vec2(40.0, 25.0), Modifiers::ALT); + assert_eq!(count(&h), 2, "the layer was copied"); + assert_eq!(pos(&h, LayerId(solid)), p0, "the original stays"); + let copy = h.state().session.state.selected_layers[0]; + assert_ne!(copy, LayerId(solid), "the copy is selected"); + let p = pos(&h, copy); + assert!(p[0] > p0[0] + 10.0 && p[1] == p0[1], "the copy moved along x: {p0:?} -> {p:?}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cbb936271..c02d0aa41 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -20,7 +20,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a hidden null, camera or light has no box or handles in the viewer and isn't picked there; other selected hidden layers keep their layer controls and paths, #1769); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849); a shape group's inner Contents (After Effects' elided Vectors group) draws no row — the group's path, fill/stroke and Transform list directly under it | | Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu | | Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed | -| Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #491 (Linux multi-layer edits) is closed | +| Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #2968 Alt-drag in the viewer duplicates the dragged layers; #491 (Linux multi-layer edits) is closed | | Numeric entry | Scrubbable values, typed entry with Tab / Shift+Tab, angle revolutions, Escape cancels, non-finite rejected | 85% | `ui_angles`, `ui_inline_edit` (#93, #141, #361); rename arrows on Linux (#488) | | Effect Controls | After Effects' twirl-downs, angle dial, point crosshair, eyedropper, curves/levels editors, effect menu on right-click | 70% | `ui_effect_controls`, `ui_fx_editors`; 26.5 rebuilt this panel with icon controls and effect colour labels: labels exist (a colour bar on the effect header and on the effect's Timeline row, right-click Label menu, `effect.setLabel`), the rebuilt controls are not matched | | Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`, `ui_project_rows`; #227, #290, #407 fixed; a comp's Project row menu has Composition Settings… (#1058); layer context menu has Layer Styles (#1611) |