Viewer: arrow keys nudge the selected layers 1 pixel at the current magnification (#290)

The arrow keys did nothing to a selected layer, so it couldn't be lined
up precisely. As in After Effects, over the Composition panel or the
Timeline an arrow key moves the selected layers 1 pixel at the viewer's
magnification (half a comp pixel at 200 %, so sub-pixel steps come from
zooming in) and Shift+arrow 10, one undo step per press; holding the
key repeats. Not while typing or with mask points selected.

The move is the new engine command layer.nudge {x, y (comp pixels),
layers?}: through each layer's parents into Position (or X/Y Position),
a key at the current time when Position is animated, locked layers left
alone. It shares the Align panel's move.

Tests: nudge_moves_layers_by_comp_pixels (engine),
arrow_keys_nudge_the_selected_layer_at_the_viewer_magnification
(ui_viewer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-08 23:43:24 +07:00
parent e6fd1461d7
commit 5b05a5ae80
3 changed files with 151 additions and 21 deletions

View File

@ -1,4 +1,5 @@
//! Align panel: `layer.align {edge, to}` and `layer.distribute {mode}`.
//! Align panel: `layer.align {edge, to}` and `layer.distribute {mode}`; the arrow keys'
//! `layer.nudge {x, y}`.
//!
//! Layers are aligned by their content bounds in comp space (source size, text glyphs, shape
//! contents through the layer's transform and its parents'). Each layer moves by a comp-space
@ -8,11 +9,11 @@
use effectcraft_geom::{Mat4, Vec3};
use effectcraft_keyframe::Value as KValue;
use effectcraft_project::{ItemId, LayerId};
use effectcraft_project::{ItemId, Layer, LayerId};
use effectcraft_render::EvalCtx;
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p};
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layers_p, merge_p, str_p};
use crate::{EngineError, Result, Session, cmd};
/// A layer's comp-space bounds and how a comp-space offset maps into its Position.
@ -43,27 +44,32 @@ fn placed(s: &Session, cid: ItemId, ids: &[LayerId]) -> Result<Vec<Placed>> {
x1 = x1.max(p.x);
y1 = y1.max(p.y);
}
// Parent chain: world(layer) = local(pn) ⋯ local(p1) · local(layer).
let mut par = Mat4::IDENTITY;
let mut cur = l.parent;
let mut guard = 0;
while let Some(pid) = cur {
guard += 1;
let Some(p) = comp.layer(pid).filter(|_| guard < 64) else { break };
par = ctx.local_matrix(p) * par;
cur = p.parent;
}
let to_parent = par.inverse().unwrap_or(Mat4::IDENTITY);
out.push(Placed { id, b: [x0, y0, x1, y1], to_parent });
out.push(Placed { id, b: [x0, y0, x1, y1], to_parent: to_parent(&ctx, l) });
}
Ok(out)
}
/// Move layers by comp-space offsets, as one undo step.
fn apply_moves(s: &mut Session, cid: ItemId, label: &str, moves: Vec<(LayerId, Mat4, [f64; 2])>) -> Result<Value> {
/// Comp space → the space of `l`'s Position (its parent's).
fn to_parent(ctx: &EvalCtx, l: &Layer) -> Mat4 {
// Parent chain: world(layer) = local(pn) ⋯ local(p1) · local(layer).
let mut par = Mat4::IDENTITY;
let mut cur = l.parent;
let mut guard = 0;
while let Some(pid) = cur {
guard += 1;
let Some(p) = ctx.comp.layer(pid).filter(|_| guard < 64) else { break };
par = ctx.local_matrix(p) * par;
cur = p.parent;
}
par.inverse().unwrap_or(Mat4::IDENTITY)
}
/// Move layers by comp-space offsets, as one undo step (merged into the last one with the same
/// `merge` key).
fn apply_moves(s: &mut Session, cid: ItemId, label: &str, moves: Vec<(LayerId, Mat4, [f64; 2])>, merge: Option<&str>) -> Result<Value> {
let t = s.time();
let moved: Vec<u64> = moves.iter().filter(|m| m.2[0].abs() > 1e-9 || m.2[1].abs() > 1e-9).map(|m| m.0.0).collect();
s.edit(label, None, |proj, _| {
s.edit(label, merge, |proj, _| {
for (lid, to_parent, d) in &moves {
if d[0].abs() <= 1e-9 && d[1].abs() <= 1e-9 {
continue;
@ -144,7 +150,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
(it.id, it.to_parent, d)
})
.collect();
apply_moves(s, cid, "Align Layers", moves)
apply_moves(s, cid, "Align Layers", moves, None)
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
@ -170,11 +176,29 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
(it.id, it.to_parent, if horiz { [dv, 0.0] } else { [0.0, dv] })
})
.collect();
apply_moves(s, cid, "Distribute Layers", moves)
apply_moves(s, cid, "Distribute Layers", moves, None)
}
/// The arrow keys in the Composition panel: move the selected (unlocked) layers by `x`, `y` comp
/// pixels; After Effects nudges 1 pixel at the panel's magnification, 10 with Shift. Each call
/// is one undo step.
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
let c = "layer.nudge";
let d = [f_p(p, "x").unwrap_or(0.0), f_p(p, "y").unwrap_or(0.0)];
if !d.iter().all(|v| v.is_finite()) {
return Err(bad(c, "`x` and `y` must be numbers"));
}
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, c)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, s.time()) };
let moves = ids.iter().filter_map(|id| comp.layer(*id)).filter(|l| l.transform().is_some()).map(|l| (l.id, to_parent(&ctx, l), d)).collect();
apply_moves(s, cid, "Nudge", moves, merge_p(p))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.nudge", "Nudge Layers", [], None, "{x?, y? (comp pixels), layers?, merge?}", has_layers, nudge),
cmd!(
"layer.align",
"Align Layers",
@ -282,4 +306,38 @@ mod tests {
s.execute("layer.align", json!({"edge": "right", "layers": [a]})).unwrap();
assert!((bounds(&s, a)[2] - 400.0).abs() < 1e-6);
}
/// The arrow keys' nudge (#290): comp pixels through a parent's scale, sub-pixel steps, one
/// undo step each, a key at the current time when Position is animated; locked layers stay.
#[test]
fn nudge_moves_layers_by_comp_pixels() {
let mut s = session();
let pos = |s: &Session, id: u64| s.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().value.as_vec3();
let a = solid(&mut s, "A", 40, 40, [100.0, 100.0]);
let parent = solid(&mut s, "P", 10, 10, [0.0, 0.0]);
s.execute("prop.set", json!({"layer": parent, "path": "transform/scale", "value": [200.0, 200.0, 100.0]})).unwrap();
let child = solid(&mut s, "C", 10, 10, [10.0, 10.0]);
s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap();
let undo = s.history.undo.len();
s.execute("layer.nudge", json!({"layers": [a, child], "x": 1.0})).unwrap();
s.execute("layer.nudge", json!({"layers": [a, child], "y": -0.25})).unwrap();
assert_eq!(pos(&s, a), [101.0, 99.75, 0.0]);
assert_eq!(pos(&s, child), [10.5, 9.875, 0.0], "half as far under a 200 % parent");
assert_eq!(s.history.undo.len(), undo + 2, "one undo step per nudge");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(pos(&s, a), [101.0, 100.0, 0.0]);
// Animated Position gets a key at the current time.
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 0.0, "value": [0, 0, 0]})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("layer.nudge", json!({"layers": [a], "x": 10.0})).unwrap();
let keys = s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.prop("transform/position").unwrap().keys.clone();
assert_eq!(
keys.iter().map(|k| (k.time, k.value.as_vec3())).collect::<Vec<_>>(),
vec![(effectcraft_time::Tick::ZERO, [0.0; 3]), (s.time(), [10.0, 0.0, 0.0])]
);
// A locked layer doesn't move.
s.execute("layer.setSwitch", json!({"layers": [child], "switch": "lock", "value": true})).unwrap();
assert!(s.execute("layer.nudge", json!({"layers": [child], "x": 1.0})).is_err());
assert_eq!(pos(&s, child), [10.5, 10.0, 0.0]);
}
}

View File

@ -35,6 +35,14 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("view.fit", "Fit", [], Some("Shift+/")),
uic!("view.actualSize", "100%", [], Some("/")),
uic!("view.res.auto", "Resolution: Auto", [], None),
uic!("viewer.nudge.up", "Move Selected Layers Up 1 Pixel at Current Magnification", [], Some("ArrowUp")),
uic!("viewer.nudge.down", "Move Selected Layers Down 1 Pixel at Current Magnification", [], Some("ArrowDown")),
uic!("viewer.nudge.left", "Move Selected Layers Left 1 Pixel at Current Magnification", [], Some("ArrowLeft")),
uic!("viewer.nudge.right", "Move Selected Layers Right 1 Pixel at Current Magnification", [], Some("ArrowRight")),
uic!("viewer.nudge.up10", "Move Selected Layers Up 10 Pixels at Current Magnification", [], Some("Shift+ArrowUp")),
uic!("viewer.nudge.down10", "Move Selected Layers Down 10 Pixels at Current Magnification", [], Some("Shift+ArrowDown")),
uic!("viewer.nudge.left10", "Move Selected Layers Left 10 Pixels at Current Magnification", [], Some("Shift+ArrowLeft")),
uic!("viewer.nudge.right10", "Move Selected Layers Right 10 Pixels at Current Magnification", [], Some("Shift+ArrowRight")),
uic!("view.safeMargins", "Title/Action Safe", [], None),
uic!("view.transparencyGrid", "Transparency Grid", [], None),
uic!("view.fastPreviews", "Fast Previews", [], None),
@ -229,6 +237,33 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec<u64> {
}
}
/// The arrow keys over the Composition panel or the Timeline: move the selected layers 1 pixel
/// at the viewer's magnification (`up`, `down`, `left`, `right`), 10 with Shift (`up10`…), as
/// After Effects does, so zoomed in they move by fractions of a comp pixel. Not while typing,
/// or with mask points selected.
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),
None => (dir, 1.0),
};
let (x, y) = match side {
"up" => (0.0, -n),
"down" => (0.0, n),
"left" => (-n, 0.0),
"right" => (n, 0.0),
_ => return Err(format!("unknown nudge `{dir}`")),
};
let st = &app.session.state;
let over = matches!(app.ui.focused, PanelKind::Composition | PanelKind::Viewer(_) | PanelKind::Timeline);
if !over || st.selected_layers.is_empty() || st.text_edit.is_some() || !st.selected_vertices.is_empty() {
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 };
run_engine(app, ctx, "layer.nudge", json!({"x": x * k, "y": y * k}))
}
fn no_params(p: &Value) -> bool {
p.as_object().is_none_or(|m| m.is_empty())
}
@ -336,6 +371,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
app.ui.tool = tool;
return Ok(json!({"tool": tool}));
}
if let Some(dir) = id.strip_prefix("viewer.nudge.") {
return nudge(app, ctx, dir);
}
if let Some(k) = id.strip_prefix("timeline.reveal.") {
if k == "animated" {
return run_engine(app, ctx, "anim.reveal", json!({"kind": "keyframes"}));
@ -1456,7 +1494,11 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, repeat, .. }
if !*repeat || matches!(key, egui::Key::PageUp | egui::Key::PageDown | egui::Key::ArrowLeft | egui::Key::ArrowRight) =>
if !*repeat
|| matches!(
key,
egui::Key::PageUp | egui::Key::PageDown | egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown
) =>
{
Some((*key, *modifiers, true))
}

View File

@ -1434,6 +1434,36 @@ fn handle_drags_snap_to_the_comp_edges() {
assert!(close(s, [100.0, 100.0]), "{s:?}");
}
/// The arrow keys over the Composition panel nudge the selected layer 1 pixel at the viewer's
/// magnification (half a comp pixel at 200 %), Shift+arrow 10, one undo step each (#290).
#[test]
fn arrow_keys_nudge_the_selected_layer_at_the_viewer_magnification() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.run_steps(2);
let at = screen(&h, [100.0, 100.0]);
click(&mut h, at);
assert_eq!(h.state().session.state.selected_layers, vec![box_id]);
let pos = |h: &Harness<'_, EffectcraftApp>| {
h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/position").unwrap().value.as_vec3()
};
let ppp = h.ctx.pixels_per_point();
h.state_mut().ui.viewer.zoom = Some(1.0 / ppp);
h.run_steps(2);
let undo = h.state().session.history.undo.len();
key(&mut h, egui::Key::ArrowRight, egui::Modifiers::NONE);
key(&mut h, egui::Key::ArrowDown, egui::Modifiers::SHIFT);
assert_eq!(pos(&h), [101.0, 110.0, 0.0]);
assert_eq!(h.state().session.history.undo.len(), undo + 2, "one undo step per press");
h.state_mut().ui.viewer.zoom = Some(2.0 / ppp);
h.run_steps(2);
key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE);
key(&mut h, egui::Key::ArrowUp, egui::Modifiers::NONE);
assert_eq!(pos(&h), [100.5, 109.5, 0.0], "sub-pixel steps when zoomed in");
}
/// 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]