diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 256faa1f8..4ac40bcac 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -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 { "=" => 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 diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 3a5575ea8..80cfdc8c1 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -196,6 +196,25 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result 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 { + 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); @@ -687,6 +706,40 @@ pub fn specs() -> Vec { 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 })) }), @@ -1623,3 +1676,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; diff --git a/crates/engine/src/cmd/library/tests_flag_cycle.rs b/crates/engine/src/cmd/library/tests_flag_cycle.rs new file mode 100644 index 000000000..6a476dc1d --- /dev/null +++ b/crates/engine/src/cmd/library/tests_flag_cycle.rs @@ -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 { + 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 { + (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 = (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")); +} diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 04eea2439..b2cfa948d 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -766,7 +766,18 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec)> { /// Compare/Survey; Auto Advance moves on). pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result { 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); } @@ -799,6 +810,8 @@ pub fn shortcut_text(sc: &str, mac: bool) -> String { "Escape" => "⎋", "Left" => "←", "Right" => "→", + "Up" => "↑", + "Down" => "↓", k => k, }; format!("{mods}{key}") diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1a21ab2fc..4211bdff7 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -168,6 +168,7 @@ pub fn format(m: Modifiers, k: Key) -> Option { Key::CloseBracket => "]", Key::Quote => "'", Key::Comma => ",", + Key::Backtick => "`", k => k.name(), }; let mut s = String::new(); @@ -451,6 +452,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() { @@ -946,3 +958,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; diff --git a/crates/ui-egui/src/shortcuts/tests_flag_cycle.rs b/crates/ui-egui/src/shortcuts/tests_flag_cycle.rs new file mode 100644 index 000000000..d4e6deec5 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/tests_flag_cycle.rs @@ -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 { + 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 { + (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), "⌘↑"); +} diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index 414e9166b..123070bca 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -11,6 +11,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 | Purple and clearing a colour label are available in Photo → Set Color Label and the photo context @@ -29,6 +31,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 diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..cc722ac71 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (KEYC-RATING: ` cycles the flag, ⌘↑/⌘↓ raise/lower it; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -770,7 +770,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 | @@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 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 (🟡) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2236f02a7..6557687df 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (backtick flag cycling, ⌘↑/⌘↓ flag status; accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -65,7 +65,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, @@ -105,6 +105,7 @@ left panel, canvas, right panel, which is sensible but not tuned). | Date | Change | Summary | |---|---|---| +| 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 |