diff --git a/crates/engine/src/commands/align.rs b/crates/engine/src/commands/align.rs index 1c8abaef..c225b1e6 100644 --- a/crates/engine/src/commands/align.rs +++ b/crates/engine/src/commands/align.rs @@ -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> { 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 { +/// 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 { let t = s.time(); let moved: Vec = 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 { (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 { @@ -170,11 +176,29 @@ fn distribute(s: &mut Session, p: &Value) -> Result { (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 { + 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 { 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![(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]); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4132f50e..6903e7c3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { } } +/// 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 { + 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)) } diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 4a84659f..a3305a43 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -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]