From 34f1ebfe55874df492c8697881164a05aa1bbb2e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 14:39:51 +0200 Subject: [PATCH] Masks: the arrow keys nudge the selected points (part of #1288) --- crates/ui-egui/src/menus.rs | 23 +++++++++++++++++++++-- crates/ui-egui/tests/ui_viewer.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 26a7b66f..f8be21f4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -255,7 +255,7 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec { /// `down`, `left`, `right`), 10 with Shift (`up10`…), as After Effects does, so zoomed in /// they move by fractions of a comp pixel. From any panel except Project (which owns the /// arrow keys for its own keyboard navigation, #961); not while typing, with a dialog or -/// menu open, or with mask points selected. +/// menu open. With mask points selected the arrows move the points instead (#1288). fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result { let (side, n) = match dir.strip_suffix("10") { Some(side) => (side, 10.0), @@ -269,15 +269,34 @@ fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result return Err(format!("unknown nudge `{dir}`")), }; let st = &app.session.state; - if app.ui.focused == PanelKind::Project || st.selected_layers.is_empty() || st.text_edit.is_some() || !st.selected_vertices.is_empty() { + if app.ui.focused == PanelKind::Project || (st.selected_layers.is_empty() && st.selected_vertices.is_empty()) || st.text_edit.is_some() { return Ok(Value::Null); } // Screen pixels per comp pixel. let zoom = (app.ui.viewer.zoom.unwrap_or_else(|| crate::panels::viewer::last_fit(ctx)) * ctx.pixels_per_point()) as f64; let k = if zoom.is_finite() && zoom > 1e-6 { 1.0 / zoom } else { 1.0 }; + if let Some(layer) = st.selected_vertices.first().map(|v| v.layer) { + let d = vertex_delta(app, layer, [x * k, y * k]); + return run_engine(app, ctx, "mask.moveVertices", json!({"delta": d})); + } run_engine(app, ctx, "layer.nudge", json!({"x": x * k, "y": y * k})) } +/// A comp-space step `d` in the layer space of `layer`, where mask points live. Like a drag of +/// the points in the viewer, every selected point takes the first one's layer space. +fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerId, d: [f64; 2]) -> [f64; 2] { + use effectcraft_engine::geom::vec2; + let s = &app.session; + let (Some(cid), Some(comp)) = (s.active_comp_id(), s.active_comp()) else { return d }; + let Some(l) = comp.layer(layer) else { return d }; + let ectx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None }; + let (l2c, _) = crate::panels::viewer::l2c(&ectx, l); + l2c.inverse().map_or(d, |inv| { + let v = inv.apply_vec(vec2(d[0], d[1])); + [v.x, v.y] + }) +} + fn no_params(p: &Value) -> bool { p.as_object().is_none_or(|m| m.is_empty()) } diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 47bb475c..0167541e 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -2000,6 +2000,37 @@ fn arrow_keys_nudge_the_selected_layer_at_the_viewer_magnification() { assert_eq!(pos(&h), [100.5, 109.5, 0.0], "sub-pixel steps when zoomed in"); } +/// #1288: with mask points selected the arrow keys move the points, not the layer: 1 pixel at the +/// viewer's magnification in comp space (half a layer pixel on a layer scaled to 200 %), 10 with +/// Shift, one undo step each. +#[test] +fn arrow_keys_nudge_the_selected_mask_points() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]]; + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + s.execute("prop.set", json!({"layer": plate, "path": "transform/scale", "value": [200, 200, 100]})).unwrap(); + s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap(); + let mask = s.active_comp().unwrap().layer(LayerId(plate)).unwrap().masks().unwrap().groups().next().unwrap().uid; + s.execute("mask.selectVertices", json!({"vertices": [{"layer": plate, "mask": mask, "index": 1}, {"layer": plate, "mask": mask, "index": 2}]})).unwrap(); + let ppp = h.ctx.pixels_per_point(); + h.state_mut().ui.viewer.zoom = Some(1.0 / ppp); + h.run_steps(2); + let pos = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(LayerId(plate)).unwrap().props.prop("transform/position").unwrap().value.as_vec3() + }; + let (pos0, undo) = (pos(&h), h.state().session.history.undo.len()); + key(&mut h, egui::Key::ArrowRight, egui::Modifiers::NONE); + key(&mut h, egui::Key::ArrowDown, egui::Modifiers::SHIFT); + let path = mask_path(&h, "Plate"); + let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6; + assert!(near(path.vertices[1], [500.5, 65.0]) && near(path.vertices[2], [500.5, 305.0]), "{:?}", path.vertices); + assert_eq!(path.vertices[0], corners[0], "unselected points stay"); + assert_eq!(pos(&h), pos0, "the layer stays"); + assert_eq!(h.state().session.history.undo.len(), undo + 2, "one undo step per press"); +} + /// A comp wider than the GPU's texture limit at Full resolution shows its frame (averaged down /// into a texture the renderer accepts) instead of failing the upload (#201: 11000×2200). #[test]