Merge pull request #824 from redonion23/fix/753-layer-viewer-zoom

Layer panel: zoom and pan; View zoom acts on the focused viewer; Roto Brush hint (#753)
This commit is contained in:
Brandon Thomas committed 2026-10-12 03:23:03 -04:00
commit 03a27f6eab
6 files changed
+391 -45

No files matched your search

+35 -6
View File
@@ -551,11 +551,22 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
app.stop();
return Ok(Value::Null);
}
// The focused Layer panel has its own magnification; otherwise the Composition viewer.
"view.fit" if app.ui.focused == PanelKind::Layer => {
app.ui.layer_zoom = None;
app.ui.layer_pan = [0.0, 0.0];
return Ok(Value::Null);
}
"view.fit" => {
app.ui.viewer.zoom = None;
app.ui.viewer.pan = [0.0, 0.0];
return Ok(Value::Null);
}
"view.actualSize" if app.ui.focused == PanelKind::Layer => {
app.ui.layer_zoom = Some(1.0 / ctx.pixels_per_point());
app.ui.layer_pan = [0.0, 0.0];
return Ok(Value::Null);
}
"view.actualSize" => {
app.ui.viewer.zoom = Some(1.0 / ctx.pixels_per_point());
app.ui.viewer.pan = [0.0, 0.0];
@@ -763,6 +774,16 @@ fn toggle(slot: &mut bool, p: &Value) -> Value {
json!(*slot)
}
/// The next View ▸ Zoom In / Zoom Out magnification (points per pixel) after `cur`.
fn zoom_step(cur: f32, zoom_in: bool) -> f32 {
const STEPS: [f32; 16] = [0.015, 0.03, 0.0625, 0.125, 0.25, 0.333, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0, 32.0];
if zoom_in {
STEPS.iter().copied().find(|s| *s > cur + 1e-3).unwrap_or(32.0)
} else {
STEPS.iter().rev().copied().find(|s| *s < cur - 1e-3).unwrap_or(0.015)
}
}
/// Perform a frontend command (from `Event::Frontend`).
pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Value) -> Result<Value, String> {
let now = ctx.input(|i| i.time);
@@ -887,14 +908,17 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
}
r
}
// Zoom In / Zoom Out act on the focused viewer: the Layer panel when it has focus, else
// the Composition viewer.
"view.zoomIn" | "view.zoomOut" if app.ui.focused == PanelKind::Layer => {
let cur = app.ui.layer_zoom.unwrap_or_else(|| crate::panels::layer_panel::last_fit(ctx));
let next = zoom_step(cur, id == "view.zoomIn");
app.ui.layer_zoom = Some(next);
json!({"zoom": next, "panel": "layer"})
}
"view.zoomIn" | "view.zoomOut" => {
let cur = v.zoom.unwrap_or_else(|| crate::panels::viewer::last_fit(ctx));
const STEPS: [f32; 16] = [0.015, 0.03, 0.0625, 0.125, 0.25, 0.333, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0, 32.0];
let next = if id == "view.zoomIn" {
STEPS.iter().copied().find(|s| *s > cur + 1e-3).unwrap_or(32.0)
} else {
STEPS.iter().rev().copied().find(|s| *s < cur - 1e-3).unwrap_or(0.015)
};
let next = zoom_step(cur, id == "view.zoomIn");
v.zoom = Some(next);
json!({"zoom": next})
}
@@ -1113,6 +1137,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
}
"layer.openLayer" => {
let id = p.get("layer").and_then(Value::as_u64).or_else(|| app.session.state.selected_layers.first().map(|l| l.0));
// Another layer opens at Fit.
if id != app.ui.layer_panel {
app.ui.layer_zoom = None;
app.ui.layer_pan = [0.0, 0.0];
}
app.ui.layer_panel = id;
app.ui.layer_view = None;
app.show_panel(PanelKind::Layer);
+94 -11
View File
@@ -44,6 +44,27 @@ fn tex_id() -> egui::Id {
egui::Id::new("layer-panel-tex")
}
fn fit_id() -> egui::Id {
egui::Id::new("layer-panel-fit")
}
/// The Layer panel's last Fit magnification (points per layer pixel), for View ▸ Zoom In / Zoom
/// Out from Fit.
pub fn last_fit(ctx: &egui::Context) -> f32 {
ctx.data(|d| d.get_temp::<f32>(fit_id())).unwrap_or(0.5)
}
/// Zoom to `nz` (points per layer pixel) keeping layer point `q` under screen point `at`.
fn zoom_at(app: &mut EffectcraftApp, area: Rect, size: [f64; 2], at: Pos2, q: [f64; 2], nz: f32) {
let nz = nz.clamp(0.01, 32.0);
let origin = at - vec2(q[0] as f32 * nz, q[1] as f32 * nz);
let pan = origin + vec2(size[0] as f32 * nz / 2.0, size[1] as f32 * nz / 2.0) - area.center();
if pan.x.is_finite() && pan.y.is_finite() {
app.ui.layer_zoom = Some(nz);
app.ui.layer_pan = [pan.x, pan.y];
}
}
/// The layer shown: the opened one, else the first selected layer.
pub fn current_layer(app: &EffectcraftApp) -> Option<Layer> {
let comp = app.session.active_comp()?;
@@ -83,15 +104,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.rect_filled(top, 0.0, t.panel_bg);
p.text(pos2(top.min.x + 10.0, top.center().y), Align2::LEFT_CENTER, tr_args("Layer: {}", &[&layer.name]), Tokens::semibold(12.0), t.text);
app.auto.add("layerPanel.title", top, &layer.name);
// Bottom bar: View menu.
// Canvas geometry: Fit, or the panel's own magnification and pan.
let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - 30.0), rect.max);
let canvas_bottom = if roto.is_some() { bar.min.y - 18.0 } else { bar.min.y };
let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, canvas_bottom));
// The layer's effect bounds, in effect space (where paint and Roto Brush strokes are).
let [lw, lh] = effectcraft_engine::render::effect_bounds(&app.session.project, &comp, &layer).0;
let fit = ((area.width() - 40.0) as f64 / lw).min((area.height() - 40.0) as f64 / lh).max(0.01) as f32;
ctx.data_mut(|d| d.insert_temp(fit_id(), fit));
let zoom = app.ui.layer_zoom.filter(|z| z.is_finite() && *z > 0.0).unwrap_or(fit) as f64;
let pan = match app.ui.layer_pan {
[x, y] if x.is_finite() && y.is_finite() && app.ui.layer_zoom.is_some() => vec2(x, y),
_ => vec2(0.0, 0.0),
};
let origin = area.center() + pan - vec2((lw * zoom / 2.0) as f32, (lh * zoom / 2.0) as f32);
// Bottom bar: magnification and View menus.
p.rect_filled(bar, 0.0, t.panel_bg);
p.text(pos2(bar.min.x + 10.0, bar.center().y), Align2::LEFT_CENTER, tr("View:"), Tokens::ui(12.0), t.text_dim);
let mr = Rect::from_min_size(pos2(bar.min.x + 8.0, bar.center().y - 10.0), vec2(80.0, 20.0));
if let Some(z) =
super::viewer_tools::magnification_menu(app, ui, mr, "layer-mag", "layerPanel.magnification", "layerMagnification", zoom as f32, app.ui.layer_zoom, fit)
{
app.ui.layer_zoom = z;
app.ui.layer_pan = [0.0, 0.0];
}
let vx = mr.max.x + 10.0;
p.text(pos2(vx, bar.center().y), Align2::LEFT_CENTER, tr("View:"), Tokens::ui(12.0), t.text_dim);
let mut names = vec!["Masks".to_string()];
if let Some(fx) = layer.effects() {
names.extend(fx.groups().map(|g| g.name.clone()));
}
let vr = Rect::from_min_size(pos2(bar.min.x + 46.0, bar.center().y - 10.0), vec2(150.0, 20.0));
let vr = Rect::from_min_size(pos2(vx + 36.0, bar.center().y - 10.0), vec2(150.0, 20.0));
let pop = egui::Id::new("layer-view-pop");
if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::new("layer-view")).clicked() {
widgets::open_popup(ui, pop);
@@ -103,21 +146,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let tc = super::timecode(&app.session, &comp, time);
p.text(pos2(bar.max.x - 10.0, bar.center().y), Align2::RIGHT_CENTER, tc, Tokens::mono(12.0), t.timecode);
// Roto Brush: view / Freeze buttons and the segmentation span bar.
let mut canvas_bottom = bar.min.y;
if roto.is_some() {
super::roto_tool::bar_buttons(app, ui, &p, bar, vr.max.x + 12.0, &layer);
let sb = Rect::from_min_max(pos2(rect.min.x, bar.min.y - 18.0), pos2(rect.max.x, bar.min.y));
let sb = Rect::from_min_max(pos2(rect.min.x, canvas_bottom), pos2(rect.max.x, bar.min.y));
super::roto_tool::span_bar(app, ui, &p, sb, &comp, &layer);
canvas_bottom = sb.min.y;
}
// Canvas.
let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, canvas_bottom));
p.rect_filled(area, 0.0, t.pasteboard);
// The layer's effect bounds, in effect space (where paint and Roto Brush strokes are).
let [lw, lh] = effectcraft_engine::render::effect_bounds(&app.session.project, &comp, &layer).0;
let zoom = ((area.width() - 40.0) as f64 / lw).min((area.height() - 40.0) as f64 / lh).max(0.01);
let origin = area.center() - vec2((lw * zoom / 2.0) as f32, (lh * zoom / 2.0) as f32);
let to_screen = |q: [f64; 2]| pos2(origin.x + (q[0] * zoom) as f32, origin.y + (q[1] * zoom) as f32);
let to_layer = |s: Pos2| [((s.x - origin.x) as f64) / zoom, ((s.y - origin.y) as f64) / zoom];
let frame = Rect::from_min_max(to_screen([0.0, 0.0]), to_screen([lw, lh]));
@@ -166,6 +202,53 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add("layerPanel.canvas", frame, &layer.name);
app.auto.add("layerPanel.area", area, "Layer panel");
// Navigation (as the Composition panel): the wheel / pinch zooms around the pointer with any
// tool; the Hand tool or Space-drag pans and the Zoom tool clicks zoom in (Alt: out).
let hovered = ui.rect_contains_pointer(area);
if let Some(hp) = ui.input(|i| i.pointer.hover_pos()).filter(|_| hovered) {
let scroll = ui.input(|i| i.smooth_scroll_delta.y + i.zoom_delta().ln() * 300.0);
if scroll.is_finite() && scroll.abs() > 0.1 {
zoom_at(app, area, [lw, lh], hp, to_layer(hp), zoom as f32 * (scroll / 300.0).exp());
}
}
// A middle-drag pans with any tool; painting and Roto Brush strokes don't run meanwhile.
if hovered && ui.input(|i| i.pointer.middle_down()) {
// Movement from before the press (in its frame) is not part of the drag.
let d = ui.input(|i| if i.pointer.button_pressed(egui::PointerButton::Middle) { egui::Vec2::ZERO } else { i.pointer.delta() });
if d != egui::Vec2::ZERO && d.x.is_finite() && d.y.is_finite() {
app.ui.layer_zoom.get_or_insert(zoom as f32);
app.ui.layer_pan = [pan.x + d.x, pan.y + d.y];
}
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
return;
}
let space = super::space_hand(&ctx);
if space || matches!(app.ui.tool, Tool::Hand | Tool::Zoom) {
let resp = ui.interact(area, egui::Id::new("layer-panel-nav"), Sense::click_and_drag());
let alt = ui.input(|i| i.modifiers.alt);
if resp.hovered() {
ctx.set_cursor_icon(match app.ui.tool {
Tool::Zoom if !space && alt => egui::CursorIcon::ZoomOut,
Tool::Zoom if !space => egui::CursorIcon::ZoomIn,
_ if resp.dragged() => egui::CursorIcon::Grabbing,
_ => egui::CursorIcon::Grab,
});
}
if resp.dragged_by(egui::PointerButton::Primary) && (space || app.ui.tool == Tool::Hand) {
let d = resp.drag_delta();
app.ui.layer_zoom.get_or_insert(zoom as f32);
app.ui.layer_pan = [pan.x + d.x, pan.y + d.y];
}
if resp.clicked()
&& !space
&& app.ui.tool == Tool::Zoom
&& let Some(pos) = resp.interact_pointer_pos()
{
zoom_at(app, area, [lw, lh], pos, to_layer(pos), zoom as f32 * if alt { 0.5 } else { 2.0 });
}
app.auto.add("layerPanel.nav", area, "Layer panel navigation");
return;
}
// Interaction: Roto Brush / Refine Edge.
if app.ui.tool.roto_kind(false).is_some() {
super::roto_tool::interact(app, ui, &painter, area, &layer, zoom, &to_screen, &to_layer);
+29 -4
View File
@@ -206,6 +206,8 @@ use super::viewer_overlays::{self as ov, VertexHit};
use super::viewer_tools as vt;
const HANDLE: f32 = 7.0;
/// Shown when a Roto Brush tool is used in the Composition panel (#753).
const ROTO_HINT: &str = "The Roto Brush works in the Layer panel. Double-click the layer to open it.";
thread_local! {
/// The 3D view camera of the viewer being drawn (None = the comp's active camera).
@@ -1423,6 +1425,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Some(Gesture::Marquee { start: press })
}
}
// The Roto Brush paints in the Layer panel only: say so instead of doing nothing.
rt if rt.roto_kind(false).is_some() => {
app.ui.status = crate::i18n::tr(ROTO_HINT).to_string();
None
}
_ => None,
};
if let Some(g) = g {
@@ -1780,6 +1787,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift, "toggle": mods.shift}));
}
}
// The Roto Brush paints in the Layer panel only: select as usual and say so.
rt if rt.roto_kind(false).is_some() => {
if pick(app, &ectx, cpt, mods.shift).is_none() {
let _ = app.session.execute("edit.deselectAll", json!({}));
}
app.ui.status = crate::i18n::tr(ROTO_HINT).to_string();
}
_ => {
if pick(app, &ectx, cpt, mods.shift).is_none() {
let _ = app.session.execute("edit.deselectAll", json!({}));
@@ -1811,10 +1825,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& let Some(pos) = resp.interact_pointer_pos()
{
let cpt = map.to_comp(pos);
if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
&& let effectcraft_engine::project::LayerSource::Comp { item } = &l.source
{
app.session.open_comp(*item);
let roto = app.ui.tool.roto_kind(false).is_some();
if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) {
match &l.source {
effectcraft_engine::project::LayerSource::Comp { item } => app.session.open_comp(*item),
// With a Roto Brush tool, double-clicking a layer opens it in the Layer panel,
// where the brush works (After Effects).
_ if roto => {
let id = l.id.0;
match crate::menus::invoke(app, &ctx, "layer.openLayer", json!({"layer": id})) {
Ok(_) => app.ui.status.clear(),
Err(e) => app.ui.status = e,
}
}
_ => {}
}
}
}
// 3D Camera Tracker points, targets and their menu (on top of the viewer's gestures while
+46 -24
View File
@@ -509,6 +509,50 @@ fn toggle_popup(ui: &egui::Ui, id: &str) {
ui.data_mut(|d| d.insert_temp(pid, !open));
}
/// A magnification ratio dropdown (the Composition and Layer panels' footers): `zoom` is the
/// shown magnification and `current` the stored one (None = fit), both in points per pixel.
/// Returns the chosen magnification (`Some(None)` = Fit).
#[allow(clippy::too_many_arguments)]
pub(crate) fn magnification_menu(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
r: Rect,
id: &str,
auto_id: &str,
auto_items: &str,
zoom: f32,
current: Option<f32>,
fit: f32,
) -> Option<Option<f32>> {
let t = app.tokens;
let ppp = ui.ctx().pixels_per_point();
let pop = format!("{id}-pop");
let response =
widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new(id)).on_hover_text(crate::i18n::tr("Magnification ratio popup"));
if response.clicked() {
toggle_popup(ui, &pop);
}
app.auto.add_response(auto_id, &response, "Magnification");
const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"];
let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0;
let items: Vec<(String, bool)> = MAGS
.iter()
.map(|m| {
let on = match *m {
"Fit" => current.is_none(),
s => s.trim_end_matches('%').parse::<f32>().is_ok_and(|v| current.is_some() && (v - cur).abs() < 0.06),
};
(m.to_string(), on)
})
.collect();
let i = popup(app, ui, &pop, r, &items, auto_items)?;
match *MAGS.get(i)? {
"Fit" => Some(None),
"Fit up to 100%" => Some(Some(fit.min(1.0 / ppp))),
s => s.trim_end_matches('%').parse::<f32>().ok().map(|v| Some(v / 100.0 / ppp)),
}
}
/// Magnification as After Effects shows it: "136.1 %", "100 %".
pub(crate) fn magnification_label(pct: f32) -> String {
if (pct - pct.round()).abs() < 0.05 { format!("{:.0} %", pct.round()) } else { format!("{pct:.1} %") }
@@ -576,31 +620,9 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
// Magnification.
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(80.0, 20.0));
let response = widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new("vw-mag"))
.on_hover_text(crate::i18n::tr("Magnification ratio popup"));
if response.clicked() {
toggle_popup(ui, "vw-mag-pop");
}
app.auto.add_response("viewer.magnification", &response, "Magnification");
const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"];
let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0;
let items: Vec<(String, bool)> = MAGS
.iter()
.map(|m| {
let on = match *m {
"Fit" => app.ui.viewer.zoom.is_none(),
s => s.trim_end_matches('%').parse::<f32>().is_ok_and(|v| app.ui.viewer.zoom.is_some() && (v - cur).abs() < 0.06),
};
(m.to_string(), on)
})
.collect();
if let Some(i) = popup(app, ui, "vw-mag-pop", r, &items, "magnification") {
if let Some(z) = magnification_menu(app, ui, r, "vw-mag", "viewer.magnification", "magnification", zoom, app.ui.viewer.zoom, fit) {
app.ui.viewer.pan = [0.0, 0.0];
app.ui.viewer.zoom = match MAGS[i] {
"Fit" => None,
"Fit up to 100%" => Some(fit.min(1.0 / ppp)),
s => s.trim_end_matches('%').parse::<f32>().ok().map(|v| v / 100.0 / ppp),
};
app.ui.viewer.zoom = z;
}
x = r.max.x + 4.0;
+10
View File
@@ -659,6 +659,14 @@ pub struct UiState {
/// Layer panel View: number of effects rendered (None = through the last Paint effect).
#[serde(default)]
pub layer_view: Option<usize>,
/// Layer panel magnification in points per layer pixel (None = fit), set by its
/// magnification menu, View ▸ Zoom In / Zoom Out while it is focused, the mouse wheel and the
/// Zoom tool.
#[serde(default)]
pub layer_zoom: Option<f32>,
/// Layer panel pan offset in points from the centred position (Hand tool, Space-drag).
#[serde(default)]
pub layer_pan: [f32; 2],
/// Wiggler / Smoother / Motion Sketch settings.
#[serde(default)]
pub anim_tools: AnimToolsState,
@@ -722,6 +730,8 @@ impl Default for UiState {
tracker_source: None,
layer_panel: None,
layer_view: None,
layer_zoom: None,
layer_pan: [0.0, 0.0],
anim_tools: AnimToolsState::default(),
mini_flowchart: None,
flowchart: Default::default(),
+177
View File
@@ -0,0 +1,177 @@
//! #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,
});
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);
}