Masks: the arrow keys nudge the selected points (part of #1288)

This commit is contained in:
Marco Arnulfo
2026-10-16 14:42:03 +02:00
parent 8f02f52a44
commit 34f1ebfe55
2 changed files with 52 additions and 2 deletions
+21 -2
View File
@@ -255,7 +255,7 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec<u64> {
/// `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<Value, String> {
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<Val
_ => 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())
}
+31
View File
@@ -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]