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