From afdb95819935998a8fda03859b767302424ed74b Mon Sep 17 00:00:00 2001 From: redonion23 <199118131+redonion23@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:41:40 +0200 Subject: [PATCH] Layer panel: zoom and pan; View zoom acts on the focused viewer; Roto Brush hint in the Composition panel (#753) - The Layer panel keeps its own magnification and pan (`ui.layer_zoom`, `ui.layer_pan`) with the Composition panel's magnification menu in its footer; the wheel / pinch zooms around the pointer, the Hand tool, Space-drag and a middle-drag pan, and the Zoom tool clicks zoom in (Alt: out). Navigation never paints or makes Roto Brush strokes. - View > Zoom In / Zoom Out / Fit / Actual Size act on the Layer panel while it is focused, else on the Composition viewer as before. - A Roto Brush click or drag in the Composition panel says the brush works in the Layer panel; double-clicking a layer there with a Roto Brush tool opens it in the Layer panel (as After Effects). - Opening another layer resets the Layer panel to Fit. Tests: crates/ui-egui/tests/ui_layer_panel_zoom.rs. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 41 ++++- crates/ui-egui/src/panels/layer_panel.rs | 105 ++++++++++-- crates/ui-egui/src/panels/viewer.rs | 33 +++- crates/ui-egui/src/panels/viewer_tools.rs | 70 +++++--- crates/ui-egui/src/state.rs | 10 ++ crates/ui-egui/tests/ui_layer_panel_zoom.rs | 177 ++++++++++++++++++++ 6 files changed, 391 insertions(+), 45 deletions(-) create mode 100644 crates/ui-egui/tests/ui_layer_panel_zoom.rs diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c61b497..df9f10e5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -525,11 +525,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]; @@ -737,6 +748,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 { let now = ctx.input(|i| i.time); @@ -859,14 +880,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}) } @@ -1085,6 +1109,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); diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index a5952d60..e8cae2cd 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -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::(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 { 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); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 5fe7697c..1d8d8ef8 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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). @@ -1419,6 +1421,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 { @@ -1776,6 +1783,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!({})); @@ -1807,10 +1821,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 diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 772d0769..68b522f3 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -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, + fit: f32, +) -> Option> { + 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::().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::().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::().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::().ok().map(|v| v / 100.0 / ppp), - }; + app.ui.viewer.zoom = z; } x = r.max.x + 4.0; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0cce2c11..5029738c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -655,6 +655,14 @@ pub struct UiState { /// Layer panel View: number of effects rendered (None = through the last Paint effect). #[serde(default)] pub layer_view: Option, + /// 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, + /// 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, @@ -717,6 +725,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(), diff --git a/crates/ui-egui/tests/ui_layer_panel_zoom.rs b/crates/ui-egui/tests/ui_layer_panel_zoom.rs new file mode 100644 index 00000000..5fa2b1d9 --- /dev/null +++ b/crates/ui-egui/tests/ui_layer_panel_zoom.rs @@ -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); +}