diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index d900eacff..b85c93c98 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -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 { + 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 { 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; diff --git a/crates/engine/src/cmd/library/tests_relative_ratings.rs b/crates/engine/src/cmd/library/tests_relative_ratings.rs new file mode 100644 index 000000000..1a1b8b23c --- /dev/null +++ b/crates/engine/src/cmd/library/tests_relative_ratings.rs @@ -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 { + 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> { + (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("1")); + assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("3")); + 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("0")); + assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("2")); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("3")); + s.execute("photo.decreaseRating", &json!({})).unwrap(); + assert!(std::fs::read_to_string(dir.join("3.xmp")).unwrap().contains("4")); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 64a485ed2..36e9bd89f 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -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" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 461fa2f65..802eded50 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -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" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 49f2ef807..144e86893 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -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": "評価を上げる" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 81e331c5c..8a2124693 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -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" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 382bfc05a..1cbbd1892 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -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": "Увеличить оценку" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 4074dfbda..cd0fc2f3f 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -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": "提高星级" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 96fcbb18b..694ab1c4f 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -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": "提高評分" } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index cd69b2daa..37a3c57d6 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -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; diff --git a/crates/ui-egui/src/shortcuts/tests_relative_ratings.rs b/crates/ui-egui/src/shortcuts/tests_relative_ratings.rs new file mode 100644 index 000000000..8ca2adc1a --- /dev/null +++ b/crates/ui-egui/src/shortcuts/tests_relative_ratings.rs @@ -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 { + 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> { + (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, 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()); +} diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index 2b4c96f8f..414e9166b 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -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 diff --git a/docs/parity.md b/docs/parity.md index c06dad1b0..6c09e921d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 |