diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7e17dc010..780713f9f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -95,7 +95,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.thumbSizeUp", "Increase Grid Thumbnail Size", Some("="), "View"), ("view.thumbSizeDown", "Decrease Grid Thumbnail Size", Some("-"), "View"), ("view.clipping", "Show Clipping", Some("J"), "View"), - ("view.visualizeHdr", "Visualize HDR", None, "View"), + ("view.visualizeHdr", "Visualize HDR", Some("Shift+H"), "View"), // in grids S expands/collapses stacks (the engine command it shadows) ("view.softProof", "Soft Proofing", Some("S"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), @@ -228,7 +228,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), ("app.newLibrary", "New Library…", None, "File"), - ("app.openLibrary", "Open Library…", None, "File"), + ("app.openLibrary", "Open Library…", Some("Cmd+O"), "File"), ("app.recentLibraries", "Recent Libraries", None, ""), ("app.clearRecentLibraries", "Clear Recent Libraries", None, ""), // Settings ▸ Display diff --git a/crates/ui-egui/src/panels/keymap.rs b/crates/ui-egui/src/panels/keymap.rs index a844b7b24..1bda17758 100644 --- a/crates/ui-egui/src/panels/keymap.rs +++ b/crates/ui-egui/src/panels/keymap.rs @@ -151,6 +151,15 @@ fn fixed_keys(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, mac: bool, nee for (sc, label) in [("6", "Red label"), ("7", "Yellow label"), ("8", "Green label"), ("9", "Blue label")] { fixed.push((sc.to_string(), label)); } + // grid navigation (Photo Grid and Square Grid): fixed keys that belong to no single command + for (sc, label) in [ + ("Home", "Grid: first photo of the view"), + ("End", "Grid: last photo of the view"), + ("Shift+Left", "Grid: add the previous photo to the selection"), + ("Shift+Right", "Grid: add the next photo to the selection"), + ] { + fixed.push((sc.to_string(), label)); + } let fixed: Vec<_> = fixed .into_iter() .filter(|(sc, label)| { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2964b2db3..4d844772e 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -362,6 +362,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new(); let keymap = &app.ui.settings.keymap; let grid = library_grid(app); + // The grid navigation keys act on the photos in view, so they wait while the full-screen preview + // covers the grid: a key must not move the selection onto photos nobody can see. + let grid_navigation = grid && !app.ui.fullscreen; // keys the user gave to a command: the fixed bindings below (aliases, ratings) yield to them let taken: Vec<(Modifiers, Key)> = keymap.values().filter_map(|s| parse(s)).collect(); // an open popup (a menu, a date picker's calendar) closes on Esc itself: Esc's command (Back, @@ -414,6 +417,20 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { fire.push(format!("label:{label}")); } } + if grid_navigation { + // Home / End: the first / last photo of the view + for (key, id) in [(Key::Home, "grid:first"), (Key::End, "grid:last")] { + if matches(i, Modifiers::NONE, key) && !taken.contains(&(Modifiers::NONE, key)) { + fire.push(id.to_string()); + } + } + // Shift+← / Shift+→: the previous / next photo of the view joins the selection + for (key, step) in [(Key::ArrowLeft, -1i64), (Key::ArrowRight, 1)] { + if matches(i, Modifiers::SHIFT, key) && !taken.contains(&(Modifiers::SHIFT, key)) { + fire.push(format!("grid:extend:{step}")); + } + } + } }); use crate::panels::compare; // rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or @@ -474,6 +491,29 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } else if f == "view.softProof" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { // S in a grid: expand / collapse the stack (Lightroom's Library binding) let _ = app.run("stack.toggle", json!({})); + } else if f == "grid:first" || f == "grid:last" { + // Home / End in a grid: make the first / last photo of the view the active, selected one + let vis = app.session.visible_cloned(); + let edge = if f == "grid:first" { vis.first() } else { vis.last() }; + if let Some(id) = edge { + let _ = app.run("library.select", json!({"ids": [id.0], "active": id.0})); + } + } else if let Some(step) = f.strip_prefix("grid:extend:") { + // Shift+← / Shift+→ in a grid: the neighbour of the active photo joins the selection and + // becomes the active one. At either end of the view the key does nothing. + let step = step.parse::().unwrap_or(0); + let vis = app.session.visible_cloned(); + let neighbour = app + .session + .active() + .and_then(|active| vis.iter().position(|p| *p == active)) + .and_then(|pos| i64::try_from(pos).ok()) + .and_then(|pos| pos.checked_add(step)) + .and_then(|pos| usize::try_from(pos).ok()) + .and_then(|pos| vis.get(pos)); + if let Some(id) = neighbour { + let _ = app.run("library.select", json!({"ids": [id.0], "mode": "add"})); + } } else if compare::culling(app) && (f == "library.next" || f == "library.previous") { let d = if f == "library.next" { 1 } else { -1 }; let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) }; diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs index 1b04a46bd..45c3ba7de 100644 --- a/crates/ui-egui/src/tests_keymap.rs +++ b/crates/ui-egui/src/tests_keymap.rs @@ -237,3 +237,236 @@ fn opening_numeric_release_keeps_keys_in_the_editor() { } } } + +/// Press `key` with the named modifiers held (`cmd`, `shift`, `alt`, `ctrl`). +fn press(h: &mut Headless, key: &str, mods: &[&str]) { + let mut p = json!({ "key": key }); + for m in mods { + p[*m] = json!(true); + } + let r = h.request("ui.key", p, T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); +} + +fn view(h: &mut Headless, p: serde_json::Value) { + let r = h.request("ui.set", p, T); + assert_eq!(r["ok"], true, "{r}"); +} + +/// Make `id` the only selected, active photo. +fn select(h: &mut Headless, id: lightcraft_catalog::PhotoId) { + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0], "active": id.0}}), T); + assert_eq!(r["ok"], true, "{r}"); +} + +/// The demo library in Photo Grid with its middle photo selected: the photos in view, and that one. +fn grid_demo() -> (Headless, Vec, lightcraft_catalog::PhotoId) { + let mut h = demo(); + view(&mut h, json!({ "view": "photoGrid" })); + let vis = h.app.session.visible_cloned(); + assert!(vis.len() > 2, "{vis:?}"); + let middle = vis[vis.len() / 2]; + select(&mut h, middle); + (h, vis, middle) +} + +/// Count how often the folder picker is asked for; every ask is cancelled. +fn count_folder_picks(h: &mut Headless) -> std::sync::Arc { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = calls.clone(); + h.app.services.pick_folder = Some(Box::new(move || { + observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + None + })); + calls +} + +/// The declared keys of the two commands these tests bind. +#[test] +fn open_library_and_visualize_hdr_keys_are_declared() { + use crate::shortcuts::{Keymap, parse, shortcut_of}; + + let keymap = Keymap::new(); + for (id, want) in [("view.visualizeHdr", "Shift+H"), ("app.openLibrary", "Cmd+O")] { + assert_eq!(shortcut_of(&keymap, id), Some(want), "{id}"); + assert!(parse(want).is_some(), "{want} does not parse"); + } +} + +/// Opening a library uses the same folder picker as File > Open Library. Cancelling it +/// leaves the current photo and library intact. +#[test] +fn cmd_o_opens_the_library_picker_once() { + let mut h = demo(); + let calls = count_folder_picks(&mut h); + let active = h.app.session.active(); + let photos = h.app.session.catalog.len(); + press(&mut h, "O", &["cmd"]); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1, "one picker for Cmd+O"); + assert_eq!(h.app.session.active(), active); + assert_eq!(h.app.session.catalog.len(), photos); +} + +/// Shift+H toggles View > Visualize HDR; Cmd+Shift+H (Histogram) is untouched. +#[test] +fn shift_h_toggles_the_hdr_view() { + let mut h = demo(); + assert!(!h.app.ui.hdr_visualize); + let histogram = h.app.ui.histogram; + press(&mut h, "H", &["shift"]); + assert!(h.app.ui.hdr_visualize, "Shift+H turns Visualize HDR on"); + assert_eq!(h.app.ui.histogram, histogram, "Shift+H leaves the Histogram unchanged"); + press(&mut h, "H", &["shift"]); + assert!(!h.app.ui.hdr_visualize, "Shift+H turns it back off"); +} + +/// Home / End make the first / last photo of the view the active one, only in the library grids, +/// and yield to a key the user gave to another command. +#[test] +fn home_and_end_select_the_ends_of_the_grid() { + let (mut h, vis, _) = grid_demo(); + let (first, last) = (vis[0], *vis.last().unwrap()); + + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(first), "Home goes to the first photo"); + assert_eq!(h.app.session.selection.ids, vec![first], "and selects only it"); + press(&mut h, "End", &[]); + assert_eq!(h.app.session.active(), Some(last), "End goes to the last photo"); + assert_eq!(h.app.session.selection.ids, vec![last]); + + view(&mut h, json!({ "view": "squareGrid" })); + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(first), "the Square Grid has the keys too"); + + select(&mut h, last); + view(&mut h, json!({ "view": "detail" })); + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(last), "Home is a grid key"); + + view(&mut h, json!({ "view": "photoGrid" })); + let r = h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Home"})).unwrap(); + assert_eq!(r["shortcut"], "Home", "{r}"); + press(&mut h, "Home", &[]); + assert_eq!(h.app.ui.view, ViewMode::Survey, "the user's Home binding fires"); + assert_eq!(h.app.session.active(), Some(last), "the grid key stayed out of the way"); +} + +/// Shift+Left / Shift+Right in a grid add the previous / next photo of the view to the selection and +/// make it active, stop at either end, stay out of the loupe and yield to the user's keymap. +#[test] +fn shift_arrows_extend_the_grid_selection() { + let (mut h, vis, middle) = grid_demo(); + let at = vis.iter().position(|p| *p == middle).unwrap(); + let (previous, next) = (vis[at - 1], vis[at + 1]); + + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle, next], "Shift+Right adds the next photo"); + assert_eq!(h.app.session.active(), Some(next), "the added photo becomes active"); + // Shift+Left steps back onto a photo that is already selected: the selection stays, the active moves + press(&mut h, "Left", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle, next]); + assert_eq!(h.app.session.active(), Some(middle)); + press(&mut h, "Left", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle, next, previous], "Shift+Left adds the previous photo"); + assert_eq!(h.app.session.active(), Some(previous)); + + // the ends of the view do nothing + select(&mut h, vis[0]); + press(&mut h, "Left", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![vis[0]], "Shift+Left on the first photo is a no-op"); + let last = *vis.last().unwrap(); + select(&mut h, last); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![last], "Shift+Right on the last photo is a no-op"); + + // outside a grid it is not the extension key + select(&mut h, middle); + view(&mut h, json!({ "view": "detail" })); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle], "the loupe has no grid extension key"); + + // a key the user gave to a command wins + view(&mut h, json!({ "view": "photoGrid" })); + h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Shift+Right"})).unwrap(); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.ui.view, ViewMode::Survey, "the user's Shift+Right binding fires"); + assert_eq!(h.app.session.selection.ids, vec![middle], "the grid key stayed out of the way"); +} + +/// A text field with the keyboard focus keeps every one of these keys. +#[test] +fn navigation_open_library_and_hdr_keys_wait_for_a_text_field() { + let (mut h, vis, middle) = grid_demo(); + let calls = count_folder_picks(&mut h); + let picks = || calls.load(std::sync::atomic::Ordering::SeqCst); + let keys: [(&str, &[&str]); 6] = [("Home", &[]), ("End", &[]), ("Right", &["shift"]), ("Left", &["shift"]), ("O", &["cmd"]), ("H", &["shift"])]; + + // with no text field focused, each of them acts + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(vis[0])); + press(&mut h, "End", &[]); + assert_eq!(h.app.session.active(), vis.last().copied()); + select(&mut h, middle); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids.len(), 2, "Shift+Right extends"); + press(&mut h, "O", &["cmd"]); + assert_eq!(picks(), 1, "Cmd+O asks for a folder"); + press(&mut h, "H", &["shift"]); + assert!(h.app.ui.hdr_visualize, "Shift+H turns Visualize HDR on"); + press(&mut h, "H", &["shift"]); + assert!(!h.app.ui.hdr_visualize); + select(&mut h, middle); + + // with the search box focused, none does + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.view.ctx.egui_wants_keyboard_input(), "the search box has the keyboard"); + let before = (h.app.session.active(), h.app.session.selection.ids.clone(), h.app.ui.hdr_visualize, h.app.ui.view, picks()); + for (key, mods) in keys { + press(&mut h, key, mods); + let after = (h.app.session.active(), h.app.session.selection.ids.clone(), h.app.ui.hdr_visualize, h.app.ui.view, picks()); + assert_eq!(after, before, "{mods:?}+{key} reached the shortcuts while the search box had the focus"); + } + assert!(h.app.ui.dialog.is_none()); +} + +/// Home / End and Shift+Left / Shift+Right belong to the grid: while the full-screen preview covers it +/// they leave the selection alone. +#[test] +fn grid_navigation_keys_do_nothing_in_the_full_screen_preview() { + let (mut h, vis, middle) = grid_demo(); + h.app.run("view.fullScreenPreview", json!({})).unwrap(); + assert!(h.app.ui.fullscreen); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "the grid is the view behind the preview"); + for (key, mods) in [("Home", &[][..]), ("End", &[][..]), ("Right", &["shift"][..]), ("Left", &["shift"][..])] { + press(&mut h, key, mods); + assert_eq!(h.app.session.active(), Some(middle), "{mods:?}+{key} moved the active photo in the full-screen preview"); + assert_eq!(h.app.session.selection.ids, vec![middle], "{mods:?}+{key} changed the selection in the full-screen preview"); + } + // back in the grid the keys work again + h.app.run("view.fullScreenPreview", json!({})).unwrap(); + assert!(!h.app.ui.fullscreen); + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(vis[0])); +} + +/// A dialog owns the keyboard: Cmd+O does not ask for a folder behind it. +#[test] +fn cmd_o_does_not_fire_while_a_dialog_is_open() { + let mut h = demo(); + let calls = count_folder_picks(&mut h); + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + press(&mut h, "O", &["cmd"]); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0, "Cmd+O reached the shortcuts behind a dialog"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + // with the dialog closed the same key asks for a folder + h.app.ui.dialog = None; + press(&mut h, "O", &["cmd"]); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); +} diff --git a/docs/gaps.md b/docs/gaps.md index 49c9a3b3a..68c2595e2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -83,6 +83,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-14 | minor | Gap 10: 21 Sony and Leica models verified on one CC0 file each (258 models) | | 2026-10-12 | minor | Gap 20 shrinks: Mamiya MEF decodes (`vendor/mef.rs`) and is imported; gap 10 count 240 → 241 | | 2026-10-12 | minor | Gap 20 shrinks: Epson ERF decodes (`vendor/erf.rs`), 3 bodies verified; gap 10 count 237 → 240 | +| 2026-10-13 | minor | Gap 28: F6 (filmstrip) and F7 (left panel) are bound, so the entry no longer lists them as missing; hours unchanged | | 2026-10-11 | minor | Gap 10 shrinks: Leica Q3 verified by a recorded check of 38 private DNGs (236 models verified; Leica 14); gap not closed | | 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | | 2026-10-12 | minor | Gap 23 (accessibility): crop handles, spots, gradient handles and mask pins by keyboard; 4–8 h → 2–4 h; gap not closed | diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index 43070dbcb..73322b2f0 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -41,6 +41,14 @@ The separate Decrease Rating / Increase Rating commands can also be assigned in Flag cycling and raise/lower use each photo's own flag, so a mixed selection steps together without being made uniform. Adobe's shortcut list names the backtick "Cycle flag settings" but does not state the order; unflagged → pick → reject is LightCraft's choice. The key is the logical grave key, so it follows the keyboard layout (on layouts where it is a dead accent key, assign another key in Help → Keyboard Shortcuts to `photo.cycleFlag`). +`Home` and `End` make the first or last photo of the view the active, selected one. `Shift` + `←` / +`→` add the previous or next photo of the view to the selection and make the added photo active; the +key never removes a photo from the selection, and at either end of the view it does nothing. These +four keys work in Photo Grid and Square Grid only. They wait in Detail, Compare and Survey, in the +full-screen preview (which covers the grid), while a text field or a dialog has the keyboard, and +when you gave the same key to a command in Help → Keyboard Shortcuts, where they are listed under +Fixed keys. + Grid actions apply to all selected photos and support Undo / Redo. In Compare and Survey, culling actions apply to the active photo. Auto Advance moves forward after an action; holding Shift with Auto Advance on still advances only once. The last photo stays selected. Text fields keep their diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index b7f0f000e..4e575aac7 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -371,7 +371,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-HDR-EDIT | HDR editing | P2 | 🟡 | `cmd:develop.hdr`, `crates/pipeline/src/tone.rs` (`ToneMap::hdr`), `crates/pipeline/src/tests_hdr.rs` | HDR tone map (SDR curve through the midtones, highlights up to the headroom limit) and float HDR render, CPU only; no on-screen HDR display yet (LR-VIEW-HDR-DISPLAY); `crs:HDREditMode` from XMP / Lightroom catalogs turns HDR editing on or off (the headroom limit and SDR rendition aren't read from crs yet) | | LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | 🟡 | `ctl:hdr.sdr*` | SDR rendition sliders (brightness, contrast, highlights, shadows, whites, clarity) used by every SDR view and export; no separate SDR-preview toggle until HDR display lands | -| LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`) | grey below SDR white, four colour bands by stops above it | +| LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`), `crates/ui-egui/src/tests_keymap.rs` (`shift_h_toggles_the_hdr_view`) | grey below SDR white, four colour bands by stops above it; ⇧H toggles it (⌘⇧H stays the Histogram panel), and a text field with the focus keeps the key | | LR-HDR-LIMIT | HDR headroom limit | P2 | ✅ | `ctl:hdr.maxEv`, `cmd:develop.hdr` | 0 to 5 stops; marked on the HDR histogram (whose scale stays at 5 stops) | | LR-HDR-EXPORT | HDR export | P2 | 🟡 | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 AVIF with PQ or HLG (`hdrTransfer`, BT.2100 / BT.2408, SDR white at signal 0.75), 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 16-bit Rec. 2020 PQ JPEG XL, 32-bit float TIFF; no HEIC (no permissive HEVC encoder) | @@ -700,7 +700,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | | LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | -| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open | +| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key | | 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; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu: files and XMP sidecars move, virtual copies follow, across volumes copied and verified before the source goes, a name taken in the destination refuses the whole move, one undo step), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | | 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; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | @@ -831,6 +831,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | KEY-SLIDER-NUDGE: scrolling sideways over a slider adjusts it | | 2026-10-13 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D90 NEFs drop their 42 filler columns (pink band on the right) and clip at one measured white level; status unchanged (🟡) | | 2026-10-13 | minor | LR-VER-HISTORY, LRC-DEV-HISTORY: History cursor (go to a step without adding one; Previous/Next History Step) | +| 2026-10-13 | minor | KEYC-COMPARE: Home / End select the first / last photo of the view and ⇧← / ⇧→ add the previous / next photo to the selection in Photo Grid and Square Grid (not in the loupe, the full-screen preview, a text field or a dialog); LRC-LIB-CATALOG-SWITCH: ⌘O runs Open Library…; LR-HDR-VISUALIZE: ⇧H toggles Visualize HDR. Statuses unchanged (KEYC-COMPARE stays 🟡: no thumbnail-size keys) | | 2026-10-12 | minor | LR-IMP-FORMATS: the Hasselblad Lunar (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data instead of opening as its preview; an uncompressed Leaf MOS imports as a preview-only raw instead of a greyscale image of its mosaic (#1275); status unchanged (🟡) | | 2026-10-12 | minor | LR-HDR-EXPORT, LR-EXP-HDR: HLG AVIF export (`hdrTransfer`); LR-VIEW-HDR-DISPLAY: tested SDR/HDR display decision | | 2026-10-12 | minor | LR-EXP-PSD, MENU-FILE-PHOTOSHOP: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy) hands the original files (the raws themselves) to the other editor in one start; statuses unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a7a7e8449..17a8b6daa 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -70,6 +70,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. **Grid thumbnail size (KEYC-COMPARE):** Classic's `=` and `-` increase and decrease the Grid thumbnail size (same keys on macOS and Windows; `+` also works for the larger size here, as Shift+`=` or the keypad). They step the bottom-bar slider by 30 pt inside its 90–480 pt range, in Photo Grid, Square Grid and People only; Detail, Compare, Survey and Reference ignore them, and ⌘= / ⌘− stay Zoom In / Out. See [library shortcuts](library-shortcuts.md). +**Library navigation keys (KEYC-COMPARE):** in Photo Grid and Square Grid, `Home` / `End` make the first / last photo of the view the active, selected one, and `Shift+Left` / `Shift+Right` add the previous / next photo of the view to the selection (the added photo becomes active; the keys never remove a photo from the selection, and do nothing at either end of the view). They wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when you gave the key to a command; they are listed under Fixed keys in Help → Keyboard Shortcuts. `Cmd+O` (`Ctrl+O` on Windows and Linux) runs File → Open Library… and `Shift+H` toggles View → Visualize HDR (`Cmd+Shift+H` stays the Histogram). Covered by `crates/ui-egui/src/tests_keymap.rs`; the macOS native menu path for `Cmd+O` is not exercised by the headless tests. + **Still missing / broken:** - No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, visualize spots and merges have commands now). @@ -132,6 +134,7 @@ a real screen reader on any platform yet. | 2026-10-13 | minor | Shortcuts: Tool Overlay modes for spot and mask pins on ⇧H (H stays Remove; issue #224), listed with the deliberate differences; dropped from the missing list | | 2026-10-13 | minor | Library culling: backtick cycles the flag; ⌘↑ / ⌘↓ raise / lower flag status | | 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` grid thumbnail size keys noted in the shortcuts section | +| 2026-10-13 | minor | Shortcuts: Home / End and Shift+Left / Right in the two library grids, Cmd+O (Open Library…) and Shift+H (Visualize HDR) get bindings and keyboard tests; percentages and the dated counts above are unchanged | | 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | | 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist |