# Conflicts:
#	docs/gaps.md
#	docs/library-shortcuts.md
#	docs/parity-checklist.md
#	docs/ui-parity.md
This commit is contained in:
Brandon Thomas
2026-10-14 22:33:11 -04:00
8 changed files with 299 additions and 4 deletions
+2 -2
View File
@@ -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
+9
View File
@@ -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)| {
+40
View File
@@ -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::<i64>().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) };
+233
View File
@@ -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>, 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<std::sync::atomic::AtomicUsize> {
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);
}
+1
View File
@@ -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 |
+8
View File
@@ -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
File diff suppressed because one or more lines are too long
+3
View File
@@ -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 |