From 3174652ce4622ad173a604cb10198f8a37dd89a2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 16:43:36 +0700 Subject: [PATCH] Viewer: double-clicking a mask point selects the whole mask (#290) Double-clicking a mask or shape path's point (or segment) put its points in a free-transform box but left only the clicked point selected, so once the box was gone a drag moved that one point. As in After Effects, the double-click now selects all the path's points too: dragging inside the box, or afterwards any of the points, moves the whole mask. The test also draws a mask with the Pen, moves a point and undoes: each undo takes back one action (the move, the closing, the last point). Test: pen_mask_undoes_step_by_step_and_double_click_moves_the_whole_mask (ui_viewer). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/viewer.rs | 9 +++- crates/ui-egui/tests/ui_viewer.rs | 71 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 52172910..837cfb98 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1603,8 +1603,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(pos) = resp.interact_pointer_pos() && let Some((l, m)) = vertex_at(pos, false).map(|h| (h.layer, h.mask)).or_else(|| ov::segment_at(&paths, &map, pos, 6.0).map(|s| (s.0, s.1))) { - // Double-click a path: free transform its points (Layer ▸ Mask and Shape Path ▸ Free - // Transform Points). + // Double-click a path: all its points selected in a free-transform box (Layer ▸ Mask and + // Shape Path ▸ Free Transform Points), so dragging any of them, or inside the box, moves + // the whole path (After Effects). + if let Some(p) = paths.iter().find(|p| p.layer == l && p.uid == m) { + let all: Vec = (0..p.sp.vertices.len()).map(|index| json!({"layer": l.0, "mask": m, "index": index})).collect(); + let _ = app.session.execute("mask.selectVertices", json!({"vertices": all})); + } ov::begin_free_transform(&ctx, l, m); } else if resp.double_clicked() && !text_owns diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index f637b56f..fca695ce 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -479,6 +479,77 @@ fn motion_path_key_drag_edits_that_key() { assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]); } +/// The first mask path of layer `name`. +fn mask_path(h: &Harness<'_, EffectcraftApp>, name: &str) -> effectcraft_engine::keyframe::ShapePath { + let l = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().clone(); + l.masks().unwrap().groups().next().unwrap().get("path").unwrap().value.as_path().unwrap().clone() +} + +/// Drawing a mask with the Pen, moving a point and undoing take one action back per undo; a +/// double-click on a point selects all of them in a free-transform box, and dragging inside it +/// (or, once the box is gone, dragging a point) moves the whole mask (#290). +#[test] +fn pen_mask_undoes_step_by_step_and_double_click_moves_the_whole_mask() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap(); + h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap(); + h.state_mut().ui.tool = Tool::Pen; + h.run_steps(2); + let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]]; + for p in corners.iter().chain([&corners[0]]) { + let at = screen(&h, [p[0] as f32, p[1] as f32]); + click(&mut h, at); + } + let drawn = mask_path(&h, "Plate"); + let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1.0 && (a[1] - b[1]).abs() < 1.0; + assert!(drawn.closed && drawn.vertices.len() == 4 && drawn.vertices.iter().zip(&corners).all(|(v, c)| near(*v, *c)), "{drawn:?}"); + h.state_mut().ui.tool = Tool::Selection; + h.run_steps(2); + // Move one point: one undo step puts it back, the next reopens the path, the next removes the + // last point. + let (from, to) = (screen(&h, [500.0, 60.0]), screen(&h, [520.0, 80.0])); + drag(&mut h, from, to); + let moved = mask_path(&h, "Plate"); + assert!(near(moved.vertices[1], [520.0, 80.0]) && moved.vertices[0] == drawn.vertices[0], "{moved:?}"); + let undo = |h: &mut Harness<'_, EffectcraftApp>| { + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + mask_path(h, "Plate") + }; + assert_eq!(undo(&mut h), drawn); + assert!(!undo(&mut h).closed); + assert_eq!(undo(&mut h).vertices.len(), 3); + for _ in 0..3 { + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + } + h.run_steps(2); + // Double-click a point: all of them selected, in a free-transform box. + let at = screen(&h, [100.0, 300.0]); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + for pressed in [true, false, true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + h.run_steps(3); + assert_eq!(h.state().session.state.selected_vertices.len(), 4); + assert!(h.state().auto.find("viewer.freeTransform.handle.0").is_some(), "the free-transform box is up"); + // Dragging inside the box moves the whole mask. + let (from, to) = (screen(&h, [300.0, 120.0]), screen(&h, [310.0, 130.0])); + drag(&mut h, from, to); + let path = mask_path(&h, "Plate"); + assert!(path.vertices.iter().zip(&moved.vertices).all(|(v, c)| near(*v, [c[0] + 10.0, c[1] + 10.0])), "{path:?}"); + // A click away ends the box; the points stay selected and dragging one moves them all. + let away = screen(&h, [600.0, 340.0]); + click(&mut h, away); + h.run_steps(2); + assert!(h.state().auto.find("viewer.freeTransform.handle.0").is_none()); + let (from, to) = (screen(&h, [110.0, 310.0]), screen(&h, [100.0, 300.0])); + drag(&mut h, from, to); + let back = mask_path(&h, "Plate"); + assert!(back.vertices.iter().zip(&moved.vertices).all(|(v, c)| near(*v, *c)), "{back:?}"); +} + /// A straight two-key motion path shows Bezier handles at both keys (it had none), and dragging /// one pulls the path into a curve (#290). #[test]