From 7b8df449470a2de753e96c7e272182c7883f7616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20L?= Date: Sat, 3 Oct 2026 18:04:58 +0200 Subject: [PATCH 1/2] Loupe: animated click-zoom with selectable 2:1 / 3:1 / 5:1 ratio Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/headless.rs | 19 +++++++++++++++++++ crates/ui-egui/src/panels/bottombar.rs | 10 ++++++++++ crates/ui-egui/src/panels/detail.rs | 22 +++++++++++++++++++--- crates/ui-egui/src/state.rs | 7 +++++++ 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index c504a46f..0624f8a5 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1089,6 +1089,25 @@ mod tests { h.settle(SETTLE); } + /// A click on the image zooms to the chosen click-zoom ratio, animating the loupe, and a second click returns to Fit. + #[test] + fn click_zoom_ratio_animates() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + h.app.ui.click_zoom = 300; + let fit = h.app.image_rect.unwrap(); + h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); + assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(300)); + assert!(h.app.ui.zoom_anim, "animation started"); + h.settle(SETTLE); + assert!(!h.app.ui.zoom_anim, "animation finished"); + assert!(h.app.image_rect.unwrap().width() > fit.width()); + h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); + assert_eq!(h.app.ui.zoom, crate::state::Zoom::Fit); + } + /// The Navigator appears when zoomed in; clicking it pans to that point. #[test] fn navigator_pans_the_zoomed_loupe() { diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index a08f520a..533fe11a 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -163,6 +163,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } }); + child.add_space(6.0); + let cz = + crate::widgets::dropdown(&mut child, "clickZoom", &format!("Click {}:1", app.ui.click_zoom / 100), t.font(13.0), t.text_label); + egui::Popup::menu(&cz).show(|ui| { + for n in [2u32, 3, 5] { + if ui.selectable_label(app.ui.click_zoom == n * 100, format!("{n}:1")).clicked() { + app.ui.click_zoom = n * 100; + } + } + }); } else { // thumbnail size slider let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag()); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index ecbb0e10..fb0b7eea 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -124,6 +124,19 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], Rect::from_center_size(c, vec2(w, h)) } +/// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly. +fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { + let t = if *anim { 0.22 } else { 0.0 }; + let id = egui::Id::new("loupe_anim"); + let v = |k: &str, x: f32| ctx.animate_value_with_time(id.with(k), x, t); + let (c, s) = (target.center(), target.size()); + let r = Rect::from_center_size(pos2(v("cx", c.x), v("cy", c.y)), vec2(v("w", s.x), v("h", s.y))); + if *anim && (r.center() - c).abs().max_elem() < 0.5 && (r.size() - s).abs().max_elem() < 0.5 { + *anim = false; + } + r +} + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let full = ui.max_rect(); @@ -177,7 +190,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { _ => vec![area], }; let main_area = *areas.last().unwrap_or(&area); - let img_rect = fit_rect(main_area, aspect, app.ui.zoom, native, ppp, app.ui.pan); + let target_rect = fit_rect(main_area, aspect, app.ui.zoom, native, ppp, app.ui.pan); + let img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect); app.image_rect = Some(img_rect); // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); @@ -580,7 +594,7 @@ fn general_interaction( } return; } - // click toggles Fit ↔ 100 % at the clicked point; drag pans when zoomed + // click toggles Fit ↔ the click-zoom ratio (2:1/3:1/5:1) at the clicked point; drag pans when zoomed let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0; if resp.double_clicked() || (resp.clicked() && !zoomed) { if let Some(q) = resp.interact_pointer_pos() { @@ -588,9 +602,11 @@ fn general_interaction( let v = ((q.y - img.top()) / img.height()).clamp(0.0, 1.0); app.ui.pan = (u, v); } - app.ui.zoom = if matches!(app.ui.zoom, Zoom::Fit) { Zoom::Percent(100) } else { Zoom::Fit }; + app.ui.zoom = if matches!(app.ui.zoom, Zoom::Fit) { Zoom::Percent(app.ui.click_zoom) } else { Zoom::Fit }; + app.ui.zoom_anim = true; } else if resp.clicked() && zoomed { app.ui.zoom = Zoom::Fit; + app.ui.zoom_anim = true; } if zoomed { ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3bd16407..5c6eb11a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -189,6 +189,11 @@ pub struct UiState { pub zoom: Zoom, /// Pan offset of the loupe when zoomed (image-normalized centre). pub pan: (f32, f32), + /// Zoom applied by a click on the image, in percent (200 = 2:1). + pub click_zoom: u32, + /// Animate the loupe rect toward its target (set by a click-zoom). + #[serde(skip)] + pub zoom_anim: bool, pub before_after: BeforeAfter, pub thumb_size: f32, /// Open Edit sections by id. @@ -470,6 +475,8 @@ impl Default for UiState { filmstrip: true, zoom: Zoom::Fit, pan: (0.5, 0.5), + click_zoom: 200, + zoom_anim: false, before_after: BeforeAfter::Off, thumb_size: 220.0, open_sections: vec!["light".into()], From a4b168550daf5b496ee2032ed2db9d6aa296a6e0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 19:07:18 -0400 Subject: [PATCH 2/2] Loupe click-zoom: 1:1 default, Z/Space use the ratio, render once at the final size Review follow-ups on the contributor's click-zoom: - Ratios 1:1 (default), 2:1, 3:1, 4:1, 8:1; set through the new view.clickZoom {ratio} command (bottom-bar dropdown, CLI, MCP). - Z / Space zoom to the same ratio as a click, with the same ease. - The loupe requests its render at the final size; the animation only changes how it is drawn (no render per animation frame). The test checks every animation frame asks for one render. - docs/parity.md: LR-VIEW-ZOOM. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/headless.rs | 31 ++++++++++++++++++++++---- crates/ui-egui/src/menus.rs | 17 +++++++++++++- crates/ui-egui/src/panels/bottombar.rs | 6 ++--- crates/ui-egui/src/panels/detail.rs | 3 ++- crates/ui-egui/src/render.rs | 5 +++++ crates/ui-egui/src/state.rs | 8 +++++-- docs/parity.md | 2 +- 7 files changed, 60 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 1349ab04..3ca3f329 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1118,23 +1118,46 @@ mod tests { h.settle(SETTLE); } - /// A click on the image zooms to the chosen click-zoom ratio, animating the loupe, and a second click returns to Fit. + /// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the + /// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit. #[test] fn click_zoom_ratio_animates() { + use crate::render::Slot; + use crate::state::Zoom; let mut h = demo([1000.0, 700.0]); let t = Duration::from_secs(10); h.request("ui.set", json!({"view": "detail", "right": "none"}), t); h.settle(SETTLE); - h.app.ui.click_zoom = 300; + assert_eq!(h.app.ui.click_zoom, 100, "1:1 by default"); + let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 3}}), t); + assert_eq!(r["result"]["ratio"], 3, "{r}"); + let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 5}}), t); + assert_ne!(r["ok"], true, "{r}"); let fit = h.app.image_rect.unwrap(); h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); - assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(300)); + assert_eq!(h.app.ui.zoom, Zoom::Percent(300)); assert!(h.app.ui.zoom_anim, "animation started"); + // every animation frame asks for the same (final-size) render + let mut keys = std::collections::HashSet::new(); + for _ in 0..40 { + h.step(); + keys.extend(h.app.renderer.wanted(Slot::Main)); + if !h.app.ui.zoom_anim { + break; + } + } + assert_eq!(keys.len(), 1, "one render for the whole animation: {keys:?}"); h.settle(SETTLE); assert!(!h.app.ui.zoom_anim, "animation finished"); assert!(h.app.image_rect.unwrap().width() > fit.width()); h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); - assert_eq!(h.app.ui.zoom, crate::state::Zoom::Fit); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + // Z zooms to the same ratio as a click + h.request("ui.key", json!({"key": "z"}), t); + assert_eq!(h.app.ui.zoom, Zoom::Percent(300)); + h.request("ui.key", json!({"key": "z"}), t); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + h.settle(SETTLE); } /// The Navigator appears when zoomed in; clicking it pans to that point. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 90b714cd..d0f47d9d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -34,6 +34,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"), ("view.zoom100", "Zoom 100%", Some("Cmd+Alt+0"), "View"), ("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"), + // the ratio a click (and Z / Space) zooms to + ("view.clickZoom", "Click Zoom Ratio", None, ""), ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), ("view.clipping", "Show Clipping", Some("J"), "View"), @@ -403,9 +405,22 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100) } else { Zoom::Fit }; + // the same ratio a click on the photo zooms to + app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom) } else { Zoom::Fit }; + app.ui.zoom_anim = true; Ok(Value::Null) } + "view.clickZoom" => { + // {ratio?: 1|2|3|4|8} → {ratio} + if let Some(r) = p.get("ratio").and_then(Value::as_f64) { + let pct = (r * 100.0).round() as u32; + if !crate::state::CLICK_ZOOMS.contains(&pct) { + return Some(Err(format!("view.clickZoom: ratio {r} (1, 2, 3, 4 or 8)"))); + } + app.ui.click_zoom = pct; + } + Ok(json!({"ratio": app.ui.click_zoom / 100})) + } "view.zoomIn" | "view.zoomOut" => { let steps = [25u32, 50, 100, 200, 400, 800]; let cur = match app.ui.zoom { diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 533fe11a..28cee606 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -167,9 +167,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let cz = crate::widgets::dropdown(&mut child, "clickZoom", &format!("Click {}:1", app.ui.click_zoom / 100), t.font(13.0), t.text_label); egui::Popup::menu(&cz).show(|ui| { - for n in [2u32, 3, 5] { - if ui.selectable_label(app.ui.click_zoom == n * 100, format!("{n}:1")).clicked() { - app.ui.click_zoom = n * 100; + for pct in crate::state::CLICK_ZOOMS { + if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() { + let _ = app.run("view.clickZoom", json!({"ratio": pct / 100})); } } }); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 89422e07..a869dbd6 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -196,7 +196,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); let scale = if interacting { 0.6 } else { 1.0 }; - let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(max_edge) as usize; + // render at the final size: a click-zoom animation only changes how the result is drawn + let want = (target_rect.width().max(target_rect.height()) * ppp * scale).min(max_edge) as usize; let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 24d275dd..3c22bfb8 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -161,6 +161,11 @@ impl Renderer { } /// Is the slot's current texture (or pending request) already for `key`? + /// Key of what `slot` shows or is rendering next (the pending request wins). + pub fn wanted(&self, slot: Slot) -> Option { + self.pending.get(&slot).map(|p| p.0).or_else(|| self.textures.get(&slot).map(|t| t.key)) + } + pub fn is_current(&self, slot: Slot, key: u64) -> bool { self.textures.get(&slot).is_some_and(|t| t.key == key) || self.pending.get(&slot).is_some_and(|p| p.0 == key) } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5c6eb11a..85d08db1 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -172,6 +172,9 @@ impl Default for AppSettings { /// Preview sizes offered in Settings → Performance. pub const PREVIEW_EDGES: [u32; 4] = [1600, 2560, 3840, 5120]; +/// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1. +pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800]; + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { @@ -189,7 +192,8 @@ pub struct UiState { pub zoom: Zoom, /// Pan offset of the loupe when zoomed (image-normalized centre). pub pan: (f32, f32), - /// Zoom applied by a click on the image, in percent (200 = 2:1). + /// Zoom applied by a click on the image (and Z / Space), in percent (100 = 1:1, 200 = 2:1); + /// one of [`CLICK_ZOOMS`]. pub click_zoom: u32, /// Animate the loupe rect toward its target (set by a click-zoom). #[serde(skip)] @@ -475,7 +479,7 @@ impl Default for UiState { filmstrip: true, zoom: Zoom::Fit, pan: (0.5, 0.5), - click_zoom: 200, + click_zoom: 100, zoom_anim: false, before_after: BeforeAfter::Off, thumb_size: 220.0, diff --git a/docs/parity.md b/docs/parity.md index 7ba27321..171b5aa0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -155,7 +155,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip` | | -| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar; a click on the photo (and Z / Space) eases to the click-zoom ratio (1:1 default, 2:1, 3:1, 4:1, 8:1 in the bottom bar) | | 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-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 |