diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 951f827d..25b2fc23 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -561,6 +561,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V } "view.fit" => { app.ui.viewer.zoom = None; + app.ui.viewer.fit_up_to_100 = false; app.ui.viewer.pan = [0.0, 0.0]; return Ok(Value::Null); } diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index e8cae2cd..c0ebf013 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -122,10 +122,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Bottom bar: magnification and View menus. p.rect_filled(bar, 0.0, t.panel_bg); 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) + if let Some((z, up)) = + super::viewer_tools::magnification_menu(app, ui, mr, "layer-mag", "layerPanel.magnification", "layerMagnification", zoom as f32, app.ui.layer_zoom, false) { - app.ui.layer_zoom = z; + // The Layer panel keeps no fit mode: Fit up to 100% is the fit, at most 100 %. + app.ui.layer_zoom = if up { Some(fit.min(1.0 / ui.ctx().pixels_per_point())) } else { z }; app.ui.layer_pan = [0.0, 0.0]; } let vx = mr.max.x + 10.0; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9ab13879..cbb842d9 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -795,6 +795,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let (cw, ch) = (comp.width as f32, comp.height as f32); let fit = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).max(0.01); + // Fit up to 100% follows the panel's size like Fit does, and stops at 100%. + let fit = if app.ui.viewer.fit_up_to_100 { fit.min(1.0 / ctx.pixels_per_point()) } else { fit }; ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-fit"), fit)); let zoom = app.ui.viewer.zoom.unwrap_or(fit); let center = area.center() + vec2(app.ui.viewer.pan[0], app.ui.viewer.pan[1]); @@ -1952,7 +1954,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { vt::draw_snap(&ctx, &painter, &map, &ectx); vt::rulers(app, ui, &map, outer, area); - vt::bottom_bar(app, ui, bar, zoom, fit, time, &comp); + vt::bottom_bar(app, ui, bar, zoom, time, &comp); if err_h > 0.0 { super::expr_bar::draw(app, ui, err_bar, cid); } diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 2f7862e2..fa198505 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -523,8 +523,8 @@ pub(crate) fn magnification_menu( auto_items: &str, zoom: f32, current: Option, - fit: f32, -) -> Option> { + fit_up: bool, +) -> Option<(Option, bool)> { let t = app.tokens; let ppp = ui.ctx().pixels_per_point(); let pop = format!("{id}-pop"); @@ -540,17 +540,20 @@ pub(crate) fn magnification_menu( .iter() .map(|m| { let on = match *m { - "Fit" => current.is_none(), + "Fit" => current.is_none() && !fit_up, + "Fit up to 100%" => current.is_none() && fit_up, 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)?; + // `(zoom, fit up to 100 %)`: Fit and Fit up to 100% leave the zoom unset (it follows the + // panel size). 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)), + "Fit" => Some((None, false)), + "Fit up to 100%" => Some((None, true)), + s => s.trim_end_matches('%').parse::().ok().map(|v| (Some(v / 100.0 / ppp), false)), } } @@ -583,7 +586,7 @@ pub(crate) fn resolution_label(res: Resolution, scale: f64) -> String { /// transparency grid, mask and shape path visibility, region of interest, grid and guide /// options, show channel, reset exposure, exposure, take / show snapshot, Fast Previews, the 3D /// renderer and view, and the current time. -pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) { +pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, time: Tick, comp: &Comp) { let t = app.tokens; let p = ui.painter().clone(); p.rect_filled(bar, 0.0, t.panel_bg); @@ -599,7 +602,7 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, egui::ScrollArea::horizontal().id_salt("viewer-controls").auto_shrink([false, false]).show(ui, |ui| { let row = Rect::from_min_size(ui.max_rect().min, vec2(bar.width(), 30.0)); let first_control = app.auto.elements.len(); - let width = bottom_bar_contents(app, ui, row, zoom, fit, time, comp); + let width = bottom_bar_contents(app, ui, row, zoom, time, comp); ui.allocate_rect(Rect::from_min_size(row.min, vec2(width, row.height())), Sense::hover()); if let Some(focused) = ui.ctx().memory(|memory| memory.focused()) && app.auto.elements.get(first_control..).is_some_and(|controls| controls.iter().any(|control| control.response_id == Some(focused))) @@ -612,7 +615,7 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, }); } -fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) -> f32 { +fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, time: Tick, comp: &Comp) -> f32 { let t = app.tokens; let p = ui.painter().clone(); let cy = bar.center().y; @@ -621,8 +624,10 @@ 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)); - if let Some(z) = magnification_menu(app, ui, r, "vw-mag", "viewer.magnification", "magnification", zoom, app.ui.viewer.zoom, fit) { + let fit_up = app.ui.viewer.fit_up_to_100; + if let Some((z, up)) = magnification_menu(app, ui, r, "vw-mag", "viewer.magnification", "magnification", zoom, app.ui.viewer.zoom, fit_up) { app.ui.viewer.pan = [0.0, 0.0]; + app.ui.viewer.fit_up_to_100 = up; 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 31f94b83..a9ac1639 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -233,6 +233,8 @@ pub use effectcraft_engine::project::Resolution; pub struct ViewerState { /// Magnification (1 = 100%); None = fit. pub zoom: Option, + /// Fit up to 100%: while fitting (`zoom` is None), the comp is never magnified past 100%. + pub fit_up_to_100: bool, /// Pan offset in points from the centred position. pub pan: [f32; 2], pub transparency_grid: bool, @@ -272,6 +274,7 @@ impl Default for ViewerState { fn default() -> Self { ViewerState { zoom: None, + fit_up_to_100: false, pan: [0.0, 0.0], transparency_grid: false, show_masks: true, diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index c5289ded..07036ca8 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -2046,3 +2046,45 @@ fn frame_sits_on_the_physical_pixel_grid() { } } } + +/// Magnification ▸ Fit up to 100% keeps fitting as the panel changes size (Maximize Panel), like +/// Fit, and never magnifies past 100%. +#[test] +fn fit_up_to_100_follows_the_maximized_panel() { + use effectcraft_ui_egui::dock::PanelKind; + let pick = |h: &mut Harness<'_, EffectcraftApp>| { + let at = rect(h, "viewer.magnification").center(); + click(h, at); + let at = rect(h, "viewer.magnification.1").center(); + click(h, at); + assert_eq!(h.state().ui.viewer.zoom, None, "a fit mode, not a fixed magnification"); + assert!(h.state().ui.viewer.fit_up_to_100); + h.run_steps(2); + }; + // A comp larger than the panel: it fills the docked panel, then the maximized one. + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Big", "width": 3840, "height": 2160, "duration": 4})).unwrap(); + EffectcraftApp::new(s) + }); + h.run_steps(3); + pick(&mut h); + let docked = rect(&h, "viewer.comp").width(); + h.state_mut().toggle_maximize(PanelKind::Composition); + h.run_steps(3); + let area = rect(&h, "viewer.area"); + let maximized = rect(&h, "viewer.comp"); + assert!(maximized.width() > docked + 100.0, "{docked} -> {}", maximized.width()); + assert!(area.contains_rect(maximized), "{maximized:?} in {area:?}"); + h.state_mut().toggle_maximize(PanelKind::Composition); + h.run_steps(3); + assert_eq!(rect(&h, "viewer.comp").width(), docked, "and back when the panel is restored"); + // A comp smaller than the panel stops at 100%. + let mut h = harness(); + pick(&mut h); + let ppp = h.ctx.pixels_per_point(); + assert!(rect(&h, "viewer.comp").width() <= 640.0 / ppp + 0.01); + h.state_mut().toggle_maximize(PanelKind::Composition); + h.run_steps(3); + assert!((rect(&h, "viewer.comp").width() - 640.0 / ppp).abs() < 0.01); +}