diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 93fd8642..41e64c02 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -610,7 +610,7 @@ impl eframe::App for WebApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { for text in safety::take_notices() { let now = ctx.input(|i| i.time); - self.app.ui.toast = Some((text, now + NOTICE_SECS)); + self.app.ui.toast = Some((text, now + NOTICE_SECS, None)); } self.run_inbox(); self.import_dropped(ctx); diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 45e90718..02d33db3 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -502,7 +502,8 @@ mod tests { assert!(installed.iter().any(|s| s == sc), "{sc} is in the menu bar"); } let owned = owned_by_menu(installed.iter().map(String::as_str), false); - for sc in ["E", "C", "Shift+P"] { + for sc in ["E", "C", "Shift+P", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "P", "U"] { + assert!(installed.iter().any(|s| s == sc), "{sc} is displayed in the menu"); assert!(!owned.contains(sc), "{sc} must be left to egui"); } for sc in ["Cmd+Shift+H", "F2"] { diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index ab802438..89568cef 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -148,6 +148,7 @@ "Cloudy": "曇り", "Collapse All Stacks": "すべてのスタックを閉じる", "Color": "カラー", + "Color label cleared": "カラーラベルを解除しました", "Color Grading": "カラーグレーディング", "Color Mixer": "カラーミキサー", "Color Noise Reduction": "カラーノイズ軽減", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index e9282fdb..cb846e89 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -188,6 +188,7 @@ "Cloudy": "阴天", "Collapse All Stacks": "折叠所有堆叠", "Color": "颜色", + "Color label cleared": "已清除颜色标签", "Color Grading": "颜色分级", "Color Label Names": "颜色标签名称", "Color Label Sets": "颜色标签集", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index f04bdbd9..1fbf9912 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -188,6 +188,7 @@ "Cloudy": "陰天", "Collapse All Stacks": "收合所有堆疊", "Color": "色彩", + "Color label cleared": "已清除色彩標籤", "Color Grading": "色彩分級", "Color Label Names": "色彩標籤名稱", "Color Label Sets": "色彩標籤集", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 773ac363..479825bf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,8 @@ mod tests_filmstrip; #[cfg(test)] mod tests_grid; #[cfg(test)] +mod tests_labels; +#[cfg(test)] mod tests_library_problem; #[cfg(test)] mod tests_masking; @@ -57,6 +59,8 @@ use serde_json::Value; pub use control::{ControlRequest, ControlResponse}; pub use state::UiState; +const TOAST_SECONDS: f64 = 1.4; + pub type PickFiles = Box Vec>; /// A save dialog: suggested file name → chosen path (`None` = cancelled). pub type SaveFile = Box Option>; @@ -270,6 +274,20 @@ impl LightcraftApp { return r; } let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + if r.is_ok() && id == "photo.label" { + let label = params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse); + let text = match label { + Some(label) => { + let name = + self.session.catalog.custom_label_name(label).map(str::to_owned).unwrap_or_else(|| i18n::tr(&format!("{label:?}")).into()); + i18n::tr_format!("{name} Label", name = name) + } + None => i18n::tr("Color label cleared").into(), + }; + // Native menu clicks can arrive before logic() updates last_time after an idle gap. + let now = self.tasks.repaint.as_ref().map(|ctx| ctx.input(|i| i.time)).unwrap_or(self.last_time); + self.ui.toast = Some((text, now + TOAST_SECONDS, label)); + } if let Err(e) = &r { log::warn!("{id}: {e}"); self.ui.status = e.clone(); @@ -344,7 +362,7 @@ impl LightcraftApp { self.ui.unsaved_seen = true; let t = ctx.input(|i| i.time); let what = if n == 1 { "1 change".to_string() } else { format!("{n} changes") }; - self.ui.toast = Some((format!("{what} saved in memory but not written to disk: {e} — LightCraft will retry"), t + 6.0)); + self.ui.toast = Some((format!("{what} saved in memory but not written to disk: {e} — LightCraft will retry"), t + 6.0, None)); } (None, true) => { self.ui.unsaved_seen = false; @@ -356,13 +374,13 @@ impl LightcraftApp { } pub fn toast(&mut self, ctx: &egui::Context, text: impl Into) { - self.toast_for(ctx, text, 1.4); + self.toast_for(ctx, text, TOAST_SECONDS); } /// A toast that stays `secs` seconds (messages that say where to look or what to do next). pub fn toast_for(&mut self, ctx: &egui::Context, text: impl Into, secs: f64) { let t = ctx.input(|i| i.time); - self.ui.toast = Some((text.into(), t + secs)); + self.ui.toast = Some((text.into(), t + secs, None)); } /// AI masks: apply finished background requests (clicks, descriptions, detail passes) and @@ -440,7 +458,7 @@ impl LightcraftApp { /// A toast for an error the user has to read and act on (stays 6 s). pub fn toast_error(&mut self, ctx: &egui::Context, text: impl Into) { let t = ctx.input(|i| i.time); - self.ui.toast = Some((text.into(), t + 6.0)); + self.ui.toast = Some((text.into(), t + 6.0, None)); } fn drain_control(&mut self, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f0e90b19..a2e2d8d8 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -576,7 +576,9 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { } fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode { - item(&e.id, Value::Null, live_label(app, &e.id, &e.label), e.shortcut.as_deref(), e.enabled, checked(app, &e.id)) + // Shift+P picks and advances in Library; don't advertise it on Presets in those views. + let shortcut = if e.id == "panel.presets" && crate::shortcuts::library_grid(app) { None } else { e.shortcut.as_deref() }; + item(&e.id, Value::Null, live_label(app, &e.id, &e.label), shortcut, e.enabled, checked(app, &e.id)) } /// Drop leading, trailing and doubled separators (also inside submenus) and empty submenus. diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index bbb5252a..cf9efd17 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -268,7 +268,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { Err(_) => { let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default(); let now = ctx.input(|i| i.time); - app.ui.toast = Some((crate::i18n::tr_format!("Merging… {stage} {:.0}%", f * 100.0, stage = stage), now + 0.5)); + app.ui.toast = Some((crate::i18n::tr_format!("Merging… {stage} {:.0}%", f * 100.0, stage = stage), now + 0.5, None)); ctx.request_repaint_after(std::time::Duration::from_millis(100)); } } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index cb74bb51..04c40fb4 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1609,11 +1609,16 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let state = app.session.selection.state_of(*id); let sel = state == lightcraft_engine::SelectionState::Active; let p = ui.painter(); - if state != lightcraft_engine::SelectionState::NotSelected { - p.rect_filled(cr, 0.0, t.cell_selected); + let label = app.session.catalog.photo(*id).and_then(|ph| ph.label); + let selected = state != lightcraft_engine::SelectionState::NotSelected; + let base = if selected { + t.cell_selected } else if resp.hovered() { - p.rect_filled(cr, 0.0, t.cell_selected.gamma_multiply(0.6)); - } + t.cell_selected.gamma_multiply(0.6) + } else { + t.canvas + }; + p.rect_filled(cr, 0.0, crate::theme::label_background(base, label, selected)); let names = app.ui.settings.film_names; if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) { let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 8f1660d0..231ab0c1 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -778,12 +778,23 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::Grid::new("shortcuts").striped(true).show(ui, |ui| { for (id, label, sc, _) in crate::menus::ui_commands() { if let Some(sc) = sc { - ui.label(crate::i18n::tr(label)); + let grid_pick = *id == "panel.presets" && crate::shortcuts::library_grid(app); + ui.label(crate::i18n::tr(if grid_pick { "Flag as Pick" } else { label })); ui.label(*sc); - ui.label(egui::RichText::new(*id).color(t.text_dim)); + ui.label(egui::RichText::new(if grid_pick { "photo.flag" } else { id }).color(t.text_dim)); ui.end_row(); } } + for sc in ["0–5", "Shift+0–5"] { + ui.label(crate::i18n::tr("Set Rating")); + ui.label(sc); + ui.label(egui::RichText::new("photo.rate").color(t.text_dim)); + ui.end_row(); + } + ui.label(crate::i18n::tr("Set Color Label")); + ui.label("6–9"); + ui.label(egui::RichText::new("photo.label").color(t.text_dim)); + ui.end_row(); for c in lightcraft_engine::command_specs() { if let Some(sc) = c.shortcut { ui.label(crate::i18n::tr(c.label)); diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index c8de2cff..e1240967 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -16,15 +16,7 @@ pub const HEIGHT: f32 = 40.0; const WIDE: f32 = 1240.0; /// Colour-label swatches (UI colours, our own choice). -pub fn label_color(l: ColorLabel) -> Color32 { - match l { - ColorLabel::Red => Color32::from_rgb(222, 72, 72), - ColorLabel::Yellow => Color32::from_rgb(232, 196, 58), - ColorLabel::Green => Color32::from_rgb(88, 176, 92), - ColorLabel::Blue => Color32::from_rgb(72, 130, 222), - ColorLabel::Purple => Color32::from_rgb(158, 100, 210), - } -} +pub use crate::theme::label_color; fn filter(app: &mut LightcraftApp, patch: Value) { let _ = app.run("library.filter", patch); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 9d63bd08..1ef6beb0 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -496,7 +496,8 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square let active = state == lightcraft_engine::SelectionState::Active; let p = ui.painter(); let img_rect = if square { - p.rect_filled(r, 0.0, if selected { t.cell_selected } else { t.cell }); + let base = if selected { t.cell_selected } else { t.cell }; + p.rect_filled(r, 0.0, crate::theme::label_background(base, photo.label, selected)); Rect::from_min_max(r.min + vec2(10.0, 24.0), r.max - vec2(10.0, 10.0)) } else { r @@ -561,9 +562,14 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square crate::state::GridBadges::Always => true, crate::state::GridBadges::Never => false, }; + let badge_bar = Rect::from_min_max(pos2(img_rect.left(), img_rect.bottom() - 24.0), img_rect.right_bottom()); + if !square && photo.label.is_some() { + // Justified photos fill the whole cell: reserve the badge footer as coloured chrome. + p.rect_filled(badge_bar, 0.0, crate::theme::label_background(t.cell, photo.label, selected)); + } if show_badges { - let bar = Rect::from_min_max(pos2(img_rect.left(), img_rect.bottom() - 24.0), img_rect.right_bottom()); - if resp.hovered() || selected { + let bar = badge_bar; + if (resp.hovered() || selected) && (square || photo.label.is_none()) { p.rect_filled(bar, 0.0, Color32::from_black_alpha(120)); } let mut x = bar.left() + 6.0; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 8317dda7..7b8f1e2d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -63,7 +63,7 @@ pub fn resizable_side( /// The HUD toast at the bottom centre of the canvas. pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let now = ctx.input(|i| i.time); - let Some((text, until)) = app.ui.toast.clone() else { return }; + let Some((text, until, label)) = app.ui.toast.clone() else { return }; if now > until { app.ui.toast = None; return; @@ -74,11 +74,13 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toast"))); // long messages (a save warning, the web build's notices) wrap within the canvas let wrap = (canvas.width() - 80.0).clamp(200.0, 720.0); - let galley = painter.layout(text, t.font(14.0), t.text.gamma_multiply(fade), wrap); + let (background, foreground) = label.map(crate::theme::label_toast_colors).unwrap_or((egui::Color32::from_black_alpha(200), t.text)); + let foreground = foreground.gamma_multiply(fade); + let galley = painter.layout(text, t.font(14.0), foreground, wrap); let size = vec2(galley.size().x + 40.0, (galley.size().y + 22.0).max(40.0)); let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size); - painter.rect_filled(r, 6.0, egui::Color32::from_black_alpha((200.0 * fade) as u8)); - painter.galley(r.center() - galley.size() / 2.0, galley, t.text); + painter.rect_filled(r, 6.0, background.gamma_multiply(fade)); + painter.galley(r.center() - galley.size() / 2.0, galley, foreground); ctx.request_repaint_after(std::time::Duration::from_millis(30)); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f76757e0..9c0b3e98 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -17,6 +17,10 @@ pub const ALIASES: &[(&str, &str, &str)] = &[ ("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#), ("Shift+Z", "photo.flag", r#"{"flag": "pick", "advance": true}"#), ("Shift+U", "photo.flag", r#"{"flag": "none", "advance": true}"#), + ("Shift+6", "photo.label", r#"{"label": "red", "advance": true}"#), + ("Shift+7", "photo.label", r#"{"label": "yellow", "advance": true}"#), + ("Shift+8", "photo.label", r#"{"label": "green", "advance": true}"#), + ("Shift+9", "photo.label", r#"{"label": "blue", "advance": true}"#), // Shift+[ / Shift+] arrive as { / } on most layouts ("Shift+{", "brush.featherLess", "{}"), ("Shift+}", "brush.featherMore", "{}"), @@ -67,12 +71,21 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool { i.events.iter().any(|e| match e { - egui::Event::Key { key, pressed: true, modifiers, .. } => { + egui::Event::Key { key, physical_key, pressed: true, modifiers, .. } => { // `Ctrl` is the physical Control key (on macOS distinct from Cmd; elsewhere Cmd = Ctrl); // "Delete" (⌫ = Backspace) also matches forward-delete let ctrl_ok = if m.ctrl { modifiers.ctrl } else { !modifiers.ctrl || modifiers.command }; let cmd_ok = m.ctrl || modifiers.command == m.command; - (*key == k || (k == Key::Backspace && *key == Key::Delete)) && ctrl_ok && cmd_ok && modifiers.shift == m.shift && modifiers.alt == m.alt + // winit can report shifted digits as punctuation (Shift+1 = `!`). Recognize the + // physical number key for culling, without changing layout-aware letter shortcuts. + let shifted_digit = m.shift + && *physical_key == Some(k) + && matches!(k, Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9); + (*key == k || shifted_digit || (k == Key::Backspace && *key == Key::Delete)) + && ctrl_ok + && cmd_ok + && modifiers.shift == m.shift + && modifiers.alt == m.alt } _ => false, }) @@ -174,6 +187,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { app.toast(ctx, label); } else if let Some(l) = f.strip_prefix("label:") { cull(app, "photo.label", json!({"label": l}), false); + } else if f == "panel.presets" && library_grid(app) { + // Classic's Shift+P picks and advances in Library; keep the Presets binding elsewhere. + cull(app, "photo.flag", json!({"flag": "pick"}), true); } 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!({})); @@ -276,9 +292,221 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } } +pub(crate) fn library_grid(app: &LightcraftApp) -> bool { + matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) +} + #[cfg(test)] mod tests { + fn library() -> crate::headless::Headless { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let mut session = lightcraft_engine::Session::new(); + for id in 1..=4 { + let photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-shortcuts/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 100, + 100, + "2026-10-08", + ); + session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + } + session.execute("library.sort", &json!({"key": "fileName", "ascending": true})).unwrap(); + session.execute("library.select", &json!({"ids": [1]})).unwrap(); + let app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() }); + let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.view = crate::state::ViewMode::PhotoGrid; + for _ in 0..3 { + h.step(); + } + h + } + + fn key(h: &mut crate::headless::Headless, key: &str, shift: bool) { + let r = h.request("ui.key", json!({"key": key, "shift": shift}), std::time::Duration::from_secs(20)); + assert_eq!(r["ok"], true, "{r}"); + } + + #[test] + fn library_rating_labels_flags_and_undo() { + use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; + for view in [crate::state::ViewMode::PhotoGrid, crate::state::ViewMode::SquareGrid] { + let mut h = library(); + h.app.ui.view = view; + h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap(); + for rating in [1, 2, 3, 4, 5, 0] { + key(&mut h, &rating.to_string(), false); + for id in [1, 2] { + assert_eq!(h.app.session.catalog.photo(PhotoId(id)).unwrap().rating, rating); + } + } + for (k, label) in [("6", ColorLabel::Red), ("7", ColorLabel::Yellow), ("8", ColorLabel::Green), ("9", ColorLabel::Blue)] { + key(&mut h, k, false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, Some(label)); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(label)); + } + for (k, flag) in [("P", Flag::Pick), ("X", Flag::Reject), ("U", Flag::None)] { + key(&mut h, k, false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, flag); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, flag); + } + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Reject); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, Flag::Reject); + h.app.session.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None); + assert_eq!(h.app.session.active(), Some(PhotoId(1))); + } + } + + #[test] + fn shift_culling_keys_advance_exactly_once() { + use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; + for auto in [false, true] { + for (k, rating, label, flag) in [ + ("0", 0, None, Flag::Pick), + ("5", 5, None, Flag::Pick), + ("6", 3, Some(ColorLabel::Red), Flag::Pick), + ("7", 3, Some(ColorLabel::Yellow), Flag::Pick), + ("8", 3, Some(ColorLabel::Green), Flag::Pick), + ("9", 3, Some(ColorLabel::Blue), Flag::Pick), + ("P", 3, None, Flag::Pick), + ("X", 3, None, Flag::Reject), + ("U", 3, None, Flag::None), + ] { + let mut h = library(); + h.app.ui.auto_advance = auto; + h.app.session.execute("photo.rate", &json!({"rating": 3})).unwrap(); + h.app.session.execute("photo.pick", &json!({})).unwrap(); + key(&mut h, k, true); + let p = h.app.session.catalog.photo(PhotoId(1)).unwrap(); + assert_eq!(p.rating, rating, "Shift+{k}"); + assert_eq!(p.label, label, "Shift+{k}"); + assert_eq!(p.flag, flag, "Shift+{k}"); + assert_eq!(h.app.session.active(), Some(PhotoId(2)), "Shift+{k}, auto={auto}"); + } + } + let mut h = library(); + h.app.session.execute("library.select", &json!({"ids": [4]})).unwrap(); + key(&mut h, "6", true); + assert_eq!(h.app.session.active(), Some(PhotoId(4)), "last photo stays selected"); + } + + #[test] + fn shift_pick_preserves_presets_outside_the_library_grids() { + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::SquareGrid; + key(&mut h, "P", true); + assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2))); + assert!(!h.app.ui.presets, "grid Shift+P must not open Presets"); + assert_eq!(h.app.ui.view, crate::state::ViewMode::SquareGrid); + h.app.ui.view = crate::state::ViewMode::Detail; + key(&mut h, "P", true); + assert!(h.app.ui.presets); + assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2))); + } + + #[test] + fn shift_label_targets_only_candidate_in_compare() { + use lightcraft_catalog::{ColorLabel, PhotoId}; + let mut h = library(); + h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap(); + crate::panels::compare::enter_compare(&mut h.app).unwrap(); + key(&mut h, "6", true); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, None); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(ColorLabel::Red)); + assert_eq!(h.app.session.active(), Some(PhotoId(3))); + } + + #[test] + fn plain_culling_keys_auto_advance_once() { + use lightcraft_catalog::PhotoId; + for k in ["0", "5", "6", "9", "P", "X", "U"] { + let mut h = library(); + h.app.ui.auto_advance = true; + key(&mut h, k, false); + assert_eq!(h.app.session.active(), Some(PhotoId(2)), "{k}"); + } + } + + #[test] + fn color_label_actions_show_feedback_only_after_success() { + use lightcraft_catalog::PhotoId; + let mut h = library(); + for shift in [false, true] { + for (k, name) in [("6", "Red"), ("7", "Yellow"), ("8", "Green"), ("9", "Blue")] { + h.app.session.execute("library.select", &json!({"ids": [1]})).unwrap(); + h.app.ui.toast = None; + key(&mut h, k, shift); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some(format!("{name} Label").as_str())); + assert_eq!(h.app.ui.toast.as_ref().and_then(|t| t.2), h.app.session.catalog.photo(PhotoId(1)).unwrap().label); + assert_eq!(h.app.session.active(), Some(PhotoId(if shift { 2 } else { 1 }))); + } + } + // Menus and the Info-panel swatches dispatch the same command, including purple/clear. + h.app.run("photo.label", json!({"label": "purple"})).unwrap(); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Purple Label")); + h.app.run("photo.label", json!({"label": "none"})).unwrap(); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Color label cleared")); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.2.is_none()), "clear uses neutral feedback"); + h.app.session.execute("label.setNames", &json!({"names": {"red": "Needs review"}})).unwrap(); + key(&mut h, "6", false); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Needs review Label")); + // Native menu callbacks run before logic() catches up to the current frame's clock. + let now = h.view.ctx.input(|i| i.time); + h.app.last_time = now - 10.0; + h.app.run("photo.label", json!({"label": "blue"})).unwrap(); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.1 > now), "menu feedback must survive an idle gap"); + h.app.ui.toast = None; + assert!(h.app.run("photo.label", json!({"label": "invalid"})).is_err()); + assert!(h.app.ui.toast.is_none(), "failed actions must not announce success"); + } + + #[test] + fn shifted_number_punctuation_rates_and_advances() { + use lightcraft_catalog::PhotoId; + let mut h = library(); + // A real winit event on a layout with Shift+1 = !, rather than ui.key's logical Num1. + for pressed in [true, false] { + h.app.synthetic.push(egui::Event::Key { + key: Key::Exclamationmark, + physical_key: Some(Key::Num1), + pressed, + repeat: false, + modifiers: Modifiers::SHIFT, + }); + } + h.step(); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().rating, 1); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + } + + #[test] + fn library_keys_yield_to_search_and_crop_context() { + use lightcraft_catalog::{Flag, PhotoId}; + let mut h = library(); + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), std::time::Duration::from_secs(20)); + assert_eq!(r["ok"], true, "{r}"); + for k in ["5", "6", "P", "X", "U"] { + key(&mut h, k, false); + key(&mut h, k, true); + } + let p = h.app.session.catalog.photo(PhotoId(1)).unwrap(); + assert_eq!(p.rating, 0); + assert_eq!(p.label, None); + assert_eq!(p.flag, Flag::None); + assert_eq!(h.app.session.active(), Some(PhotoId(1))); + + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::Detail; + h.app.ui.right = crate::state::RightPanel::Crop; + key(&mut h, "X", false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None); + } + #[test] fn delete_means_the_backspace_key() { assert_eq!(parse("Delete"), Some((Modifiers::NONE, Key::Backspace))); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3b6794a2..ca9b0d36 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -374,9 +374,9 @@ pub struct UiState { /// Reference view: the reference photo. #[serde(skip)] pub reference: Option, - /// Transient toast text and its expiry (seconds of app time). + /// Transient toast text, expiry (seconds of app time), and optional colour-label styling. #[serde(skip)] - pub toast: Option<(String, f64)>, + pub toast: Option<(String, f64, Option)>, /// The result of the last Find Missing Photos (it searches in the background). #[serde(skip)] pub last_find_missing: Option, diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs new file mode 100644 index 00000000..9325ab77 --- /dev/null +++ b/crates/ui-egui/src/tests_labels.rs @@ -0,0 +1,85 @@ +//! Rendered regressions: label colour belongs on thumbnail chrome and the label HUD, not pixels. + +use std::time::Duration; + +use egui::{Color32, ColorImage, Pos2}; +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::headless::Headless; +use crate::state::ViewMode; +use crate::{LightcraftApp, Services}; + +const SETTLE: Duration = Duration::from_secs(120); + +fn app(view: ViewMode) -> Headless { + let mut s = lightcraft_engine::Session::new(); + for id in 1..=6 { + let p = Photo::new(PhotoId(id), Source::Demo { scene: 1 }, &format!("{id}.jpg"), "JPEG", 640, 480, "2026-10-08"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + s.execute("library.sort", &json!({"key": "fileName", "ascending": true, "group": "none"})).unwrap(); + s.execute("library.select", &json!({"ids": [1]})).unwrap(); + let mut h = Headless::new(LightcraftApp::new(s, Services { png: None, ..Default::default() }), [1200.0, 800.0], 1.0); + h.app.ui.view = view; + h.app.ui.right = crate::state::RightPanel::None; + h.app.ui.thumb_size = 160.0; + // Parallel UI tests can leave a quiet spell before thumbnail jobs complete. Require the + // fixture's real textures before comparing photo pixels, as well as settled layout. + assert!(h.step_until(SETTLE, |h| h.app.renderer.thumb_textures() == 6), "all fixture thumbnails must be ready"); + assert!(h.settle(SETTLE), "label fixture did not settle"); + h +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).unwrap().1 +} + +fn pixel(image: &ColorImage, p: Pos2) -> Color32 { + image.pixels[p.y as usize * image.size[0] + p.x as usize] +} + +#[test] +fn label_tints_grid_and_filmstrip_chrome_without_changing_photo_pixels() { + for view in [ViewMode::SquareGrid, ViewMode::PhotoGrid, ViewMode::Detail] { + let mut h = app(view); + let widget = if view == ViewMode::Detail { "film:1" } else { "thumb:1" }; + let cell = rect(&h, widget); + let sample = if view == ViewMode::PhotoGrid { + egui::pos2(cell.right() - 40.0, cell.bottom() - 8.0) + } else { + egui::pos2(cell.left() + 4.0, cell.center().y) + }; + let neutral = h.paint(); + let centre = pixel(&neutral, cell.center()); + let mut colours = Vec::new(); + for label in ["red", "yellow", "green", "blue", "purple"] { + h.app.run("photo.label", json!({"label": label})).unwrap(); + h.app.ui.toast = None; + h.step(); + let image = h.paint(); + let c = pixel(&image, sample); + assert_ne!(c, pixel(&neutral, sample), "{view:?} {label} must tint the thumbnail surround/footer"); + assert!(!colours.contains(&c), "each label has a distinct tint: {view:?} {label}"); + colours.push(c); + assert_eq!(pixel(&image, cell.center()), centre, "{view:?} {label} must not tint image pixels"); + } + h.app.run("photo.label", json!({"label": "none"})).unwrap(); + h.app.ui.toast = None; + h.step(); + assert_eq!(pixel(&h.paint(), sample), pixel(&neutral, sample), "clearing restores neutral chrome"); + } +} + +#[test] +fn label_toast_has_pale_colour_and_rating_restores_neutral_style() { + let mut h = app(ViewMode::SquareGrid); + h.app.run("photo.label", json!({"label": "red"})).unwrap(); + h.step(); + let at = egui::pos2(h.app.canvas_rect.unwrap().center().x, h.app.canvas_rect.unwrap().bottom() - 45.0); + let red = pixel(&h.paint(), at); + assert!(red.r() > 180 && red.r() > red.g() && red.r() > red.b(), "pale red toast: {red:?}"); + h.request("ui.key", json!({"key": "5"}), Duration::from_secs(20)); + let rating = pixel(&h.paint(), at); + assert!(rating.r() < 80 && rating.r() == rating.g() && rating.g() == rating.b(), "neutral rating toast: {rating:?}"); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c77fb0a2..9534ebb6 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -7,6 +7,28 @@ use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, pub const FONT_SEMIBOLD: &str = "semibold"; +/// Shared colour-label palette for badges, thumbnail surrounds and feedback. +pub fn label_color(label: lightcraft_catalog::ColorLabel) -> Color32 { + use lightcraft_catalog::ColorLabel; + match label { + ColorLabel::Red => Color32::from_rgb(222, 72, 72), + ColorLabel::Yellow => Color32::from_rgb(232, 196, 58), + ColorLabel::Green => Color32::from_rgb(88, 176, 92), + ColorLabel::Blue => Color32::from_rgb(72, 130, 222), + ColorLabel::Purple => Color32::from_rgb(158, 100, 210), + } +} + +/// A translucent label colour over thumbnail chrome; selection remains brighter. +pub fn label_background(base: Color32, label: Option, selected: bool) -> Color32 { + label.map_or(base, |l| base.lerp_to_gamma(label_color(l), if selected { 0.32 } else { 0.22 })) +} + +/// Label confirmations use a pale colour and dark text, distinct from neutral/error HUDs. +pub fn label_toast_colors(label: lightcraft_catalog::ColorLabel) -> (Color32, Color32) { + (Color32::WHITE.lerp_to_gamma(label_color(label), 0.3), Color32::from_rgb(36, 24, 24)) +} + #[derive(Clone, Copy, Debug)] pub struct Tokens { /// Top bar, side panels, bottom bar, tool strip. diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md new file mode 100644 index 00000000..5305fe57 --- /dev/null +++ b/docs/library-shortcuts.md @@ -0,0 +1,47 @@ +# Library ratings, labels and flags + +Select photos in Photo Grid or Square Grid, then use: + +| Key | Action | +|---|---| +| `0` | Clear the star rating | +| `1`–`5` | Set the star rating | +| `6`, `7`, `8`, `9` | Set a red, yellow, green or blue label | +| `P` | Flag as a pick | +| `X` | Flag as rejected | +| `U` | Clear the flag | +| `Shift` + any key above | 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 +menu. Purple has no default number key, matching Lightroom Classic. Rejection marks a photo for +culling; it does not delete the original or remove it from the library. +Setting a colour label shows the same brief bottom toast as a rating, using the colour or your +custom label name. Clearing it shows a confirmation too, whether applied by a key, menu or swatch. +Labelled thumbnails have a translucent matching colour on the square-grid surround, photo-grid +footer and Detail filmstrip surround. The photo pixels and white active-photo outline remain clear. +The label confirmation uses a pale matching background with dark text; ratings and errors retain +their neutral HUD styling. Custom names use the underlying label's colour. + +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 +keyboard input, and `X` in the Crop panel swaps the crop aspect. +Shifted number keys also use the physical number key when the keyboard reports punctuation +(for example, `Shift+1` as `!`). Letter shortcuts continue to follow the keyboard layout. + +`Shift+P` picks and advances in the two library grids. In other views it retains LightCraft's +Presets-panel binding. `Shift+Z` remains an alternative pick-and-advance key in all views. + +The macOS native menu displays plain-key bindings but leaves execution to egui; only shortcuts +AppKit delivers to the menu (Command, Control or function keys) are skipped by egui. The native +menu correction is adapted from [PR #261](https://github.com/storytold/lightcraft/pull/261), +commit `4e012a59a6d2442ba749f109ea27aadb5c0b11a5` by Gardy, and addresses the cause reported in +[issue #283](https://github.com/storytold/lightcraft/issues/283). That PR was open when checked +on 2026-10-08; this work extends its fix with library culling bindings and tests. + +Public behaviour references, checked 2026-10-08: + +- [Lightroom Classic keyboard shortcuts](https://helpx.adobe.com/lightroom-classic/help/keyboard-shortcuts.html) +- [Lightroom Classic flagging, labelling and rating](https://helpx.adobe.com/lightroom-classic/help/flag-label-rate-photos.html) + +Only public descriptions were consulted. No Adobe assets or application data are included. diff --git a/docs/parity.md b/docs/parity.md index 05491f2e..73a8e39b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -108,8 +108,10 @@ 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. + **Still missing / broken:** -- No command yet: F1 help, ⇧6–9 label + advance (verify the rest of the old list: full screen, settings, stacks, +- No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, visualize spots and merges have commands now). - `H` opens Remove; Lightroom also uses it (Classic) to hide pins — pins toggle from View → Show Mask Pins. - ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). @@ -150,9 +152,9 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | -| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `crates/ui-egui/src/shortcuts.rs` | | -| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | -| LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | +| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `crates/ui-egui/src/shortcuts.rs` | 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | +| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file | +| LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop | | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | @@ -670,7 +672,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-ADDPHOTOS | Import photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | | | KEY-VERSIONS | Versions panel — ⇧V | P1 | ✅ | `cmd:panel.versions` | | | KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | ✅ | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | ⌘1–⌘5 (Light, Color, Effects, Detail, Optics) as observed; our Edit panel has no Lens Blur / Geometry section for ⌘6 / ⌘7; Zoom 100% moved to ⌥⌘0 | -| KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | | +| KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | Shift+P opens Presets outside the two library grids; in grids it picks and advances (Classic convention). Menu hides the Presets key there; shortcut help follows the view | | KEY-HISTOGRAM | Histogram — ⌘0 | P0 | 🟡 | `cmd:view.histogram` | bound to ⌘⇧H; ⌘0 = zoom to fit | | KEY-BRUSHSIZE | Brush size — `[` / `]` | P0 | ✅ | `cmd:brush.smaller`, `cmd:brush.larger` | Masking brush and Remove tool (and its selected spot) | | KEY-BRUSHFEATHER | Brush feather — ⇧`[` / ⇧`]` | P0 | ✅ | `cmd:brush.featherLess`, `cmd:brush.featherMore` | | @@ -680,12 +682,12 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-PICK | Pick — Z | P0 | 🟡 | `cmd:photo.pick` | bound to P; Z toggles zoom | | KEY-UNFLAG | Unflag — U | P0 | ✅ | `cmd:photo.unflag` | | | KEY-REJECT | Reject — X | P0 | ✅ | `cmd:photo.reject` | swaps crop aspect while cropping | -| KEY-RATING | Ratings — 0…5 | P0 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.rate` | | -| KEY-LABELS | Labels — 6…9 | P1 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.label` | | +| KEY-RATING | Ratings — 0…5 | P0 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.rate` | 0 clears, 1–5 set; Shift applies and advances; native macOS plain keys stay with egui; grid multi-selection and undo/redo tested | +| KEY-LABELS | Labels — 6…9 | P1 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.label` | 6 red, 7 yellow, 8 green, 9 blue; Shift+6–9 labels and advances; purple/none available from menus | | KEY-MASKING | Masking — M | P0 | ✅ | `cmd:panel.masking` | | | KEY-ERASE | Erase while held — ⌥ | P0 | ✅ | `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/panels/masking.rs` | ⌥ while painting flips Add ↔ Erase for that stroke; Add/Erase buttons too | | KEY-RATEADVANCE | Rate and advance — ⇧0…5 | P1 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.rate` (`advance`) | | -| KEY-FLAGADVANCE | Flag and advance — ⇧Z / ⇧X / ⇧U | P1 | ✅ | `cmd:photo.flag` (`advance`), `crates/ui-egui/src/shortcuts.rs` | ⇧Z pick, ⇧X reject, ⇧U unflag, each moving to the next photo | +| KEY-FLAGADVANCE | Flag and advance — ⇧Z / ⇧X / ⇧U | P1 | ✅ | `cmd:photo.flag` (`advance`), `crates/ui-egui/src/shortcuts.rs` | Shift+Z pick, Shift+X reject, Shift+U clear; Shift+P also picks and advances in the library grids; exactly one step with Auto Advance enabled | | KEY-NEXTPREV | Next / previous — → / ← | P0 | ✅ | `cmd:library.next`, `cmd:library.previous` | | | KEY-BA-CYCLE | Before/after — Y | P0 | ✅ | `cmd:view.beforeAfter` | toggles side by side (no cycling) | | KEY-BA-TOPBOTTOM | Before/after top/bottom — ⌥Y | P1 | ✅ | `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | ⌥Y, ⇧⌥Y | @@ -799,7 +801,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` | most work; no ⇧P / ⇧6–9 advance, rating `[` `]`, flag cycle, 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` | 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-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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 2de2748a..111b6201 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,13 @@ ## October 2026 +### Library keyboard culling +- Colour labels tint thumbnail surrounds in Square Grid and the Detail filmstrip, and the footer in Photo Grid. Label confirmations use a matching pale colour; the photo pixels retain their original appearance. +- Setting or clearing a colour label shows a brief bottom confirmation, like rating a photo; custom label names appear in the message too. +- On macOS, ratings `0–5`, labels `6–9` and pick/unflag `P/U` now reach the app even when shown in the native menu (issue #283; adapted from PR #261). +- `Shift+6–9` labels and advances. `Shift+P` picks and advances in Photo Grid and Square Grid; it opens Presets in other views. With Auto Advance on, Shift still moves only once. +- Help → Keyboard Shortcuts includes the number-key bindings. See [library shortcuts](library-shortcuts.md). + ### RAW decoding - Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB, preserving RAW editing & full-resolution export instead of using embedded JPEG previews.