Files
effectcraft/crates/ui-egui/tests/ui_viewer.rs
T
Claude 657f34d40b Layers: with several layers selected, value edits, stopwatches and viewer Scale / Rotate change them all (#355, #491)
With several layers selected, After Effects applies a property edit to the same property of
every selected layer; we changed only the layer whose row was edited (the Properties panel
showed only the first selected layer), so a stopwatch switched on one layer and later moves
of the others had nothing to keyframe (#355), and scaling or rotating a selection changed
one layer (#491).

- Engine: `prop.set`, `prop.toggleAnimation` and `prop.toggleKey` take `selected: true`:
  the change also goes to the same property (by match path, `transform/scale`,
  `effects/<effect>#n/<param>`) of every other selected, unlocked layer whose value has the
  same kind, in one undo step (a scrub keeps folding into it through its `merge` key). With
  `offset: true` (a scrub) each layer changes by the same amount from its own value; a typed
  value sets them all. Position with Separate Dimensions is handled per layer.
- The Timeline, Properties and Effect Controls panels pass `selected` (and `offset` while a
  value is dragged). The Properties panel heads "(N) Selected Objects" (zh-CN / zh-TW / ja /
  uk rows).
- Viewer: a bounding-box handle drag scales every selected layer by the same ratio about its
  own anchor point; the Rotation tool turns them all by the same angle (one undo step).

Tests: engine test (stopwatch, scrub offset, typed value, keyframe button, one undo step,
unselected layers untouched); kittests: the Properties panel with three layers selected
(header, stopwatch on all three undone in one step, a scrub moving all three by the same
amount) and a handle drag scaling two selected layers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 21:23:36 +00:00

1903 lines
98 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Headless checks for the Composition viewer's interaction parity: bottom bar order, region of
//! interest drawing, rulers and guides, snapping, the shape Pen and motion-path key drags
//! (egui_kittest, UI logic only).
use effectcraft_engine::Session;
use effectcraft_engine::commands::shape_tool::PaintKind;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::state::Tool;
use egui::{Event, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn app() -> EffectcraftApp {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap();
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
EffectcraftApp::new(s)
}
fn harness() -> Harness<'static, EffectcraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app());
h.run_steps(3);
h
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
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.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
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.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i 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.run_steps(2);
}
/// Comp pixel → screen point.
fn screen(h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> Pos2 {
let c = rect(h, "viewer.comp");
let z = c.width() / 640.0;
c.min + vec2(p[0] * z, p[1] * z)
}
fn selection_harness() -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Selection", "width": 640, "height": 360, "duration": 4})).unwrap();
for name in ["Back", "Front"] {
s.execute("layer.newSolid", json!({"name": name, "color": "#406080", "width": 80, "height": 80})).unwrap();
}
s.execute("edit.deselectAll", json!({})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
h.run_steps(3);
h
}
#[test]
fn viewer_click_skips_hidden_and_unsoloed_layers() {
for switch in ["video", "solo"] {
let mut h = selection_harness();
let comp = h.state().session.active_comp().unwrap();
let (front, back) = (comp.layers[0].id, comp.layers[1].id);
let at = screen(&h, [320.0, 180.0]);
click(&mut h, at);
assert_eq!(h.state().session.state.selected_layers, vec![front]);
let s = &mut h.state_mut().session;
let (target, value) = if switch == "video" { (front, false) } else { (back, true) };
s.execute("layer.setSwitch", json!({"layers": [target.0], "switch": switch, "value": value})).unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
h.run_steps(40); // Start a new click rather than a double-click.
click(&mut h, at);
assert_eq!(h.state().session.state.selected_layers, vec![back], "{switch}");
}
}
#[test]
fn viewer_marquee_skips_hidden_and_unsoloed_layers() {
for switch in ["video", "solo"] {
let mut h = selection_harness();
let comp = h.state().session.active_comp().unwrap();
let (front, back) = (comp.layers[0].id, comp.layers[1].id);
let (target, value) = if switch == "video" { (front, false) } else { (back, true) };
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [target.0], "switch": switch, "value": value})).unwrap();
h.run_steps(3);
let (from, to) = (screen(&h, [250.0, 110.0]), screen(&h, [390.0, 250.0]));
drag(&mut h, from, to);
assert_eq!(h.state().session.state.selected_layers, vec![back], "{switch}");
}
}
#[test]
fn viewer_click_does_not_pick_through_hidden_or_locked_solo_layers() {
for switch in ["video", "lock"] {
let mut h = selection_harness();
let front = h.state().session.active_comp().unwrap().layers[0].id;
let s = &mut h.state_mut().session;
s.execute("layer.setSwitch", json!({"layers": [front.0], "switch": "solo", "value": true})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [front.0], "switch": switch, "value": switch == "lock"})).unwrap();
h.run_steps(3);
let at = screen(&h, [320.0, 180.0]);
click(&mut h, at);
assert!(h.state().session.state.selected_layers.is_empty(), "{switch}");
}
}
#[test]
fn viewer_click_ignores_inactive_solo_layers() {
let mut h = selection_harness();
let comp = h.state().session.active_comp().unwrap();
let (front, back) = (comp.layers[0].id, comp.layers[1].id);
let s = &mut h.state_mut().session;
s.execute("layer.setSwitch", json!({"layers": [front.0], "switch": "solo", "value": true})).unwrap();
let cid = s.active_comp_id().unwrap();
std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(front).unwrap().in_point = effectcraft_engine::time::Tick::from_seconds_f64(1.0);
h.run_steps(3);
let at = screen(&h, [320.0, 180.0]);
click(&mut h, at);
assert_eq!(h.state().session.state.selected_layers, vec![back]);
}
/// A selected layer under the pointer takes the press before the layers in front of it, as in
/// After Effects: it stays selected and a drag moves it. With none selected there, the topmost
/// layer does (#230).
#[test]
fn viewer_press_prefers_a_selected_layer_under_the_pointer() {
let mut h = selection_harness();
let comp = h.state().session.active_comp().unwrap();
let (front, back) = (comp.layers[0].id, comp.layers[1].id);
h.state_mut().session.execute("layer.select", json!({"layers": [back.0]})).unwrap();
h.run_steps(3);
let at = screen(&h, [320.0, 180.0]);
click(&mut h, at);
assert_eq!(h.state().session.state.selected_layers, vec![back], "a click keeps the selected layer behind");
h.run_steps(40);
let (front0, back0) = (position_and_anchor(&h, front).0, position_and_anchor(&h, back).0);
drag(&mut h, at, at + vec2(60.0, 0.0));
assert_eq!(h.state().session.state.selected_layers, vec![back]);
assert_eq!(position_and_anchor(&h, front).0, front0, "the layer in front stays");
assert!(position_and_anchor(&h, back).0[0] > back0[0] + 10.0, "the selected layer behind moves");
h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap();
h.run_steps(40);
let at = screen(&h, [300.0, 180.0]);
click(&mut h, at);
assert_eq!(h.state().session.state.selected_layers, vec![front], "nothing selected: the topmost layer");
}
#[test]
fn bottom_bar_in_after_effects_order() {
let h = harness();
let order = [
"viewer.magnification",
"viewer.resolution",
"viewer.transparency",
"viewer.masks",
"viewer.roi",
"viewer.grid",
"viewer.channel",
"viewer.resetExposure",
"viewer.exposure",
"viewer.snapshot",
"viewer.showSnapshot",
"viewer.fastPreviews",
"viewer.timecode",
];
let xs: Vec<f32> = order.iter().map(|id| rect(&h, id).min.x).collect();
assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}");
let mag = &h.state().auto.find("viewer.magnification").unwrap().label;
assert_eq!(mag, "Magnification");
}
#[test]
fn region_of_interest_is_drawn_in_the_viewer() {
let mut h = harness();
let at = rect(&h, "viewer.roi").center();
click(&mut h, at);
assert!(h.state().ui.viewer.roi_draw);
let (a, b) = (screen(&h, [100.0, 50.0]), screen(&h, [300.0, 250.0]));
drag(&mut h, a, b);
let r = h.state().session.state.region_of_interest.expect("roi");
assert!((r[0] - 100.0).abs() <= 2.0 && (r[1] - 50.0).abs() <= 2.0 && (r[2] - 200.0).abs() <= 3.0 && (r[3] - 200.0).abs() <= 3.0, "{r:?}");
assert!(!h.state().ui.viewer.roi_draw);
h.run_steps(3);
assert!(h.state().auto.find("viewer.regionOfInterest").is_some());
// The button clears it again.
let at = rect(&h, "viewer.roi").center();
click(&mut h, at);
assert!(h.state().session.state.region_of_interest.is_none());
}
#[test]
fn rulers_make_guides_and_guides_move() {
let mut h = harness();
h.state_mut().ui.viewer.rulers = true;
h.run_steps(3);
let top = rect(&h, "viewer.ruler.top");
let to = screen(&h, [0.0, 120.0]);
drag(&mut h, pos2(to.x + 200.0, top.center().y), pos2(to.x + 200.0, to.y));
let g = h.state().session.active_comp().unwrap().guides.clone();
assert_eq!(g.len(), 1);
assert!(!g[0].vertical && (g[0].position - 120.0).abs() <= 2.0, "{g:?}");
// Drag the guide down with the Selection tool.
let from = pos2(screen(&h, [500.0, 0.0]).x, screen(&h, [0.0, g[0].position as f32]).y);
let to2 = pos2(from.x, screen(&h, [0.0, 200.0]).y);
drag(&mut h, from, to2);
let g = h.state().session.active_comp().unwrap().guides.clone();
assert!((g[0].position - 200.0).abs() <= 2.0, "{g:?}");
}
#[test]
fn layer_drag_snaps_to_comp_centre_and_ctrl_disables() {
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.run_steps(2);
// Grab the box at its centre and drop it 3 px from the comp centre: it snaps there.
let from = screen(&h, [100.0, 100.0]);
let to = screen(&h, [323.0, 182.0]);
drag(&mut h, from, to);
let pos = |h: &Harness<'_, EffectcraftApp>| {
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
l.props.prop("transform/position").unwrap().value.as_vec3()
};
let p = pos(&h);
assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "{p:?}");
// Snapping off: the same drag lands where the pointer is.
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
h.run_steps(2);
drag(&mut h, from, to);
let p = pos(&h);
assert!((p[0] - 320.0).abs() > 1.0, "{p:?}");
}
/// #253: a dragged layer's feature nearest the pointer snaps to other layers' edges; Cmd/Ctrl
/// held during the drag turns snapping on while the Snapping checkbox is off (and off while it
/// is on), and the Tools bar's Snapping options turn Snap Edges Extended off, so an edge only
/// snaps along the layer.
#[test]
fn layer_drag_snaps_to_other_layers_edges_ctrl_toggles_and_options_apply() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
// A 100×100 target at x 450–550, y 50–150.
let target = h.state_mut().session.execute("layer.newSolid", json!({"name": "Target", "color": "#20e040", "width": 100, "height": 100})).unwrap()["layer"]
.as_u64()
.unwrap();
let place = |h: &mut Harness<'_, EffectcraftApp>| {
let s = &mut h.state_mut().session;
s.execute("prop.set", json!({"layer": target, "path": "transform/position", "value": [500, 100, 0]})).unwrap();
s.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 250, 0]})).unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
h.run_steps(2);
};
let pos = |h: &Harness<'_, EffectcraftApp>| {
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
l.props.prop("transform/position").unwrap().value.as_vec3()
};
// Grab the box by its top right corner (140, 210) and drop it 3 px left of the target's
// left edge, well below the target: the corner lands on the edge's line (x 450).
let ctrl = egui::Modifiers { ctrl: true, command: true, ..Default::default() };
let drop = |h: &mut Harness<'_, EffectcraftApp>, mods: egui::Modifiers| {
place(h);
let (from, to) = (screen(h, [138.0, 212.0]), screen(h, [445.0, 232.0]));
hold_drag(h, from, to, mods);
pos(h)
};
let snapped = |p: [f64; 3]| (p[0] - 410.0).abs() < 0.01;
let p = drop(&mut h, Default::default());
assert!(snapped(p), "snapping on: {p:?}");
let p = drop(&mut h, ctrl);
assert!(!snapped(p), "Ctrl turns it off: {p:?}");
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
let p = drop(&mut h, Default::default());
assert!(!snapped(p), "snapping off: {p:?}");
let p = drop(&mut h, ctrl);
assert!(snapped(p), "Ctrl turns it on: {p:?}");
h.state_mut().session.execute("view.snapping", json!({"value": true})).unwrap();
// Snapping options ▸ Snap Edges Extended off: below the target its edge no longer snaps.
let menu = rect(&h, "header.snappingOptions").center();
click(&mut h, menu);
// After Effects' two options, without per-feature toggles.
assert!(h.query_by_label("✓ Snap to Features in Collapsed Compositions and Text Layers").is_some());
for gone in ["✓ Corners", "✓ Anchor Points", "✓ Mask and Shape Path Points"] {
assert!(h.query_by_label(gone).is_none(), "{gone}");
}
let item = h.query_by_label("✓ Snap Edges Extended").expect("the Snapping options menu").rect().center();
click(&mut h, item);
assert!(!h.state().session.state.snap_features.edges_extended);
let p = drop(&mut h, Default::default());
assert!(!snapped(p), "edges not extended: {p:?}");
}
/// With a shape layer selected the Rectangle tool draws a new group into its Contents (After
/// Effects' behaviour); with nothing selected it draws a new shape layer (#227).
#[test]
fn shape_tool_draws_into_the_selected_shape_layer() {
let mut h = harness();
let l = h.state_mut().session.execute("layer.newShape", json!({"kind": "rect", "size": [100, 100], "position": [320, 180]})).unwrap()["layer"]
.as_u64()
.unwrap();
h.state_mut().ui.tool = Tool::Rectangle;
h.run_steps(2);
let n0 = h.state().session.active_comp().unwrap().layers.len();
let (a, b) = (screen(&h, [100.0, 100.0]), screen(&h, [200.0, 160.0]));
drag(&mut h, a, b);
let comp = h.state().session.active_comp().unwrap().clone();
assert_eq!(comp.layers.len(), n0, "no new layer");
let contents = comp.layer(LayerId(l)).unwrap().props.sub("contents").unwrap().clone();
let names: Vec<&str> = contents.groups().map(|g| g.name.as_str()).collect();
assert_eq!(names, ["Rectangle 2", "Rectangle 1"]);
// Placed where it was drawn (centred near comp (150, 130)), in the layer's space: the layer
// sits at the comp centre.
let g = contents.groups().next().unwrap();
let at = g.sub("transform").unwrap().get("position").unwrap().value.components();
assert!((at[0] + 170.0).abs() < 8.0 && (at[1] + 50.0).abs() < 8.0, "{at:?}");
// Nothing selected: a new shape layer.
h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap();
let (a, b) = (screen(&h, [400.0, 250.0]), screen(&h, [500.0, 300.0]));
drag(&mut h, a, b);
let comp = h.state().session.active_comp().unwrap();
assert_eq!(comp.layers.len(), n0 + 1);
assert!(matches!(comp.layers[0].source, effectcraft_engine::project::LayerSource::Shape));
assert_ne!(comp.layers[0].id, LayerId(l));
}
/// With a shape layer selected and a shape tool active, the Tools bar's Tool Creates Mask makes
/// the tool draw a mask on the layer (and hides Fill and Stroke); Tool Creates Shape draws
/// shapes again (#227).
#[test]
fn tool_creates_mask_draws_a_mask_on_the_selected_shape_layer() {
let mut h = harness();
let l = h.state_mut().session.execute("layer.newShape", json!({"kind": "rect", "size": [100, 100], "position": [320, 180]})).unwrap()["layer"]
.as_u64()
.unwrap();
h.state_mut().ui.tool = Tool::Star;
h.run_steps(2);
assert!(h.state().auto.find("header.fill").is_some());
let at = rect(&h, "header.createsMask").center();
click(&mut h, at);
assert!(h.state().session.state.shape_tool.creates_mask);
assert!(h.state().auto.find("header.fill").is_none(), "no Fill or Stroke for masks");
let (a, b) = (screen(&h, [100.0, 100.0]), screen(&h, [200.0, 200.0]));
drag(&mut h, a, b);
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
let masks = layer.props.sub("masks").unwrap();
assert_eq!(masks.groups().count(), 1, "a mask");
assert_eq!(masks.groups().next().unwrap().get("path").unwrap().value.as_path().unwrap().vertices.len(), 10, "a star");
assert_eq!(layer.props.sub("contents").unwrap().groups().count(), 1, "no new shape");
let at = rect(&h, "header.createsShape").center();
click(&mut h, at);
assert!(!h.state().session.state.shape_tool.creates_mask);
}
// ---- the shape tools' drag ghost
/// Run frames until the viewer's background frame has landed, so the comp is painted (and not
/// still black) before the drag begins: the ghost is then the only thing that changes.
fn settle_viewer(h: &mut Harness<'_, EffectcraftApp>) {
for _ in 0..600 {
h.step();
if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
h.run_steps(4);
}
/// Press at `from`, drag to `to` with `mods` held and return the frame drawn with the button still
/// down — what the user sees mid-drag, before anything is committed. egui only takes modifiers
/// from [`egui::Event::ModifiersChanged`], so they are set before the press and stay set.
fn mid_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, mods: egui::Modifiers) -> image::RgbaImage {
h.input_mut().events.push(Event::ModifiersChanged(mods));
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: mods });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
h.render().expect("the mid-drag frame")
}
/// Release the button where the drag left it.
fn release(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, mods: egui::Modifiers) {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: mods });
h.run_steps(2);
}
/// The pixel at a comp point of a rendered frame.
fn px_at(img: &image::RgbaImage, h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> [u8; 3] {
let s = screen(h, p);
let p = img.get_pixel(s.x.round() as u32, s.y.round() as u32).0;
[p[0], p[1], p[2]]
}
/// How different two pixels are, summed over the channels.
fn delta(a: [u8; 3], b: [u8; 3]) -> u32 {
(0..3).map(|i| (a[i] as i32 - b[i] as i32).unsigned_abs()).sum()
}
/// The Tools-bar Fill as bytes, the colour a filled ghost carries.
fn fill_rgb(h: &Harness<'_, EffectcraftApp>) -> [u8; 3] {
let c = h.state().session.state.shape_tool.fill.color;
std::array::from_fn(|i| (c[i].clamp(0.0, 1.0) * 255.0).round() as u8)
}
/// The shape tools show the shape they will draw under the cursor while the drag is still going:
/// filled with the Tools bar's Fill, so it reads at any zoom where the 1 px outline it replaces was
/// invisible, and in the tool's own form — only the Rectangle fills the corner of the box.
#[test]
fn shape_drag_ghost_shows_the_tools_own_shape_while_dragging() {
let (from, to) = ([140.0, 100.0], [400.0, 300.0]);
let centre = [270.0, 200.0];
// 6 comp px inside the box's top-left corner: inside a rectangle, outside every other kind.
let corner = [146.0, 106.0];
for (tool, fills_corner) in [(Tool::Rectangle, true), (Tool::RoundedRect, false), (Tool::Ellipse, false), (Tool::Polygon, false), (Tool::Star, false)] {
let mut h = harness();
h.state_mut().ui.tool = tool;
settle_viewer(&mut h);
let (a, b) = (screen(&h, from), screen(&h, to));
let before = h.render().expect("the frame before the drag");
let mid = mid_drag(&mut h, a, b, egui::Modifiers::default());
let fill = fill_rgb(&h);
// Drawn while the button is down, and painted with the Fill rather than left to a hairline.
let was = px_at(&before, &h, centre);
let now = px_at(&mid, &h, centre);
assert!(delta(was, now) > 30, "{tool:?}: no ghost drawn mid-drag ({was:?} → {now:?})");
assert!(delta(now, fill) < delta(was, fill), "{tool:?}: the ghost is not the Fill colour ({now:?} vs {was:?})");
// The tool's own form: only the Rectangle reaches the corner of its box.
let was_c = px_at(&before, &h, corner);
let now_c = px_at(&mid, &h, corner);
assert_eq!(delta(was_c, now_c) > 30, fills_corner, "{tool:?}: the ghost is not drawn in its own shape");
release(&mut h, b, egui::Modifiers::default());
}
}
/// The ghost and the shape the release commits share one normalisation, so nothing it shows is a
/// lie: Shift squares the box and Ctrl/Cmd draws it from the press point (as in After Effects), both live.
#[test]
fn shape_drag_ghost_normalises_the_same_way_as_the_release() {
let from = [140.0f64, 100.0];
let to = [400.0f64, 260.0];
// (mods, centre, size): Shift squares the box from its top-left, Ctrl/Cmd centres it on the press.
for (mods, centre, size) in [
(egui::Modifiers::default(), [270.0, 180.0], [260.0, 160.0]),
(egui::Modifiers::SHIFT, [270.0, 230.0], [260.0, 260.0]),
(egui::Modifiers::COMMAND, [140.0, 100.0], [520.0, 320.0]),
(egui::Modifiers { shift: true, ..egui::Modifiers::COMMAND }, [140.0, 100.0], [520.0, 520.0]),
] {
let mut h = harness();
h.state_mut().ui.tool = Tool::Ellipse;
settle_viewer(&mut h);
let (a, b) = (screen(&h, from.map(|v| v as f32)), screen(&h, to.map(|v| v as f32)));
let before = h.render().expect("the frame before the drag");
// Measured with the button down, so this is the ghost and not the committed shape.
let mid = mid_drag(&mut h, a, b, mods);
let on = [centre[0] as f32 + size[0] as f32 / 4.0, centre[1] as f32 + size[1] as f32 / 4.0];
assert!(delta(px_at(&before, &h, on), px_at(&mid, &h, on)) > 30, "{mods:?}: the ghost is not where it will land");
release(&mut h, b, mods);
// And the shape it commits is that box: a new shape layer carries the position, its
// group sits at its own centre's origin.
let comp = h.state().session.active_comp().unwrap().clone();
let layer = comp.layers.first().expect("a new shape layer").clone();
assert!(matches!(layer.source, effectcraft_engine::project::LayerSource::Shape), "{mods:?}: no shape layer");
let at = layer.transform().unwrap().get("position").unwrap().value.components();
assert!((at[0] - centre[0]).abs() < 1.0 && (at[1] - centre[1]).abs() < 1.0, "{mods:?}: at {at:?}, not {centre:?}");
let group = layer.props.sub("contents").unwrap().groups().next().unwrap().clone();
let path = group.sub("contents").unwrap().sub("ellipse").unwrap();
let s = path.get("size").unwrap().value.components();
assert!((s[0] - size[0]).abs() < 1.0 && (s[1] - size[1]).abs() < 1.0, "{mods:?}: sized {s:?}, not {size:?}");
}
}
/// The ghost says where the shape lands: a mask on the selected layer is drawn in the mask colour
/// and not painted with the Fill, where a shape drawn into the selected shape layer is.
#[test]
fn shape_drag_ghost_shows_where_the_shape_will_land() {
let (from, to) = ([140.0, 100.0], [400.0, 300.0]);
let centre = [270.0, 200.0];
// Nothing selected: the shape becomes a new layer, painted with the Fill.
let mut h = harness();
h.state_mut().ui.tool = Tool::Ellipse;
settle_viewer(&mut h);
let (a, b) = (screen(&h, from), screen(&h, to));
let shape_mid = mid_drag(&mut h, a, b, egui::Modifiers::default());
let shape_px = px_at(&shape_mid, &h, centre);
let fill = fill_rgb(&h);
assert!(delta(shape_px, fill) < delta(shape_px, [0xff, 0xc0, 0x00]), "a shape ghost is painted with the Fill, not the mask colour");
release(&mut h, b, egui::Modifiers::default());
// A solid selected: the same drag is a mask on it, in the mask colour.
let mut h = harness();
let plate = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id;
h.state_mut().session.execute("layer.select", json!({"layers": [plate.0]})).unwrap();
h.state_mut().ui.tool = Tool::Ellipse;
settle_viewer(&mut h);
let (a, b) = (screen(&h, from), screen(&h, to));
let mask_mid = mid_drag(&mut h, a, b, egui::Modifiers::default());
let mask_px = px_at(&mask_mid, &h, centre);
assert!(delta(mask_px, shape_px) > 20, "a mask ghost must not look like the shape ghost ({mask_px:?} vs {shape_px:?})");
release(&mut h, b, egui::Modifiers::default());
let comp = h.state().session.active_comp().unwrap();
assert_eq!(comp.layer(plate).unwrap().props.sub("masks").unwrap().groups().count(), 1, "the drag drew a mask");
}
/// Dragging a layer moves its picture while the button is still down. Every pointer move replaces
/// the comp's content identity, so the exact frame on screen is re-rendered at every step: waiting
/// for it would freeze the viewer on the last completed frame for the whole drag.
#[test]
fn a_layer_drag_moves_the_picture_while_the_button_is_down() {
let mut h = harness();
let box_ = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id;
h.state_mut().session.execute("layer.select", json!({"layers": [box_.0]})).unwrap();
settle_viewer(&mut h);
let from = screen(&h, [320.0, 180.0]);
let to = screen(&h, [520.0, 180.0]);
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.step();
// Where the layer is not (yet): the plate behind it.
let plate = px_at(&h.render().unwrap(), &h, [440.0, 200.0]);
// Drag in small steps, still holding the button, and give the renders a chance to land.
let mut moved = false;
for i in 1..=40 {
let p = from + (to - from) * (i as f32 / 40.0);
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
std::thread::sleep(std::time::Duration::from_millis(15));
// 30 comp px behind the pointer: inside the layer, well away from its outline, its anchor
// icon and where it started.
let at = px_at(&h.render().expect("mid-drag frame"), &h, [320.0 + 200.0 * (i as f32 / 40.0) - 30.0, 200.0]);
if i >= 30 && delta(at, plate) > 30 {
moved = true;
break;
}
}
assert!(moved, "the layer's pixels did not follow the pointer while the button was down");
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
/// Clicking the word "Fill" opens Fill Options: a radial gradient in Multiply at 40% paints the
/// next shape drawn (#227).
#[test]
fn fill_options_paint_the_next_shape() {
let mut h = harness();
h.state_mut().ui.tool = Tool::Ellipse;
h.run_steps(2);
let at = rect(&h, "header.fillOptions").center();
click(&mut h, at);
let at = rect(&h, "header.fillOptions.radial").center();
click(&mut h, at);
let at = rect(&h, "header.fillOptions.blend").center();
click(&mut h, at);
let at = h.get_by_label("Multiply").rect().center();
click(&mut h, at);
let at = rect(&h, "header.fillOptions.opacity").center();
click(&mut h, at);
h.input_mut().events.push(Event::Text("40".into()));
h.step();
h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.run_steps(2);
let fill = h.state().session.state.shape_tool.fill.clone();
assert_eq!((fill.kind, fill.blend, fill.opacity), (PaintKind::Radial, effectcraft_engine::color::BlendMode::Multiply, 40.0));
h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.run_steps(3);
assert!(h.state().auto.find("header.fillOptions.radial").is_none(), "closed");
let (a, b) = (screen(&h, [100.0, 100.0]), screen(&h, [220.0, 160.0]));
drag(&mut h, a, b);
let layer = h.state().session.active_comp().unwrap().layers[0].clone();
let g = layer.props.sub("contents").unwrap().groups().next().unwrap().sub("contents").unwrap().clone();
assert_eq!(g.groups().map(|x| x.match_id.as_str()).collect::<Vec<_>>(), ["ellipse", "gfill"]);
let gfill = g.groups().find(|x| x.match_id == "gfill").unwrap();
let multiply = effectcraft_engine::color::BlendMode::ALL.iter().position(|m| *m == effectcraft_engine::color::BlendMode::Multiply).unwrap() as u32;
assert_eq!(gfill.get("type").unwrap().value, effectcraft_keyframe::Value::Enum(1));
assert_eq!(gfill.get("blend").unwrap().value, effectcraft_keyframe::Value::Enum(multiply));
assert_eq!(gfill.get("opacity").unwrap().value.as_f64(), 40.0);
}
#[test]
fn shape_pen_draws_a_closed_shape_layer() {
let mut h = harness();
h.state_mut().ui.tool = Tool::Pen;
h.run_steps(2);
let n0 = h.state().session.active_comp().unwrap().layers.len();
for p in [[200.0, 100.0], [400.0, 120.0], [300.0, 260.0], [200.0, 100.0]] {
let at = screen(&h, p);
click(&mut h, at);
}
let comp = h.state().session.active_comp().unwrap().clone();
assert_eq!(comp.layers.len(), n0 + 1);
let l = &comp.layers[0];
assert!(matches!(l.source, effectcraft_engine::project::LayerSource::Shape));
let g = l.props.sub("contents").unwrap().groups().next().unwrap();
assert_eq!(g.name, "Shape 1");
let path = g.sub("contents").unwrap().groups().next().unwrap();
let sp = path.get("path").unwrap().value.as_path().unwrap().clone();
assert_eq!(sp.vertices.len(), 3);
assert!(sp.closed);
assert!((sp.vertices[0][0] - (200.0 - 320.0)).abs() < 1.5, "{:?}", sp.vertices);
// G cycles the Pen slot: Add Vertex, Delete Vertex, Convert Vertex, Mask Feather, Pen.
let ctx = h.ctx.clone();
let mut seen = vec![];
for _ in 0..5 {
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "tool.pen", json!({})).unwrap();
seen.push(h.state().ui.tool);
}
assert_eq!(seen, vec![Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather, Tool::Pen]);
// Add Vertex on the first segment.
h.state_mut().ui.tool = Tool::PenAdd;
h.run_steps(2);
let at = screen(&h, [300.0, 110.0]);
click(&mut h, at);
let comp = h.state().session.active_comp().unwrap().clone();
let sp = comp.layers[0]
.props
.sub("contents")
.unwrap()
.groups()
.next()
.unwrap()
.sub("contents")
.unwrap()
.groups()
.next()
.unwrap()
.get("path")
.unwrap()
.value
.as_path()
.unwrap()
.clone();
assert_eq!(sp.vertices.len(), 4);
}
#[test]
fn motion_path_key_drag_edits_that_key() {
let mut h = harness();
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 0.0, "value": [100, 100, 0]})).unwrap();
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 2.0, "value": [500, 100, 0]})).unwrap();
s.execute("view.snapping", json!({"value": false})).unwrap();
s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0));
h.run_steps(3);
assert!(h.state().auto.find(&format!("viewer.motionPath.{}.1", box_id.0)).is_some());
let from = screen(&h, [500.0, 100.0]);
let to2 = screen(&h, [500.0, 300.0]);
drag(&mut h, from, to2);
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
let k = &l.props.prop("transform/position").unwrap().keys;
let v1 = k[1].value.as_vec3();
assert!((v1[1] - 300.0).abs() < 2.0 && (v1[0] - 500.0).abs() < 2.0, "{v1:?}");
assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]);
}
/// The first mask path of layer `name`.
fn mask_path(h: &Harness<'_, EffectcraftApp>, name: &str) -> effectcraft_engine::keyframe::ShapePath {
let l = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().clone();
l.masks().unwrap().groups().next().unwrap().get("path").unwrap().value.as_path().unwrap().clone()
}
/// Drawing a mask with the Pen, moving a point and undoing take one action back per undo; a
/// double-click on a point selects all of them in a free-transform box, and dragging inside it
/// (or, once the box is gone, dragging a point) moves the whole mask (#290).
#[test]
fn pen_mask_undoes_step_by_step_and_double_click_moves_the_whole_mask() {
let mut h = harness();
let plate = layer_id(&h, "Plate");
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.state_mut().ui.tool = Tool::Pen;
h.run_steps(2);
let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]];
for p in corners.iter().chain([&corners[0]]) {
let at = screen(&h, [p[0] as f32, p[1] as f32]);
click(&mut h, at);
}
let drawn = mask_path(&h, "Plate");
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1.0 && (a[1] - b[1]).abs() < 1.0;
assert!(drawn.closed && drawn.vertices.len() == 4 && drawn.vertices.iter().zip(&corners).all(|(v, c)| near(*v, *c)), "{drawn:?}");
h.state_mut().ui.tool = Tool::Selection;
h.run_steps(2);
// Move one point: one undo step puts it back, the next reopens the path, the next removes the
// last point.
let (from, to) = (screen(&h, [500.0, 60.0]), screen(&h, [520.0, 80.0]));
drag(&mut h, from, to);
let moved = mask_path(&h, "Plate");
assert!(near(moved.vertices[1], [520.0, 80.0]) && moved.vertices[0] == drawn.vertices[0], "{moved:?}");
let undo = |h: &mut Harness<'_, EffectcraftApp>| {
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
mask_path(h, "Plate")
};
assert_eq!(undo(&mut h), drawn);
assert!(!undo(&mut h).closed);
assert_eq!(undo(&mut h).vertices.len(), 3);
for _ in 0..3 {
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
}
h.run_steps(2);
// Double-click a point: all of them selected, in a free-transform box.
let at = screen(&h, [100.0, 300.0]);
h.input_mut().events.push(Event::PointerMoved(at));
h.step();
for pressed in [true, false, true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
h.run_steps(3);
assert_eq!(h.state().session.state.selected_vertices.len(), 4);
assert!(h.state().auto.find("viewer.freeTransform.handle.0").is_some(), "the free-transform box is up");
// Dragging inside the box moves the whole mask.
let (from, to) = (screen(&h, [300.0, 120.0]), screen(&h, [310.0, 130.0]));
drag(&mut h, from, to);
let path = mask_path(&h, "Plate");
assert!(path.vertices.iter().zip(&moved.vertices).all(|(v, c)| near(*v, [c[0] + 10.0, c[1] + 10.0])), "{path:?}");
// A click away ends the box; the points stay selected and dragging one moves them all.
let away = screen(&h, [600.0, 340.0]);
click(&mut h, away);
h.run_steps(2);
assert!(h.state().auto.find("viewer.freeTransform.handle.0").is_none());
let (from, to) = (screen(&h, [110.0, 310.0]), screen(&h, [100.0, 300.0]));
drag(&mut h, from, to);
let back = mask_path(&h, "Plate");
assert!(back.vertices.iter().zip(&moved.vertices).all(|(v, c)| near(*v, *c)), "{back:?}");
}
/// #513: clicking away from a mask's path, even on its own layer, deselects the mask's points;
/// the layer stays selected.
#[test]
fn clicking_off_a_mask_deselects_its_points() {
let mut h = harness();
let plate = layer_id(&h, "Plate");
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [plate]})).unwrap();
s.execute("mask.new", json!({"layer": plate, "vertices": [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]], "closed": true})).unwrap();
h.state_mut().ui.tool = Tool::Selection;
h.run_steps(2);
let at = screen(&h, [500.0, 60.0]);
click(&mut h, at);
assert_eq!(h.state().session.state.selected_vertices.len(), 1, "a click on a point selects it");
let away = screen(&h, [200.0, 250.0]);
click(&mut h, away);
assert!(h.state().session.state.selected_vertices.is_empty(), "a click off the path deselects its points");
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(plate)], "the layer stays selected");
}
/// A straight two-key motion path shows Bezier handles at both keys (it had none), and dragging
/// one pulls the path into a curve (#290).
#[test]
fn a_straight_motion_path_has_handles_to_curve_it() {
let mut h = harness();
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 0.0, "value": [100, 100, 0]})).unwrap();
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 2.0, "value": [400, 100, 0]})).unwrap();
s.execute("view.snapping", json!({"value": false})).unwrap();
s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0));
h.run_steps(3);
let (out0, in1) = (format!("viewer.motionPath.{}.0.out", box_id.0), format!("viewer.motionPath.{}.1.in", box_id.0));
assert!(h.state().auto.find(&in1).is_some(), "the second key has a handle");
// The first key's handle lies a third of the way along the path: drag it down 150 px.
let from = rect(&h, &out0).center();
assert!(from.distance(screen(&h, [200.0, 100.0])) < 2.0, "{from:?}");
let to = screen(&h, [200.0, 250.0]);
drag(&mut h, from, to);
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/position").unwrap().clone();
assert!(!p.keys[0].spatial_auto);
let t = p.keys[0].spatial_out;
assert!((t[0] - 100.0).abs() < 2.0 && (t[1] - 150.0).abs() < 2.0, "{t:?}");
let mid = p.value_at(effectcraft_engine::time::Tick::from_seconds_f64(1.0)).as_vec3();
assert!(mid[1] > 130.0, "the path curves: {mid:?}");
}
#[test]
fn graph_editor_transform_box_scales_selected_keys_in_time() {
let mut h = harness();
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid;
s.execute("prop.select", json!({"layer": box_id.0, "prop": uid})).unwrap();
let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": box_id.0, "prop": uid, "time": t})).collect();
s.execute("keys.select", json!({"keys": keys})).unwrap();
h.state_mut().ui.timeline.graph_editor = true;
h.run_steps(4);
for id in ["timeline.graph.snap", "timeline.graph.reference", "timeline.graph.transformBox", "timeline.graph.transformBox.5"] {
assert!(h.state().auto.find(id).is_some(), "missing {id}");
}
// Drag the right edge handle to the right: the keys spread out in time, the first stays.
let r = rect(&h, "timeline.graph.transformBox.5");
let k0 = rect(&h, &format!("timeline.graph.key.{uid}.0.0")).center();
let k2 = rect(&h, &format!("timeline.graph.key.{uid}.0.2")).center();
let to = r.center() + vec2((k2.x - k0.x) * 0.5, 0.0);
drag(&mut h, r.center(), to);
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone();
let ts: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
assert_eq!(ts[0], 0.0);
assert!(ts[2] > 2.5 && ts[2] < 3.5, "{ts:?}");
assert_eq!(p.keys.iter().map(|k| k.value.as_f64()).collect::<Vec<_>>(), vec![0.0, 50.0, 100.0]);
let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Transform Keyframes").count();
assert_eq!(undo, 1, "one undo step per drag");
}
#[test]
fn timeline_alt_drag_scales_a_key_group_in_time() {
let mut h = harness();
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid;
s.execute("keys.selectAll", json!({"layers": [box_id.0]})).unwrap();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap();
h.run_steps(4);
let mut ks: Vec<Pos2> =
h.state().auto.query(&format!("timeline.key.{uid}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect();
ks.sort_by(|a, b| a.x.total_cmp(&b.x));
assert_eq!(ks.len(), 3, "{ks:?}");
let (k0, k2) = (ks[0], ks[2]);
// Alt-drag the last key to where 3 s would be: the group scales by 1.5 about the first key.
let to = pos2(k0.x + (k2.x - k0.x) * 1.5, k2.y);
let alt = egui::Modifiers { alt: true, ..Default::default() };
h.input_mut().events.push(Event::PointerMoved(k2));
h.step();
h.input_mut().events.push(Event::ModifiersChanged(alt));
h.input_mut().events.push(Event::PointerButton { pos: k2, button: egui::PointerButton::Primary, pressed: true, modifiers: alt });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(k2 + (to - k2) * (i as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: alt });
h.run_steps(2);
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone();
let ts: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
assert_eq!(ts.len(), 3);
assert_eq!(ts[0], 0.0);
assert!((ts[2] - 3.0).abs() < 0.1 && (ts[1] - 1.5).abs() < 0.1, "{ts:?}");
}
#[test]
fn mask_feather_tool_adds_and_drags_feather_points() {
let mut h = harness();
// A mask on the Box (80×80 at the comp centre, comp 280…360 × 140…220): layer 10…70.
let (box_id, mask) = {
let s = &mut h.state_mut().session;
let b = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id;
let m = s.execute("mask.new", json!({"layer": b.0, "vertices": [[10, 10], [70, 10], [70, 70], [10, 70]], "closed": true})).unwrap()["mask"]
.as_u64()
.unwrap();
s.execute("layer.select", json!({"layers": [b.0]})).unwrap();
(b, m)
};
h.state_mut().ui.tool = Tool::MaskFeather;
h.run_steps(3);
// Press on the top edge (comp 320,150) and drag 12 px up: an outer feather point of ≈12 px.
let from = screen(&h, [320.0, 150.0]);
let z = rect(&h, "viewer.comp").width() / 640.0;
drag(&mut h, from, from - vec2(0.0, 12.0 * z));
let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap();
let r = pts["points"][0]["radius"].as_f64().unwrap();
assert!((r - 12.0).abs() < 1.5, "{pts}");
assert_eq!(pts["points"][0]["segment"], json!(0));
// The handle is registered; dragging it inwards makes it an inner feather point.
h.run_steps(2);
let handle = rect(&h, &format!("viewer.mask.{mask}.feather.0")).center();
let to = screen(&h, [320.0, 160.0]);
drag(&mut h, handle, to);
let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap();
let r = pts["points"][0]["radius"].as_f64().unwrap();
assert!((r + 10.0).abs() < 1.5, "{pts}");
assert_eq!(pts["points"].as_array().unwrap().len(), 1);
// One drag = one undo step each.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap();
assert!(pts["points"][0]["radius"].as_f64().unwrap() > 0.0);
}
#[test]
fn region_of_interest_resizes_by_its_handles() {
let mut h = harness();
h.state_mut().session.execute("view.setRegionOfInterest", json!({"rect": [100, 50, 200, 200]})).unwrap();
h.run_steps(3);
// Bottom-right corner (handle 2) to (400, 300): the top-left stays.
let c = rect(&h, "viewer.regionOfInterest.handle.2").center();
let to = screen(&h, [400.0, 300.0]);
drag(&mut h, c, to);
let r = h.state().session.state.region_of_interest.expect("roi");
assert!((r[0] - 100.0).abs() <= 1.0 && (r[1] - 50.0).abs() <= 1.0 && (r[2] - 300.0).abs() <= 2.0 && (r[3] - 250.0).abs() <= 2.0, "{r:?}");
// The left edge (handle 7) only moves x.
h.run_steps(2);
let c = rect(&h, "viewer.regionOfInterest.handle.7").center();
let to = pos2(screen(&h, [150.0, 0.0]).x, c.y + 40.0);
drag(&mut h, c, to);
let r = h.state().session.state.region_of_interest.expect("roi");
assert!((r[0] - 150.0).abs() <= 1.0 && (r[1] - 50.0).abs() <= 1.0 && (r[2] - 250.0).abs() <= 2.0 && (r[3] - 250.0).abs() <= 2.0, "{r:?}");
}
#[test]
fn pan_behind_snaps_the_anchor_point() {
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);
// 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, 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:?}");
}
/// 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) {
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 });
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 });
h.run_steps(2);
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]
fn reference_axes_toggle_from_the_grid_menu() {
let mut h = harness();
// A 3D layer makes the axes relevant.
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [box_id.0], "switch": "threeD", "value": true})).unwrap();
h.run_steps(3);
assert!(h.state().auto.find("viewer.referenceAxes").is_some(), "on by default (Settings ▸ 3D)");
// Issue #64: one compass, not two.
assert_eq!(h.state().auto.query("viewer.referenceAxes").len(), 1);
let g = rect(&h, "viewer.grid").center();
click(&mut h, g);
h.run_steps(2);
let item = rect(&h, "viewer.gridItem.6").center();
click(&mut h, item);
h.run_steps(3);
assert!(!h.state().session.prefs.three_d.show_reference_axes);
assert!(h.state().auto.find("viewer.referenceAxes").is_none());
}
/// A Box layer pinned with two Position pins and a Bend pin in the middle (Bend tool active).
fn puppet_harness() -> (Harness<'static, EffectcraftApp>, u64, u64) {
let mut h = harness();
let s = &mut h.state_mut().session;
// No full-frame Plate behind the Box: the comp around the Box is empty, like the canvas
// around a character.
s.execute("edit.clear", json!({"layers": ["Plate"]})).unwrap();
s.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
let id = s.state.selected_layers[0].0;
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 40]})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [70, 40]})).unwrap();
let bend = s.execute("puppet.addPin", json!({"layer": id, "kind": "bend", "position": [40, 40]})).unwrap()["pin"].as_u64().unwrap();
h.state_mut().ui.tool = Tool::PuppetBend;
h.run_steps(3);
(h, id, bend)
}
fn pin_value(h: &Harness<'_, EffectcraftApp>, layer: u64, pin: u64, prop: &str) -> f64 {
let l = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().clone();
l.props.find_group(pin).unwrap().get(prop).unwrap().value.as_f64()
}
#[test]
fn puppet_bend_pin_rotates_by_its_ring_and_scales_by_its_square() {
let (mut h, id, bend) = puppet_harness();
let center = rect(&h, &format!("viewer.puppetPin.{bend}")).center();
let ring = rect(&h, &format!("viewer.puppetPin.{bend}.rotate")).center();
let square = rect(&h, &format!("viewer.puppetPin.{bend}.scale")).center();
// At 0° the square is on the right of the ring, the rotate target opposite it.
assert!(square.x > center.x + 7.0 && (square.y - center.y).abs() < 0.5, "{square:?} {center:?}");
// Drag the ring a quarter turn clockwise (left → top): +90°, one undo step.
let undo_before = h.state().session.history.undo.len();
drag(&mut h, ring, center + vec2(0.0, -(center.x - ring.x)));
let r = pin_value(&h, id, bend, "rotation");
assert!((r - 90.0).abs() < 1.0, "rotation {r}");
assert_eq!(h.state().session.history.undo.len(), undo_before + 1);
// The square follows the rotation (now below the pin; the bend carries the pin itself a
// little); dragging it twice as far from the pin scales 200 %.
let center = rect(&h, &format!("viewer.puppetPin.{bend}")).center();
let square = rect(&h, &format!("viewer.puppetPin.{bend}.scale")).center();
assert!(square.y > center.y + 7.0, "{square:?}");
drag(&mut h, square, center + (square - center) * 2.0);
let sc = pin_value(&h, id, bend, "scale");
assert!((sc - 200.0).abs() < 2.0, "scale {sc}");
}
#[test]
fn puppet_pin_click_selects_and_delete_removes_only_the_pins() {
let (mut h, id, bend) = puppet_harness();
let pin = |h: &Harness<'_, EffectcraftApp>| h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect::<Vec<_>>();
// Clicking a pin selects it (instead of adding a pin on top of it).
let at = rect(&h, &format!("viewer.puppetPin.{bend}")).center();
click(&mut h, at);
assert_eq!(pin(&h), vec![bend]);
let info = h.state_mut().session.execute("puppet.info", json!({"layer": id})).unwrap();
assert_eq!(info["meshes"][0]["pins"].as_array().unwrap().len(), 3, "no pin added");
// Delete removes the pin; the layer stays.
h.input_mut().events.push(Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.run_steps(2);
let info = h.state_mut().session.execute("puppet.info", json!({"layer": id})).unwrap();
assert_eq!(info["meshes"][0]["pins"].as_array().unwrap().len(), 2);
assert!(h.state().session.active_comp().unwrap().layer(LayerId(id)).is_some());
}
/// A pin's Position property and its keys' times (seconds).
fn pin_keys(h: &Harness<'_, EffectcraftApp>, layer: u64, pin: u64) -> (u64, Vec<f64>) {
let l = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().clone();
let pos = l.props.find_group(pin).unwrap().get("position").unwrap().clone();
(pos.uid, pos.keys.iter().map(|k| k.time.seconds()).collect())
}
/// The keys the Timeline draws for a property, left to right.
fn timeline_keys(h: &Harness<'_, EffectcraftApp>, prop: u64) -> Vec<Pos2> {
let mut ks: Vec<Pos2> =
h.state().auto.query(&format!("timeline.key.{prop}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect();
ks.sort_by(|a, b| a.x.total_cmp(&b.x));
ks
}
/// The pin the last click placed (placing a pin selects it).
fn placed_pin(h: &Harness<'_, EffectcraftApp>) -> u64 {
h.state().session.state.selected_props.last().map(|(_, u)| *u).unwrap()
}
/// #273: U shows each pin's keyframed Position under its pin (Puppet ▸ Mesh 1 ▸ Deform ▸ Puppet
/// Pin 1 ▸ Position) rather than as one more "Position"; a pin moved or placed in the viewer
/// afterwards shows its keys there too (they were made but not shown); and pin keys select, move
/// and delete like any others.
#[test]
fn puppet_pin_keys_show_under_their_pins_in_the_timeline() {
let (mut h, id, bend) = puppet_harness();
h.state_mut().ui.tool = Tool::Puppet;
let pins: Vec<u64> = h.state().auto.previous.iter().filter_map(|e| e.id.strip_prefix("viewer.puppetPin.")?.parse().ok()).filter(|p| *p != bend).collect();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap();
h.run_steps(3);
assert_eq!(pins.len(), 2);
for pin in &pins {
let row = h.state().auto.find(&format!("timeline.group.{pin}.name")).map(|e| e.label.clone()).unwrap_or_default();
assert!(row.starts_with("Puppet Pin"), "the pin's row: {row:?}");
assert_eq!(timeline_keys(&h, pin_keys(&h, id, *pin).0).len(), 1, "its key at 0 s");
}
// Moved at 1 s: keyed there, in view.
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0));
h.run_steps(2);
let pa = rect(&h, &format!("viewer.puppetPin.{}", pins[0])).center();
drag(&mut h, pa, pa + vec2(20.0, 10.0));
let (pos, times) = pin_keys(&h, id, pins[0]);
assert_eq!(times.len(), 2);
assert_eq!(timeline_keys(&h, pos).len(), 2, "both keys drawn");
// A pin placed after U shows with its key too.
let at = screen(&h, [320.0, 210.0]);
click(&mut h, at);
let new = placed_pin(&h);
assert!(!pins.contains(&new) && new != bend);
assert!(h.state().auto.find(&format!("timeline.group.{new}.name")).is_some(), "the new pin's row");
assert_eq!(timeline_keys(&h, pin_keys(&h, id, new).0).len(), 1, "and its key");
// Click the 1 s key, drag it half a second later, delete it.
let ks = timeline_keys(&h, pos);
click(&mut h, ks[1]);
assert_eq!(h.state().session.state.selected_keys.iter().map(|k| k.prop).collect::<Vec<_>>(), [pos]);
let half = (ks[1].x - ks[0].x) / 2.0;
drag(&mut h, ks[1], ks[1] + vec2(half, 0.0));
let t = pin_keys(&h, id, pins[0]).1;
assert!((t[1] - 1.5).abs() < 0.05, "moved: {t:?}");
h.input_mut().events.push(Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.run_steps(2);
assert_eq!(pin_keys(&h, id, pins[0]).1, [0.0], "deleted; the pin stays");
}
/// #273: on a layer twirled open in the Timeline, placing a pin twirls open Effects ▸ Puppet ▸
/// Mesh 1 ▸ Deform ▸ the pin, so its Position key shows; a collapsed layer stays collapsed, as
/// in After Effects.
#[test]
fn placing_a_pin_opens_its_groups_on_a_twirled_open_layer() {
let mut h = harness();
let s = &mut h.state_mut().session;
s.execute("edit.clear", json!({"layers": ["Plate"]})).unwrap();
s.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
let id = s.state.selected_layers[0].0;
h.state_mut().ui.tool = Tool::Puppet;
h.run_steps(2);
let at = screen(&h, [300.0, 160.0]);
click(&mut h, at);
assert!(h.state().ui.timeline.open_groups.is_empty() && h.state().ui.timeline.open_layers.is_empty(), "collapsed stays collapsed");
let twirl = rect(&h, &format!("timeline.layer.{id}.twirl")).center();
click(&mut h, twirl);
assert!(h.state().ui.timeline.open_layers.contains(&id));
let at = screen(&h, [340.0, 200.0]);
click(&mut h, at);
let pin = placed_pin(&h);
assert_eq!(timeline_keys(&h, pin_keys(&h, id, pin).0).len(), 1, "the new pin's Position key shows");
}
/// #284: the Puppet tools stay on the layer being rigged: a pin goes on its mesh where the
/// deformation has stretched it past the layer's own bounds (over another layer), and a click on
/// another layer neither selects it nor rigs it.
#[test]
fn puppet_pins_go_on_the_deformed_mesh_and_the_tool_stays_on_the_rigged_layer() {
let mut h = harness();
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
let (boxl, plate) = (s.state.selected_layers[0], s.active_comp().unwrap().layers[1].id);
s.execute("puppet.addPin", json!({"layer": boxl.0, "position": [10, 40]})).unwrap();
let right = s.execute("puppet.addPin", json!({"layer": boxl.0, "position": [70, 40]})).unwrap()["pin"].as_u64().unwrap();
// Stretch the Box to the right: its right edge (layer x 80, comp x 360) moves well past 400.
s.execute("puppet.movePin", json!({"layer": boxl.0, "pin": right, "position": [140, 40]})).unwrap();
h.state_mut().ui.tool = Tool::Puppet;
h.run_steps(3);
let pins = |h: &mut Harness<'_, EffectcraftApp>| {
let info = h.state_mut().session.execute("puppet.info", json!({"layer": boxl.0})).unwrap();
info["meshes"].as_array().unwrap().iter().map(|m| m["pins"].as_array().unwrap().len()).sum::<usize>()
};
let rigged = |h: &Harness<'_, EffectcraftApp>, l: LayerId| {
h.state().session.active_comp().unwrap().layer(l).unwrap().effects().is_some_and(|f| f.groups().next().is_some())
};
// Comp (380, 180) is layer (100, 40): past the Box's own edge, over the Plate, on the
// stretched mesh.
let at = screen(&h, [380.0, 180.0]);
click(&mut h, at);
assert_eq!(pins(&mut h), 3, "the pin went on the deformed mesh");
assert_eq!(h.state().session.state.selected_layers, vec![boxl]);
assert!(!rigged(&h, plate), "the Plate got no Puppet");
// Far from the Box, on the Plate only: nothing.
let at = screen(&h, [40.0, 40.0]);
click(&mut h, at);
assert_eq!(pins(&mut h), 3);
assert_eq!(h.state().session.state.selected_layers, vec![boxl], "the tool stays on the Box");
assert!(!rigged(&h, plate));
}
/// Filled circles painted this frame (flattening nested shape lists).
fn circles(h: &Harness<'_, EffectcraftApp>) -> Vec<egui::epaint::CircleShape> {
fn walk(s: &egui::Shape, out: &mut Vec<egui::epaint::CircleShape>) {
match s {
egui::Shape::Circle(c) => out.push(*c),
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut out = vec![];
for c in &h.output().shapes {
walk(&c.shape, &mut out);
}
out
}
/// Issue #96: a selected puppet pin is filled with its colour and ringed; an unselected one is
/// hollow (dark centre, coloured outline), like selected and unselected mask vertices. They used to
/// differ by 1 px and a slight brightness change.
#[test]
fn selected_puppet_pins_are_filled_and_unselected_hollow() {
let (mut h, _id, bend) = puppet_harness();
let pins: Vec<u64> = h.state().auto.previous.iter().filter_map(|e| e.id.strip_prefix("viewer.puppetPin.").and_then(|r| r.parse().ok())).collect();
let position: Vec<u64> = pins.into_iter().filter(|p| *p != bend).collect();
let (a, b) = (position[0], position[1]);
let pa = rect(&h, &format!("viewer.puppetPin.{a}")).center();
let pb = rect(&h, &format!("viewer.puppetPin.{b}")).center();
click(&mut h, pa);
assert_eq!(h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect::<Vec<_>>(), vec![a]);
// Pointer away from the pins (hover enlarges a pin), then look at the painted pins.
h.input_mut().events.push(Event::PointerMoved(pos2(5.0, 5.0)));
h.run_steps(2);
let col = effectcraft_ui_egui::panels::puppet_tool::pin_color(effectcraft_engine::effects::puppet::PinKind::Position);
let at = |p: Pos2| circles(&h).into_iter().filter(move |c| c.center.distance(p) < 0.5).collect::<Vec<_>>();
let dark = |c: egui::Color32| c.r().max(c.g()).max(c.b()) < 0x60;
let sel = at(pa);
assert!(sel.iter().any(|c| c.fill == col), "the selected pin is filled with its colour: {sel:?}");
assert!(sel.iter().any(|c| c.fill == egui::Color32::TRANSPARENT && c.stroke.color == egui::Color32::WHITE), "and ringed in white: {sel:?}");
let unsel = at(pb);
assert!(!unsel.is_empty(), "the unselected pin is drawn");
assert!(unsel.iter().all(|c| c.fill == egui::Color32::TRANSPARENT || dark(c.fill)), "the unselected pin is hollow: {unsel:?}");
assert!(unsel.iter().any(|c| c.stroke.color == col), "with a coloured outline: {unsel:?}");
}
#[test]
fn puppet_marquee_selects_pins_and_alt_drag_works_over_the_art() {
let (mut h, _id, bend) = puppet_harness();
let p1 = rect(&h, &format!("viewer.puppetPin.{bend}")).center();
let pins: Vec<u64> = h.state().auto.previous.iter().filter_map(|e| e.id.strip_prefix("viewer.puppetPin.").and_then(|r| r.parse().ok())).collect();
assert_eq!(pins.len(), 3, "{pins:?}");
// A box around every pin, started on empty canvas (outside all art).
let comp = rect(&h, "viewer.comp");
drag(&mut h, comp.min + vec2(4.0, 4.0), comp.max - vec2(4.0, 4.0));
let mut sel: Vec<u64> = h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect();
sel.sort();
let mut want = pins.clone();
want.sort();
assert_eq!(sel, want);
// Alt-drag a small box over the art (around the Bend pin only).
h.input_mut().events.push(Event::PointerMoved(p1 - vec2(6.0, 6.0)));
h.input_mut().events.push(Event::PointerButton {
pos: p1 - vec2(6.0, 6.0),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::ALT,
});
h.step();
for i in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(p1 - vec2(6.0, 6.0) + vec2(12.0, 12.0) * (i as f32 / 6.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton {
pos: p1 + vec2(6.0, 6.0),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::ALT,
});
h.run_steps(2);
let sel: Vec<u64> = h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect();
assert_eq!(sel, vec![bend]);
}
/// Drag with `button` from `from` to `to` in steps, then release.
fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: Pos2, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button, pressed: true, modifiers: Default::default() });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button, pressed: false, modifiers: Default::default() });
h.run_steps(3);
}
#[test]
fn middle_and_hand_drags_pan_the_viewer_and_the_pan_stays_after_release() {
let mut h = harness();
let c = rect(&h, "viewer.comp").center();
let before = h.state().ui.viewer.pan;
// Middle-button drag with the Selection tool: pans, and stays panned after the release.
drag_with(&mut h, egui::PointerButton::Middle, c, c + vec2(120.0, 60.0));
let after = h.state().ui.viewer.pan;
assert!((after[0] - before[0] - 120.0).abs() < 1.0 && (after[1] - before[1] - 60.0).abs() < 1.0, "{before:?} → {after:?}");
assert_eq!(h.state().ui.tool, Tool::Selection, "the tool is unchanged");
// The Hand tool's primary drag keeps its pan too.
h.state_mut().ui.tool = Tool::Hand;
h.run_steps(2);
let c = rect(&h, "viewer.comp").center();
drag_with(&mut h, egui::PointerButton::Primary, c, c - vec2(50.0, 30.0));
let end = h.state().ui.viewer.pan;
assert!((end[0] - after[0] + 50.0).abs() < 1.0 && (end[1] - after[1] + 30.0).abs() < 1.0, "{after:?} → {end:?}");
}
/// A Spacebar press or release (`repeat`: the keyboard's auto-repeat while it is held).
fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool, repeat: bool) {
h.input_mut().events.push(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat, modifiers: Default::default() });
h.step();
}
/// #227: a Spacebar tap starts and stops the preview when it is released; held, Spacebar is the
/// Hand tool, so a drag pans the viewer and neither starts nor stops the preview. Auto-repeat
/// while it is held toggles nothing more, and a space typed in a text field doesn't preview.
#[test]
fn spacebar_taps_preview_and_held_spacebar_pans_the_viewer() {
let mut h = harness();
let playing = |h: &Harness<'_, EffectcraftApp>| h.state().playback.playing;
let tap = |h: &mut Harness<'_, EffectcraftApp>| {
space(h, true, false);
space(h, false, false);
};
space(&mut h, true, false);
assert!(!playing(&h), "the press alone doesn't play");
space(&mut h, false, false);
assert!(playing(&h), "the release does");
tap(&mut h);
assert!(!playing(&h), "another tap stops");
// Held with auto-repeat: one toggle, on the release.
space(&mut h, true, false);
for _ in 0..5 {
space(&mut h, true, true);
}
assert!(!playing(&h));
space(&mut h, false, false);
assert!(playing(&h));
tap(&mut h);
// Held and dragged: pans the viewer (the Box under the pointer stays), and plays nothing.
let box_pos = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().value.clone();
let (pos, before) = (box_pos(&h), h.state().ui.viewer.pan);
let c = rect(&h, "viewer.comp").center();
space(&mut h, true, false);
drag_with(&mut h, egui::PointerButton::Primary, c, c + vec2(80.0, 40.0));
space(&mut h, false, false);
let after = h.state().ui.viewer.pan;
assert!((after[0] - before[0] - 80.0).abs() < 1.0 && (after[1] - before[1] - 40.0).abs() < 1.0, "{before:?} → {after:?}");
assert_eq!(box_pos(&h), pos);
assert!(!playing(&h), "a Spacebar drag doesn't start the preview");
// Nor stop one.
tap(&mut h);
let c = rect(&h, "viewer.comp").center();
space(&mut h, true, false);
drag_with(&mut h, egui::PointerButton::Primary, c, c - vec2(30.0, 0.0));
space(&mut h, false, false);
assert!(playing(&h), "a Spacebar drag doesn't stop the preview");
tap(&mut h);
assert!(!playing(&h));
// In a text field Spacebar types.
let search = rect(&h, "timeline.search").center();
click(&mut h, search);
tap(&mut h);
assert!(!playing(&h), "a space typed in the Timeline search doesn't preview");
}
#[test]
fn timeline_rows_drag_to_reorder_layers() {
let mut h = harness();
let names = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
assert_eq!(names(&h), ["Box", "Plate"]);
let row = |h: &Harness<'_, EffectcraftApp>, name: &str| {
let l = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0;
rect(h, &format!("timeline.layer.{l}.row"))
};
// Drag "Box" (top) below "Plate": it becomes the bottom layer, in one undo step.
let (from, plate) = (row(&h, "Box").center(), row(&h, "Plate"));
let steps = h.state().session.history.undo.len();
drag(&mut h, from, plate.center() + vec2(0.0, plate.height() * 0.4));
assert_eq!(names(&h), ["Plate", "Box"]);
assert_eq!(h.state().session.history.undo.len(), steps + 1);
// Dropping a layer where it already is changes nothing.
let b = row(&h, "Box").center();
drag(&mut h, b, b + vec2(0.0, 3.0));
assert_eq!(names(&h), ["Plate", "Box"]);
assert_eq!(h.state().session.history.undo.len(), steps + 1);
// And back to the top.
let (from, top) = (row(&h, "Box").center(), row(&h, "Plate"));
drag(&mut h, from, top.center() - vec2(0.0, top.height() * 0.4));
assert_eq!(names(&h), ["Box", "Plate"]);
}
fn layer_id(h: &Harness<'_, EffectcraftApp>, name: &str) -> u64 {
h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0
}
/// Uid of a layer's property by match path (`transform/position`).
fn prop_uid(h: &Harness<'_, EffectcraftApp>, layer: &str, path: &str) -> u64 {
let id = layer_id(h, layer);
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop(path).unwrap().uid
}
/// Is the property's row on screen in the Timeline?
fn shown(h: &Harness<'_, EffectcraftApp>, uid: u64) -> bool {
h.state().auto.find(&format!("timeline.prop.{uid}.stopwatch")).is_some()
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: egui::Key, modifiers: egui::Modifiers) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
h.run_steps(2);
}
#[test]
fn timeline_twirl_arrows_open_layers_and_groups() {
let mut h = harness();
let id = layer_id(&h, "Box");
let transform = {
let comp = h.state().session.active_comp().unwrap().clone();
comp.layer(LayerId(id)).unwrap().props.sub("transform").unwrap().uid
};
let at = rect(&h, &format!("timeline.layer.{id}.twirl")).center();
click(&mut h, at);
assert!(h.state().ui.timeline.open_layers.contains(&id), "the layer twirls open");
let at = rect(&h, &format!("timeline.group.{transform}.twirl")).center();
click(&mut h, at);
for p in ["anchor", "position", "scale", "rotation", "opacity"] {
assert!(shown(&h, prop_uid(&h, "Box", &format!("transform/{p}"))), "{p} row");
}
// And closed again.
let at = rect(&h, &format!("timeline.layer.{id}.twirl")).center();
click(&mut h, at);
h.run_steps(2);
assert!(!h.state().ui.timeline.open_layers.contains(&id));
assert!(!shown(&h, prop_uid(&h, "Box", "transform/position")));
}
#[test]
fn timeline_rename_commits_on_enter_and_on_click_away() {
// The rename field (double-click a name, or Enter on a selected layer) used to grab the
// keyboard every frame: clicking away or Enter could never leave it. (The harness can't
// double-click — its frames are longer than a double-click — so this opens it with Enter.)
let mut h = harness();
h.state_mut().session.execute("layer.newNull", json!({"name": "Ctrl"})).unwrap();
let id = layer_id(&h, "Ctrl");
let name = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().name.clone();
let rename = |h: &mut Harness<'_, EffectcraftApp>| {
h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap();
h.run_steps(2);
key(h, egui::Key::Enter, Default::default());
h.run_steps(1);
assert!(h.ctx.memory(|m| m.focused().is_some()), "the name field has focus");
};
// The whole name is selected: typing replaces it; Enter commits and frees the keyboard.
rename(&mut h);
h.input_mut().events.push(Event::Text("Rig".into()));
h.step();
key(&mut h, egui::Key::Enter, Default::default());
assert_eq!(name(&h), "Rig");
assert!(h.ctx.memory(|m| m.focused().is_none()), "the field let go of the keyboard");
key(&mut h, egui::Key::P, Default::default());
assert_eq!(h.state().ui.timeline.reveal, vec!["position"], "P works after renaming");
// Clicking away commits too.
rename(&mut h);
h.input_mut().events.push(Event::Text("Hand".into()));
h.step();
let away = rect(&h, "viewer.comp").center();
click(&mut h, away);
assert_eq!(name(&h), "Hand");
assert!(h.ctx.memory(|m| m.focused().is_none()));
// Escape cancels.
rename(&mut h);
h.input_mut().events.push(Event::Text("Nope".into()));
h.step();
key(&mut h, egui::Key::Escape, Default::default());
assert_eq!(name(&h), "Hand");
}
/// Press `k` twice within one frame (a quick double press: the harness' frames are long).
fn key_twice(h: &mut Harness<'_, EffectcraftApp>, k: egui::Key) {
for _ in 0..2 {
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
}
h.run_steps(2);
}
#[test]
fn property_shortcuts_reveal_and_key_transform_properties() {
let mut h = harness();
h.state_mut().session.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
h.run_steps(2);
let t = |h: &Harness<'_, EffectcraftApp>, p: &str| shown(h, prop_uid(h, "Box", &format!("transform/{p}")));
let only = |h: &Harness<'_, EffectcraftApp>, want: &[&str]| {
for p in ["anchor", "position", "scale", "rotation", "opacity"] {
assert_eq!(t(h, p), want.contains(&p), "{p} shown? want {want:?}");
}
};
let none = egui::Modifiers::default();
// P, then T: one property at a time; Shift+S adds Scale; P again hides.
key(&mut h, egui::Key::P, none);
only(&h, &["position"]);
key(&mut h, egui::Key::T, none);
only(&h, &["opacity"]);
key(&mut h, egui::Key::S, egui::Modifiers::SHIFT);
only(&h, &["scale", "opacity"]);
key(&mut h, egui::Key::A, none);
only(&h, &["anchor"]);
key(&mut h, egui::Key::R, none);
only(&h, &["rotation"]);
// Alt+Shift+P: a Position keyframe at the current time; again removes it.
let pos_keys = |h: &Harness<'_, EffectcraftApp>| {
let id = layer_id(h, "Box");
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().keys.len()
};
let alt_shift = egui::Modifiers { alt: true, shift: true, ..Default::default() };
key(&mut h, egui::Key::P, alt_shift);
assert_eq!(pos_keys(&h), 1);
key(&mut h, egui::Key::P, alt_shift);
assert_eq!(pos_keys(&h), 0);
// U: only keyframed properties (Opacity animated); U again later hides; UU modified ones.
h.state_mut().session.execute("prop.addKey", json!({"layer": "Box", "path": "transform/opacity", "time": 0, "value": 50})).unwrap();
h.state_mut().session.execute("prop.addKey", json!({"layer": "Box", "path": "transform/opacity", "time": 1, "value": 100})).unwrap();
h.run_steps(2);
key(&mut h, egui::Key::U, none);
only(&h, &["opacity"]);
h.run_steps(40);
key(&mut h, egui::Key::U, none);
only(&h, &[]);
h.state_mut().session.execute("prop.set", json!({"layer": "Box", "path": "transform/rotation", "value": 30})).unwrap();
h.run_steps(40);
key_twice(&mut h, egui::Key::U);
assert!(t(&h, "opacity") && t(&h, "rotation"), "UU: animated and changed properties");
assert!(!t(&h, "scale"), "an untouched property stays hidden");
// Ctrl+`: twirl the selected layer open, then closed.
h.run_steps(40);
key(&mut h, egui::Key::Backtick, egui::Modifiers::COMMAND);
let id = layer_id(&h, "Box");
assert!(h.state().ui.timeline.open_layers.contains(&id) && h.state().ui.timeline.reveal.is_empty());
key(&mut h, egui::Key::Backtick, egui::Modifiers::COMMAND);
assert!(!h.state().ui.timeline.open_layers.contains(&id));
}
#[test]
fn double_press_shortcuts_reveal_their_second_set() {
let mut h = harness();
h.state_mut().session.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
h.state_mut().session.execute("prop.setExpression", json!({"layer": "Box", "path": "transform/scale", "expression": "value"})).unwrap();
h.run_steps(2);
let reveal = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.reveal.clone();
// EE: properties with expressions (Scale), replacing E's effects.
key_twice(&mut h, egui::Key::E);
assert_eq!(reveal(&h), vec!["expressions"]);
assert!(shown(&h, prop_uid(&h, "Box", "transform/scale")));
h.run_steps(40);
// TT: Mask Opacity; MM: every mask property; a single M: Mask Path.
key_twice(&mut h, egui::Key::T);
assert_eq!(reveal(&h), vec!["maskOpacity"]);
h.run_steps(40);
key(&mut h, egui::Key::M, Default::default());
assert_eq!(reveal(&h), vec!["maskPath"]);
h.run_steps(40);
key_twice(&mut h, egui::Key::M);
assert_eq!(reveal(&h), vec!["masks"]);
h.run_steps(40);
// RR Time Remap, AA Material Options, PP paint/puppet, SS selected properties, FF missing effects.
for (k, want) in
[(egui::Key::R, "timeRemap"), (egui::Key::A, "material"), (egui::Key::P, "paint"), (egui::Key::S, "selected"), (egui::Key::F, "missingEffects")]
{
key_twice(&mut h, k);
assert_eq!(reveal(&h), vec![want], "{k:?}{k:?}");
h.run_steps(40);
}
}
/// Drag through `path` with `modifiers` held (press at the first point, release at the last).
fn drag_path(h: &mut Harness<'_, EffectcraftApp>, path: &[Pos2], modifiers: egui::Modifiers) {
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerMoved(path[0]));
h.input_mut().events.push(Event::PointerButton { pos: path[0], button: egui::PointerButton::Primary, pressed: true, modifiers });
h.step();
for w in path.windows(2) {
for i in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(w[0] + (w[1] - w[0]) * (i as f32 / 6.0)));
h.step();
}
}
let end = *path.last().unwrap();
h.input_mut().events.push(Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.run_steps(2);
// The keys come up after the button, as a hand does.
h.input_mut().events.push(Event::ModifiersChanged(Default::default()));
h.step();
}
/// Issue #63: a bounding-box handle scales about the anchor point so the grabbed corner follows
/// the pointer, relative to the scale the drag began with: dragging through the anchor and back
/// out recovers (it used to stick at 0), edges scale one axis, Shift keeps the proportions.
#[test]
fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
let set = |h: &mut Harness<'_, EffectcraftApp>, path: &str, v: serde_json::Value| {
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": path, "value": v})).unwrap();
};
let scale = |h: &Harness<'_, EffectcraftApp>| {
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
l.props.prop("transform/scale").unwrap().value.as_vec3()
};
let close = |a: [f64; 3], b: [f64; 2]| (a[0] - b[0]).abs() < 1.0 && (a[1] - b[1]).abs() < 1.0;
h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
// The 80×80 box is centred at (320, 180) with its anchor in the middle.
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.run_steps(2);
let undo0 = h.state().session.history.undo.len();
// Bottom-right corner (360, 220) to (400, 260): twice as far from the anchor.
let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center();
let path = [corner, screen(&h, [400.0, 260.0])];
drag_path(&mut h, &path, Default::default());
assert!(close(scale(&h), [200.0, 200.0]), "{:?}", scale(&h));
assert_eq!(h.state().session.history.undo.len(), undo0 + 1, "one undo step per drag");
// Through the anchor (scale ≈ 0) and back out to 1.5×: the layer follows, nothing sticks.
set(&mut h, "transform/scale", json!([100, 100, 100]));
h.run_steps(2);
let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center();
let path = [corner, screen(&h, [320.0, 180.0]), screen(&h, [300.0, 170.0]), screen(&h, [380.0, 240.0])];
drag_path(&mut h, &path, Default::default());
assert!(close(scale(&h), [150.0, 150.0]), "{:?}", scale(&h));
// Past the anchor the layer flips, as in After Effects.
set(&mut h, "transform/scale", json!([100, 100, 100]));
h.run_steps(2);
let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center();
let path = [corner, screen(&h, [280.0, 140.0])];
drag_path(&mut h, &path, Default::default());
assert!(close(scale(&h), [-100.0, -100.0]), "{:?}", scale(&h));
// The right edge (handle 5) scales x only.
set(&mut h, "transform/scale", json!([100, 100, 100]));
h.run_steps(2);
let edge = rect(&h, &format!("viewer.handle.{}.5", box_id.0)).center();
let path = [edge, screen(&h, [380.0, 200.0])];
drag_path(&mut h, &path, Default::default());
assert!(close(scale(&h), [150.0, 100.0]), "{:?}", scale(&h));
// Shift on a corner keeps the proportions: the pointer's place along the diagonal.
set(&mut h, "transform/scale", json!([100, 100, 100]));
h.run_steps(2);
let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center();
let path = [corner, screen(&h, [400.0, 230.0])];
drag_path(&mut h, &path, egui::Modifiers::SHIFT);
assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h));
}
/// #491: with several layers selected, a handle drag scales all of them by the same ratio, each
/// about its own anchor point, in one undo step.
#[test]
fn handle_drags_scale_every_selected_layer() {
let mut h = harness();
let layers = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect::<Vec<_>>();
let (box_id, plate) = (layers[0], layers[1]);
let scale = |h: &Harness<'_, EffectcraftApp>, id: LayerId| {
let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone();
l.props.prop("transform/scale").unwrap().value.as_vec3()
};
h.state_mut().session.execute("prop.set", json!({"layer": plate.0, "path": "transform/scale", "value": [50, 50, 100]})).unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0, plate.0]})).unwrap();
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.run_steps(2);
let undo0 = h.state().session.history.undo.len();
// The box's bottom-right corner (360, 220) to (400, 260): twice the box's size.
let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center();
let to = screen(&h, [400.0, 260.0]);
drag_path(&mut h, &[corner, to], Default::default());
let (b, p) = (scale(&h, box_id), scale(&h, plate));
assert!((b[0] - 200.0).abs() < 1.0 && (b[1] - 200.0).abs() < 1.0, "{b:?}");
assert!((p[0] - 100.0).abs() < 1.0 && (p[1] - 100.0).abs() < 1.0, "the other layer doubles too: {p:?}");
assert_eq!(h.state().session.history.undo.len(), undo0 + 1, "one undo step");
}
/// #252: with snapping on, a dragged handle snaps to the comp's corners and edges (and other
/// layers'), so a layer scales exactly to the comp; Shift keeps the proportions and still lands
/// on the comp's size.
#[test]
fn handle_drags_snap_to_the_comp_edges() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id.0;
let scale = |h: &Harness<'_, EffectcraftApp>, id: u64| {
let l = h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
l.props.prop("transform/scale").unwrap().value.as_vec3()
};
let close = |a: [f64; 3], b: [f64; 2]| (a[0] - b[0]).abs() < 0.01 && (a[1] - b[1]).abs() < 0.01;
let drag_handle = |h: &mut Harness<'_, EffectcraftApp>, id: u64, handle: usize, to: [f32; 2], mods: egui::Modifiers| {
h.state_mut().session.execute("prop.set", json!({"layer": id, "path": "transform/scale", "value": [100, 100, 100]})).unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap();
h.run_steps(2);
let from = rect(h, &format!("viewer.handle.{id}.{handle}")).center();
let to = screen(h, to);
drag_path(h, &[from, to], mods);
scale(h, id)
};
// The 80×80 box at the comp centre: its bottom right corner (360, 220) dropped 3 px inside
// the comp's (640, 360) lands on it, its right edge 3 px inside the comp's right edge too.
let s = drag_handle(&mut h, box_id, 2, [637.0, 357.0], Default::default());
assert!(close(s, [800.0, 450.0]), "{s:?}");
let s = drag_handle(&mut h, box_id, 5, [637.0, 200.0], Default::default());
assert!(close(s, [800.0, 100.0]), "{s:?}");
// Snapping off: where the pointer is.
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
let s = drag_handle(&mut h, box_id, 2, [637.0, 357.0], Default::default());
assert!(s[0] < 795.0, "{s:?}");
h.state_mut().session.execute("view.snapping", json!({"value": true})).unwrap();
// A comp-shaped layer at 110 % Shift-scaled down by its corner to 3 px outside the comp's:
// the comp's size exactly.
let wide = h.state_mut().session.execute("layer.newSolid", json!({"name": "Wide", "color": "#20e040", "width": 640, "height": 360})).unwrap()["layer"]
.as_u64()
.unwrap();
h.state_mut().session.execute("prop.set", json!({"layer": wide, "path": "transform/scale", "value": [110, 110, 100]})).unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": [wide]})).unwrap();
h.run_steps(2);
let path = [rect(&h, &format!("viewer.handle.{wide}.2")).center(), screen(&h, [643.0, 362.0])];
drag_path(&mut h, &path, egui::Modifiers::SHIFT);
let s = scale(&h, wide);
assert!(close(s, [100.0, 100.0]), "{s:?}");
}
/// Each bounding-box handle shows the resize cursor along the direction it scales, turning with
/// the layer (#393: every handle showed the north-west / south-east cursor).
#[test]
fn handle_cursors_follow_each_handle_and_the_layer_rotation() {
use egui::CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V};
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id.0;
let set = |h: &mut Harness<'_, EffectcraftApp>, path: &str, v: serde_json::Value| {
h.state_mut().session.execute("prop.set", json!({"layer": box_id, "path": path, "value": v})).unwrap();
h.run_steps(2);
};
let cursors = |h: &mut Harness<'_, EffectcraftApp>| {
(0..8)
.map(|i| {
let c = rect(h, &format!("viewer.handle.{box_id}.{i}")).center();
h.input_mut().events.push(Event::PointerMoved(c));
h.run_steps(2);
h.output().platform_output.cursor_icon
})
.collect::<Vec<_>>()
};
h.state_mut().session.execute("layer.select", json!({"layers": [box_id]})).unwrap();
// A wide box: its corners still take the diagonal cursors.
set(&mut h, "transform/scale", json!([300, 100, 100]));
// Corners clockwise from the top left, then the top, right, bottom and left edges.
assert_eq!(cursors(&mut h), [NwSe, NeSw, NwSe, NeSw, V, H, V, H]);
set(&mut h, "transform/rotation", json!(90));
assert_eq!(cursors(&mut h), [NeSw, NwSe, NeSw, NwSe, H, V, H, V], "turned a quarter");
set(&mut h, "transform/rotation", json!(45));
assert_eq!(cursors(&mut h), [V, H, V, H, NeSw, NwSe, NeSw, NwSe], "turned an eighth");
}
/// 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]
fn full_resolution_frames_wider_than_the_texture_limit_fit() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Wide", "width": 2400, "height": 400, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
// (egui's font atlas needs 1024.)
h.input_mut().max_texture_side = Some(1024);
h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Full;
let full = |h: &mut Harness<'_, EffectcraftApp>| h.state_mut().viewer_pixels().is_some_and(|px| px.size == [2400, 400]);
for _ in 0..400 {
h.step();
if full(&mut h) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().ui.viewer.res);
assert_eq!(h.state().viewer_texture_size(), Some([800, 134]), "2400×400 averaged by 3");
}
/// #509: ⌘/Ctrl-drag in the Layer panel resizes the Roto Brush (as in After Effects) without
/// painting, the next stroke uses the new size, and the tool options show the Diameter.
#[test]
fn roto_brush_ctrl_drag_resizes_the_brush() {
let mut h = harness();
let id = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0;
h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap();
h.state_mut().ui.layer_panel = Some(id);
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Layer);
h.state_mut().ui.tool = Tool::RotoBrush;
h.run_steps(3);
assert!(h.state().auto.find("header.roto.diameter").is_some(), "the Diameter field");
let canvas = rect(&h, "layerPanel.canvas");
let zoom = canvas.width() / 80.0;
let c = canvas.center();
h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::COMMAND));
drag(&mut h, c, c + vec2(20.0 * zoom, 0.0));
h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE));
h.run_steps(2);
let dia = h.state().session.state.roto.diameter;
assert!((dia - 40.0).abs() <= 1.0, "a 20 px drag makes a 40 px brush: {dia}");
let strokes = |h: &Harness<'_, EffectcraftApp>| {
let l = h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
let roto = effectcraft_engine::effects::roto::ID;
let g = l
.effects()
.and_then(|fx| fx.groups().find(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == roto)).cloned());
g.map(|g| effectcraft_engine::effects::roto::data(&effectcraft_engine::effects::flatten_params(&g, &mut |p| p.value.clone())).strokes.clone())
.unwrap_or_default()
};
assert!(strokes(&h).is_empty(), "resizing paints nothing");
drag(&mut h, c - vec2(10.0 * zoom, 0.0), c + vec2(10.0 * zoom, 0.0));
let s = strokes(&h);
assert_eq!(s.len(), 1);
assert!((s[0].radius - dia / 2.0).abs() < 1e-6, "{} vs {dia}", s[0].radius);
}