LR-LIB-RATING: fix relative ratings and grid bracket shortcuts

Co-authored-by: Codex <[email protected]>
This commit is contained in:
sanyamsingh001andCodex committed 2026-10-09 22:48:00 +10:00
1 parent c435d143de
commit 265e7e4d50
13 files changed
+361 -10

No files matched your search

+28
View File
@@ -146,6 +146,24 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O
Ok(json!({"changed": n}))
}
/// Adjust each target's own rating; unchanged bounds do not create an undo step.
fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> 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))?.rating;
let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8;
if rating != old {
ops.push(Op::SetRating { id, rating });
}
}
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);
@@ -564,6 +582,12 @@ pub fn specs() -> Vec<CommandSpec> {
advance_if(s, p);
Ok(v)
}),
cmd!("photo.decreaseRating", "Decrease Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
change_rating(s, p, -1, "Decrease Rating")
}),
cmd!("photo.increaseRating", "Increase Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
change_rating(s, p, 1, "Increase Rating")
}),
cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| {
let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?;
let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?;
@@ -1382,3 +1406,7 @@ mod gps_tests {
assert_eq!(p("hello"), None);
}
}
#[cfg(test)]
#[path = "library/tests_relative_ratings.rs"]
mod relative_rating_tests;
@@ -0,0 +1,102 @@
//! Relative ratings dispatch through the real engine, including history and AutoWrite.
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::{Result, Selection, Session};
fn session() -> Result<Session> {
let mut s = Session::new();
for (id, rating) in [(1, 0), (2, 2), (3, 5), (4, 1)] {
let mut photo = Photo::new(
PhotoId(id),
Source::File { path: format!("/lightcraft-relative-ratings/{id}.jpg") },
&format!("{id}.jpg"),
"JPEG",
40,
30,
"2026-10-09",
);
photo.rating = rating;
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 ratings(s: &Session) -> Result<Vec<u8>> {
(1..=4)
.map(|id| s.catalog.photo(PhotoId(id)).map(|p| p.rating).ok_or_else(|| lightcraft_catalog::CatalogError::NoPhoto(PhotoId(id)).into()))
.collect()
}
#[test]
fn relative_ratings_clamp_each_photo_and_form_one_history_step() {
let mut s = session().unwrap();
let undo = s.undo.len();
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 2);
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.active(), Some(PhotoId(2)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(ratings(&s).unwrap(), [0, 2, 5, 1]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
assert_eq!(s.execute("photo.decreaseRating", &json!({})).unwrap()["changed"], 3);
assert_eq!(ratings(&s).unwrap(), [0, 2, 4, 1]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
}
#[test]
fn bounds_active_fallback_explicit_targets_and_errors_preserve_history() {
let mut s = session().unwrap();
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [1]})).unwrap()["changed"], 0);
assert_eq!(s.execute("photo.increaseRating", &json!({"ids": [3]})).unwrap()["changed"], 0);
assert!(s.undo.is_empty());
s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) };
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 1);
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 1]);
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [4]})).unwrap()["changed"], 1);
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
let undo = s.undo.len();
assert!(s.execute("photo.increaseRating", &json!({"ids": [1, 999]})).is_err());
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
assert_eq!(s.undo.len(), undo);
s.execute("photo.increaseRating", &json!({"advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(3)));
s.selection = Selection::default();
assert!(s.execute("photo.decreaseRating", &json!({})).is_err());
}
#[test]
fn relative_ratings_and_history_use_auto_write() {
let mut s = session().unwrap();
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("lc-relative-ratings-{}-{stamp}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// Metadata-only fixtures: no import, image decoding or model/service dependency.
for id in 1..=4 {
s.catalog
.apply(Op::SetFile {
id: PhotoId(id),
file_name: format!("{id}.jpg"),
source: Source::File { path: dir.join(format!("{id}.jpg")).to_string_lossy().into_owned() },
})
.unwrap();
}
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
s.execute("photo.increaseRating", &json!({})).unwrap();
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>1</xmp:Rating>"));
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
assert!(!dir.join("3.xmp").exists(), "unchanged upper bound must not write");
assert!(!dir.join("4.xmp").exists(), "unselected photo must not write");
s.execute("edit.undo", &json!({})).unwrap();
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>0</xmp:Rating>"));
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>2</xmp:Rating>"));
s.execute("edit.redo", &json!({})).unwrap();
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
s.execute("photo.decreaseRating", &json!({})).unwrap();
assert!(std::fs::read_to_string(dir.join("3.xmp")).unwrap().contains("<xmp:Rating>4</xmp:Rating>"));
std::fs::remove_dir_all(dir).unwrap();
}
+3 -1
View File
@@ -1887,5 +1887,7 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Decrease Rating": "Bewertung verringern",
"Increase Rating": "Bewertung erhöhen"
}
+3 -1
View File
@@ -1732,5 +1732,7 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Decrease Rating": "Reducir valoración",
"Increase Rating": "Aumentar valoración"
}
+3 -1
View File
@@ -1732,5 +1732,7 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Decrease Rating": "評価を下げる",
"Increase Rating": "評価を上げる"
}
+3 -1
View File
@@ -1732,5 +1732,7 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Decrease Rating": "Diminuir avaliação",
"Increase Rating": "Aumentar avaliação"
}
+3 -1
View File
@@ -1403,5 +1403,7 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Decrease Rating": "Уменьшить оценку",
"Increase Rating": "Увеличить оценку"
}
+3 -1
View File
@@ -1732,5 +1732,7 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Decrease Rating": "降低星级",
"Increase Rating": "提高星级"
}
+3 -1
View File
@@ -1732,5 +1732,7 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Decrease Rating": "降低評分",
"Increase Rating": "提高評分"
}
+20 -1
View File
@@ -281,6 +281,17 @@ fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool {
})
}
/// Default brackets rate in grids. Remapped brush keys retain their brush action, and a
/// saved assignment of either bracket to another command takes precedence over this default.
fn grid_bracket_command(keymap: &Keymap, grid: bool, id: &'static str, shortcut: (Modifiers, Key)) -> Option<&'static str> {
let command = match (grid, id, shortcut) {
(true, "brush.smaller", (m, Key::OpenBracket)) if m == Modifiers::NONE => "photo.decreaseRating",
(true, "brush.larger", (m, Key::CloseBracket)) if m == Modifiers::NONE => "photo.increaseRating",
_ => return Some(id),
};
if keymap.iter().any(|(other, sc)| other != id && find_bindable(other).is_some() && parse(sc) == Some(shortcut)) { None } else { Some(command) }
}
pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) {
app.recording_shortcut = None;
@@ -294,6 +305,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
let native = |sc: &str| app.native_shortcuts.contains(sc);
let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new();
let keymap = &app.ui.settings.keymap;
let grid = library_grid(app);
// keys the user gave to a command: the fixed bindings below (aliases, ratings) yield to them
let taken: Vec<(Modifiers, Key)> = keymap.values().filter_map(|s| parse(s)).collect();
ctx.input(|i| {
@@ -302,8 +314,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
&& let Some((m, k)) = parse(sc)
&& !native(sc)
&& matches(i, m, k)
&& let Some(id) = grid_bracket_command(keymap, grid, b.id, (m, k))
{
fire.push(b.id.to_string());
fire.push(id.to_string());
}
}
for (sc, id, params) in ALIASES {
@@ -375,6 +388,8 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0)))
};
app.toast(ctx, label);
} else if matches!(f.as_str(), "photo.decreaseRating" | "photo.increaseRating") {
cull(app, &f, json!({}), false);
} 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) {
@@ -844,3 +859,7 @@ mod tests {
}
}
}
#[cfg(test)]
#[path = "shortcuts/tests_relative_ratings.rs"]
mod relative_rating_tests;
@@ -0,0 +1,181 @@
//! Real app command dispatch using egui input, without rendering an image or linking a facade.
use egui::{Key, Modifiers};
use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::LightcraftApp;
use crate::state::{Dialog, RightPanel, ViewMode};
fn app() -> lightcraft_engine::Result<LightcraftApp> {
let mut s = lightcraft_engine::Session::new();
for (id, rating) in [(1, 0), (2, 2), (3, 5)] {
let mut photo = Photo::new(
PhotoId(id),
Source::File { path: format!("/lightcraft-relative-rating-keys/{id}.jpg") },
&format!("{id}.jpg"),
"JPEG",
40,
30,
"2026-10-09",
);
photo.rating = rating;
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(LightcraftApp::new(s, crate::Services { png: None, ..Default::default() }))
}
fn ratings(app: &LightcraftApp) -> lightcraft_engine::Result<Vec<u8>> {
(1..=3)
.map(|id| {
app.session.catalog.photo(PhotoId(id)).map(|p| p.rating).ok_or_else(|| lightcraft_catalog::CatalogError::NoPhoto(PhotoId(id)).into())
})
.collect()
}
fn frame(app: &mut LightcraftApp, ctx: &egui::Context, key: Option<Key>, text: Option<&mut String>) {
let events = key
.map(|key| egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE })
.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("relative-rating-search"))).request_focus();
}
super::handle(app, ui.ctx());
},
);
out.textures_delta.clear();
}
#[test]
fn grid_brackets_rate_mixed_selection_without_resizing_brushes() {
for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] {
let mut app = app().unwrap();
app.ui.view = view;
let ctx = egui::Context::default();
let brush = app.ui.brush_size;
let remove = app.ui.remove_size;
frame(&mut app, &ctx, Some(Key::CloseBracket), None);
assert_eq!(ratings(&app).unwrap(), [1, 3, 5]);
assert_eq!(app.session.undo.len(), 1);
app.session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(ratings(&app).unwrap(), [0, 2, 5]);
app.session.execute("edit.redo", &json!({})).unwrap();
frame(&mut app, &ctx, Some(Key::OpenBracket), None);
assert_eq!(ratings(&app).unwrap(), [0, 2, 4]);
assert_eq!(app.ui.brush_size, brush);
assert_eq!(app.ui.remove_size, remove);
assert_eq!(app.session.active(), Some(PhotoId(2)));
}
}
#[test]
fn detail_masking_and_remove_keep_bracket_brush_sizing() {
for panel in [RightPanel::Edit, RightPanel::Masking, RightPanel::Remove] {
let mut app = app().unwrap();
app.ui.view = ViewMode::Detail;
app.ui.right = panel;
let ctx = egui::Context::default();
app.ui.brush_size = 0.05;
app.ui.remove_size = 0.05;
frame(&mut app, &ctx, Some(Key::CloseBracket), None);
let size = if panel == RightPanel::Remove { app.ui.remove_size } else { app.ui.brush_size };
assert!((size - 0.06).abs() < 1e-6);
frame(&mut app, &ctx, Some(Key::OpenBracket), None);
let size = if panel == RightPanel::Remove { app.ui.remove_size } else { app.ui.brush_size };
assert!((size - 0.05).abs() < 1e-6);
assert_eq!(ratings(&app).unwrap(), [0, 2, 5]);
assert!(app.session.undo.is_empty());
}
}
#[test]
fn saved_keymap_remapping_unbinding_and_reassignment_win() {
let mut app = app().unwrap();
app.ui.view = ViewMode::PhotoGrid;
let ctx = egui::Context::default();
super::assign(&mut app.ui.settings.keymap, "brush.larger", Some("F2")).unwrap();
super::assign(&mut app.ui.settings.keymap, "brush.smaller", None).unwrap();
let brush = app.ui.brush_size;
frame(&mut app, &ctx, Some(Key::CloseBracket), None);
frame(&mut app, &ctx, Some(Key::OpenBracket), None);
assert_eq!(ratings(&app).unwrap(), [0, 2, 5]);
assert_eq!(app.ui.brush_size, brush);
frame(&mut app, &ctx, Some(Key::F2), None);
assert!(app.ui.brush_size > brush, "remapped brush command keeps its meaning");
super::reset(&mut app.ui.settings.keymap, "brush.smaller").unwrap();
super::assign(&mut app.ui.settings.keymap, "photo.pick", Some("[")).unwrap();
frame(&mut app, &ctx, Some(Key::OpenBracket), None);
assert_eq!(app.session.catalog.photo(PhotoId(2)).unwrap().flag, Flag::Pick);
assert_eq!(ratings(&app).unwrap(), [0, 2, 5]);
// Also honor an existing saved override that did not explicitly disable the default.
super::reset(&mut app.ui.settings.keymap, "brush.larger").unwrap();
app.ui.settings.keymap.insert("photo.reject".into(), "]".into());
frame(&mut app, &ctx, Some(Key::CloseBracket), None);
assert_eq!(app.session.catalog.photo(PhotoId(2)).unwrap().flag, Flag::Reject);
assert_eq!(ratings(&app).unwrap(), [0, 2, 5]);
}
#[test]
fn unknown_saved_bracket_assignment_does_not_shadow_grid_ratings() {
let mut app = app().unwrap();
app.ui.view = ViewMode::PhotoGrid;
let ctx = egui::Context::default();
app.ui.settings.keymap.insert("retired.command".into(), "]".into());
assert!(super::find_bindable("retired.command").is_none());
let brush = app.ui.brush_size;
frame(&mut app, &ctx, Some(Key::CloseBracket), None);
assert_eq!(ratings(&app).unwrap(), [1, 3, 5]);
assert_eq!(app.session.undo.len(), 1);
assert_eq!(app.ui.brush_size, brush);
app.session.execute("edit.undo", &json!({})).unwrap();
app.ui.settings.keymap.remove("retired.command");
app.ui.settings.keymap.insert("photo.pick".into(), "]".into());
frame(&mut app, &ctx, Some(Key::CloseBracket), None);
assert_eq!(app.session.catalog.photo(PhotoId(2)).unwrap().flag, Flag::Pick);
assert_eq!(ratings(&app).unwrap(), [0, 2, 5], "a known saved override still wins");
assert_eq!(app.ui.brush_size, brush);
}
#[test]
fn relative_rating_keys_use_auto_advance_and_compare_active_target() {
let mut app = app().unwrap();
app.ui.view = ViewMode::PhotoGrid;
let ctx = egui::Context::default();
app.session.execute("library.select", &json!({"ids": [1]})).unwrap();
app.ui.auto_advance = true;
frame(&mut app, &ctx, Some(Key::CloseBracket), None);
assert_eq!(ratings(&app).unwrap(), [1, 2, 5]);
assert_eq!(app.session.active(), Some(PhotoId(2)));
app.ui.auto_advance = false;
app.ui.view = ViewMode::Compare;
app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap();
super::assign(&mut app.ui.settings.keymap, "photo.increaseRating", Some("F3")).unwrap();
frame(&mut app, &ctx, Some(Key::F3), None);
assert_eq!(ratings(&app).unwrap(), [1, 3, 5]);
assert_eq!(app.session.active(), Some(PhotoId(2)));
}
#[test]
fn brackets_yield_to_text_focus_and_shortcut_recording() {
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::CloseBracket), Some(&mut text));
assert!(ctx.egui_wants_keyboard_input());
assert_eq!(ratings(&app).unwrap(), [0, 2, 5]);
assert!(app.session.undo.is_empty());
let ctx = egui::Context::default();
app.ui.dialog = Some(Dialog::Shortcuts);
app.recording_shortcut = Some("brush.larger".into());
frame(&mut app, &ctx, Some(Key::CloseBracket), None);
assert_eq!(ratings(&app).unwrap(), [0, 2, 5]);
assert!(app.session.undo.is_empty());
}
+8 -1
View File
@@ -6,11 +6,12 @@ Select photos in Photo Grid or Square Grid, then use:
|---|---|
| `0` | Clear the star rating |
| `1`–`5` | Set the star rating |
| `[` / `]` | Decrease / increase each selected photo's rating by one, within 0–5 |
| `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 |
| `Shift` + `0`–`9`, `P`, `X` or `U` | Apply the action and select the next photo |
Purple and clearing a colour label are available in Photo → Set Color Label and the photo context
menu. Purple has no default number key, matching Lightroom Classic. Rejection marks a photo for
@@ -22,6 +23,12 @@ footer (translucent, over the bottom of the photo) and Detail filmstrip surround
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.
Bracket ratings use each photo's own starting rating, and the changed photos form one Undo / Redo
step. AutoWrite uses the normal rating metadata path. In Detail, including Masking and Remove,
`[` / `]` keep resizing the brush and shifted brackets keep adjusting feather. Saved overrides,
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.
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
+1 -1
View File
@@ -161,7 +161,7 @@ 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` | 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-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs` | 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. 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 |