Files
effectcraft/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs
T
skylartaylor 9ee853715d M0.37: migrate EffectCraft to egui 0.37
Reconcile main 991904888a and preserve the reviewed lifecycle, IME, rendering and lockfile changes.

Formatting, strict lint, layers, assets and WASM passed. Full CI retained 3,961 passes, 14 baseline-matching GPU failures and 31 ignored executions. The local review commit has explicit approval to retain these documented failures; native/browser acceptance and paired app performance remain open.
2026-10-25 02:01:16 +01:00

179 lines
7.6 KiB
Rust

//! #753: the Layer panel zooms and pans (magnification menu, View ▸ Zoom In / Zoom Out while it
//! is focused, the wheel, the Zoom and Hand tools), and the Roto Brush explains itself in the
//! Composition panel (egui_kittest, UI logic only).
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use effectcraft_ui_egui::state::Tool;
use egui::{Event, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
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": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
EffectcraftApp::new(s)
}
fn box_id(h: &Harness<'_, EffectcraftApp>) -> u64 {
h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0
}
/// The harness with Box open in the Layer panel (focused).
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);
let id = box_id(&h);
h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.openLayer", json!({"layer": id})).unwrap();
h.run_steps(3);
assert_eq!(h.state().ui.focused, PanelKind::Layer);
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 press(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, button: egui::PointerButton, pressed: bool) {
h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed, modifiers: Default::default() });
}
fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
press(h, p, egui::PointerButton::Primary, true);
h.step();
press(h, p, egui::PointerButton::Primary, false);
h.run_steps(2);
}
fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: Pos2, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(from));
press(h, from, button, true);
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
press(h, to, button, false);
h.run_steps(2);
}
fn command(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, json!({})).unwrap();
h.run_steps(3);
}
/// Shown magnification: the canvas width per layer pixel (Box is 80 px wide).
fn shown(h: &Harness<'_, EffectcraftApp>) -> f32 {
rect(h, "layerPanel.canvas").width() / 80.0
}
#[test]
fn zoom_commands_act_on_the_focused_layer_panel() {
let mut h = harness();
assert!(h.state().auto.find("layerPanel.magnification").is_some(), "the Layer panel has a magnification menu");
let fit = shown(&h);
let comp_zoom = h.state().ui.viewer.zoom;
command(&mut h, "view.zoomOut");
let z = h.state().ui.layer_zoom.expect("Zoom Out sets the Layer panel's magnification");
assert!(z < fit, "{z} < {fit}");
assert!((shown(&h) - z).abs() < 0.01, "the canvas is drawn at the new magnification");
assert_eq!(h.state().ui.viewer.zoom, comp_zoom, "the Composition viewer keeps its magnification");
command(&mut h, "view.zoomIn");
assert!(h.state().ui.layer_zoom.unwrap() > z);
command(&mut h, "view.fit");
assert_eq!(h.state().ui.layer_zoom, None);
assert!((shown(&h) - fit).abs() < 0.01);
// With the Composition panel focused, the same commands zoom it instead.
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(2);
command(&mut h, "view.zoomIn");
assert!(h.state().ui.viewer.zoom.is_some());
assert_eq!(h.state().ui.layer_zoom, None);
}
#[test]
fn wheel_zooms_around_the_pointer_and_hand_tool_pans() {
let mut h = harness();
let canvas = rect(&h, "layerPanel.canvas");
// A point a quarter of the way into the layer stays under the pointer.
let at = canvas.min + canvas.size() * 0.25;
h.input_mut().events.push(Event::PointerMoved(at));
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 120.0),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.run_steps(20);
let after = rect(&h, "layerPanel.canvas");
assert!(after.width() > canvas.width(), "the wheel zooms in: {} → {}", canvas.width(), after.width());
let still = after.min + after.size() * 0.25;
assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}");
// Hand tool drag pans; no paint or Roto stroke is made.
h.state_mut().ui.tool = Tool::Hand;
h.run_steps(2);
let before = rect(&h, "layerPanel.canvas").min;
let c = rect(&h, "layerPanel.area").center();
drag_with(&mut h, egui::PointerButton::Primary, c, c + vec2(60.0, 30.0));
let moved = rect(&h, "layerPanel.canvas").min - before;
assert!((moved.x - 60.0).abs() < 1.0 && (moved.y - 30.0).abs() < 1.0, "{moved:?}");
// Middle-drag pans with a Roto Brush tool, painting nothing.
h.state_mut().ui.tool = Tool::RotoBrush;
h.run_steps(2);
let before = rect(&h, "layerPanel.canvas").min;
drag_with(&mut h, egui::PointerButton::Middle, c, c - vec2(40.0, 20.0));
let moved = rect(&h, "layerPanel.canvas").min - before;
assert!((moved.x + 40.0).abs() < 1.0 && (moved.y + 20.0).abs() < 1.0, "{moved:?}");
let layer = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(box_id(&h))).unwrap().clone();
assert!(layer.effects().is_none_or(|fx| fx.groups().count() == 0), "panning adds no Roto Brush effect");
}
#[test]
fn zoom_tool_click_zooms_in_the_layer_panel() {
let mut h = harness();
let fit = shown(&h);
h.state_mut().ui.tool = Tool::Zoom;
h.run_steps(2);
let c = rect(&h, "layerPanel.canvas").center();
click(&mut h, c);
let z = h.state().ui.layer_zoom.expect("the Zoom tool sets the Layer panel's magnification");
assert!((z - fit * 2.0).abs() < 0.01, "{fit} → {z}");
assert_eq!(h.state().ui.viewer.zoom, None, "the Composition viewer keeps Fit");
}
#[test]
fn roto_brush_in_the_composition_panel_says_where_it_works() {
let mut h = harness();
h.state_mut().show_panel(PanelKind::Composition);
h.state_mut().ui.layer_panel = None;
h.state_mut().ui.tool = Tool::RotoBrush;
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
let c = comp.center();
click(&mut h, c);
assert!(h.state().ui.status.contains("Layer panel"), "a hint, not silence: {:?}", h.state().ui.status);
// Double-clicking the layer opens it in the Layer panel.
h.run_steps(30);
h.input_mut().events.push(Event::PointerMoved(c));
h.step();
// Both clicks inside egui's double-click time; the second release (its own frame, as with a
// real mouse) opens the layer after the press has focused the Composition panel.
press(&mut h, c, egui::PointerButton::Primary, true);
press(&mut h, c, egui::PointerButton::Primary, false);
press(&mut h, c, egui::PointerButton::Primary, true);
h.step();
press(&mut h, c, egui::PointerButton::Primary, false);
h.run_steps(3);
assert_eq!(h.state().ui.layer_panel, Some(box_id(&h)));
assert_eq!(h.state().ui.focused, PanelKind::Layer);
}