# Conflicts:
#	docs/parity-checklist.md
#	docs/ui-parity.md
This commit is contained in:
Brandon Thomas
2026-10-14 22:32:25 -04:00
9 changed files with 339 additions and 5 deletions
+4 -2
View File
@@ -26,8 +26,9 @@ const QUIT: &str = "app.quit";
const SETTINGS: &str = "app.settings";
const SETTINGS_KEY: &str = "Cmd+,";
/// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo
/// or the active mask): left to the egui handler, which knows the context.
const CONTEXTUAL: &[&str] = &["X", "Delete"];
/// or the active mask; ⌘↑/⌘↓ flag status follows Compare/Survey's active photo): left to the egui
/// handler, which knows the context.
const CONTEXTUAL: &[&str] = &["X", "Delete", "Cmd+Up", "Cmd+Down"];
/// Shortcuts text fields need while they have focus.
const TEXT_EDIT: &[&str] = &["Cmd+A", "Cmd+C", "Cmd+V", "Cmd+X", "Cmd+Z", "Cmd+Shift+Z"];
@@ -126,6 +127,7 @@ fn accelerator(sc: &str) -> Option<Accelerator> {
"=" => Code::Equal,
"-" => Code::Minus,
"'" => Code::Quote,
"`" => Code::Backquote,
"," => Code::Comma,
"Delete" => Code::Backspace,
// with modifiers only: a bare Enter belongs to text fields and tools
+57
View File
@@ -196,6 +196,25 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<V
Ok(json!({"changed": n}))
}
/// Move each target's own flag by `step`: cycling wraps unflagged -> pick -> reject -> unflagged,
/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step.
fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result<Value> {
let mut ops = Vec::new();
for id in s.targets(p) {
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag;
let flag = step(old);
if flag != old {
ops.push(Op::SetFlag { id, flag });
}
}
let n = ops.len();
if n > 0 {
s.commit(label, Op::Batch { ops })?;
}
advance_if(s, p);
Ok(json!({"changed": n}))
}
fn advance_if(s: &mut Session, p: &Value) {
if bool_or(p, "advance", false) {
let _ = step(s, 1);
@@ -691,6 +710,40 @@ pub fn specs() -> Vec<CommandSpec> {
advance_if(s, p);
Ok(v)
}),
// Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux)
cmd!(
"photo.cycleFlag",
"Cycle Flag",
["Photo", "Set Flag"],
Some("`"),
"{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged",
has_selection,
|s, p| {
change_flag(s, p, "Cycle Flag", |f| match f {
Flag::None => Flag::Pick,
Flag::Pick => Flag::Reject,
Flag::Reject => Flag::None,
})
}
),
cmd!(
"photo.increaseFlag",
"Increase Flag Status",
["Photo", "Set Flag"],
Some("Cmd+Up"),
"{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick)",
has_selection,
|s, p| { change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick }) }
),
cmd!(
"photo.decreaseFlag",
"Decrease Flag Status",
["Photo", "Set Flag"],
Some("Cmd+Down"),
"{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject)",
has_selection,
|s, p| { change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject }) }
),
cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| {
for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))
}),
@@ -1648,3 +1701,7 @@ mod gps_tests {
#[cfg(test)]
#[path = "library/tests_relative_ratings.rs"]
mod relative_rating_tests;
#[cfg(test)]
#[path = "library/tests_flag_cycle.rs"]
mod flag_cycle_tests;
@@ -0,0 +1,100 @@
//! `photo.cycleFlag` / `photo.increaseFlag` / `photo.decreaseFlag` through the real engine.
use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::{Result, Selection, Session};
fn session() -> Result<Session> {
let mut s = Session::new();
for (id, flag) in [(1, Flag::None), (2, Flag::Pick), (3, Flag::Reject), (4, Flag::None)] {
let mut photo = Photo::new(
PhotoId(id),
Source::File { path: format!("/lightcraft-flag-cycle/{id}.jpg") },
&format!("{id}.jpg"),
"JPEG",
40,
30,
"2026-10-13",
);
photo.flag = flag;
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;
}
s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?;
s.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2}))?;
Ok(s)
}
fn flags(s: &Session) -> Vec<Flag> {
(1..=4).map(|id| s.catalog.photo(PhotoId(id)).unwrap().flag).collect()
}
#[test]
fn cycle_steps_unflagged_pick_reject_and_wraps() {
let mut s = session().unwrap();
s.execute("library.select", &json!({"ids": [1]})).unwrap();
let seen: Vec<Flag> = (0..4)
.map(|_| {
s.execute("photo.cycleFlag", &json!({})).unwrap();
flags(&s)[0]
})
.collect();
assert_eq!(seen, [Flag::Pick, Flag::Reject, Flag::None, Flag::Pick]);
}
#[test]
fn cycle_uses_each_photos_own_flag_in_one_undo_step() {
let mut s = session().unwrap();
let undo = s.undo.len();
assert_eq!(s.execute("photo.cycleFlag", &json!({})).unwrap()["changed"], 3);
assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]);
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.active(), Some(PhotoId(2)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::None]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]);
}
#[test]
fn explicit_ids_active_fallback_advance_and_errors() {
let mut s = session().unwrap();
s.execute("photo.cycleFlag", &json!({"ids": [4]})).unwrap();
assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::Pick]);
s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) };
s.execute("photo.cycleFlag", &json!({})).unwrap();
assert_eq!(flags(&s)[1], Flag::Reject);
s.execute("photo.cycleFlag", &json!({"advance": true})).unwrap();
assert_eq!(flags(&s)[1], Flag::None);
assert_eq!(s.active(), Some(PhotoId(3)));
let undo = s.undo.len();
assert!(s.execute("photo.cycleFlag", &json!({"ids": [1, 999]})).is_err());
assert_eq!(flags(&s)[0], Flag::None);
assert_eq!(s.undo.len(), undo);
s.selection = Selection::default();
assert!(s.execute("photo.cycleFlag", &json!({})).is_err());
assert_eq!(s.undo.len(), undo);
}
#[test]
fn increase_and_decrease_clamp_without_wrapping() {
let mut s = session().unwrap();
assert_eq!(s.execute("photo.increaseFlag", &json!({})).unwrap()["changed"], 2);
assert_eq!(flags(&s), [Flag::Pick, Flag::Pick, Flag::None, Flag::None]);
assert_eq!(s.execute("photo.increaseFlag", &json!({"ids": [2]})).unwrap()["changed"], 0);
s.execute("photo.decreaseFlag", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::None, Flag::None, Flag::Reject, Flag::None]);
s.execute("photo.decreaseFlag", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::Reject, Flag::Reject, Flag::Reject, Flag::None]);
let undo = s.undo.len();
assert_eq!(s.execute("photo.decreaseFlag", &json!({})).unwrap()["changed"], 0);
assert_eq!(s.undo.len(), undo, "no change, no history step");
}
#[test]
fn registered_with_classic_shortcuts() {
let specs = crate::command_specs();
let key = |id: &str| specs.iter().find(|c| c.id == id).and_then(|c| c.shortcut);
assert_eq!(key("photo.cycleFlag"), Some("`"));
assert_eq!(key("photo.increaseFlag"), Some("Cmd+Up"));
assert_eq!(key("photo.decreaseFlag"), Some("Cmd+Down"));
}
+14 -1
View File
@@ -830,7 +830,18 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec<MenuNode>)> {
/// Compare/Survey; Auto Advance moves on).
pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result<Value, String> {
let mut params = if params.is_null() { json!({}) } else { params };
let culling_cmd = matches!(id, "photo.rate" | "photo.flag" | "photo.label" | "photo.pick" | "photo.reject" | "photo.unflag");
let culling_cmd = matches!(
id,
"photo.rate"
| "photo.flag"
| "photo.label"
| "photo.pick"
| "photo.reject"
| "photo.unflag"
| "photo.cycleFlag"
| "photo.increaseFlag"
| "photo.decreaseFlag"
);
if culling_cmd {
crate::panels::compare::target_active(app, &mut params);
}
@@ -863,6 +874,8 @@ pub fn shortcut_text(sc: &str, mac: bool) -> String {
"Escape" => "⎋",
"Left" => "←",
"Right" => "→",
"Up" => "↑",
"Down" => "↓",
k => k,
};
format!("{mods}{key}")
+16
View File
@@ -168,6 +168,7 @@ pub fn format(m: Modifiers, k: Key) -> Option<String> {
Key::CloseBracket => "]",
Key::Quote => "'",
Key::Comma => ",",
Key::Backtick => "`",
k => k.name(),
};
let mut s = String::new();
@@ -470,6 +471,17 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
} 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) };
} else if matches!(f.as_str(), "photo.cycleFlag" | "photo.increaseFlag" | "photo.decreaseFlag") {
// `, ⌘↑, ⌘↓: step the flag; the toast names the active photo's new state (read before Auto Advance moves on)
let active = app.session.active();
cull(app, &f, json!({}), false);
let flag = active.and_then(|id| app.session.catalog.photo(id)).map(|p| p.flag);
match flag {
Some(lightcraft_catalog::Flag::Pick) => app.toast(ctx, crate::i18n::tr("Flagged as Pick")),
Some(lightcraft_catalog::Flag::Reject) => app.toast(ctx, crate::i18n::tr("Flagged as Reject")),
Some(lightcraft_catalog::Flag::None) => app.toast(ctx, crate::i18n::tr("Unflagged")),
None => {}
}
} else if matches!(f.as_str(), "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop {
cull(app, &f, json!({}), false);
match f.as_str() {
@@ -1207,3 +1219,7 @@ mod tests {
#[cfg(test)]
#[path = "shortcuts/tests_relative_ratings.rs"]
mod relative_rating_tests;
#[cfg(test)]
#[path = "shortcuts/tests_flag_cycle.rs"]
mod flag_cycle_tests;
@@ -0,0 +1,140 @@
//! The backtick (`) key cycles the flag of the selection / active photo; Cmd+Up / Cmd+Down raise / lower it.
use egui::{Key, Modifiers};
use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::LightcraftApp;
use crate::state::ViewMode;
fn app() -> lightcraft_engine::Result<LightcraftApp> {
let mut s = lightcraft_engine::Session::new();
for id in 1..=3 {
let photo = Photo::new(
PhotoId(id),
Source::File { path: format!("/lightcraft-flag-cycle-keys/{id}.jpg") },
&format!("{id}.jpg"),
"JPEG",
40,
30,
"2026-10-13",
);
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;
}
s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?;
s.execute("library.select", &json!({"ids": [2]}))?;
Ok(LightcraftApp::new(s, crate::Services { png: None, ..Default::default() }))
}
fn flags(app: &LightcraftApp) -> Vec<Flag> {
(1..=3).map(|id| app.session.catalog.photo(PhotoId(id)).unwrap().flag).collect()
}
fn frame(app: &mut LightcraftApp, ctx: &egui::Context, key: Option<(Key, Modifiers)>, text: Option<&mut String>) {
let events =
key.map(|(key, modifiers)| egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers }).into_iter().collect();
let mut text = text;
let mut out = ctx.run_ui(
egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() },
|ui| {
if let Some(text) = text.as_deref_mut() {
ui.add(egui::TextEdit::singleline(text).id(egui::Id::new("flag-cycle-search"))).request_focus();
}
super::handle(app, ui.ctx());
},
);
out.textures_delta.clear();
}
fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
frame(app, ctx, Some((Key::Backtick, Modifiers::NONE)), None);
}
#[test]
fn backtick_parses_and_formats_as_the_grave_key() {
assert_eq!(super::parse("`"), Some((Modifiers::NONE, Key::Backtick)));
assert_eq!(super::format(Modifiers::NONE, Key::Backtick).as_deref(), Some("`"));
assert_eq!(super::shortcut_of(&Default::default(), "photo.cycleFlag"), Some("`"));
}
#[test]
fn backtick_cycles_the_selected_photo_in_grid_and_loupe() {
for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail] {
let mut app = app().unwrap();
app.ui.view = view;
let ctx = egui::Context::default();
let mut seen = Vec::new();
for _ in 0..3 {
tick(&mut app, &ctx);
seen.push(flags(&app)[1]);
}
assert_eq!(seen, [Flag::Pick, Flag::Reject, Flag::None], "{view:?}");
assert_eq!(flags(&app)[0], Flag::None);
assert_eq!(flags(&app)[2], Flag::None);
assert_eq!(app.session.undo.len(), 3);
app.session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(flags(&app)[1], Flag::Reject);
}
}
#[test]
fn cmd_up_and_down_raise_and_lower_the_flag() {
let mut app = app().unwrap();
app.ui.view = ViewMode::PhotoGrid;
let ctx = egui::Context::default();
let cmd = Modifiers::COMMAND;
frame(&mut app, &ctx, Some((Key::ArrowUp, cmd)), None);
assert_eq!(flags(&app)[1], Flag::Pick);
frame(&mut app, &ctx, Some((Key::ArrowDown, cmd)), None);
frame(&mut app, &ctx, Some((Key::ArrowDown, cmd)), None);
assert_eq!(flags(&app)[1], Flag::Reject);
assert_eq!(app.session.undo.len(), 3);
}
#[test]
fn backtick_does_nothing_while_a_text_field_has_focus() {
let mut app = app().unwrap();
app.ui.view = ViewMode::PhotoGrid;
let ctx = egui::Context::default();
let mut text = String::new();
frame(&mut app, &ctx, None, Some(&mut text));
frame(&mut app, &ctx, Some((Key::Backtick, Modifiers::NONE)), Some(&mut text));
assert!(ctx.egui_wants_keyboard_input());
assert_eq!(flags(&app), [Flag::None; 3]);
assert!(app.session.undo.is_empty());
}
#[test]
fn compare_flags_only_the_active_photo_and_auto_advance_moves_on() {
let mut app = app().unwrap();
app.ui.view = ViewMode::Compare;
let ctx = egui::Context::default();
app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap();
tick(&mut app, &ctx);
assert_eq!(flags(&app), [Flag::None, Flag::Pick, Flag::None]);
app.ui.view = ViewMode::PhotoGrid;
app.ui.auto_advance = true;
app.session.execute("library.select", &json!({"ids": [1]})).unwrap();
tick(&mut app, &ctx);
assert_eq!(flags(&app), [Flag::Pick, Flag::Pick, Flag::None]);
assert_eq!(app.session.active(), Some(PhotoId(2)));
}
#[test]
fn a_saved_assignment_of_the_key_wins_over_the_default() {
let mut app = app().unwrap();
app.ui.view = ViewMode::PhotoGrid;
let ctx = egui::Context::default();
super::assign(&mut app.ui.settings.keymap, "photo.reject", Some("`")).unwrap();
tick(&mut app, &ctx);
assert_eq!(flags(&app)[1], Flag::Reject);
}
#[test]
fn the_menu_item_follows_the_same_culling_rules() {
let mut app = app().unwrap();
app.ui.view = ViewMode::Compare;
app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap();
crate::menubar::run_item(&mut app, "photo.cycleFlag", serde_json::Value::Null).unwrap();
assert_eq!(flags(&app), [Flag::None, Flag::Pick, Flag::None]);
assert_eq!(crate::menubar::shortcut_text("Cmd+Up", true), "⌘↑");
}
+4
View File
@@ -13,6 +13,8 @@ Select photos in Photo Grid or Square Grid, then use:
| `P` | Flag as a pick |
| `X` | Flag as rejected |
| `U` | Clear the flag |
| `` ` `` (backtick) | Cycle the flag: unflagged → pick → reject → unflagged |
| `Cmd`/`Ctrl` + `↑` / `↓` | Raise / lower flag status (reject < unflagged < pick; stops at either end) |
| `Shift` + `0`–`9`, `P`, `X` or `U` | Apply the action and select the next photo |
Enter opens the active photo without changing the selection. Text fields, dialogs and open popups keep the key; a custom Enter binding takes precedence. Enter has no additional action in Detail.
@@ -37,6 +39,8 @@ step. AutoWrite uses the normal rating metadata path. In Detail, including Maski
remapping and disabling the default brush bindings take precedence over their grid bracket actions.
The separate Decrease Rating / Increase Rating commands can also be assigned in Help → Keyboard Shortcuts.
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`).
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
+2 -1
View File
@@ -778,7 +778,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | |
| KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | ✅ | `cmd:library.import`, `cmd:photo.delete`, `cmd:photo.virtualCopy`, `cmd:app.showInFinder`, `cmd:dialog.rename`, `cmd:photo.editInExternal` | ⇧⌘I add, ⌘' virtual copy, ⌘R show in Finder, F2 rename, ⌫ delete, ⇧⌘E external editor |
| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle` | Z toggles zoom; no compare, stacks, thumbnail-size keys |
| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), rating `[` / `]`, flag cycle and filter-bar keys |
| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`, ⌘↑ ⌘↓) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag`, `cmd:photo.cycleFlag`, `cmd:photo.increaseFlag`, `cmd:photo.decreaseFlag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; ` (backtick) cycles each selected photo's flag unflagged → pick → reject → unflagged and ⌘↑ / ⌘↓ (Ctrl on Windows/Linux) raise / lower it (reject < unflagged < pick, clamped); one undo step, Compare/Survey act on the active photo, Auto Advance follows; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), filter-bar keys |
| KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection |
| KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste |
| KEYC-DEVELOP | Classic develop keys (V, ⌘U, ⇧⌘U, R, Q, K, M, ⇧M, ⇧W, ⇧J, ⇧Q…) | P2 | 🟡 | `cmd:develop.treatment`, `cmd:develop.reset`, `cmd:crop.reset` | V, ⇧⌘R, ⌥⌘R, W, J, Y, ⇧Y, \ match; R/K/M/⇧M differ; no Classic keymap layer |
@@ -812,6 +812,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: 21 Sony and Leica Adobe models verified on one CC0 file each; status unchanged (🟡) |
| 2026-10-13 | minor | KEY-HIDEPINS ⬜ → ✅: Tool Overlay (Auto / Always / Selected / Never, `view.toolOverlay`, ⇧H cycles; H stays Remove) for the Remove tool's spot pins and the mask pins (issue #224); LR-MASK-PINS loses its "no Auto mode" note, LR-REM-SPOT-EDIT notes the modes |
| 2026-10-13 | minor | LR-EDIT-DETAIL-NR, LR-EDIT-DETAIL-CNR: noise-reduction radii in the original's pixels, so previews at any size and smaller exports look like the full-size render scaled down (issue #960); status unchanged |
| 2026-10-13 | minor | KEYC-RATING: backtick cycles the flag (`photo.cycleFlag`), ⌘↑ / ⌘↓ raise / lower it (`photo.increaseFlag`, `photo.decreaseFlag`); status unchanged (🟡) |
| 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged |
| 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) |
| 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) |
+2 -1
View File
@@ -66,7 +66,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — |
| Select None | ⌘⇧A (+ ⌘D) | ⌘D | — |
**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. Remaining Classic gaps: rating `[` / `]`, flag cycling and filter-bar keys.
**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. `` ` `` cycles the flag (unflagged → pick → reject → unflagged) and ⌘↑ / ⌘↓ raise / lower it; the native menu leaves ⌘↑ / ⌘↓ to egui so Compare/Survey act on the active photo. Remaining Classic gaps: filter-bar keys.
**Still missing / broken:**
- No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks,
@@ -128,6 +128,7 @@ a real screen reader on any platform yet.
|---|---|---|
| 2026-10-12 | minor | Accessibility: crop handles, spots, gradient handles and mask pins operable by keyboard with AccessKit names and values; Tab order test for the right panels; hand-drawn icons and rows named; 55% → 75%; UI remaining 34–58 h → 32–54 h |
| 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-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 |