# Conflicts:
#	docs/parity-checklist.md
#	docs/ui-parity.md
This commit is contained in:
Brandon Thomas
2026-10-14 22:32:25 -04:00
9 changed files with 339 additions and 5 deletions
+57
View File
@@ -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"));
}