mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Merge PR #313
# Conflicts: # crates/ui-egui/src/panels/detail.rs # docs/parity.md
This commit is contained in:
10 files changed
+372
-74
No files matched your search
+10
-6
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user