diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae2..142ac1a4 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -531,7 +531,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let p = ui.painter_at(canvas); // what a view slot shows: its own render of this photo, else the stand-ins (no blank frame // between photos, and the full render replaces them in place) - let draw = |slot: Slot, r: Rect| -> &'static str { + let draw = |slot: Slot, r: Rect, clip: Rect| -> &'static str { let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id); let (tex, what) = if let Some(t) = mine(slot) { (t, "render") @@ -543,7 +543,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { return "none"; }; let image_rect = fit_texture_rect(r, tex.size); - p.image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); what }; // soft proofing: a paper-white surround and the proof's name, as Lightroom shows it @@ -554,13 +554,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } let shown; if split { + // each view stays in its pane: zoomed in, a photo reaches past it (and would cover the + // other view's margins with its whole-frame render, magnified and blurred) let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan); - draw(Slot::Before, br); - shown = draw(Slot::Main, img_rect); - p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim); - p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); + draw(Slot::Before, br, areas[0]); + shown = draw(Slot::Main, img_rect, main_area); + // the labels under each photo, or under its pane when the photo reaches past it + let label_at = |r: Rect, pane: Rect| pos2(r.left().max(pane.left()), r.bottom().min(pane.bottom()) + 14.0); + p.text(label_at(br, areas[0]), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim); + p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); } else if show_before { - shown = draw(Slot::Before, img_rect); + shown = draw(Slot::Before, img_rect, canvas); p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text); } else if let Some((_key, label)) = &hover_key && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id) @@ -573,7 +577,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); shown = "hover"; } else { - shown = draw(Slot::Main, img_rect); + shown = draw(Slot::Main, img_rect, canvas); if shown == "none" { match app.renderer.failure(Slot::Main) { Some(e) => { diff --git a/crates/ui-egui/src/tests_zoom.rs b/crates/ui-egui/src/tests_zoom.rs index b9050607..ddea400d 100644 --- a/crates/ui-egui/src/tests_zoom.rs +++ b/crates/ui-egui/src/tests_zoom.rs @@ -69,3 +69,42 @@ fn a_rotated_photo_at_100_is_its_pixel_size_swapped() { let (w, hh) = at_100(&mut h); assert!((w - nh).abs() < 1.5 && (hh - nw).abs() < 1.5, "{w}Γ—{hh} for {nh}Γ—{nw}"); } + +/// Zoomed in, side by side and stacked Before / After views each stay in their pane: the margins +/// around the panes and the gap between them show the background, as at Fit. The whole-frame +/// renders used to be drawn unclipped, so the After's (magnified, blurred) covered the Before +/// pane's margins. +#[test] +fn zoomed_before_after_views_stay_in_their_panes() { + use crate::state::{BeforeAfter, Zoom}; + let mut h = detail(); + for mode in [BeforeAfter::SideBySide, BeforeAfter::TopBottom] { + let mut shots = Vec::new(); + for zoom in [Zoom::Fit, Zoom::Percent(100.0)] { + h.app.ui.before_after = mode; + h.app.ui.zoom = zoom; + h.app.ui.pan = (0.3, 0.4); + h.settle(SETTLE); + let img = h.app.image_rect.expect("After view on screen"); + let canvas = h.app.canvas_rect.expect("canvas"); + shots.push((h.paint(), img, canvas)); + } + let (zoomed, img, canvas) = &shots[1]; + assert!(!canvas.contains_rect(*img), "{mode:?}: 100 % reaches past the pane ({img:?})"); + // outside both panes: the canvas' edges and the gap between the panes + let c = canvas.center(); + let points = [ + egui::pos2(canvas.left() + 4.0, c.y), + egui::pos2(c.x, canvas.top() + 4.0), + egui::pos2(c.x, canvas.bottom() - 4.0), + match mode { + BeforeAfter::SideBySide => c, + _ => egui::pos2(canvas.left() + 200.0, c.y), + }, + ]; + for q in points { + let px = |shot: &egui::ColorImage| shot[(q.x as usize, q.y as usize)]; + assert_eq!(px(zoomed), px(&shots[0].0), "{mode:?}: background at {q:?}, zoomed in as at Fit"); + } + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 09941108..aeb6370d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -130,7 +130,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-FILMSTRIP | Filmstrip | P0 | βœ… | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟑 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 Γ— 900 canvas and 11–25 ms on a 2800 Γ— 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | βœ… | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | -| LR-VIEW-BEFOREAFTER | Before / after | P0 | βœ… | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View β†’ Before/After Settings, History row menu). The chosen before lasts for the session | +| LR-VIEW-BEFOREAFTER | Before / after | P0 | βœ… | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View β†’ Before/After Settings, History row menu). The chosen before lasts for the session. Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | | LR-VIEW-COMPARE | Compare two photos | P1 | βœ… | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | βœ… | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≀ 48), keys act on the active photo, hover Γ— removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | βœ… | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) |