diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4b638495..56341733 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -549,13 +549,18 @@ impl LightcraftApp { } panels::bottombar::show(self, ui); let t = theme::Tokens::get(&ctx); - let bg = - if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare | state::ViewMode::Survey) { t.canvas } else { t.grid_bg }; + let bg = if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare | state::ViewMode::Survey | state::ViewMode::Reference) + { + t.canvas + } else { + t.grid_bg + }; egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match self.ui.view { state::ViewMode::PhotoGrid | state::ViewMode::SquareGrid => panels::grid::show(self, ui), state::ViewMode::Detail => panels::detail::show(self, ui), state::ViewMode::Compare => panels::compare::show_compare(self, ui), state::ViewMode::Survey => panels::compare::show_survey(self, ui), + state::ViewMode::Reference => panels::compare::show_reference(self, ui), }); panels::second::show(self, &ctx); panels::dialogs::show(self, &ctx); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index d2b08a26..855c02d1 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -251,6 +251,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.detail" => Some(u.view == ViewMode::Detail), "view.compare" => Some(u.view == ViewMode::Compare), "view.survey" => Some(u.view == ViewMode::Survey), + "view.reference" => Some(u.view == ViewMode::Reference), "view.leftPanel" => Some(u.left_panel), "view.filmstrip" => Some(u.filmstrip), "view.histogram" => Some(u.histogram), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 80a866f6..d7fbee3c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -19,6 +19,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.detail", "Detail", Some("D"), "View"), ("view.compare", "Compare", Some("Shift+C"), "View"), ("view.survey", "Survey", Some("N"), "View"), + ("view.reference", "Reference View", Some("Shift+R"), "View"), + ("photo.setReference", "Set as Reference Photo", None, ""), ("compare.swap", "Swap Compare Photos", None, "View"), ("compare.makeSelect", "Make Candidate the Select", None, "View"), ("view.autoAdvance", "Auto Advance", None, "Photo"), @@ -246,6 +248,30 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // the reference: the one set before, else the active photo (the next one becomes active) + let vis = app.session.visible_cloned(); + let reference = app + .ui + .reference + .filter(|r| app.session.catalog.photo(lightcraft_catalog::PhotoId(*r)).is_some()) + .or_else(|| app.session.active().map(|a| a.0)); + let Some(r) = reference else { return Some(Err("select a photo to use as the reference".into())) }; + app.ui.reference = Some(r); + if app.session.active().is_none_or(|a| a.0 == r) + && let Some(i) = vis.iter().position(|x| x.0 == r) + && let Some(next) = vis.get(i + 1).or(i.checked_sub(1).and_then(|j| vis.get(j))) + { + let _ = app.session.execute("library.select", &json!({"ids": [next.0]})); + } + app.ui.view = ViewMode::Reference; + Ok(json!({"reference": r, "active": app.session.active().map(|a| a.0)})) + } + "photo.setReference" => { + let id = p.get("id").and_then(Value::as_u64).or_else(|| app.session.active().map(|a| a.0)); + app.ui.reference = id; + Ok(json!({"reference": id})) + } "compare.swap" => crate::panels::compare::swap(app), "compare.makeSelect" => crate::panels::compare::make_select(app), "view.autoAdvance" => { diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index ef38fb1f..04b072df 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -337,3 +337,31 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().text(pos2(canvas.right() - 16.0, canvas.top() + 10.0), Align2::RIGHT_TOP, msg, t.font(12.0), t.text_dim); } } + +/// Reference view: the reference photo (left, fixed) beside the active photo (right) — the one +/// the Edit panel works on, so a look can be matched by eye. +pub fn show_reference(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let canvas = area_and_filmstrip(app, ui); + let reference = app.ui.reference.map(PhotoId).filter(|r| app.session.catalog.photo(*r).is_some()); + let (Some(r), Some(active)) = (reference, app.session.active()) else { + super::empty_message(ui, canvas, "No reference photo", "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)"); + return; + }; + let area = canvas.shrink(18.0); + let half = (area.width() - 16.0) / 2.0; + let zoom = app.ui.zoom; + 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); + app.image_rect = Some(img); + pan(app, &resp, img); + lresp.context_menu(|ui| { + if ui.button("Clear Reference").clicked() { + app.ui.reference = None; + app.ui.view = ViewMode::Detail; + } + }); + let mid = area.center().x; + ui.painter().line_segment([pos2(mid, area.top()), pos2(mid, area.bottom())], Stroke::new(1.0, Tokens::get(ui.ctx()).divider)); +} diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 23a80650..bd8e2c92 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -592,6 +592,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button("Open in Detail").clicked() { let _ = app.run("view.detail", json!({})); } + if ui.button("Set as Reference Photo").clicked() { + let _ = app.run("photo.setReference", json!({"id": id.0})); + } ui.separator(); ui.menu_button("Set Rating", |ui| { for r in 0..=5 { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c4d946e3..eb35afa6 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -14,6 +14,8 @@ pub enum ViewMode { Compare, /// The selected photos tiled. Survey, + /// A reference photo (left, fixed) beside the active photo (right, being edited). + Reference, } /// The right-hand tool/panel shown next to the tool strip. @@ -296,6 +298,9 @@ pub struct UiState { /// Compare view: (select, candidate) photo ids. #[serde(skip)] pub compare: Option<(u64, u64)>, + /// Reference view: the reference photo. + #[serde(skip)] + pub reference: Option, /// Transient toast text and its expiry (seconds of app time). #[serde(skip)] pub toast: Option<(String, f64)>, @@ -515,6 +520,7 @@ impl Default for UiState { window_fullscreen: None, filter_bar: false, compare: None, + reference: None, toast: None, status: String::new(), dialog: None, diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 3e992ca4..463fb298 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -546,3 +546,22 @@ fn grid_info_cycles_caption() { exec(&mut h, "view.gridInfo", json!({"info": "exposure"})); h.settle(SETTLE); } + +#[test] +fn reference_view_pins_a_photo_beside_the_active_one() { + let mut h = detail("panel.edit"); + let first = h.app.session.active().unwrap(); + exec(&mut h, "photo.setReference", json!({})); + let r = h.request("ui.key", json!({"key": "r", "shift": true}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.view, crate::state::ViewMode::Reference); + assert_eq!(h.app.ui.reference, Some(first.0)); + let active = h.app.session.active().unwrap(); + assert_ne!(active, first, "the next photo is the one being edited"); + // edits go to the active photo, the reference stays put + exec(&mut h, "develop.set", json!({"control": "light.exposure", "value": 0.7})); + assert_eq!(h.app.session.develop_of(active).unwrap().light.exposure, 0.7); + assert_ne!(h.app.session.develop_of(first).unwrap().light.exposure, 0.7); + h.settle(SETTLE); + assert!(h.app.renderer.textures.get(&crate::render::Slot::Compare(0)).is_some_and(|t| t.photo == first), "the reference is drawn"); +} diff --git a/docs/parity.md b/docs/parity.md index f8ba92c0..b40efe31 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | X. Cross-cutting behaviours (BEHAV) | 14 | 1 | 2 | 1 | 8/8 (100%) | 5/5 (100%) | | Y. Menus | 80 | 1 | 4 | 8 | 46/47 (98%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | -| Lightroom Classic extras | 24 | 8 | 47 | 9 | — | 20/21 (95%) | -| **Total** | 377 | 25 | 98 | 37 | 193/198 (97%) | 136/144 (94%) | +| Lightroom Classic extras | 25 | 8 | 46 | 9 | — | 20/21 (95%) | +| **Total** | 378 | 25 | 97 | 37 | 193/198 (97%) | 136/144 (94%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **77.9%** of 500 in-scope rows — P0 98.7% of 198 · P1 96.2% of 144 · P2 35.1% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **78.1%** of 500 in-scope rows — P0 98.7% of 198 · P1 96.2% of 144 · P2 35.8% of 158. ## Top gaps @@ -691,7 +691,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info); no people view | | LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; zoom always linked | | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | -| LRC-LIB-REFVIEW | Reference view | P2 | ⬜ | | | +| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; like the file manager, not an undo step) | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) |