Merge PR #308
# Conflicts: # apps/lightcraft/src/native_menu.rs # crates/ui-egui/src/panels/detail.rs # docs/parity.md
This commit is contained in:
commit
99fa19099b
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@
|
|||
"Cloudy": "曇り",
|
||||
"Collapse All Stacks": "すべてのスタックを閉じる",
|
||||
"Color": "カラー",
|
||||
"Color label cleared": "カラーラベルを解除しました",
|
||||
"Color Grading": "カラーグレーディング",
|
||||
"Color Mixer": "カラーミキサー",
|
||||
"Color Noise Reduction": "カラーノイズ軽減",
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@
|
|||
"Cloudy": "阴天",
|
||||
"Collapse All Stacks": "折叠所有堆叠",
|
||||
"Color": "颜色",
|
||||
"Color label cleared": "已清除颜色标签",
|
||||
"Color Grading": "颜色分级",
|
||||
"Color Label Names": "颜色标签名称",
|
||||
"Color Label Sets": "颜色标签集",
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@
|
|||
"Cloudy": "陰天",
|
||||
"Collapse All Stacks": "收合所有堆疊",
|
||||
"Color": "色彩",
|
||||
"Color label cleared": "已清除色彩標籤",
|
||||
"Color Grading": "色彩分級",
|
||||
"Color Label Names": "色彩標籤名稱",
|
||||
"Color Label Sets": "色彩標籤集",
|
||||
|
|
|
|||
|
|
@ -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, ¶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<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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue