# Conflicts:
#	crates/ui-egui/src/panels/detail.rs
#	docs/parity.md
This commit is contained in:
Claude committed 2026-10-08 17:33:58 +00:00
commit 9e9e45b3c7
10 files changed
+372 -74

No files matched your search

+10 -6
View File
@@ -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.
+8
View File
@@ -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);
+176 -3
View File
@@ -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::<Zoom>(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);
+33 -7
View File
@@ -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<Re
Ok(Value::Null)
}
"view.zoom100" => {
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<Re
Ok(json!({"ratio": app.ui.click_zoom / 100}))
}
"view.zoomIn" | "view.zoomOut" => {
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::<Zoom>(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)
+5 -5
View File
@@ -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;
+10 -14
View File
@@ -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) {
@@ -233,15 +240,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 {
@@ -260,12 +258,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() {
@@ -361,9 +358,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;
+112 -34
View File
@@ -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,16 +116,12 @@ 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))
}
@@ -133,6 +133,60 @@ pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect {
Rect::from_center_size(area.center(), vec2(w * scale, h * scale))
}
/// 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 };
@@ -211,8 +265,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// another crop): a texture's whole-pixel size is a little off the exact aspect, and the crop
// tool's overlays need the frame's own geometry.
let display_aspect = visible_texture_aspect.filter(|a| !crop_tool && (a / aspect - 1.0).abs() > 0.02).unwrap_or(aspect);
let target_rect = fit_rect(main_area, display_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, display_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, display_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, display_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();
@@ -356,7 +421,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);
@@ -386,12 +450,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);
@@ -693,8 +763,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()
{
@@ -764,6 +833,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,
@@ -775,7 +864,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()
{
@@ -786,12 +874,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()
{
@@ -804,7 +891,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()
{
@@ -816,7 +902,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() {
@@ -824,22 +910,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);
}
+2 -2
View File
@@ -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)]
+10 -1
View File
@@ -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
+6 -2
View File
@@ -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) | 41 | 2 | 5 | 1 | 27/28 (96%) | 13/14 (93%) |
@@ -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; 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` | 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 |