diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4b4aca08e2..7f9db071c7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -6553,7 +6553,7 @@ pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) { let rect = ui.available_rect_before_wrap(); view = canvas_view(app, ui, w.document, rect, view.clone(), false); // Preferences › Zoom Resizes Windows: a zoom wraps the window around the image. - crate::zoom_window_fit::follow(app, ui.ctx(), class, vid, &w, &view, rect); + crate::zoom_window_fit::follow(app, ui.ctx(), class, vid, &w, &mut view, rect); }); }); if let Some(win) = app.ui.windows.iter_mut().find(|x| x.id == w.id) { diff --git a/crates/ui-egui/src/zoom_hold.rs b/crates/ui-egui/src/zoom_hold.rs index 216ee698ea..ec0d5f9376 100644 --- a/crates/ui-egui/src/zoom_hold.rs +++ b/crates/ui-egui/src/zoom_hold.rs @@ -110,7 +110,10 @@ pub fn update(ctx: &egui::Context, view: &mut View, xf: &ViewXform, enabled: boo } let dt = now - h.last.unwrap_or(now); let zoom = hold_zoom(view.zoom, dt, out, view.doc_size); - crate::zoom_tool::anchor_view(view, xf, h.anchor, h.doc, zoom, ppp); + // At the zoom limit the view stays as it is (a window wrapped around the image centred it). + if zoom.to_bits() != view.zoom.to_bits() { + crate::zoom_tool::anchor_view(view, xf, h.anchor, h.doc, zoom, ppp); + } h.last = Some(now); ctx.data_mut(|d| d.insert_temp(id, h)); ctx.request_repaint(); @@ -246,6 +249,29 @@ mod tests { assert!(xf(&h).to_screen(doc[0] as f32, doc[1] as f32).distance(p) < 0.5); } + #[test] + fn a_hold_at_the_zoom_limit_leaves_the_view_alone() { + // Zoom Resizes Windows centres the image in a window wrapped around it + // (zoom_window_fit.rs); a hold that can't zoom further must not move it back. + let mut h = harness(true); + let p = h.state().last_canvas_rect.center() + vec2(50.0, -30.0); + h.event(Event::PointerMoved(p)); + h.step(); + button(&mut h, p, true, true); + let min = crate::zoom_levels::min(DOC); + for _ in 0..200 { + if h.state().current_zoom() == min { + break; + } + h.step(); + } + assert_eq!(h.state().current_zoom(), min); + h.state_mut().ui.views[0].center = [200.0, 150.0]; + h.run_steps(3); + assert_eq!(h.state().ui.views[0].center, [200.0, 150.0]); + button(&mut h, p, false, true); + } + #[test] fn with_animated_zoom_off_a_hold_is_one_click_step() { let mut h = harness(false); diff --git a/crates/ui-egui/src/zoom_tool.rs b/crates/ui-egui/src/zoom_tool.rs index 7ace657bcb..1a789c7e28 100644 --- a/crates/ui-egui/src/zoom_tool.rs +++ b/crates/ui-egui/src/zoom_tool.rs @@ -65,7 +65,14 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) { match z.rect_to.as_mut() { Some(to) => *to = p, - None => anchor_view(view, xf, z.anchor, z.doc, scrub_zoom(z.zoom0, p.x - z.anchor.x, view.doc_size), app.ppp), + None => { + // Only a new zoom re-anchors: a window that Zoom Resizes Windows grew around the + // image has centred it since (zoom_window_fit.rs), and the release keeps that. + let zoom = scrub_zoom(z.zoom0, p.x - z.anchor.x, view.doc_size); + if zoom.to_bits() != view.zoom.to_bits() { + anchor_view(view, xf, z.anchor, z.doc, zoom, app.ppp); + } + } } ctx.data_mut(|d| d.insert_temp(id(), z)); } @@ -421,6 +428,28 @@ mod tests { } } + #[test] + fn a_scrub_release_keeps_a_view_centred_since_its_last_zoom() { + // Zoom Resizes Windows centres the image once its window wraps it (zoom_window_fit.rs): + // the release frame, at the same zoom, must not pull it back to the press point. + let mut h = harness(true, 1.0); + let p0 = h.state().last_canvas_rect.center() + vec2(-150.0, 60.0); + h.event(Event::PointerMoved(p0)); + h.step(); + press(&mut h, p0, true); + let end = p0 + vec2(90.0, 0.0); + h.event(Event::PointerMoved(end)); + h.step(); + let z = h.state().current_zoom(); + h.state_mut().ui.views[0].center = [200.0, 150.0]; + h.step(); + assert_eq!(h.state().ui.views[0].center, [200.0, 150.0], "a held, unmoved scrub keeps the view"); + press(&mut h, end, false); + h.run_steps(2); + assert_eq!(h.state().current_zoom(), z); + assert_eq!(h.state().ui.views[0].center, [200.0, 150.0], "the release keeps the view"); + } + #[test] fn zoom_drags_keep_the_press_point_in_rotated_and_flipped_views() { for (scrubby, out, rotation, flip, ppp) in [ diff --git a/crates/ui-egui/src/zoom_window_fit.rs b/crates/ui-egui/src/zoom_window_fit.rs index 61071c655a..a44692c14f 100644 --- a/crates/ui-egui/src/zoom_window_fit.rs +++ b/crates/ui-egui/src/zoom_window_fit.rs @@ -8,8 +8,12 @@ //! //! Only a change of zoom resizes. Fit on Screen derives the zoom from the window, so a fit leaves //! the window alone and the two can never chase each other. +//! +//! A zoom about the pointer leaves the image off centre, so a window grown to wrap it also +//! re-centres the image in what the scrollbars leave free; a window the screen caps keeps the +//! zoomed spot where it is. -use egui::{Context, Id, Rect, Vec2, ViewportClass, ViewportCommand, ViewportId, vec2}; +use egui::{Context, Id, Pos2, Rect, Vec2, ViewportClass, ViewportCommand, ViewportId, vec2}; use crate::PhotocraftApp; use crate::state::{DocWindow, View}; @@ -29,6 +33,11 @@ pub struct Layout { pub aspect: f32, /// The window's own chrome around the image, the rulers, in points. pub chrome: Vec2, + /// The scrollbars along the right and bottom (points; 0 when hidden). They are drawn over the + /// canvas, so the window makes room for them too. + pub bars: f32, + /// View › Flip Horizontal. + pub flip: bool, /// The size of the screen the window is on, in points, when the platform reports it. pub monitor: Option, } @@ -37,6 +46,11 @@ pub struct Layout { /// at least [`MIN_SIZE`] but never bigger than the screen, even a screen smaller than that /// minimum. `None` until the view has a document size. pub fn wrapped_size(view: &View, layout: &Layout) -> Option { + wrap(view, layout).map(|(_, size)| size) +} + +/// The window size the image and chrome need, and the size [`wrapped_size`] asks for. +fn wrap(view: &View, layout: &Layout) -> Option<(Vec2, Vec2)> { let usable = |x: f32| x.is_finite() && x > 0.0; if !usable(view.zoom) || !usable(layout.ppp) { return None; @@ -51,11 +65,36 @@ pub fn wrapped_size(view: &View, layout: &Layout) -> Option { let (sin, cos) = view.rotation.to_radians().sin_cos(); let (sin, cos) = (sin.abs(), cos.abs()); let image = vec2(w * cos + h * sin, w * sin + h * cos); - let chrome = if layout.chrome.is_finite() { layout.chrome.max(Vec2::ZERO) } else { Vec2::ZERO }; + let bars = if usable(layout.bars) { layout.bars } else { 0.0 }; + let chrome = if layout.chrome.is_finite() { layout.chrome.max(Vec2::ZERO) } else { Vec2::ZERO } + Vec2::splat(bars); // The screen is the hard cap: applied last, it wins over the minimum on a tiny screen. let screen = layout.monitor.filter(|m| usable(m.x) && usable(m.y)).map_or(Vec2::splat(f32::INFINITY), |m| (m - Vec2::splat(SCREEN_MARGIN)).max(Vec2::splat(1.0))); - Some((image + chrome).max(MIN_SIZE).min(screen)) + Some((image + chrome, (image + chrome).max(MIN_SIZE).min(screen))) +} + +/// The view centre that shows the whole image in the middle of a window of [`wrapped_size`]: +/// the image centred in the canvas less its scrollbars. `None` when the screen capped the window +/// and the image doesn't fit, so the zoomed spot stays put. +pub fn wrapped_center(view: &View, layout: &Layout) -> Option<[f32; 2]> { + let (needed, size) = wrap(view, layout)?; + if needed.x > size.x + 0.5 || needed.y > size.y + 0.5 { + return None; + } + let bars = if layout.bars.is_finite() && layout.bars > 0.0 { layout.bars } else { 0.0 }; + let middle = [view.doc_size[0] as f32 / 2.0, view.doc_size[1] as f32 / 2.0]; + // The canvas centre sits half a bar right of and below the middle of the free area. + let xf = crate::canvas::ViewXform { + rect: Rect::from_center_size(Pos2::ZERO, Vec2::ZERO), + zoom: view.zoom / layout.ppp, + center: middle, + flip: layout.flip, + rotation: view.rotation, + aspect: layout.aspect, + }; + let [x, y] = xf.to_doc(Pos2::new(bars / 2.0, bars / 2.0)); + let center = [x as f32, y as f32]; + center.iter().all(|c| c.is_finite()).then_some(center) } /// Where the zoom a window was last wrapped for (or first seen at) is kept between frames. @@ -85,19 +124,39 @@ pub fn resize_for(ctx: &Context, window: u64, on: bool, before: &View, after: &V /// Called by a floating document window (`canvas::extra_windows`) after its canvas drew in `rect`: /// with Zoom Resizes Windows on, a zoom resizes the window around the image. A window drawn /// embedded in the main one (a backend without multiple windows, the web) is never resized, as the -/// command would resize the main window instead. Returns the size asked for. -pub fn follow(app: &PhotocraftApp, ctx: &Context, class: ViewportClass, viewport: ViewportId, window: &DocWindow, after: &View, rect: Rect) -> Option { +/// command would resize the main window instead, and a maximized or full-screen one keeps the +/// size the user gave it. A window that grows around the whole image also centres it in `after`. +/// Returns the size asked for. +pub fn follow( + app: &PhotocraftApp, + ctx: &Context, + class: ViewportClass, + viewport: ViewportId, + window: &DocWindow, + after: &mut View, + rect: Rect, +) -> Option { if class != ViewportClass::Immediate { return None; } + // Still recorded while maximized, so leaving it doesn't resize for a zoom made meanwhile. + let pinned = ctx.input(|i| i.viewport().maximized == Some(true) || i.viewport().fullscreen == Some(true)); let layout = Layout { ppp: ctx.pixels_per_point(), aspect: app.ui.view.display_aspect(), chrome: rect.size() - crate::rulers::content_rect(app, rect).size(), + bars: if app.ui.view.shows_scrollbars() { crate::scrollbars::THICKNESS } else { 0.0 }, + flip: app.ui.view.flip_horizontal, monitor: ctx.input(|i| i.viewport().monitor_size), }; - let size = resize_for(ctx, window.id, app.session.prefs().general.zoom_resizes_windows, &window.view, after, &layout)?; + let on = app.session.prefs().general.zoom_resizes_windows && !pinned; + let size = resize_for(ctx, window.id, on, &window.view, after, &layout)?; ctx.send_viewport_cmd_to(viewport, ViewportCommand::InnerSize(size)); + if let Some(center) = wrapped_center(after, &layout) { + after.center = center; + // The canvas drew before this: show the centred view on the next frame. + ctx.request_repaint_of(viewport); + } Some(size) } @@ -112,7 +171,7 @@ mod tests { } fn layout() -> Layout { - Layout { ppp: 1.0, aspect: 1.0, chrome: Vec2::ZERO, monitor: Some(vec2(1920.0, 1080.0)) } + Layout { ppp: 1.0, aspect: 1.0, chrome: Vec2::ZERO, bars: 0.0, flip: false, monitor: Some(vec2(1920.0, 1080.0)) } } #[test] @@ -215,13 +274,16 @@ mod tests { let rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0)); let at = |zoom| view([400, 300], zoom); let window = DocWindow { id: 3, document: 0, view: at(1.0), open: true, position: None }; - let frame = |app: &PhotocraftApp, class, after: &View| follow(app, &ctx, class, viewport, &window, after, rect); + let frame = |app: &PhotocraftApp, class, after: &View| follow(app, &ctx, class, viewport, &window, &mut after.clone(), rect); // Off by default, as in Photoshop. assert!(!app.session.prefs().general.zoom_resizes_windows); assert_eq!(frame(&app, ViewportClass::Immediate, &at(1.0)), None); assert_eq!(frame(&app, ViewportClass::Immediate, &at(2.0)), None); app.run("prefs.set", json!({"path": "general.zoomResizesWindows", "value": true})).unwrap(); - assert_eq!(frame(&app, ViewportClass::Immediate, &at(1.0)), Some(vec2(400.0, 300.0))); + // The scrollbars are drawn over the canvas: the window makes room for them. + assert!(app.ui.view.shows_scrollbars()); + let bars = Vec2::splat(crate::scrollbars::THICKNESS); + assert_eq!(frame(&app, ViewportClass::Immediate, &at(1.0)), Some(vec2(400.0, 300.0) + bars)); // Drawn inside the main window, a resize command would resize the main window: skipped. assert_eq!(frame(&app, ViewportClass::EmbeddedWindow, &at(2.0)), None); // With the rulers on, the window makes room for them. @@ -230,6 +292,117 @@ mod tests { assert!(ruled.x > 800.0 && ruled.y > 600.0, "{ruled:?}"); } + /// Where the image lands in a window of the wrapped size, and the canvas area the scrollbars + /// leave free, both in points from the window's top-left. + fn landing(view: &View, layout: &Layout) -> (Rect, Rect) { + let size = wrapped_size(view, layout).unwrap(); + let canvas = Rect::from_min_max(Pos2::ZERO + layout.chrome, Pos2::ZERO + size); + let free = Rect::from_min_max(canvas.min, canvas.max - Vec2::splat(layout.bars)); + let xf = crate::canvas::ViewXform { + rect: canvas, + zoom: view.zoom / layout.ppp, + center: view.center, + flip: layout.flip, + rotation: view.rotation, + aspect: layout.aspect, + }; + (xf.image_bounds(view.doc_size), free) + } + + fn near(a: Rect, b: Rect) -> bool { + (a.min - b.min).length() < 0.01 && (a.max - b.max).length() < 0.01 + } + + #[test] + fn the_window_makes_room_for_the_scrollbars() { + let barred = Layout { bars: 12.0, chrome: vec2(16.0, 16.0), ..layout() }; + assert_eq!(wrapped_size(&view([400, 300], 1.0), &barred), Some(vec2(428.0, 328.0))); + } + + #[test] + fn a_wrapped_window_shows_the_whole_image_centred() { + // Zoomed in about the top-left corner: the view centre is far from the image's middle. + let zoomed = View { center: [60.0, 40.0], ..view([400, 300], 2.0) }; + for layout in [ + layout(), + Layout { bars: 12.0, chrome: vec2(16.0, 16.0), ..layout() }, + Layout { bars: 12.0, flip: true, ..layout() }, + Layout { bars: 12.0, ppp: 2.0, aspect: 1.5, ..layout() }, + ] { + for rotation in [0.0, 90.0, -30.0] { + let mut v = View { rotation, ..zoomed.clone() }; + let (before, free) = landing(&v, &layout); + assert!(!near(before, free), "{layout:?} {rotation}: off centre before"); + v.center = wrapped_center(&v, &layout).unwrap(); + let (after, free) = landing(&v, &layout); + assert!(near(after, free), "{layout:?} {rotation}: {after:?} fills {free:?}"); + } + } + // Zoomed far out the window keeps its minimum size, and the image sits in its middle. + let small = View { center: [0.0, 0.0], ..view([400, 300], 0.1) }; + let c = wrapped_center(&small, &layout()).unwrap(); + assert!((c[0] - 200.0).abs() < 0.01 && (c[1] - 150.0).abs() < 0.01, "{c:?}"); + } + + #[test] + fn a_window_the_screen_caps_keeps_the_zoomed_spot() { + let zoomed = View { center: [60.0, 40.0], ..view([400, 300], 16.0) }; + assert_eq!(wrapped_center(&zoomed, &layout()), None); + } + + #[test] + fn follow_centres_the_image_in_the_grown_window() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.run("prefs.set", json!({"path": "general.zoomResizesWindows", "value": true})).unwrap(); + let ctx = Context::default(); + let viewport = ViewportId::from_hash_of(("docwin", 4_u64)); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let window = DocWindow { id: 4, document: 0, view: view([400, 300], 1.0), open: true, position: None }; + let mut first = view([400, 300], 1.0); + assert_eq!(follow(&app, &ctx, ViewportClass::Immediate, viewport, &window, &mut first, rect), None); + // A Zoom tool click near the top-left corner doubles the zoom about that spot. + let mut zoomed = View { center: [60.0, 40.0], ..view([400, 300], 2.0) }; + assert!(follow(&app, &ctx, ViewportClass::Immediate, viewport, &window, &mut zoomed, rect).is_some()); + let half_bar = crate::scrollbars::THICKNESS / 2.0 / 2.0; + assert_eq!(zoomed.center, [200.0 + half_bar, 150.0 + half_bar], "the whole image, centred"); + assert_eq!(zoomed.zoom, 2.0); + } + + #[test] + fn a_maximized_or_full_screen_window_keeps_its_size() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.run("prefs.set", json!({"path": "general.zoomResizesWindows", "value": true})).unwrap(); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let window = DocWindow { id: 5, document: 0, view: view([400, 300], 1.0), open: true, position: None }; + for (maximized, fullscreen) in [(true, false), (false, true)] { + let ctx = Context::default(); + let mut raw = egui::RawInput::default(); + let info = egui::ViewportInfo { maximized: Some(maximized), fullscreen: Some(fullscreen), ..Default::default() }; + raw.viewports.insert(ViewportId::ROOT, info); + let viewport = ViewportId::ROOT; + let mut sizes = Vec::new(); + let frame = |raw: egui::RawInput, zoom: f32, sizes: &mut Vec>| { + let mut out = ctx.run_ui(raw, |ui| { + let mut v = View { center: [60.0, 40.0], ..view([400, 300], zoom) }; + sizes.push(follow(&app, ui.ctx(), ViewportClass::Immediate, viewport, &window, &mut v, rect)); + assert_eq!(v.center, [60.0, 40.0]); + }); + out.textures_delta.clear(); + }; + let mut normal = egui::RawInput::default(); + normal.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { maximized: Some(false), fullscreen: Some(false), ..Default::default() }); + frame(normal.clone(), 1.0, &mut sizes); + frame(raw, 2.0, &mut sizes); + // Back to a normal window: the zoom made meanwhile is not caught up on. + frame(normal, 2.0, &mut sizes); + assert_eq!(sizes, [None, None, None], "maximized {maximized}, full screen {fullscreen}"); + } + } + #[test] fn the_zoom_tool_options_bar_shows_the_same_setting() { // Photoshop's Zoom tool option Resize Windows to Fit is Preferences › Zoom Resizes Windows.