diff --git a/ROADMAP.md b/ROADMAP.md index aeb07567b..acc5e55b3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi [`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can **switch** from Lightroom. Update it when a gap below closes.* -**In one line:** the checklist says **79%** (P0 98.5%, P1 95.9%, P2 39%), but measured by whether a working +**In one line:** the checklist says **79.7%** (P0 98.2%, P1 95.6%, P2 41.6%), but measured by whether a working photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly **quality of results and camera coverage**, not missing buttons. @@ -22,7 +22,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Estimate | What's true today | Biggest gaps | |---|---:|---|---| -| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | +| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | | **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) | | **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF and RW2 have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | | **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) | @@ -67,6 +67,10 @@ Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29 Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32). +Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views. +The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not +yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM). + ## Milestones **Status legend:** ✅ done · 🚧 in progress · ⬜ not started @@ -98,10 +102,10 @@ parity` prints this line on every run, so it stays current): | Scope | Weighted completion | Rows | 2026-10-02 | |---|---:|---:|---:| -| P0 (core) | **98.5%** | 200 | 98.7% | -| P1 (important parity) | **95.9%** | 147 | 94.4% | -| P2 (later / AI / niche, incl. Classic modules) | **39.2%** | 158 | 29.4% | -| **All in-scope rows** | **79.2%** | 505 | 75.6% | +| P0 (core) | **98.2%** | 200 | 98.7% | +| P1 (important parity) | **95.6%** | 149 | 94.4% | +| P2 (later / AI / niche, incl. Classic modules) | **41.6%** | 160 | 29.4% | +| **All in-scope rows** | **79.7%** | 509 | 75.6% | The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified camera coverage, render fidelity), which is why P0 dipped slightly. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1a6abddef..beafbffaf 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -258,6 +258,14 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest ctx.request_repaint(); ok(Value::Null) } + "ui.zoom" => { + let Some(factor) = f("factor").map(|v| v as f32).filter(|v| v.is_finite() && *v > 0.0) else { + return err("ui.zoom: factor must be a finite positive number (1 = unchanged)"); + }; + app.synthetic.push(egui::Event::Zoom(factor)); + ctx.request_repaint(); + ok(Value::Null) + } "ui.set" => { let mut v = serde_json::to_value(&app.ui).unwrap_or_default(); lightcraft_develop::presets::deep_merge(&mut v, p); diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 976ecd55a..ef6865883 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1782,7 +1782,7 @@ mod tests { 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, Zoom::Percent(300)); + assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0)); 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(); @@ -1801,12 +1801,185 @@ mod tests { 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)); + assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0)); h.request("ui.key", json!({"key": "z"}), t); assert_eq!(h.app.ui.zoom, Zoom::Fit); h.settle(SETTLE); } + #[test] + fn trackpad_navigation_keeps_the_image_cursor_between_events() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), t); + h.request("engine.execute", json!({"command": "view.zoom100"}), t); + h.settle(SETTLE); + let anchor = h.app.canvas_rect.unwrap().center(); + h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t); + for (tool, expected) in [ + ("", egui::CursorIcon::Grab), + ("wbPicker", egui::CursorIcon::Crosshair), + ("colorRange", egui::CursorIcon::Crosshair), + ("pointColor", egui::CursorIcon::Crosshair), + ("tat:curve", egui::CursorIcon::ResizeVertical), + ] { + h.app.ui.tool = tool.into(); + for event in [ + None, + Some(egui::Event::Zoom(1.01)), + None, + Some(egui::Event::Zoom(1.01)), + Some(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(10.0, -10.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }), + None, + ] { + let raw = HeadlessView::raw_input(h.size, h.pixels_per_point, h.time, event.into_iter().collect()); + let mut cursor = egui::CursorIcon::Default; + h.view.run(raw, |ui| { + h.app.logic(ui.ctx()); + h.app.ui(ui); + cursor = ui.ctx().output(|output| output.cursor_icon); + }); + h.time += FRAME_DT; + assert_eq!(cursor, expected, "cursor changed between navigation events with tool {tool:?}"); + } + } + } + + #[test] + fn trackpad_pinch_anchors_fractional_zoom_and_scroll_pans() { + use crate::state::Zoom; + let mut h = demo([1000.0, 700.0]); + h.pixels_per_point = 2.0; + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t); + h.request("engine.execute", json!({"command": "view.zoom100"}), t); + h.settle(SETTLE); + let before = h.app.image_rect.unwrap(); + let anchor = h.app.canvas_rect.unwrap().center() + egui::vec2(30.0, -20.0); + h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t); + let point = (anchor - before.min) / before.size(); + h.request("ui.zoom", json!({"factor": 1.001}), t); + let after = h.app.image_rect.unwrap(); + assert!((after.width() / before.width() - 1.001).abs() < 0.00001); + assert!(((anchor - after.min) / after.size() - point).length() < 0.00001, "point under pointer moved"); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(p) if (p - 100.1).abs() < 0.001), "{:?}", h.app.ui.zoom); + assert!(!h.app.ui.zoom_anim, "pinch follows the fingers immediately"); + + h.request("ui.scroll", json!({"dx": 40.0, "dy": -30.0}), t); + for _ in 0..40 { + h.step(); + } + let panned = h.app.image_rect.unwrap(); + assert!(panned.left() > after.left() && panned.top() < after.top(), "two-finger pan must move both axes"); + h.request("ui.scroll", json!({"dx": 100000.0, "dy": -100000.0}), t); + for _ in 0..40 { + h.step(); + } + let edge = h.app.image_rect.unwrap(); + let area = h.app.canvas_rect.unwrap().shrink(24.0); + assert!((edge.left() - area.left()).abs() < 0.01 && (edge.bottom() - area.bottom()).abs() < 0.01, "{edge:?} vs {area:?}"); + + h.request("ui.zoom", json!({"factor": 1e20}), t); + assert_eq!(h.app.ui.zoom, Zoom::Percent(800.0)); + h.request("ui.zoom", json!({"factor": 0.000001}), t); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + assert_eq!(h.app.ui.pan, (0.5, 0.5)); + // Panel / toolbar gestures must not move the image. + h.request("ui.move", json!({"x": 10, "y": 10}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + h.request("ui.scroll", json!({"dx": 100, "dy": 100}), t); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + assert_eq!(h.app.ui.pan, (0.5, 0.5)); + } + + #[test] + fn trackpad_navigation_works_in_tools_compare_reference_and_before_after() { + 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", "leftPanel": false, "filmstrip": false}), t); + h.settle(SETTLE); + let active = h.app.session.active().unwrap(); + let settings = h.app.session.catalog.photo(active).unwrap().develop.clone(); + for right in ["crop", "masking", "remove", "redEye", "none"] { + h.request("ui.set", json!({"right": right, "zoom": "fit", "pan": [0.5, 0.5]}), t); + h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch in {right}"); + assert_eq!(h.app.session.catalog.photo(active).unwrap().develop, settings, "navigation must not edit the photo"); + } + h.request("ui.set", json!({"fullscreen": true, "zoom": "fit"}), t); + h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "full-screen pinch"); + + h.request("ui.set", json!({"fullscreen": false, "zoom": "fit", "beforeAfter": "sideBySide"}), t); + let canvas = h.app.canvas_rect.unwrap(); + h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch over the Before pane"); + + h.request("ui.set", json!({"beforeAfter": "off", "zoom": "fit"}), t); + h.request("engine.execute", json!({"command": "view.compare"}), t); + // The left Compare pane must navigate too; zoom/pan are shared with the right pane. + let canvas = h.app.canvas_rect.unwrap(); + h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Compare pinch"); + let pan = h.app.ui.pan; + h.request("ui.scroll", json!({"dx": -50, "dy": -50}), t); + assert_ne!(h.app.ui.pan, pan, "Compare pan"); + + h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t); + h.request("engine.execute", json!({"command": "view.reference"}), t); + h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t); + h.request("ui.zoom", json!({"factor": 2}), t); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Reference pinch"); + } + + #[test] + fn navigation_rejects_invalid_input_and_preserves_old_zoom_state() { + use crate::state::Zoom; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + assert_eq!(serde_json::from_value::(json!({"percent": 100})).unwrap(), Zoom::Percent(100.0)); + for factor in [json!(0), json!(-1), json!(1e308), json!("big"), Value::Null] { + let r = h.request("ui.zoom", json!({"factor": factor}), t); + assert_eq!(r["ok"], false, "{r}"); + } + for params in [ + json!({"zoom": {"percent": -1}}), + json!({"zoom": {"percent": 1e308}}), + json!({"zoom": "oops"}), + json!({"zoom": {"percent": 123.4}, "pan": [-1, 0.5]}), + json!({"pan": [0.5, 1e308]}), + json!({"pan": [0.5]}), + ] { + assert!(h.app.run("view.navigate", params).is_err()); + assert_eq!(h.app.ui.zoom, Zoom::Fit, "failed request changed zoom"); + assert_eq!(h.app.ui.pan, (0.5, 0.5), "failed request changed pan"); + } + h.app.run("view.navigate", json!({"zoom": {"percent": 123.4}, "pan": [0.4, 0.6]})).unwrap(); + assert_eq!(h.app.ui.zoom, Zoom::Percent(123.4)); + assert_eq!(h.app.ui.pan, (0.4, 0.6)); + h.app.run("view.zoomIn", json!({})).unwrap(); + assert_eq!(h.app.ui.zoom, Zoom::Percent(200.0)); + h.app.run("view.zoomOut", json!({})).unwrap(); + assert_eq!(h.app.ui.zoom, Zoom::Percent(100.0)); + + let area = egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(1000.0, 700.0)); + for pan in [(0.0, 0.0), (1.0, 1.0)] { + let image = crate::panels::detail::fit_rect(area, 10.0, Zoom::Percent(100.0), [4000, 400], 1.0, pan); + assert_eq!(image.center().y, area.center().y, "smaller axis must stay centred"); + assert!(image.left() <= area.left() && image.right() >= area.right(), "pan exposed space outside the photo"); + } + } + /// The Navigator appears when zoomed in; clicking it pans to that point. #[test] fn navigator_pans_the_zoomed_loupe() { @@ -1821,7 +1994,7 @@ mod tests { h.request("ui.clickWidget", json!({"id": "canvas:navigator", "fx": 0.1, "fy": 0.2}), t); let (u, v) = h.app.ui.pan; assert!((u - 0.1).abs() < 0.03 && (v - 0.2).abs() < 0.03, "pan {:?}", h.app.ui.pan); - assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100), "the click didn't reach the loupe (which would zoom out)"); + assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0), "the click didn't reach the loupe (which would zoom out)"); h.settle(SETTLE); let nav = h.app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r).unwrap(); let to = nav.min + egui::vec2(nav.width() * 0.8, nav.height() * 0.7); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9e62a382c..f37ebd492 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -67,6 +67,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"), // the ratio a click (and Z / Space) zooms to ("view.clickZoom", "Click Zoom Ratio", None, ""), + ("view.navigate", "Set Image Zoom and Pan", None, ""), ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), ("view.clipping", "Show Clipping", Some("J"), "View"), @@ -463,12 +464,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.zoom = Zoom::Percent(100); + app.ui.zoom = Zoom::Percent(100.0); Ok(Value::Null) } "view.zoomToggle" => { // 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 = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; app.ui.zoom_anim = true; Ok(Value::Null) } @@ -484,19 +485,44 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let steps = [25u32, 50, 100, 200, 400, 800]; + let steps = [25.0, 50.0, 100.0, 200.0, 400.0, 800.0]; let cur = match app.ui.zoom { Zoom::Percent(p) => p, - _ => 25, + _ => 25.0, }; let next = if id == "view.zoomIn" { - steps.iter().find(|s| **s > cur).copied().unwrap_or(800) + steps.iter().find(|s| **s > cur).copied().unwrap_or(800.0) } else { - steps.iter().rev().find(|s| **s < cur).copied().unwrap_or(0) + steps.iter().rev().find(|s| **s < cur).copied().unwrap_or(0.0) }; - app.ui.zoom = if next == 0 { Zoom::Fit } else { Zoom::Percent(next) }; + app.ui.zoom = if next == 0.0 { Zoom::Fit } else { Zoom::Percent(next) }; Ok(Value::Null) } + "view.navigate" => { + // {zoom?: "fit"|"fill"|{percent: number}, pan?: [x, y]} (normalized image centre). + // Validate the complete request before changing either part of the viewport. + let zoom = match p.get("zoom") { + Some(v) => match serde_json::from_value::(v.clone()) { + Ok(Zoom::Percent(p)) if !p.is_finite() || p <= 0.0 || p > 800.0 => { + return Some(Err("view.navigate: zoom percent must be greater than 0 and at most 800".into())); + } + Ok(z) => z, + Err(e) => return Some(Err(format!("view.navigate: {e}"))), + }, + None => app.ui.zoom, + }; + let pan = match p.get("pan") { + Some(v) => match serde_json::from_value::<(f32, f32)>(v.clone()) { + Ok((x, y)) if x.is_finite() && y.is_finite() && (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y) => (x, y), + _ => return Some(Err("view.navigate: pan must be [x, y] with finite coordinates from 0 to 1".into())), + }, + None => app.ui.pan, + }; + app.ui.zoom = zoom; + app.ui.pan = pan; + app.ui.zoom_anim = false; + Ok(json!({"zoom": zoom, "pan": pan})) + } "view.clipping" => { app.ui.show_clipping = !app.ui.show_clipping; Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index f75feca4d..bd3ba9286 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -178,17 +178,17 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 { let zoom_label = match app.ui.zoom { Zoom::Fit => "Fit".to_string(), Zoom::Fill => "Fill".to_string(), - Zoom::Percent(p) => format!("{p}%"), + Zoom::Percent(p) => format!("{p:.0}%"), }; let zr = crate::widgets::dropdown(&mut child, "zoom", crate::i18n::tr(&zoom_label), t.font(13.0), t.text_label); egui::Popup::menu(&zr).show(|ui| { for (label, z) in [ ("Fit", Zoom::Fit), ("Fill", Zoom::Fill), - ("50%", Zoom::Percent(50)), - ("100%", Zoom::Percent(100)), - ("200%", Zoom::Percent(200)), - ("400%", Zoom::Percent(400)), + ("50%", Zoom::Percent(50.0)), + ("100%", Zoom::Percent(100.0)), + ("200%", Zoom::Percent(200.0)), + ("400%", Zoom::Percent(400.0)), ] { if ui.selectable_label(app.ui.zoom == z, label).clicked() { app.ui.zoom = z; diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 07abae24b..16db642ee 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -160,7 +160,14 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo ); let aspect = frame.aspect() as f32; let native = [photo.width.max(1) as usize, photo.height.max(1) as usize]; - let img = super::detail::fit_rect(img_area, aspect, zoom, native, ppp, app.ui.pan); + let mut img = super::detail::fit_rect(img_area, aspect, zoom, native, ppp, app.ui.pan); + if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) { + if super::detail::navigate_gesture(app, ui, &resp, img_area, img, native) { + img = super::detail::fit_rect(img_area, aspect, app.ui.zoom, native, ppp, app.ui.pan); + } else if resp.dragged() { + super::detail::pan_image(app, img_area, img, resp.drag_delta()); + } + } let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).min(2560.0) 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.render_job(id, rw.max(8), rh.max(8), false, true) { @@ -228,15 +235,6 @@ fn slot_index(s: Slot) -> u8 { } } -/// Drag-to-pan when zoomed in (shared by both compare panes: zoom and pan are synced). -fn pan(app: &mut LightcraftApp, resp: &egui::Response, img: Rect) { - if app.ui.zoom != Zoom::Fit && resp.dragged() { - let d = resp.drag_delta(); - let (px, py) = app.ui.pan; - app.ui.pan = ((px - d.x / img.width().max(1.0)).clamp(0.0, 1.0), (py - d.y / img.height().max(1.0)).clamp(0.0, 1.0)); - } -} - pub fn show_compare(app: &mut LightcraftApp, ui: &mut egui::Ui) { let canvas = area_and_filmstrip(app, ui); let Some((sel, cand)) = compare_pair(app) else { @@ -255,12 +253,11 @@ pub fn show_compare(app: &mut LightcraftApp, ui: &mut egui::Ui) { if i == 1 { app.image_rect = Some(img); } - pan(app, &resp, img); if resp.clicked() { select_pair(app, sel, cand, id); } if resp.double_clicked() { - app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100) } else { Zoom::Fit }; + app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100.0) } else { Zoom::Fit }; } resp.context_menu(|ui| { if ui.button(crate::i18n::tr("Swap")).clicked() { @@ -356,9 +353,8 @@ pub fn show_reference(app: &mut LightcraftApp, ui: &mut egui::Ui) { let left = Rect::from_min_size(area.min, vec2(half, area.height())); let right = Rect::from_min_size(pos2(area.right() - half, area.top()), vec2(half, area.height())); let (_, lresp) = photo_tile(app, ui, r, Slot::Compare(0), left, "Reference", zoom); - let (img, resp) = photo_tile(app, ui, active, Slot::Compare(1), right, "Active", zoom); + let (img, _) = photo_tile(app, ui, active, Slot::Compare(1), right, "Active", zoom); app.image_rect = Some(img); - pan(app, &resp, img); lresp.context_menu(|ui| { if ui.button(crate::i18n::tr("Clear Reference")).clicked() { app.ui.reference = None; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 3b3622bed..b15a7fd88 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -94,6 +94,10 @@ impl CanvasMap { } pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect { + let zoom = match zoom { + Zoom::Percent(p) if !p.is_finite() || p <= 0.0 => Zoom::Fit, + _ => zoom, + }; let (aw, ah) = (area.width(), area.height()); let (w, h) = match zoom { Zoom::Fit => { @@ -112,19 +116,69 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], } Zoom::Percent(p) => { // full-resolution pixels at p% (the photo's native width) - let w = img_px[0] as f32 * p as f32 / 100.0 / ppp; + let w = img_px[0] as f32 * p / 100.0 / ppp; (w, w / aspect) } }; - let c = if w <= aw && h <= ah { - area.center() - } else { - // pan: which normalized point of the image sits at the area centre - pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h) - }; + let pan = bounded_pan(area, vec2(w, h), pan); + let c = pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h); Rect::from_center_size(c, vec2(w, h)) } +/// Keep the viewport inside the photo; axes smaller than the viewport stay centred. +fn bounded_pan(area: Rect, size: egui::Vec2, pan: (f32, f32)) -> (f32, f32) { + let bound = |visible: f32, image: f32, centre: f32| { + let centre = if centre.is_finite() { centre } else { 0.5 }; + if image > visible.max(0.0) { + let half = visible.max(0.0) / image / 2.0; + centre.clamp(half, 1.0 - half) + } else { + 0.5 + } + }; + (bound(area.width(), size.x, pan.0), bound(area.height(), size.y, pan.1)) +} + +pub(crate) fn pan_image(app: &mut LightcraftApp, area: Rect, img: Rect, delta: egui::Vec2) { + let centre = area.center() - img.min - delta; + let pan = bounded_pan(area, img.size(), (centre.x / img.width().max(1.0), centre.y / img.height().max(1.0))); + let _ = app.run("view.navigate", json!({"pan": pan})); +} + +/// Native pinch (also modifier-wheel zoom) and two-finger scroll, scoped to this image view. +pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, area: Rect, img: Rect, native: [usize; 2]) -> bool { + if !resp.hovered() { + return false; + } + let (factor, delta, anchor) = + ui.input(|i| (i.zoom_delta(), i.translation_delta(), i.multi_touch().map(|t| t.center_pos).or(i.pointer.hover_pos()))); + if !factor.is_finite() || factor <= 0.0 || !delta.is_finite() || (factor == 1.0 && delta == egui::Vec2::ZERO) { + return false; + } + let Some(anchor) = anchor.filter(|p| area.contains(*p)) else { return false }; + let ppp = ui.ctx().pixels_per_point(); + let aspect = img.width() / img.height().max(1.0); + let fit = fit_rect(area, aspect, Zoom::Fit, native, ppp, (0.5, 0.5)); + let fit_pct = fit.width() * ppp * 100.0 / native[0].max(1) as f32; + let mut zoom = app.ui.zoom; + let size = if factor != 1.0 { + // Start at the displayed scale (including Fit/Fill and an interrupted click animation). + let pct = (img.width() * ppp * 100.0 / native[0].max(1) as f32 * factor).clamp(fit_pct.min(800.0), 800.0); + zoom = if pct <= fit_pct { Zoom::Fit } else { Zoom::Percent(pct) }; + fit_rect(area, aspect, zoom, native, ppp, (0.5, 0.5)).size() + } else { + img.size() + }; + // The image point under the pointer remains there as its size changes, then follows the pan. + let point = (anchor - img.min) / img.size(); + let offset = area.center() - anchor - delta; + let pan = bounded_pan(area, size, (point.x + offset.x / size.x.max(1.0), point.y + offset.y / size.y.max(1.0))); + let _ = app.run("view.navigate", json!({"zoom": zoom, "pan": pan})); + ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); + ui.ctx().request_repaint(); + true +} + /// 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 }; @@ -191,8 +245,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { _ => vec![area], }; let main_area = *areas.last().unwrap_or(&area); - 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); + let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); + let mut target_rect = fit_rect(main_area, aspect, app.ui.zoom, native, ppp, app.ui.pan); + let mut img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect); + let mut navigating = false; + for &view_area in &areas { + let view_img = if view_area == main_area { img_rect } else { fit_rect(view_area, aspect, app.ui.zoom, native, ppp, app.ui.pan) }; + if navigate_gesture(app, ui, &resp, view_area, view_img, native) { + navigating = true; + target_rect = fit_rect(main_area, aspect, app.ui.zoom, native, ppp, app.ui.pan); + img_rect = target_rect; + break; + } + } app.image_rect = Some(img_rect); // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); @@ -332,7 +397,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } register(ui.ctx(), "canvas:image", img_rect); let map = CanvasMap::new(&frame, img_rect); - let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); info_overlay(app, &p, canvas, &photo); if !fullscreen { filter_pill(app, ui, canvas); @@ -362,12 +426,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { p.line_segment([pos2(img_rect.left(), fy), pos2(img_rect.right(), fy)], stroke); } } - match right { - RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id), - RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d), - RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d), - RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d), - _ => general_interaction(app, ui, &resp, &map, img_rect, canvas, native, aspect), + // Cursor selection must also run on gesture frames, when tool input is suppressed. + if !matches!(right, RightPanel::Crop | RightPanel::Masking | RightPanel::Remove | RightPanel::RedEye) { + general_cursor(app, ui, &resp, img_rect, main_area); + } + if !navigating { + match right { + RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id), + RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d), + RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d), + RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d), + _ => general_interaction(app, ui, &resp, &map, img_rect, main_area, native, aspect), + } } // drawn and hit-tested above the loupe and its tools: clicks on it pan navigator(app, ui, canvas, img_rect, id); @@ -669,8 +739,7 @@ fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { /// Targeted adjustment tool: dragging up/down on the photo raises/lowers the tone-curve region or /// the colour-mixer bands under the press point (`develop.targeted`, one call per whole step, all /// in one interaction = one undo step). -fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, target: &str) { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); +fn targeted_drag(app: &mut LightcraftApp, resp: &egui::Response, map: &CanvasMap, target: &str) { if resp.drag_started() && let Some(q) = resp.interact_pointer_pos() { @@ -740,6 +809,26 @@ fn clipping_overlay(app: &LightcraftApp, p: &egui::Painter, r: Rect) { } } +fn general_cursor(app: &LightcraftApp, ui: &egui::Ui, resp: &egui::Response, img: Rect, canvas: Rect) { + // A panel or overlay under the pointer keeps its own cursor. + if !resp.dragged() && !resp.hovered() { + return; + } + let cursor = match app.ui.tool.as_str() { + "wbPicker" | "colorRange" | "pointColor" => egui::CursorIcon::Crosshair, + tool if tool.starts_with("tat:") => egui::CursorIcon::ResizeVertical, + _ if img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0 => { + if resp.dragged() { + egui::CursorIcon::Grabbing + } else { + egui::CursorIcon::Grab + } + } + _ => return, + }; + ui.ctx().set_cursor_icon(cursor); +} + fn general_interaction( app: &mut LightcraftApp, ui: &mut egui::Ui, @@ -751,7 +840,6 @@ fn general_interaction( aspect: f32, ) { if app.ui.tool == "wbPicker" { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.clicked() && let Some(q) = resp.interact_pointer_pos() { @@ -762,12 +850,11 @@ fn general_interaction( return; } if let Some(target) = app.ui.tool.strip_prefix("tat:").map(str::to_string) { - targeted_drag(app, ui, resp, map, &target); + targeted_drag(app, resp, map, &target); return; } if app.ui.tool == "colorRange" { // click: sample the colour for the selected mask's colour range; ⇧-click adds (up to 5) - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.clicked() && let Some(q) = resp.interact_pointer_pos() { @@ -780,7 +867,6 @@ fn general_interaction( return; } if app.ui.tool == "pointColor" { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.clicked() && let Some(q) = resp.interact_pointer_pos() { @@ -792,7 +878,7 @@ fn general_interaction( } return; } - // click toggles Fit ↔ the click-zoom ratio (2:1/3:1/5:1) at the clicked point; drag pans when zoomed + // click toggles Fit ↔ the chosen click-zoom ratio 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() { @@ -800,22 +886,14 @@ 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(app.ui.click_zoom) } else { Zoom::Fit }; + app.ui.zoom = if matches!(app.ui.zoom, Zoom::Fit) { Zoom::Percent(app.ui.click_zoom as f32) } 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 { - // only over the photo: a panel drawn earlier (sliders) must keep its own cursor - if resp.dragged() || resp.hovered() { - ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); - } - if resp.dragged() { - let dlt = resp.drag_delta(); - app.ui.pan.0 = (app.ui.pan.0 - dlt.x / img.width()).clamp(0.0, 1.0); - app.ui.pan.1 = (app.ui.pan.1 - dlt.y / img.height()).clamp(0.0, 1.0); - } + if zoomed && resp.dragged() { + pan_image(app, canvas, img, resp.drag_delta()); } let _ = (native, aspect); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9d4df8592..e32a29d8f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -45,14 +45,14 @@ impl RightPanel { } } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Zoom { #[default] Fit, Fill, /// 100 % = one image pixel per physical screen pixel. - Percent(u32), + Percent(f32), } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 24fd2bdb4..b58688730 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -37,7 +37,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `ui.pointer` | `{events: [{kind: down\|drag\|up, x, y}], alt?, shift?, cmd?}` | Gesture in normalized image coordinates (Detail view) | | `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press | | `ui.text` | `{text}` | Text input | -| `ui.scroll` | `{dx, dy}` | Mouse wheel | +| `ui.scroll` | `{dx, dy, cmd?, ctrl?, shift?, alt?}` | Wheel / two-finger scroll at the current pointer; pans over the image, modifier-scroll zooms | +| `ui.zoom` | `{factor}` | Pinch zoom at the current pointer (positive scale multiplier; 1 = unchanged). Position it first with `ui.move` or `ui.hoverWidget` | | `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state | | `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog | | `ui.resize` | `{width, height}` | Resize the window | @@ -46,6 +47,14 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | | `app.quit` | — | Close the app | +Image navigation is also available directly as the UI command `view.navigate`, with +`{zoom?: "fit" | "fill" | {"percent": number}, pan?: [x, y]}`. Percentage zoom accepts fractional +values greater than 0 and at most 800; pan is the normalized image centre, with coordinates from 0 to 1. +Pinching keeps the image point under the pointer steady and zooms between Fit and 800%; two-finger +scrolling pans in both axes and respects the operating system's scrolling direction and momentum. +These gestures work in Detail (including editing tools and full-screen preview), Compare and Reference +views, and only apply over their image areas. Panning stops at the image edges. + ### When the library can't be saved With a persistent library, every command that changes something is written to the catalog journal (fsynced) before diff --git a/docs/parity.md b/docs/parity.md index d5a31ea9c..ccd24528e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -23,7 +23,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 11 | 3 | 1 | 1 | 3/5 (60%) | 5/5 (100%) | | B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) | -| C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) | +| C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | | F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) | @@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) | -| **Total** | 389 | 34 | 86 | 37 | 194/200 (97%) | 139/149 (93%) | +| **Total** | 388 | 35 | 86 | 37 | 193/200 (97%) | 139/149 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 509 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 41.6% of 160. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.7%** of 509 in-scope rows — P0 98.2% of 200 · P1 95.6% of 149 · P2 41.6% of 160. ## Top gaps @@ -75,6 +75,10 @@ Take the first one nobody is working on. use the same model with fixed prompts; denoise / super-resolution models remain a maintainer decision. 7. **HDR** (Q. HDR, LR-EXP-HDR), **video** (R. Video), **Classic output modules** (Map view, Book, Slideshow module, Print, publish): large, well understood, lower priority than 1–5. +8. **LR-VIEW-ZOOM** (P0): pinch zoom and two-finger pan now work, with stable image cursors between gesture events. + The whole-image preview is still capped by + Settings → Performance → Preview size (2,560 px by default). Render visible regions at native resolution so + 100% can reliably show original-pixel detail on larger photos. ## Shortcuts: conflicts and missing bindings @@ -172,7 +176,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`, `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 | -| 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-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). Missing: native-resolution detail beyond the configured whole-image preview size (2,560 px default); no visible-region tiles | | 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 |