# Conflicts:
#	apps/lightcraft/src/native_menu.rs
#	crates/ui-egui/src/panels/detail.rs
#	docs/parity.md
This commit is contained in:
Claude 2026-10-08 17:38:20 +00:00
commit 99fa19099b
No known key found for this signature in database
20 changed files with 474 additions and 43 deletions

View File

@ -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);

View File

@ -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"] {

View File

@ -148,6 +148,7 @@
"Cloudy": "曇り",
"Collapse All Stacks": "すべてのスタックを閉じる",
"Color": "カラー",
"Color label cleared": "カラーラベルを解除しました",
"Color Grading": "カラーグレーディング",
"Color Mixer": "カラーミキサー",
"Color Noise Reduction": "カラーノイズ軽減",

View File

@ -188,6 +188,7 @@
"Cloudy": "阴天",
"Collapse All Stacks": "折叠所有堆叠",
"Color": "颜色",
"Color label cleared": "已清除颜色标签",
"Color Grading": "颜色分级",
"Color Label Names": "颜色标签名称",
"Color Label Sets": "颜色标签集",

View File

@ -188,6 +188,7 @@
"Cloudy": "陰天",
"Collapse All Stacks": "收合所有堆疊",
"Color": "色彩",
"Color label cleared": "已清除色彩標籤",
"Color Grading": "色彩分級",
"Color Label Names": "色彩標籤名稱",
"Color Label Sets": "色彩標籤集",

View File

@ -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<dyn FnMut() -> Vec<String>>;
/// A save dialog: suggested file name → chosen path (`None` = cancelled).
pub type SaveFile = Box<dyn FnMut(&str) -> Option<String>>;
@ -270,6 +274,20 @@ impl LightcraftApp {
return r;
}
let r = self.session.execute(id, &params).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<String>) {
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<String>, 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<String>) {
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) {

View File

@ -576,7 +576,9 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
}
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.

View File

@ -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));
}
}

View File

@ -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);

View File

@ -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));

View File

@ -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);

View File

@ -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;

View File

@ -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));
}

View File

@ -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)));

View File

@ -374,9 +374,9 @@ pub struct UiState {
/// Reference view: the reference photo.
#[serde(skip)]
pub reference: Option<u64>,
/// 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<lightcraft_catalog::ColorLabel>)>,
/// The result of the last Find Missing Photos (it searches in the background).
#[serde(skip)]
pub last_find_missing: Option<serde_json::Value>,

View File

@ -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:?}");
}

View File

@ -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<lightcraft_catalog::ColorLabel>, 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.

47
docs/library-shortcuts.md Normal file
View File

@ -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.

View File

@ -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 |

View File

@ -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.