mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
LR-LIB-RATING: fix relative ratings and grid bracket shortcuts
Co-authored-by: Codex <[email protected]>
This commit is contained in:
1 parent
c435d143de
commit
265e7e4d50
13 files changed
+361
-10
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "評価を上げる"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Увеличить оценку"
|
||||
}
|
||||
@@ -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": "提高星级"
|
||||
}
|
||||
@@ -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": "提高評分"
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user