From 83f17dda37977a7fd2f81f28024f233499e22e33 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:37:43 +0700 Subject: [PATCH 1/3] Viewer: Pan Behind snaps the anchor to its own layer (#147) The Pan Behind drag passed its layer to the snapper's exclude list, which dropped all of that layer's targets: its corners, edge midpoints and centre as well as the anchor being dragged. The layer's box doesn't move in comp space while the anchor pans (Position compensates), so those points are valid targets. The engine's per-layer snap targets are now one function, layer_targets, which can leave out the anchor point. The gesture records its layer's targets without the anchor as the drag begins and passes them to the snapper (snap_with) as extra targets. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/viewer.rs | 81 +++++++++++++++-------- crates/ui-egui/src/panels/viewer.rs | 10 ++- crates/ui-egui/src/panels/viewer_tools.rs | 15 +++++ crates/ui-egui/tests/ui_viewer.rs | 56 +++++++++++++--- 4 files changed, 121 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index 45551ce6..680e9ab9 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use effectcraft_color::{ColorSpace, space}; -use effectcraft_geom::vec2; +use effectcraft_geom::{Mat3, vec2}; use effectcraft_project::{Layer, LayerId}; use effectcraft_raster::Image; use effectcraft_render::EvalCtx; @@ -131,23 +131,32 @@ pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option Vec<[f64; 2]> { - let mut out = vec![]; let (m, _) = ctx.layer_to_comp(layer); - if let Some(b) = effectcraft_render::content_bounds(ctx, layer) { - let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0); - for p in [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]], [cx, b[1]], [b[2], cy], [cx, b[3]], [b[0], cy], [cx, cy]] { - let q = m.apply(vec2(p[0], p[1])); - out.push([q.x, q.y]); - } - } - if let Some(tr) = layer.transform() { - let a = ctx.v3(layer, tr, "anchor", [0.0; 3]); - let q = m.apply(vec2(a[0], a[1])); - out.push([q.x, q.y]); - } + let mut out = box_features(ctx, layer, &m); + out.extend(anchor_feature(ctx, layer, &m)); out } +/// The corners, edge midpoints and centre of a layer's content box (`m`: layer → comp). +fn box_features(ctx: &EvalCtx, layer: &Layer, m: &Mat3) -> Vec<[f64; 2]> { + let Some(b) = effectcraft_render::content_bounds(ctx, layer) else { return vec![] }; + let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0); + [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]], [cx, b[1]], [b[2], cy], [cx, b[3]], [b[0], cy], [cx, cy]] + .into_iter() + .map(|p| { + let q = m.apply(vec2(p[0], p[1])); + [q.x, q.y] + }) + .collect() +} + +/// A layer's anchor point (`m`: layer → comp). +fn anchor_feature(ctx: &EvalCtx, layer: &Layer, m: &Mat3) -> Option<[f64; 2]> { + let a = ctx.v3(layer, layer.transform()?, "anchor", [0.0; 3]); + let q = m.apply(vec2(a[0], a[1])); + Some([q.x, q.y]) +} + /// Mask and shape-path vertices of a layer in comp space. pub fn layer_vertices(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { let (m, _) = ctx.layer_to_comp(layer); @@ -163,7 +172,7 @@ pub fn layer_vertices(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { } } for (uid, sp) in shape_paths(ctx, layer) { - let pm = m * effectcraft_render::shapes::item_matrix(ctx, layer, uid).unwrap_or(effectcraft_geom::Mat3::IDENTITY); + let pm = m * effectcraft_render::shapes::item_matrix(ctx, layer, uid).unwrap_or(Mat3::IDENTITY); out.extend(sp.vertices.iter().map(|v| { let q = pm.apply(vec2(v[0], v[1])); [q.x, q.y] @@ -214,19 +223,8 @@ pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec= 9 && (f[0][1] - f[1][1]).abs() < 1e-6 && (f[0][0] - f[3][0]).abs() < 1e-6 { - for x in [f[0][0], f[1][0]] { - out.push(SnapTarget { kind: SnapKind::VLine, pos: [x, f[0][1]], priority: PRI_LAYER, source: SnapSource::Layer(l.id) }); - } - for y in [f[0][1], f[3][1]] { - out.push(SnapTarget { kind: SnapKind::HLine, pos: [f[0][0], y], priority: PRI_LAYER, source: SnapSource::Layer(l.id) }); - } - } - out.extend(f.into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, SnapSource::Layer(l.id)))); - out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id)))); + for l in comp.layers.iter().filter(|l| l.is_active_at(ctx.time) && !exclude.contains(&l.id)) { + out.extend(layer_targets(ctx, l, true)); } if opts.guides { for g in &comp.guides { @@ -253,6 +251,33 @@ pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec Vec { + if l.is_camera() || l.is_light() || l.is_3d() { + return vec![]; + } + let (m, _) = ctx.layer_to_comp(l); + let f = box_features(ctx, l, &m); + let mut out = vec![]; + if let [nw, ne, _, sw, ..] = f.as_slice() + && (nw[1] - ne[1]).abs() < 1e-6 + && (nw[0] - sw[0]).abs() < 1e-6 + { + for x in [nw[0], ne[0]] { + out.push(SnapTarget { kind: SnapKind::VLine, pos: [x, nw[1]], priority: PRI_LAYER, source: SnapSource::Layer(l.id) }); + } + for y in [nw[1], sw[1]] { + out.push(SnapTarget { kind: SnapKind::HLine, pos: [nw[0], y], priority: PRI_LAYER, source: SnapSource::Layer(l.id) }); + } + } + let a = if anchor { anchor_feature(ctx, l, &m) } else { None }; + out.extend(f.into_iter().chain(a).map(|p| SnapTarget::point(p, PRI_LAYER, SnapSource::Layer(l.id)))); + out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id)))); + out +} + // ---------------------------------------------------------------- channels and exposure /// Show Channel and Color Management Settings. diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 56d180ac..c366dae2 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -93,6 +93,8 @@ enum Gesture { start: [f64; 2], inv: Mat3, l2p: Mat3, + /// The layer's own snap targets as the drag began (its box and path vertices). + own: Vec, }, Pan { start_pan: [f32; 2], @@ -1057,6 +1059,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { start: map.to_comp(press), inv: l2c.inverse().unwrap_or(Mat3::IDENTITY), l2p, + own: effectcraft_engine::viewer::layer_targets(&ectx, layer, false), }) }), t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }), @@ -1225,11 +1228,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge})); } - Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p } => { - // Pan Behind snaps the anchor point (to layer features, guides and the grid). + Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own } => { + // Pan Behind snaps the anchor point (to its own layer's box, other layers' features, + // guides and the grid). let anchor_c = inv.inverse().map(|m| m.apply(gv2(start_anchor[0], start_anchor[1]))).unwrap_or(gv2(start[0], start[1])); let want = [anchor_c.x + cpt[0] - start[0], anchor_c.y + cpt[1] - start[1]]; - let sc = vt::snap(app, &ctx, &ectx, &map, &[layer], &[want], mods); + let sc = vt::snap_with(app, &ctx, &ectx, &map, &[layer], &[want], mods, &own); let cpt = [cpt[0] + sc[0], cpt[1] + sc[1]]; let a = inv.apply(gv2(start[0], start[1])); let b = inv.apply(gv2(cpt[0], cpt[1])); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 9448c6cb..0d37ad13 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -45,6 +45,20 @@ pub(crate) fn snap( exclude: &[LayerId], sources: &[[f64; 2]], mods: egui::Modifiers, +) -> [f64; 2] { + snap_with(app, ctx, ectx, map, exclude, sources, mods, &[]) +} + +/// [`snap`] with `extra` layer targets (the Pan Behind layer's own box). +pub(crate) fn snap_with( + app: &EffectcraftApp, + ctx: &egui::Context, + ectx: &EvalCtx, + map: &ViewerMap, + exclude: &[LayerId], + sources: &[[f64; 2]], + mods: egui::Modifiers, + extra: &[vw::SnapTarget], ) -> [f64; 2] { let layers = snapping_on(app, mods); let v = &app.ui.viewer; @@ -55,6 +69,7 @@ pub(crate) fn snap( } let opts = vw::SnapOptions { guides, grid, grid_spacing: app.session.prefs.grids.grid_spacing }; let mut targets = vw::targets(ectx, exclude, opts); + targets.extend_from_slice(extra); if !layers { targets.retain(|t| matches!(t.source, SnapSource::Guide | SnapSource::Grid)); } diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 124292ef..dfa91955 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -440,23 +440,59 @@ fn pan_behind_snaps_the_anchor_point() { h.run_steps(2); // Drag the anchor (at the box centre) to 3 px from the comp centre: it snaps there, and the // position follows so the box doesn't move. - let from = screen(&h, [100.0, 100.0]); - let to = screen(&h, [323.0, 182.0]); - // (The gesture applies the pointer of the previous frame: hold the end point a frame.) + let (from, to) = (screen(&h, [100.0, 100.0]), screen(&h, [323.0, 182.0])); + hold_drag(&mut h, from, to, Default::default()); + let (p, a) = position_and_anchor(&h, box_id); + assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "anchor point snapped to the comp centre: {p:?}"); + assert!((a[0] - 260.0).abs() < 0.01 && (a[1] - 120.0).abs() < 0.01, "{a:?}"); +} + +/// Issue #147: Pan Behind snaps the anchor to its own layer's corners, edge midpoints and centre +/// (only the anchor itself is left out: the box stays put while the anchor moves). +#[test] +fn pan_behind_snaps_the_anchor_to_its_own_layer() { + 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("layer.select", json!({"layers": [box_id.0]})).unwrap(); + h.state_mut().ui.tool = Tool::PanBehind; + h.run_steps(2); + // The 80×80 box spans (60, 60)–(140, 140). Its anchor, dragged to 3 px from the top-left + // corner, lands on it. + let (from, to) = (screen(&h, [100.0, 100.0]), screen(&h, [63.0, 62.0])); + hold_drag(&mut h, from, to, Default::default()); + let (p, a) = position_and_anchor(&h, box_id); + assert!(a[0].abs() < 0.01 && a[1].abs() < 0.01, "anchor point snapped to the corner: {a:?}"); + assert!((p[0] - 60.0).abs() < 0.01 && (p[1] - 60.0).abs() < 0.01, "the box stays put: {p:?}"); + // And back to near the centre: it snaps there. + let (from, to) = (screen(&h, [61.0, 61.0]), screen(&h, [103.0, 98.0])); + hold_drag(&mut h, from, to, Default::default()); + let (p, a) = position_and_anchor(&h, box_id); + assert!((a[0] - 40.0).abs() < 0.01 && (a[1] - 40.0).abs() < 0.01, "anchor point snapped to the centre: {a:?}"); + assert!((p[0] - 100.0).abs() < 0.01 && (p[1] - 100.0).abs() < 0.01, "{p:?}"); +} + +/// Drag from `from` to `to` with `modifiers` held, holding the end point a frame (the viewer's +/// gestures apply the pointer of the previous frame). +fn hold_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: egui::Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); h.input_mut().events.push(Event::PointerMoved(from)); - h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }); h.step(); for i in 1..=10 { h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i.min(8) as f32 / 8.0))); h.step(); } - h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }); h.run_steps(2); - let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone(); - let p = l.props.prop("transform/position").unwrap().value.as_vec3(); - let a = l.props.prop("transform/anchor").unwrap().value.as_vec3(); - assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "anchor point snapped to the comp centre: {p:?}"); - assert!((a[0] - 260.0).abs() < 0.01 && (a[1] - 120.0).abs() < 0.01, "{a:?}"); + h.input_mut().events.push(Event::ModifiersChanged(Default::default())); + h.step(); +} + +/// A layer's Position and Anchor Point. +fn position_and_anchor(h: &Harness<'_, EffectcraftApp>, layer: LayerId) -> ([f64; 3], [f64; 3]) { + let l = h.state().session.active_comp().unwrap().layer(layer).unwrap().clone(); + (l.props.prop("transform/position").unwrap().value.as_vec3(), l.props.prop("transform/anchor").unwrap().value.as_vec3()) } #[test] From 72b039c6d56bbe8691c25d8bbe6f40d700e24453 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:38:23 +0700 Subject: [PATCH 2/3] Viewer: Pan Behind Alt-drag, Shift-drag and Ctrl+double-click to center (#162) The Pan Behind gesture ignored modifiers other than Ctrl (snapping): it always moved Anchor Point and Position together and never constrained the move, and the tool's button had no double-click action. - Alt held as the drag begins moves the Anchor Point alone (Position is left as it is, so the layer shifts). - Shift keeps the move to the axis dragged along most; Move and Pan Behind now share that rule (one_axis). - Ctrl+double-clicking the Pan Behind tool's button runs layer.centerAnchor (Center Anchor Point in Layer Content). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/header.rs | 8 ++++++ crates/ui-egui/src/panels/viewer.rs | 38 +++++++++++++++++--------- crates/ui-egui/tests/ui_viewer.rs | 42 +++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index d2ec5f8a..214d2712 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -62,6 +62,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.clicked() { app.ui.tool = cur; } + // Ctrl+double-click Pan Behind: Center Anchor Point in Layer Content. + if cur == Tool::PanBehind + && resp.double_clicked() + && ui.input(|i| i.modifiers.command) + && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({})) + { + app.ui.status = e; + } if slot.len() > 1 { resp.context_menu(|ui| { for tool in slot.iter() { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index c366dae2..83174218 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -95,6 +95,8 @@ enum Gesture { l2p: Mat3, /// The layer's own snap targets as the drag began (its box and path vertices). own: Vec, + /// Alt held as the drag began: the anchor point moves alone, so the layer shifts. + anchor_only: bool, }, Pan { start_pan: [f32; 2], @@ -276,6 +278,17 @@ fn drag_scale(start_scale: [f64; 3], start: [f64; 2], cur: [f64; 2], axes: [bool [start_scale[0] * fx, start_scale[1] * fy, start_scale[2]] } +/// A drag `d` (comp pixels), kept to the axis it moves along most when `shift` is held. +fn one_axis(d: [f64; 2], shift: bool) -> [f64; 2] { + if !shift { + d + } else if d[0].abs() > d[1].abs() { + [d[0], 0.0] + } else { + [0.0, d[1]] + } +} + fn point_in_quad(p: [f64; 2], q: &[[f64; 2]; 4]) -> bool { let mut sign = 0.0; for i in 0..4 { @@ -1060,6 +1073,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { inv: l2c.inverse().unwrap_or(Mat3::IDENTITY), l2p, own: effectcraft_engine::viewer::layer_targets(&ectx, layer, false), + anchor_only: mods.alt, }) }), t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }), @@ -1201,10 +1215,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } Gesture::Move { layers, start, snap_src } => { - let mut d = [cpt[0] - start[0], cpt[1] - start[1]]; - if mods.shift { - if d[0].abs() > d[1].abs() { d[1] = 0.0 } else { d[0] = 0.0 } - } + let mut d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift); if let Some(src) = snap_src { let ids: Vec = layers.iter().map(|x| x.0).collect(); let c = vt::snap(app, &ctx, &ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods); @@ -1228,21 +1239,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge})); } - Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own } => { + Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => { // Pan Behind snaps the anchor point (to its own layer's box, other layers' features, - // guides and the grid). + // guides and the grid); Shift keeps the move to one axis. + let d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift); let anchor_c = inv.inverse().map(|m| m.apply(gv2(start_anchor[0], start_anchor[1]))).unwrap_or(gv2(start[0], start[1])); - let want = [anchor_c.x + cpt[0] - start[0], anchor_c.y + cpt[1] - start[1]]; + let want = [anchor_c.x + d[0], anchor_c.y + d[1]]; let sc = vt::snap_with(app, &ctx, &ectx, &map, &[layer], &[want], mods, &own); - let cpt = [cpt[0] + sc[0], cpt[1] + sc[1]]; let a = inv.apply(gv2(start[0], start[1])); - let b = inv.apply(gv2(cpt[0], cpt[1])); + let b = inv.apply(gv2(start[0] + d[0] + sc[0], start[1] + d[1] + sc[1])); let dl = gv2(b.x - a.x, b.y - a.y); let na = [start_anchor[0] + dl.x, start_anchor[1] + dl.y, start_anchor[2]]; - let dp = l2p.apply_vec(dl); - let np = [start_pos[0] + dp.x, start_pos[1] + dp.y, start_pos[2]]; let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/anchor", "value": na, "merge": merge})); - let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/position", "value": np, "merge": merge})); + // Position follows so the layer stays put, unless Alt moves the anchor point alone. + if !anchor_only { + let dp = l2p.apply_vec(dl); + let np = [start_pos[0] + dp.x, start_pos[1] + dp.y, start_pos[2]]; + let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/position", "value": np, "merge": merge})); + } } Gesture::Camera { tool } => { let d = resp.drag_delta(); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index dfa91955..bb610223 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -472,6 +472,48 @@ fn pan_behind_snaps_the_anchor_to_its_own_layer() { assert!((p[0] - 100.0).abs() < 0.01 && (p[1] - 100.0).abs() < 0.01, "{p:?}"); } +/// Issue #162: with Pan Behind, Alt-drag moves the anchor point alone (the layer shifts, its +/// position stays), Shift keeps the move to one axis, and Ctrl+double-clicking the tool's button +/// centres the anchor point in the layer content. +#[test] +fn pan_behind_alt_moves_the_anchor_alone_shift_constrains_and_the_tool_button_centres_it() { + 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("layer.select", json!({"layers": [box_id.0]})).unwrap(); + h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap(); + h.state_mut().ui.tool = Tool::PanBehind; + h.run_steps(2); + let close = |a: [f64; 3], b: [f64; 2]| (a[0] - b[0]).abs() < 0.01 && (a[1] - b[1]).abs() < 0.01; + // Alt: the anchor point moves by the drag, Position stays. + let (from, to) = (screen(&h, [100.0, 100.0]), screen(&h, [120.0, 105.0])); + hold_drag(&mut h, from, to, egui::Modifiers::ALT); + let (p, a) = position_and_anchor(&h, box_id); + assert!(close(a, [60.0, 45.0]) && close(p, [100.0, 100.0]), "Alt moves the anchor point alone: {a:?} {p:?}"); + // Shift: the move keeps to the axis dragged along most (Position compensates). + h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/anchor", "value": [40, 40, 0]})).unwrap(); + h.run_steps(2); + let (from, to) = (screen(&h, [100.0, 100.0]), screen(&h, [130.0, 110.0])); + hold_drag(&mut h, from, to, egui::Modifiers::SHIFT); + let (p, a) = position_and_anchor(&h, box_id); + assert!(close(a, [70.0, 40.0]) && close(p, [130.0, 100.0]), "Shift keeps to x: {a:?} {p:?}"); + // Ctrl+double-click the tool's button: Center Anchor Point in Layer Content (the layer stays). + let ctrl = egui::Modifiers { ctrl: true, command: true, ..Default::default() }; + let b = rect(&h, "tools.PanBehind").center(); + h.input_mut().events.push(Event::ModifiersChanged(ctrl)); + h.input_mut().events.push(Event::PointerMoved(b)); + h.step(); + for _ in 0..2 { + h.input_mut().events.push(Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: true, modifiers: ctrl }); + h.input_mut().events.push(Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: ctrl }); + } + h.run_steps(2); + h.input_mut().events.push(Event::ModifiersChanged(Default::default())); + h.step(); + let (p, a) = position_and_anchor(&h, box_id); + assert!(close(a, [40.0, 40.0]) && close(p, [100.0, 100.0]), "anchor point centred: {a:?} {p:?}"); +} + /// Drag from `from` to `to` with `modifiers` held, holding the end point a frame (the viewer's /// gestures apply the pointer of the previous frame). fn hold_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: egui::Modifiers) { From 254e89531688f6341339bd2fb460065401c01c18 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:38:35 +0700 Subject: [PATCH 3/3] Viewer: a locked viewer keeps its comp when the Project panel opens another (#156) The Project panel (like other panels) runs comp.open while it draws, so the new comp became the active comp mid-frame. Later in the same frame the active, locked viewer drew and recorded the active comp as its own. On the next frame on_open_comp compared that record with the opened comp, found them equal and skipped the lock, so the locked viewer switched comps. The active viewer no longer records its comp while an OpenComp event is pending (it asks for a repaint instead); on_open_comp then sees the comp the locked viewer really shows and routes the new one to an unlocked or new viewer. The viewers test's click helper now releases a frame after the press, as a hand does: a press in a tab strip focuses the shown panel first, and a same-frame press and release refocused it after the tab's own click. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/viewers.rs | 10 ++++- crates/ui-egui/tests/ui_viewers.rs | 63 ++++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index 0527f137..9c3add1c 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -73,8 +73,14 @@ pub fn comp_of(app: &EffectcraftApp, id: u32) -> Option { /// becomes the active viewer when clicked. pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) { if id == app.ui.active_viewer { - let c = app.session.active_comp_id().map(|c| c.0); - app.ui.viewers.insert(id, c); + // A comp opened earlier this frame (the Project panel) is routed next frame by + // `on_open_comp`, which needs the comp this viewer showed (to keep it when locked). + if app.session.events.iter().any(|e| matches!(e, effectcraft_engine::Event::OpenComp(_))) { + ui.ctx().request_repaint(); + } else { + let c = app.session.active_comp_id().map(|c| c.0); + app.ui.viewers.insert(id, c); + } super::viewer::show(app, ui, rect); return; } diff --git a/crates/ui-egui/tests/ui_viewers.rs b/crates/ui-egui/tests/ui_viewers.rs index 0fe891e8..e556d92a 100644 --- a/crates/ui-egui/tests/ui_viewers.rs +++ b/crates/ui-egui/tests/ui_viewers.rs @@ -17,13 +17,35 @@ fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, p: Value) { h.run_steps(3); } -fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { +/// Hover element `id`; returns its centre. +fn hover(h: &mut Harness<'_, EffectcraftApp>, id: &str) -> egui::Pos2 { let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); let p = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); h.input_mut().events.push(Event::PointerMoved(p)); h.step(); - h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); - h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + p +} + +fn button(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2, pressed: bool) { + h.input_mut().events.push(Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); +} + +/// Click element `id`, the release a frame after the press (as a hand does: a press in a tab +/// strip focuses the shown panel first, the click then the tab). +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = hover(h, id); + button(h, p, true); + h.step(); + button(h, p, false); + h.run_steps(3); +} + +/// Double-click element `id` (in one frame: the harness' frame time is longer than a double-click's). +fn double_click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = hover(h, id); + for pressed in [true, false, true, false] { + button(h, p, pressed); + } h.run_steps(3); } @@ -31,14 +53,16 @@ fn shows(h: &Harness<'_, EffectcraftApp>, viewer: u32) -> Option { viewers::comp_of(h.state(), viewer).map(|c| c.0) } +fn new_comp(s: &mut Session, name: &str) -> u64 { + s.execute("comp.new", json!({"name": name, "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap() +} + #[test] fn new_viewers_lock_route_comps_and_activate_on_click() { let mut s = Session::default(); - let new = - |s: &mut Session, name: &str| s.execute("comp.new", json!({"name": name, "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); - let c = new(&mut s, "C"); - let b = new(&mut s, "B"); - let a = new(&mut s, "A"); + let c = new_comp(&mut s, "C"); + let b = new_comp(&mut s, "B"); + let a = new_comp(&mut s, "A"); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(3); // View ▸ New Viewer: viewer 1 shows A too, viewer 0 (the Composition panel) is locked. @@ -87,6 +111,29 @@ fn new_viewers_lock_route_comps_and_activate_on_click() { assert!(titles.iter().any(|t| t == "Composition A") && titles.iter().any(|t| t == "Composition C"), "{titles:?}"); } +/// Issue #156: a locked viewer keeps its comp when the Project panel opens another one. The +/// Project panel opens it mid-frame, before the viewers draw; the active (locked) viewer used to +/// record the new comp as its own then, so the lock no longer applied. +#[test] +fn a_locked_viewer_keeps_its_comp_when_the_project_panel_opens_another() { + let mut s = Session::default(); + let b = new_comp(&mut s, "B"); + let a = new_comp(&mut s, "A"); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + // Viewer 0 locked to A, and B in viewer 1 (a tab next to it). + invoke(&mut h, "view.newViewer", json!({})); + invoke(&mut h, "comp.open", json!({"comp": b})); + // Back to the locked viewer by its tab. + click(&mut h, "panel.tab.Composition"); + assert!(viewers::locked(h.state(), 0)); + assert_eq!((h.state().ui.active_viewer, shows(&h, 0), shows(&h, 1)), (0, Some(a), Some(b))); + // Double-click B in the Project panel: it shows in its viewer, the locked one keeps A. + double_click(&mut h, &format!("project.item.{b}.name")); + assert_eq!(h.state().session.active_comp_id(), Some(ItemId(b))); + assert_eq!((h.state().ui.active_viewer, shows(&h, 0), shows(&h, 1)), (1, Some(a), Some(b))); +} + /// `cargo test -p effectcraft-ui-egui --test ui_viewers -- --ignored`: two viewers side by side /// (`target/test-out/viewers.png`). #[test]