From 50741eceb4194150041b93e5854531cb11765347 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:47:38 -0400 Subject: [PATCH 1/6] =?UTF-8?q?M1.13:=20smart=20albums=20=E2=80=94=20saved?= =?UTF-8?q?=20filters=20that=20update=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Album.smart holds Filter rules (rating, flag, label, kind, edited, keyword, camera, lens, capture date range, text, album); SetAlbumRules op with inverse, validation (no nested smart albums, no deleted), journal replay property test. album.createSmart (from the current view or explicit rules), album.setRules; New Smart Album dialog, smart icon, live counts. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 70 +++++++++++++++++++- crates/catalog/src/model.rs | 14 ++++ crates/catalog/src/query.rs | 65 +++++++++++++++++- crates/catalog/src/tests.rs | 54 ++++++++++++++- crates/catalog/src/tests_journal.rs | 18 ++++- crates/engine/src/cmd/library.rs | 99 +++++++++++++++++++++++++++- crates/engine/src/cmd/query.rs | 18 +++-- crates/engine/src/demo.rs | 6 +- crates/engine/src/lib.rs | 3 + crates/engine/src/tests_organize.rs | 80 ++++++++++++++++++++++ crates/ui-egui/src/icons.rs | 7 ++ crates/ui-egui/src/menus.rs | 5 ++ crates/ui-egui/src/panels/dialogs.rs | 16 +++++ crates/ui-egui/src/panels/grid.rs | 2 +- crates/ui-egui/src/panels/left.rs | 15 ++++- crates/ui-egui/src/state.rs | 4 ++ 16 files changed, 456 insertions(+), 20 deletions(-) create mode 100644 crates/engine/src/tests_organize.rs diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 4b03795c..6b334474 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -113,6 +113,11 @@ pub enum Op { id: AlbumId, cover: Option, }, + /// Replace a smart album's rules. + SetAlbumRules { + id: AlbumId, + rules: Box, + }, /// Several ops as one step (undo applies the inverses in reverse). Batch { ops: Vec, @@ -168,8 +173,51 @@ impl Catalog { pub fn albums(&self) -> impl Iterator { self.albums.values() } + /// Albums (regular and smart) that contain the photo. pub fn albums_of(&self, id: PhotoId) -> Vec { - self.albums.values().filter(|a| a.photos.contains(&id)).map(|a| a.id).collect() + let Some(p) = self.photos.get(&id) else { return Vec::new() }; + self.albums.values().filter(|a| self.album_contains(a.id, p)).map(|a| a.id).collect() + } + + /// Whether album `id` contains `p` (a smart album evaluates its rules; deleted photos are in + /// no smart album). + pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool { + match self.albums.get(&id) { + Some(Album { smart: Some(rules), .. }) => !p.deleted && rules.matches(p, self), + Some(a) => a.photos.contains(&p.id), + None => false, + } + } + + /// The photos of an album: the stored list, or a smart album's current matches (id order). + pub fn album_photos(&self, id: AlbumId) -> Vec { + match self.albums.get(&id) { + Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).map(|p| p.id).collect(), + Some(a) => a.photos.clone(), + None => Vec::new(), + } + } + + /// Number of photos shown for an album in the sources list (excludes deleted photos). + pub fn album_count(&self, id: AlbumId) -> usize { + match self.albums.get(&id) { + Some(Album { smart: Some(_), .. }) => self.album_photos(id).len(), + Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(), + None => 0, + } + } + + /// Smart-album rules must not reference another smart album (no recursion) or deleted photos. + fn validate_rules(&self, rules: &Filter) -> Result<()> { + if rules.deleted { + return Err(CatalogError::Invalid("smart album rules can't select deleted photos".into())); + } + if let Some(a) = rules.album + && self.albums.get(&a).is_some_and(Album::is_smart) + { + return Err(CatalogError::Invalid("smart album rules can't reference another smart album".into())); + } + Ok(()) } // ---- writes @@ -261,6 +309,12 @@ impl Catalog { { return Err(CatalogError::Invalid("parent must be an existing folder".into())); } + if let Some(rules) = &album.smart { + if album.folder || !album.photos.is_empty() { + return Err(CatalogError::Invalid("a smart album holds rules, not photos".into())); + } + self.validate_rules(rules)?; + } let id = album.id; self.next_album = self.next_album.max(id.0 + 1); self.albums.insert(id, album); @@ -296,8 +350,10 @@ impl Catalog { } Op::SetAlbumPhotos { id, photos } => { let a = self.album_mut(id)?; - if a.folder && !photos.is_empty() { - return Err(CatalogError::Invalid("folders can't hold photos".into())); + if (a.folder || a.smart.is_some()) && !photos.is_empty() { + return Err(CatalogError::Invalid( + if a.folder { "folders can't hold photos" } else { "smart albums update automatically" }.into(), + )); } Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) } } @@ -305,6 +361,14 @@ impl Catalog { let a = self.album_mut(id)?; Op::SetAlbumCover { id, cover: std::mem::replace(&mut a.cover, cover) } } + Op::SetAlbumRules { id, rules } => { + self.validate_rules(&rules)?; + let a = self.album_mut(id)?; + let Some(old) = a.smart.as_mut() else { + return Err(CatalogError::Invalid("not a smart album".into())); + }; + Op::SetAlbumRules { id, rules: std::mem::replace(old, rules) } + } Op::Batch { ops } => { let mut inverses = Vec::with_capacity(ops.len()); for op in ops { diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index ad15b7ef..f0fa839e 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -203,4 +203,18 @@ pub struct Album { pub photos: Vec, #[serde(default)] pub cover: Option, + /// A smart album: its photos are every (non-deleted) photo matching these rules, evaluated + /// live; `photos` stays empty. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub smart: Option>, +} + +impl Album { + /// A regular (manual) album. + pub fn new(id: AlbumId, name: impl Into) -> Album { + Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None } + } + pub fn is_smart(&self) -> bool { + self.smart.is_some() + } } diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index e7a406d2..416b996a 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -33,6 +33,12 @@ pub struct Filter { pub date: Option, pub keyword: Option, pub camera: Option, + /// Lens (case-insensitive substring). + pub lens: Option, + /// Capture date range, inclusive: `dateFrom` is compared as a lower bound (`2026-04-01`), + /// `dateTo` as a prefix upper bound (`2026-04` includes all of April). + pub date_from: Option, + pub date_to: Option, /// Import date prefix (Recently Added). pub imported: Option, } @@ -100,6 +106,48 @@ fn token_matches(p: &Photo, tok: &str) -> bool { } impl Filter { + /// Human-readable summary of the active rules (smart album tooltips, `album.list`). + pub fn describe(&self) -> String { + let mut v: Vec = Vec::new(); + if self.rating > 0 { + let op = match self.rating_op { + RatingOp::AtLeast => "≥", + RatingOp::Exactly => "=", + RatingOp::AtMost => "≤", + }; + v.push(format!("rating {op} {}", self.rating)); + } + if let Some(f) = self.flag { + v.push(format!("flag {}", format!("{f:?}").to_lowercase())); + } + if let Some(l) = self.label { + v.push(format!("label {}", format!("{l:?}").to_lowercase())); + } + if let Some(k) = self.kind { + v.push(format!("kind {}", format!("{k:?}").to_lowercase())); + } + if let Some(e) = self.edited { + v.push(if e { "edited".into() } else { "unedited".into() }); + } + for (name, val) in + [("keyword", &self.keyword), ("camera", &self.camera), ("lens", &self.lens), ("date", &self.date), ("imported", &self.imported)] + { + if let Some(x) = val { + v.push(format!("{name} {x}")); + } + } + match (&self.date_from, &self.date_to) { + (Some(a), Some(b)) => v.push(format!("captured {a} – {b}")), + (Some(a), None) => v.push(format!("captured from {a}")), + (None, Some(b)) => v.push(format!("captured until {b}")), + _ => {} + } + if !self.text.trim().is_empty() { + v.push(format!("“{}”", self.text.trim())); + } + if v.is_empty() { "all photos".into() } else { v.join(", ") } + } + pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool { if p.deleted != self.deleted { return false; @@ -121,7 +169,22 @@ impl Filter { return false; } if let Some(a) = self.album - && !cat.album(a).is_some_and(|al| al.photos.contains(&p.id)) + && !cat.album_contains(a, p) + { + return false; + } + if let Some(from) = &self.date_from + && p.date() < from.as_str() + { + return false; + } + if let Some(to) = &self.date_to + && p.date().get(..to.len()).unwrap_or(p.date()) > to.as_str() + { + return false; + } + if let Some(l) = &self.lens + && !p.meta.lens.to_lowercase().contains(&l.to_lowercase()) { return false; } diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 37ecec43..a18f9479 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -57,9 +57,11 @@ fn albums_and_folders() { let mut c = Catalog::new(); let a = photo(&mut c, "a.jpg", "2026-04-01"); let f = c.alloc_album_id(); - c.apply(Op::AddAlbum { album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None } }).unwrap(); + c.apply(Op::AddAlbum { album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None } }) + .unwrap(); let al = c.alloc_album_id(); - c.apply(Op::AddAlbum { album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None } }).unwrap(); + c.apply(Op::AddAlbum { album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None } }) + .unwrap(); assert_eq!(c.albums_of(a), vec![al]); assert!(c.apply(Op::RemoveAlbum { id: f }).is_err(), "non-empty folder"); assert!(c.apply(Op::SetAlbumPhotos { id: f, photos: vec![a] }).is_err(), "folders hold no photos"); @@ -141,3 +143,51 @@ proptest::proptest! { proptest::prop_assert_eq!(c.to_snapshot(), start); } } + +#[test] +fn smart_albums_update_live() { + let mut c = Catalog::new(); + let a = photo(&mut c, "a.jpg", "2026-04-01T10:00:00"); + let b = photo(&mut c, "b.jpg", "2026-05-02T10:00:00"); + let z = photo(&mut c, "z.jpg", "2025-12-31T10:00:00"); + let id = c.alloc_album_id(); + let rules = Filter { rating: 3, date_from: Some("2026-01-01".into()), date_to: Some("2026-04".into()), ..Default::default() }; + c.apply(Op::AddAlbum { album: Album { smart: Some(Box::new(rules)), ..Album::new(id, "Good spring") } }).unwrap(); + assert!(c.album_photos(id).is_empty()); + for p in [a, b, z] { + c.apply(Op::SetRating { id: p, rating: 4 }).unwrap(); + } + // b is after the range, z before it + assert_eq!(c.album_photos(id), vec![a]); + assert_eq!(c.album_count(id), 1); + assert_eq!(c.albums_of(a), vec![id]); + // via the album filter (what the grid uses) + assert_eq!(c.query(&Filter { album: Some(id), ..Default::default() }, &Sort::default()), vec![a]); + // deleted photos drop out + c.apply(Op::SetDeleted { id: a, deleted: true }).unwrap(); + assert!(c.album_photos(id).is_empty()); + c.apply(Op::SetDeleted { id: a, deleted: false }).unwrap(); + // change the rules; inverse restores + let inv = c.apply(Op::SetAlbumRules { id, rules: Box::new(Filter { rating: 4, rating_op: RatingOp::Exactly, ..Default::default() }) }).unwrap(); + assert_eq!(c.album_photos(id), vec![a, b, z]); + c.apply(inv).unwrap(); + assert_eq!(c.album_photos(id), vec![a]); + // smart albums hold no photos and can't nest smart rules + assert!(c.apply(Op::SetAlbumPhotos { id, photos: vec![b] }).is_err()); + let id2 = c.alloc_album_id(); + let bad = Filter { album: Some(id), ..Default::default() }; + assert!(c.apply(Op::AddAlbum { album: Album { smart: Some(Box::new(bad)), ..Album::new(id2, "x") } }).is_err()); + assert!(c.apply(Op::SetAlbumRules { id, rules: Box::new(Filter { deleted: true, ..Default::default() }) }).is_err()); + // rules on a manual album are rejected + let manual = c.alloc_album_id(); + c.apply(Op::AddAlbum { album: Album::new(manual, "m") }).unwrap(); + assert!(c.apply(Op::SetAlbumRules { id: manual, rules: Box::new(Filter::default()) }).is_err()); + // a smart album may narrow a manual album + c.apply(Op::SetAlbumPhotos { id: manual, photos: vec![b, z] }).unwrap(); + c.apply(Op::SetAlbumRules { id, rules: Box::new(Filter { album: Some(manual), lens: Some(String::new()), ..Default::default() }) }).unwrap(); + assert_eq!(c.album_photos(id), vec![b, z]); + // persisted in snapshots + let back = Catalog::from_snapshot(&c.to_snapshot()).unwrap(); + assert_eq!(back.album_photos(id), vec![b, z]); + assert!(Filter { rating: 3, keyword: Some("sea".into()), ..Default::default() }.describe().contains("rating ≥ 3, keyword sea")); +} diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 2cbe8098..1cd6e6c4 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -19,7 +19,14 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { let albums: Vec = c.albums().map(|a| a.id).collect(); let pid = photos.get(a as usize % photos.len().max(1)).copied().unwrap_or(PhotoId(99)); let aid = albums.get(a as usize % albums.len().max(1)).copied().unwrap_or(AlbumId(99)); - match kind % 10 { + let rules = |b: u8| Filter { + rating: b % 6, + flag: [None, Some(Flag::Pick), Some(Flag::Reject)][b as usize % 3], + date_from: (b % 4 == 1).then(|| "2026-01".to_string()), + text: if b.is_multiple_of(5) { format!("{}", b % 10) } else { String::new() }, + ..Default::default() + }; + match kind % 12 { 0 | 1 => { let id = c.alloc_photo_id(); let mut p = Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, &format!("{}.jpg", id.0), "JPEG", 60, 40, "2026-01-01"); @@ -42,11 +49,18 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { } 5 => { let id = c.alloc_album_id(); - Op::AddAlbum { album: Album { id, name: format!("A{b}"), parent: None, folder: b.is_multiple_of(4), photos: vec![], cover: None } } + Op::AddAlbum { + album: Album { id, name: format!("A{b}"), parent: None, folder: b.is_multiple_of(4), photos: vec![], cover: None, smart: None }, + } } 6 => Op::SetAlbumPhotos { id: aid, photos: photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(3)).collect() }, 7 => Op::RenameAlbum { id: aid, name: format!("Renamed {b} ✓") }, 8 => Op::SetDeleted { id: pid, deleted: b.is_multiple_of(2) }, + 10 => { + let id = c.alloc_album_id(); + Op::AddAlbum { album: Album { smart: Some(Box::new(rules(b))), ..Album::new(id, format!("Smart {b}")) } } + } + 11 => Op::SetAlbumRules { id: aid, rules: Box::new(rules(b)) }, _ => c.delete_permanently_ops(pid), } } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 5030286e..b2d0d4e5 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -309,10 +309,56 @@ pub fn specs() -> Vec { let photos = if !folder && bool_or(p, "addSelected", false) { s.targets(&Value::Null) } else { vec![] }; let id = s.catalog.alloc_album_id(); let cover = photos.first().copied(); - s.commit(if folder { "New Folder" } else { "New Album" }, Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover } })?; + s.commit( + if folder { "New Folder" } else { "New Album" }, + Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None } }, + )?; Ok(json!({"id": id.0})) } ), + cmd!( + "album.createSmart", + "New Smart Album…", + [], + None, + "{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album), parent?: folderId} — without `rules`, saves the current view (source + filter)", + always, + |s, p| { + let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string(); + if name.is_empty() { + return Err(bad("album.createSmart", "empty name")); + } + let rules = match p.get("rules") { + Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart")?, + None => view_rules(s), + }; + let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); + let id = s.catalog.alloc_album_id(); + let album = Album { parent, smart: Some(Box::new(rules)), ..Album::new(id, name) }; + s.commit("New Smart Album", Op::AddAlbum { album })?; + Ok(json!({"id": id.0, "count": s.catalog.album_count(id)})) + } + ), + cmd!( + "album.setRules", + "Edit Smart Album", + [], + None, + "{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view)}", + always, + |s, p| { + let id = album_param(p, "id", "album.setRules")?; + let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?; + let rules = if bool_or(p, "fromView", false) { + view_rules(s) + } else { + let base = if bool_or(p, "replace", false) { Default::default() } else { cur }; + merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")? + }; + s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?; + Ok(json!({"count": s.catalog.album_count(id)})) + } + ), cmd!("album.rename", "Rename Album", [], None, "{id, name}", always, |s, p| { let id = album_param(p, "id", "album.rename")?; let name = str_param(p, "name").ok_or_else(|| bad("album.rename", "missing name"))?.to_string(); @@ -337,6 +383,9 @@ pub fn specs() -> Vec { let id = album_param(p, "id", "album.addPhotos")?; let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; + if al.is_smart() || al.folder { + return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); + } let mut photos = al.photos.clone(); let before = photos.len(); for t in targets { @@ -353,6 +402,9 @@ pub fn specs() -> Vec { let id = album_param(p, "id", "album.removePhotos")?; let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; + if al.is_smart() { + return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); + } let photos: Vec = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; ok() @@ -452,6 +504,51 @@ pub fn specs() -> Vec { ] } +/// `base` with a partial Filter (JSON) merged on top. +fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result { + let mut v = serde_json::to_value(base).unwrap_or_default(); + lightcraft_develop::presets::deep_merge(&mut v, patch); + serde_json::from_value(v).map_err(|e| bad(c, e.to_string())) +} + +/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from +/// its rules with the filter bar's settings on top. +fn view_rules(s: &Session) -> lightcraft_catalog::Filter { + use lightcraft_catalog::Filter; + let smart = match s.source { + LibrarySource::Album(a) => s.catalog.album(a).and_then(|a| a.smart.as_deref().cloned()), + _ => None, + }; + match smart { + Some(base) => { + // overlay the fields the filter bar changed + let cur = serde_json::to_value(&s.filter).unwrap_or_default(); + let def = serde_json::to_value(Filter::default()).unwrap_or_default(); + let mut patch = serde_json::Map::new(); + if let (Some(c), Some(d)) = (cur.as_object(), def.as_object()) { + for (k, v) in c { + if d.get(k) != Some(v) { + patch.insert(k.clone(), v.clone()); + } + } + } + merge_rules(&base, &Value::Object(patch), "").unwrap_or(base) + } + None => { + let mut f = s.source.to_filter(&s.filter, &s.catalog); + f.deleted = false; + f + } + } +} + +impl Session { + /// The current view (source + filter) as smart-album rules (see `album.createSmart`). + pub fn view_rules(&self) -> lightcraft_catalog::Filter { + view_rules(self) + } +} + fn has_library(s: &Session) -> std::result::Result<(), String> { if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) } } diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 5348c351..67677319 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -27,15 +27,21 @@ pub fn photo_summary(p: &Photo) -> Value { }) } -fn album_json(a: &Album, all: &[Album]) -> Value { - json!({ +fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Value { + let mut v = json!({ "id": a.id.0, "name": a.name, "folder": a.folder, - "count": a.photos.len(), + "count": cat.album_count(a.id), "cover": a.cover.map(|c| c.0), - "children": all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all)).collect::>(), - }) + "children": all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat)).collect::>(), + }); + if let Some(rules) = &a.smart { + v["smart"] = json!(true); + v["rules"] = serde_json::to_value(rules).unwrap_or_default(); + v["rulesText"] = json!(rules.describe()); + } + v } fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result { @@ -88,7 +94,7 @@ pub fn specs() -> Vec { }), cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| { let all: Vec = s.catalog.albums().cloned().collect(); - Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all)).collect())) + Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog)).collect())) }), cmd!(query "photo.inspect", "Inspect Photo", [], None, "{id?}", always, |s, p| { let id = photo_arg(s, p, "photo.inspect")?; diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 9c2358f8..7b4951c7 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -61,7 +61,9 @@ pub fn load(s: &mut Session) { ops.push(Op::AddPhoto { photo: Box::new(p) }); } let folder = s.catalog.alloc_album_id(); - ops.push(Op::AddAlbum { album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None } }); + ops.push(Op::AddAlbum { + album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None }, + }); let by_kw = |kw: &str| -> Vec<_> { scenes.iter().zip(&ids).filter(|(sc, _)| sc.meta.keywords.contains(&kw)).map(|(_, id)| *id).collect() }; for (name, parent, photos) in [ ("Mountains", Some(folder), by_kw("mountains")), @@ -73,7 +75,7 @@ pub fn load(s: &mut Session) { ] { let id = s.catalog.alloc_album_id(); let cover = photos.first().copied(); - ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover } }); + ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None } }); } for op in ops { let _ = s.catalog.apply(op); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2f971c97..18b40549 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -285,6 +285,7 @@ impl Session { && self.sort.key == lightcraft_catalog::SortKey::CaptureDate && let LibrarySource::Album(a) = self.source && let Some(al) = self.catalog.album(a) + && !al.is_smart() && self.filter == Filter::default() { let order = al.photos.clone(); @@ -321,4 +322,6 @@ mod tests_import; #[cfg(test)] mod tests_library; #[cfg(test)] +mod tests_organize; +#[cfg(test)] mod tests_xmp; diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs new file mode 100644 index 00000000..b088e718 --- /dev/null +++ b/crates/engine/src/tests_organize.rs @@ -0,0 +1,80 @@ +//! Organizing commands: smart albums, stacks, virtual copies — through `Session::execute`, with +//! undo and a persistent library (journal replay must reproduce the live state). + +use lightcraft_catalog::Flag; +use serde_json::json; + +use crate::{LibrarySource, Session}; + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("lc-organize-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d +} + +fn open(dir: &std::path::Path) -> Session { + let mut s = Session::new(); + s.open_library(dir, true).unwrap(); + s +} + +#[test] +fn smart_album_from_view_updates_live_and_persists() { + let dir = temp_dir("smart"); + let mut s = open(&dir); + s.execute("library.filter", &json!({"rating": 4})).unwrap(); + let want = s.visible_cloned(); + assert!(!want.is_empty()); + let r = s.execute("album.createSmart", &json!({"name": "Four plus"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + assert_eq!(r["count"].as_u64().unwrap() as usize, want.len()); + s.execute("library.clearFilter", &json!({})).unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap(); + let mut got = s.visible_cloned(); + got.sort(); + let mut w = want.clone(); + w.sort(); + assert_eq!(got, w); + // live: rating another photo 5 adds it; rating a member 1 removes it + let other = s.catalog.photos().find(|p| p.rating < 4 && !p.deleted).unwrap().id; + s.execute("photo.rate", &json!({"ids": [other.0], "rating": 5})).unwrap(); + assert!(s.visible_cloned().contains(&other)); + s.execute("photo.rate", &json!({"ids": [want[0].0], "rating": 1})).unwrap(); + assert!(!s.visible_cloned().contains(&want[0])); + // manual membership changes are refused + assert!(s.execute("album.addPhotos", &json!({"id": id, "ids": [want[0].0]})).is_err()); + // edit rules (merge), rename; undo the rename + s.execute("album.setRules", &json!({"id": id, "rules": {"flag": "pick"}})).unwrap(); + let rules = s.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().smart.clone().unwrap(); + assert_eq!((rules.rating, rules.flag), (4, Some(Flag::Pick))); + s.execute("album.rename", &json!({"id": id, "name": "Best picks"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().name, "Four plus"); + // rules via params: camera + lens + date range + let r2 = s + .execute("album.createSmart", &json!({"name": "Range", "rules": {"dateFrom": "2000-01-01", "dateTo": "2100", "lens": "", "edited": true}})) + .unwrap(); + let edited = s.catalog.photos().filter(|p| !p.deleted && p.is_edited()).count(); + assert_eq!(r2["count"].as_u64().unwrap() as usize, edited); + let listed = s.execute("albums.list", &json!({})).unwrap(); + assert!(listed.to_string().contains("\"rulesText\""), "{listed}"); + let expect = s.catalog.to_snapshot(); + drop(s); // crash: replay the log + let mut s2 = open(&dir); + assert_eq!(s2.catalog.to_snapshot(), expect); + // deleting the smart album + s2.execute("album.delete", &json!({"id": id})).unwrap(); + assert!(s2.catalog.album(lightcraft_catalog::AlbumId(id)).is_none()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn smart_album_from_smart_album_view_keeps_its_rules() { + let mut s = Session::with_demo(); + let id = s.execute("album.createSmart", &json!({"name": "Picks", "rules": {"flag": "pick"}})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap(); + s.execute("library.filter", &json!({"rating": 3})).unwrap(); + let r = s.view_rules(); + assert_eq!((r.flag, r.rating, r.album), (Some(Flag::Pick), 3, None)); + assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id))); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a8656c6f..439f15ad 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -45,6 +45,7 @@ pub enum Icon { Plus, Minus, Album, + SmartAlbum, Folder, Photos, Clock, @@ -302,6 +303,12 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.rect(3.0, 5.0, 15.0, 17.0, 1.0); pen.line(&[(6.0, 2.5), (17.5, 2.5), (17.5, 14.0)]); } + SmartAlbum => { + pen.rect(3.0, 5.0, 15.0, 17.0, 1.0); + pen.line(&[(6.0, 2.5), (17.5, 2.5), (17.5, 14.0)]); + // a small funnel: the album is a saved filter + pen.closed(&[(5.5, 8.5), (12.5, 8.5), (10.0, 11.5), (10.0, 14.5), (8.0, 13.5), (8.0, 11.5)]); + } Folder => pen.closed(&[(2.5, 5.0), (8.0, 5.0), (9.5, 7.0), (17.5, 7.0), (17.5, 15.5), (2.5, 15.5)]), Photos => { pen.rect(2.5, 4.0, 17.5, 16.0, 1.5); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1dd7101f..6916f80f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -52,6 +52,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("tool.wbPicker", "White Balance Selector", Some("W"), "View"), ("tool.none", "No Tool", None, "View"), ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), + ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Photo"), ("dialog.export", "Export…", Some("Cmd+Shift+E"), "File"), @@ -266,6 +267,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() }); + Ok(Value::Null) + } "dialog.createPreset" => { app.ui.dialog = Some(Dialog::CreatePreset { name: String::new(), group: "User Presets".into() }); Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 0269aad2..340556a5 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -22,6 +22,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::NewAlbum { folder: true, .. } => "Create Folder", Dialog::NewAlbum { .. } => "Create Album", Dialog::RenameAlbum { .. } => "Rename Album", + Dialog::NewSmartAlbum { .. } => "Create Smart Album", Dialog::CreatePreset { .. } => "Create Preset", Dialog::CopySettings { .. } => "Choose Edit Settings to Copy", Dialog::Export { .. } => "Export", @@ -38,6 +39,20 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .show(ctx, |ui| { ui.spacing_mut().item_spacing.y = 8.0; match &mut dlg { + Dialog::NewSmartAlbum { name } => { + let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY)); + r.request_focus(); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + confirm = true; + } + let rules = app.session.view_rules(); + let n = app.session.catalog.query(&rules, &Default::default()).len(); + ui.label(egui::RichText::new(format!("Matches: {}", rules.describe())).color(t.text_label)); + ui.label( + egui::RichText::new(format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" })) + .color(t.text_dim), + ); + } Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => { let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY)); r.request_focus(); @@ -217,6 +232,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})), Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), + Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})), Dialog::CreatePreset { name, group } => { app.run("preset.create", json!({"name": if name.is_empty() { "My Preset" } else { name }, "group": group})) } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 6312c0cb..dc8b83da 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -239,7 +239,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.menu_button("Add to Album", |ui| { - let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder).map(|a| (a.id.0, a.name.clone())).collect(); + let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); for (aid, name) in albums { if ui.button(name).clicked() { let _ = app.run("album.addPhotos", json!({"id": aid})); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 3a4bc495..48ecc175 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -88,6 +88,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ui.button("Create Album…").clicked() { app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false }); } + if ui.button("Create Smart Album from Filter…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new() }); + } if ui.button("Create Folder…").clicked() { app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true }); } @@ -132,7 +135,12 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent } } else { let sel = app.session.source == LibrarySource::Album(a.id); - let resp = row(app, ui, &format!("album:{}", a.id.0), Icon::Album, &a.name, Some(a.photos.len()), sel, indent); + let icon = if a.is_smart() { Icon::SmartAlbum } else { Icon::Album }; + let n = app.session.catalog.album_count(a.id); + let mut resp = row(app, ui, &format!("album:{}", a.id.0), icon, &a.name, Some(n), sel, indent); + if let Some(rules) = &a.smart { + resp = resp.on_hover_text(format!("Smart album: {}", rules.describe())); + } if resp.clicked() { let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0})); } @@ -143,9 +151,12 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { resp.context_menu(|ui| { - if !a.folder && ui.button("Add Selected Photos").clicked() { + if !a.folder && !a.is_smart() && ui.button("Add Selected Photos").clicked() { let _ = app.run("album.addPhotos", json!({"id": a.id.0})); } + if a.is_smart() && ui.button("Update Rules from Current Filter").clicked() { + let _ = app.run("album.setRules", json!({"id": a.id.0, "fromView": true})); + } if ui.button("Rename…").clicked() { app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() }); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 34b3b15a..8e3dfc4e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -127,6 +127,10 @@ pub enum Dialog { id: u64, name: String, }, + /// Save the current view (source + filter) as a smart album. + NewSmartAlbum { + name: String, + }, CreatePreset { name: String, group: String, From 5434c5d17235095068dc0c0437e51172efcb4297 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:52:11 -0400 Subject: [PATCH 2/6] =?UTF-8?q?M1.14:=20stacks=20=E2=80=94=20group,=20expa?= =?UTF-8?q?nd/collapse,=20top=20of=20stack,=20auto-stack=20by=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stack entity in the catalog (AddStack/RemoveStack/SetStack ops with inverses, validation, snapshot + journal replay property test). Helpers build ops for group (merging stacks), ungroup, remove, set top, collapse, and stack_with_ops() for future merge results. Views arrange stack members together and collapse to the top. Commands stack.* (Cmd+G, Cmd+Shift+G, S, Shift+S, expand/collapse all, stack.auto with preview); grid/filmstrip count badges (click to toggle), context menu, Auto-Stack dialog. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 54 ++++- crates/catalog/src/model.rs | 24 ++ crates/catalog/src/stacks.rs | 316 +++++++++++++++++++++++++++ crates/catalog/src/tests_journal.rs | 8 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/organize.rs | 142 ++++++++++++ crates/engine/src/cmd/query.rs | 8 + crates/engine/src/lib.rs | 3 + crates/engine/src/tests_organize.rs | 52 +++++ crates/ui-egui/src/icons.rs | 6 + crates/ui-egui/src/menus.rs | 7 +- crates/ui-egui/src/panels/detail.rs | 9 + crates/ui-egui/src/panels/dialogs.rs | 30 +++ crates/ui-egui/src/panels/grid.rs | 53 +++++ crates/ui-egui/src/state.rs | 4 + 15 files changed, 714 insertions(+), 4 deletions(-) create mode 100644 crates/catalog/src/stacks.rs create mode 100644 crates/engine/src/cmd/organize.rs diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 6b334474..7a86fb5c 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -10,6 +10,7 @@ pub mod journal; pub mod model; pub mod query; +pub mod stacks; pub mod store; use std::collections::BTreeMap; @@ -28,6 +29,8 @@ pub enum CatalogError { NoPhoto(PhotoId), #[error("no such album {0:?}")] NoAlbum(AlbumId), + #[error("no such stack {0:?}")] + NoStack(StackId), #[error("invalid: {0}")] Invalid(String), #[error("corrupt catalog data: {0}")] @@ -118,6 +121,18 @@ pub enum Op { id: AlbumId, rules: Box, }, + AddStack { + stack: Stack, + }, + RemoveStack { + id: StackId, + }, + /// Replace a stack's members (first = top) and collapsed state. + SetStack { + id: StackId, + photos: Vec, + collapsed: bool, + }, /// Several ops as one step (undo applies the inverses in reverse). Batch { ops: Vec, @@ -130,6 +145,10 @@ pub struct Catalog { albums: BTreeMap, next_photo: u64, next_album: u64, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + stacks: BTreeMap, + #[serde(default)] + next_stack: u64, /// Increments on every applied op. #[serde(skip)] pub revision: u64, @@ -137,7 +156,7 @@ pub struct Catalog { impl Catalog { pub fn new() -> Catalog { - Catalog { next_photo: 1, next_album: 1, ..Default::default() } + Catalog { next_photo: 1, next_album: 1, next_stack: 1, ..Default::default() } } // ---- ids @@ -152,6 +171,11 @@ impl Catalog { self.next_album = id.0 + 1; id } + pub fn alloc_stack_id(&mut self) -> StackId { + let id = StackId(self.next_stack.max(1)); + self.next_stack = id.0 + 1; + id + } // ---- reads @@ -369,6 +393,30 @@ impl Catalog { }; Op::SetAlbumRules { id, rules: std::mem::replace(old, rules) } } + Op::AddStack { stack } => { + if self.stacks.contains_key(&stack.id) { + return Err(CatalogError::Invalid(format!("stack {:?} exists", stack.id))); + } + self.validate_stack(stack.id, &stack.photos)?; + let id = stack.id; + self.next_stack = self.next_stack.max(id.0 + 1); + self.stacks.insert(id, stack); + Op::RemoveStack { id } + } + Op::RemoveStack { id } => { + let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?; + Op::AddStack { stack: s } + } + Op::SetStack { id, photos, collapsed } => { + if !self.stacks.contains_key(&id) { + return Err(CatalogError::NoStack(id)); + } + self.validate_stack(id, &photos)?; + let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?; + let old = Op::SetStack { id, photos: std::mem::replace(&mut s.photos, photos), collapsed: s.collapsed }; + s.collapsed = collapsed; + old + } Op::Batch { ops } => { let mut inverses = Vec::with_capacity(ops.len()); for op in ops { @@ -389,7 +437,8 @@ impl Catalog { }) } - /// Ops that remove a photo permanently including album memberships (one undoable batch). + /// Ops that remove a photo permanently including album and stack memberships (one undoable + /// batch). pub fn delete_permanently_ops(&self, id: PhotoId) -> Op { let mut ops: Vec = self .albums @@ -397,6 +446,7 @@ impl Catalog { .filter(|a| a.photos.contains(&id)) .map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| *p != id).collect() }) .collect(); + ops.extend(self.remove_from_stacks_ops(&[id])); ops.push(Op::RemovePhoto { id }); Op::Batch { ops } } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index f0fa839e..c27eefa9 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -13,6 +13,30 @@ pub struct PhotoId(pub u64); #[serde(transparent)] pub struct AlbumId(pub u64); +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StackId(pub u64); + +/// A stack: photos grouped under a top photo (bursts, brackets, merge results with their sources). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Stack { + pub id: StackId, + /// Members in stack order; `photos[0]` is the top of the stack. At least two. + pub photos: Vec, + /// Collapsed stacks show only their top photo in the grid. + #[serde(default)] + pub collapsed: bool, +} + +impl Stack { + pub fn top(&self) -> PhotoId { + self.photos[0] + } + pub fn position(&self, id: PhotoId) -> Option { + self.photos.iter().position(|p| *p == id) + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum MediaKind { diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs new file mode 100644 index 00000000..ba97e0f8 --- /dev/null +++ b/crates/catalog/src/stacks.rs @@ -0,0 +1,316 @@ +//! Stacks: photos grouped under a top photo. +//! +//! A stack is catalog data ([`Stack`], changed by `AddStack` / `RemoveStack` / `SetStack` ops), so +//! grouping is undoable and journaled like everything else. The helpers here build the ops for the +//! usual gestures (group, ungroup, remove from stack, set top, collapse) and for programmatic +//! grouping such as merge results stacked with their sources ([`Catalog::stack_with_ops`]). +//! [`Catalog::arrange_stacks`] turns a query result into display order (members together, +//! collapsed stacks reduced to one photo). + +use std::collections::{HashMap, HashSet}; + +use crate::{Catalog, CatalogError, Op, PhotoId, Result, Stack, StackId}; + +impl Catalog { + pub fn stack(&self, id: StackId) -> Option<&Stack> { + self.stacks.get(&id) + } + pub fn stacks(&self) -> impl Iterator { + self.stacks.values() + } + /// The stack a photo belongs to. + pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> { + self.stacks.values().find(|s| s.photos.contains(&photo)) + } + /// Photo → (stack, position in the stack), for every stacked photo. + pub fn stack_index(&self) -> HashMap { + self.stacks.values().flat_map(|s| s.photos.iter().enumerate().map(move |(i, p)| (*p, (s.id, i)))).collect() + } + + pub(crate) fn validate_stack(&self, id: StackId, photos: &[PhotoId]) -> Result<()> { + if photos.len() < 2 { + return Err(CatalogError::Invalid("a stack needs at least two photos".into())); + } + let mut seen = HashSet::new(); + for p in photos { + if !seen.insert(*p) { + return Err(CatalogError::Invalid(format!("photo {p:?} twice in a stack"))); + } + if !self.photos.contains_key(p) { + return Err(CatalogError::NoPhoto(*p)); + } + } + if self.stacks.values().any(|s| s.id != id && s.photos.iter().any(|p| seen.contains(p))) { + return Err(CatalogError::Invalid("a photo can be in only one stack".into())); + } + Ok(()) + } + + /// Ops that put `top` and `members` into one stack with `top` on top. Stacks they already + /// belong to are merged into it (their other members follow, in order). `None` if fewer than + /// two photos would be in the stack. + pub fn group_ops(&mut self, top: PhotoId, members: &[PhotoId], collapsed: bool) -> Option { + let mut order: Vec = Vec::new(); + let push = |order: &mut Vec, p: PhotoId| { + if !order.contains(&p) && self.photos.contains_key(&p) { + order.push(p); + } + }; + push(&mut order, top); + for m in members { + push(&mut order, *m); + } + let mut touched: Vec = Vec::new(); + for p in order.clone() { + if let Some(s) = self.stack_of(p) + && !touched.contains(&s.id) + { + touched.push(s.id); + } + } + for sid in &touched { + for p in self.stacks[sid].photos.clone() { + push(&mut order, p); + } + } + if order.len() < 2 || order.first() != Some(&top) { + return None; + } + let mut ops: Vec = touched.iter().skip(1).map(|id| Op::RemoveStack { id: *id }).collect(); + match touched.first() { + Some(keep) => ops.push(Op::SetStack { id: *keep, photos: order, collapsed }), + None => { + let id = self.alloc_stack_id(); + ops.push(Op::AddStack { stack: Stack { id, photos: order, collapsed } }); + } + } + Some(Op::Batch { ops }) + } + + /// Stack `sources` under `result` (e.g. an HDR or panorama merge with the photos it was made + /// from), collapsed so the grid shows the result. + pub fn stack_with_ops(&mut self, result: PhotoId, sources: &[PhotoId]) -> Option { + self.group_ops(result, sources, true) + } + + /// Ops that dissolve every stack containing one of `photos`. + pub fn ungroup_ops(&self, photos: &[PhotoId]) -> Vec { + self.stacks.values().filter(|s| s.photos.iter().any(|p| photos.contains(p))).map(|s| Op::RemoveStack { id: s.id }).collect() + } + + /// Ops that take `photos` out of their stacks (a stack left with one photo is dissolved). + pub fn remove_from_stacks_ops(&self, photos: &[PhotoId]) -> Vec { + self.stacks + .values() + .filter(|s| s.photos.iter().any(|p| photos.contains(p))) + .map(|s| { + let rest: Vec = s.photos.iter().copied().filter(|p| !photos.contains(p)).collect(); + if rest.len() < 2 { Op::RemoveStack { id: s.id } } else { Op::SetStack { id: s.id, photos: rest, collapsed: s.collapsed } } + }) + .collect() + } + + /// The op that moves `photo` to the top of its stack (`None` if unstacked or already on top). + pub fn set_top_ops(&self, photo: PhotoId) -> Option { + let s = self.stack_of(photo)?; + if s.top() == photo { + return None; + } + let mut photos = vec![photo]; + photos.extend(s.photos.iter().copied().filter(|p| *p != photo)); + Some(Op::SetStack { id: s.id, photos, collapsed: s.collapsed }) + } + + /// Ops that collapse or expand stacks. + pub fn set_collapsed_ops(&self, stacks: &[StackId], collapsed: bool) -> Vec { + stacks + .iter() + .filter_map(|id| self.stacks.get(id)) + .filter(|s| s.collapsed != collapsed) + .map(|s| Op::SetStack { id: s.id, photos: s.photos.clone(), collapsed }) + .collect() + } + + /// Display order for a query result: a stack's members appear together (in stack order) where + /// its first member appears; a collapsed stack shows only its highest-ranked member in `ids` (the top, + /// unless the filter hides it). + pub fn arrange_stacks(&self, ids: &[PhotoId]) -> Vec { + if self.stacks.is_empty() { + return ids.to_vec(); + } + let index = self.stack_index(); + let present: HashSet = ids.iter().copied().collect(); + let mut done: HashSet = HashSet::new(); + let mut out = Vec::with_capacity(ids.len()); + for id in ids { + match index.get(id) { + None => out.push(*id), + Some((sid, _)) => { + if !done.insert(*sid) { + continue; + } + let s = &self.stacks[sid]; + let members = s.photos.iter().copied().filter(|p| present.contains(p)); + if s.collapsed { + out.extend(members.take(1)); + } else { + out.extend(members); + } + } + } + } + out + } + + /// Auto-stack by capture time: runs of `ids` (not deleted, not already stacked, with a + /// capture time) whose consecutive captures are at most `gap_secs` apart, in time order. + /// Only runs of two or more are returned. + pub fn auto_stack_groups(&self, ids: &[PhotoId], gap_secs: f64) -> Vec> { + let index = self.stack_index(); + let mut timed: Vec<(i64, PhotoId)> = ids + .iter() + .filter_map(|id| self.photos.get(id)) + .filter(|p| !p.deleted && !index.contains_key(&p.id)) + .filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.id))) + .collect(); + timed.sort(); + timed.dedup_by_key(|(_, id)| *id); + let mut groups: Vec> = Vec::new(); + let mut last: Option = None; + for (t, id) in timed { + match (last, groups.last_mut()) { + (Some(l), Some(g)) if (t - l) as f64 <= gap_secs => g.push(id), + _ => groups.push(vec![id]), + } + last = Some(t); + } + groups.retain(|g| g.len() >= 2); + groups + } +} + +/// Seconds since 1970-01-01 for an ISO 8601 local time (`2026-04-01T10:00:00`, `2026-04-01 10:00`, +/// `2026-04-01`; fractions and zone suffixes are ignored). `None` if it doesn't parse. +pub fn iso_seconds(s: &str) -> Option { + let s = s.trim(); + let num = |a: usize, b: usize| -> Option { s.get(a..b).filter(|x| x.bytes().all(|c| c.is_ascii_digit())).and_then(|x| x.parse().ok()) }; + let (y, m, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?); + if !(1..=12).contains(&m) || !(1..=31).contains(&d) { + return None; + } + let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(0)) } else { (0, 0, 0) }; + // days from civil (proleptic Gregorian) + let y2 = if m <= 2 { y - 1 } else { y }; + let era = y2.div_euclid(400); + let yoe = y2 - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146_097 + doe - 719_468; + Some(days * 86_400 + hh * 3600 + mm * 60 + ss) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Photo, Source}; + + fn cat(n: usize, times: &[&str]) -> (Catalog, Vec) { + let mut c = Catalog::new(); + let mut ids = Vec::new(); + for i in 0..n { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, &format!("{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); + p.captured = times.get(i).map(|t| t.to_string()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + (c, ids) + } + + #[test] + fn iso_seconds_parses() { + assert_eq!(iso_seconds("1970-01-01T00:00:00"), Some(0)); + assert_eq!(iso_seconds("1970-01-02"), Some(86_400)); + assert_eq!(iso_seconds("2026-04-01T10:00:05.123+02:00").unwrap() - iso_seconds("2026-04-01T10:00:00").unwrap(), 5); + assert_eq!(iso_seconds("2024-03-01").unwrap() - iso_seconds("2024-02-28").unwrap(), 2 * 86_400, "leap year"); + assert_eq!(iso_seconds("2000-01-01T00:00:00"), Some(946_684_800)); + assert_eq!(iso_seconds("garbage"), None); + } + + #[test] + fn group_merge_set_top_remove_and_arrange() { + let (mut c, ids) = cat(6, &[]); + let [a, b, x, d, e, f] = ids[..] else { unreachable!() }; + let op = c.group_ops(b, &[a, b, x], false).unwrap(); + let inv = c.apply(op).unwrap(); + let s = c.stack_of(a).unwrap().clone(); + assert_eq!(s.photos, vec![b, a, x]); + // query order a b x d e f → stack shown where its first member appears, in stack order + assert_eq!(c.arrange_stacks(&ids), vec![b, a, x, d, e, f]); + // collapse: only the top + let ops = c.set_collapsed_ops(&[s.id], true); + c.apply(Op::Batch { ops }).unwrap(); + assert_eq!(c.arrange_stacks(&ids), vec![b, d, e, f]); + // filtered-out top: first visible member stands in + assert_eq!(c.arrange_stacks(&[x, d, a]), vec![a, d]); + // grouping with another stack merges both + let op = c.group_ops(d, &[e], false).unwrap(); + c.apply(op).unwrap(); + let op = c.group_ops(f, &[a, e], true).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.stacks().count(), 1); + assert_eq!(c.stack_of(f).unwrap().photos, vec![f, a, e, b, x, d]); + // set top, remove from stack, ungroup + c.apply(c.set_top_ops(x).unwrap()).unwrap(); + assert_eq!(c.stack_of(f).unwrap().top(), x); + assert!(c.set_top_ops(x).is_none()); + c.apply(Op::Batch { ops: c.remove_from_stacks_ops(&[x, a, e, b]) }).unwrap(); + assert_eq!(c.stack_of(f).unwrap().photos, vec![f, d]); + c.apply(Op::Batch { ops: c.remove_from_stacks_ops(&[d]) }).unwrap(); + assert_eq!(c.stacks().count(), 0, "a one-photo stack dissolves"); + // undo everything back to the first group's inverse state + let (mut c2, _) = cat(6, &[]); + let op = c2.group_ops(a, &[b], false).unwrap(); + c2.apply(op).unwrap(); + c2.apply(Op::Batch { ops: c2.ungroup_ops(&[b]) }).unwrap(); + assert_eq!(c2.stacks().count(), 0); + let _ = inv; + } + + #[test] + fn stack_validation_and_permanent_delete() { + let (mut c, ids) = cat(3, &[]); + let id = c.alloc_stack_id(); + assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0]], collapsed: false } }).is_err(), "one photo"); + assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], ids[0]], collapsed: false } }).is_err(), "duplicate"); + assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], PhotoId(99)], collapsed: false } }).is_err(), "missing"); + c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], ids[1]], collapsed: true } }).unwrap(); + let id2 = c.alloc_stack_id(); + assert!(c.apply(Op::AddStack { stack: Stack { id: id2, photos: vec![ids[1], ids[2]], collapsed: false } }).is_err(), "one stack per photo"); + // deleting a member permanently dissolves the 2-photo stack; undo restores it + let before = c.to_snapshot(); + let inv = c.apply(c.delete_permanently_ops(ids[1])).unwrap(); + assert!(c.stack_of(ids[0]).is_none()); + c.apply(inv).unwrap(); + assert_eq!(c.to_snapshot(), before); + // snapshot round trip keeps stacks and the id allocator + let back = Catalog::from_snapshot(&c.to_snapshot()).unwrap(); + assert_eq!(back.stack(id), c.stack(id)); + assert_eq!(back.clone().alloc_stack_id(), c.clone().alloc_stack_id()); + } + + #[test] + fn auto_stack_by_capture_time() { + let times = ["2026-04-01T10:00:00", "2026-04-01T10:00:02", "2026-04-01T10:00:09", "2026-04-01T11:00:00", "2026-04-01T11:00:01"]; + let (mut c, ids) = cat(6, ×); + let g = c.auto_stack_groups(&ids, 5.0); + assert_eq!(g, vec![vec![ids[0], ids[1]], vec![ids[3], ids[4]]]); + assert_eq!(c.auto_stack_groups(&ids, 10.0), vec![vec![ids[0], ids[1], ids[2]], vec![ids[3], ids[4]]]); + assert_eq!(c.auto_stack_groups(&ids, 3600.0).len(), 1); + // already stacked photos are skipped + let op = c.group_ops(ids[0], &[ids[1]], true).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.auto_stack_groups(&ids, 10.0), vec![vec![ids[3], ids[4]]]); + } +} diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 1cd6e6c4..d04427ba 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -26,7 +26,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { text: if b.is_multiple_of(5) { format!("{}", b % 10) } else { String::new() }, ..Default::default() }; - match kind % 12 { + match kind % 15 { 0 | 1 => { let id = c.alloc_photo_id(); let mut p = Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, &format!("{}.jpg", id.0), "JPEG", 60, 40, "2026-01-01"); @@ -61,6 +61,12 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { Op::AddAlbum { album: Album { smart: Some(Box::new(rules(b))), ..Album::new(id, format!("Smart {b}")) } } } 11 => Op::SetAlbumRules { id: aid, rules: Box::new(rules(b)) }, + 12 => { + let members: Vec = photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(4)).collect(); + c.group_ops(pid, &members, b.is_multiple_of(2)).unwrap_or(Op::Batch { ops: vec![] }) + } + 13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) }, + 14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }), _ => c.delete_permanently_ops(pid), } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3e1b8c54..cce167ed 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -8,6 +8,7 @@ mod develop; mod edit; mod library; mod masks; +mod organize; mod preset_files; mod query; mod xmp; @@ -102,6 +103,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(library::specs()); v.extend(develop::specs()); v.extend(masks::specs()); + v.extend(organize::specs()); v.extend(query::specs()); v.extend(xmp::specs()); v.extend(preset_files::specs()); diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs new file mode 100644 index 00000000..8b4b6f73 --- /dev/null +++ b/crates/engine/src/cmd/organize.rs @@ -0,0 +1,142 @@ +//! Organizing commands: stacks (group, ungroup, set top, expand/collapse, auto-stack by capture +//! time). + +use lightcraft_catalog::{Op, PhotoId, StackId}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection}; +use crate::{Result, Selection, Session}; + +/// Stacks containing any of `targets`, in first-seen order. +fn stacks_of(s: &Session, targets: &[PhotoId]) -> Vec { + let mut v = Vec::new(); + for t in targets { + if let Some(st) = s.catalog.stack_of(*t) + && !v.contains(&st.id) + { + v.push(st.id); + } + } + v +} + +fn in_stack(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if stacks_of(s, &s.targets(&Value::Null)).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) } +} + +fn has_stacks(s: &Session) -> std::result::Result<(), String> { + if s.catalog.stacks().next().is_none() { Err("no stacks".into()) } else { Ok(()) } +} + +fn commit_ops(s: &mut Session, label: &str, ops: Vec) -> Result { + let n = ops.len(); + if n > 0 { + s.commit(label, Op::Batch { ops })?; + } + Ok(n) +} + +/// After collapsing, keep the active photo visible (move it to its stack's top). +fn fix_active(s: &mut Session) { + let vis = s.visible_cloned(); + if let Some(a) = s.selection.active + && !vis.contains(&a) + && let Some(st) = s.catalog.stack_of(a) + { + let top = vis.iter().copied().find(|p| st.photos.contains(p)).unwrap_or(st.top()); + s.selection = Selection::single(top); + } +} + +fn set_collapsed(s: &mut Session, stacks: Vec, collapsed: bool, label: &str) -> Result { + let ops = s.catalog.set_collapsed_ops(&stacks, collapsed); + let n = commit_ops(s, label, ops)?; + if collapsed { + fix_active(s); + } + Ok(json!({"changed": n})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "stack.group", + "Group into Stack", + ["Photo", "Stack"], + Some("Cmd+G"), + "{ids?, top?: photoId (default: the active photo), collapsed?: bool (default true)} — stacks the photos are already in are merged", + has_selection, + |s, p| { + let targets = s.targets(p); + let top = p + .get("top") + .and_then(Value::as_u64) + .map(PhotoId) + .or(s.selection.active.filter(|a| targets.contains(a))) + .or(targets.first().copied()) + .ok_or_else(|| bad("stack.group", "no photos"))?; + let op = s + .catalog + .group_ops(top, &targets, bool_or(p, "collapsed", true)) + .ok_or_else(|| bad("stack.group", "select at least two photos"))?; + s.commit("Group into Stack", op)?; + let st = s.catalog.stack_of(top).map(|st| (st.id.0, st.photos.len())).unwrap_or_default(); + s.selection = Selection::single(top); + Ok(json!({"stack": st.0, "count": st.1})) + } + ), + cmd!("stack.ungroup", "Ungroup Stack", ["Photo", "Stack"], Some("Cmd+Shift+G"), "{ids?}", in_stack, |s, p| { + let ops = s.catalog.ungroup_ops(&s.targets(p)); + Ok(json!({"ungrouped": commit_ops(s, "Ungroup Stack", ops)?})) + }), + cmd!("stack.remove", "Remove from Stack", ["Photo", "Stack"], None, "{ids?}", in_stack, |s, p| { + let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)); + Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?})) + }), + cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| { + let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.setTop", "no photo"))?; + let op = s.catalog.set_top_ops(id); + Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?})) + }), + cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| { + let stacks = stacks_of(s, &s.targets(p)); + let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false); + let collapsed = bool_or(p, "collapsed", !first); + set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" }) + }), + cmd!("stack.expandAll", "Expand All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| { + let all: Vec = s.catalog.stacks().map(|st| st.id).collect(); + set_collapsed(s, all, false, "Expand All Stacks") + }), + cmd!("stack.collapseAll", "Collapse All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| { + let all: Vec = s.catalog.stacks().map(|st| st.id).collect(); + set_collapsed(s, all, true, "Collapse All Stacks") + }), + cmd!( + "stack.auto", + "Auto-Stack by Capture Time", + [], + None, + "{gap?: seconds between consecutive captures (default 60), ids? (default: the selection if several photos, else the current view), preview?: bool} → {stacks, photos}", + always, + |s, p| { + let gap = f64_or(p, "gap", 60.0).max(0.0); + let explicit = p.get("ids").is_some(); + let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() }; + let groups = s.catalog.auto_stack_groups(&pool, gap); + let photos: usize = groups.iter().map(Vec::len).sum(); + if bool_or(p, "preview", false) { + return Ok(json!({"stacks": groups.len(), "photos": photos})); + } + let mut ops = Vec::new(); + for g in &groups { + ops.extend(s.catalog.group_ops(g[0], g, true)); + } + commit_ops(s, "Auto-Stack", ops)?; + fix_active(s); + Ok(json!({"stacks": groups.len(), "photos": photos})) + } + ), + ] +} diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 67677319..db4be842 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -102,6 +102,14 @@ pub fn specs() -> Vec { let mut v = serde_json::to_value(ph.as_ref()).unwrap_or_default(); v["albums"] = json!(s.catalog.albums_of(id).iter().map(|a| a.0).collect::>()); v["history"] = json!(ph.history.iter().map(|h| h.label.clone()).collect::>()); + if let Some(st) = s.catalog.stack_of(id) { + v["stack"] = json!({ + "id": st.id.0, + "photos": st.photos.iter().map(|p| p.0).collect::>(), + "position": st.position(id), + "collapsed": st.collapsed, + }); + } Ok(v) }), cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 18b40549..39b1dc74 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -294,6 +294,9 @@ impl Session { self.visible.reverse(); } } + if self.source != LibrarySource::RecentlyDeleted { + self.visible = self.catalog.arrange_stacks(&self.visible); + } self.visible_key = Some(key); } &self.visible diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index b088e718..2930ec68 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -78,3 +78,55 @@ fn smart_album_from_smart_album_view_keeps_its_rules() { assert_eq!((r.flag, r.rating, r.album), (Some(Flag::Pick), 3, None)); assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id))); } + +#[test] +fn stacks_group_collapse_and_replay() { + let dir = temp_dir("stacks"); + let mut s = open(&dir); + let vis = s.visible_cloned(); + let n = vis.len(); + let (a, b, c) = (vis[2], vis[3], vis[4]); + s.execute("library.select", &json!({"ids": [a.0, b.0, c.0], "active": b.0})).unwrap(); + let r = s.execute("stack.group", &json!({})).unwrap(); + assert_eq!(r["count"], 3); + // collapsed by default: the top (the active photo) stands for the stack + let v = s.visible_cloned(); + assert_eq!(v.len(), n - 2); + assert!(v.contains(&b) && !v.contains(&a) && !v.contains(&c)); + assert_eq!(s.selection.active, Some(b)); + let info = s.execute("photo.inspect", &json!({"id": a.0})).unwrap(); + assert_eq!(info["stack"]["photos"], json!([b.0, a.0, c.0])); + // expand: members follow the top + s.execute("stack.toggle", &json!({})).unwrap(); + let v = s.visible_cloned(); + let i = v.iter().position(|x| *x == b).unwrap(); + assert_eq!(&v[i..i + 3], &[b, a, c]); + // set top, collapse with a member active → active moves to the visible top + s.execute("library.select", &json!({"ids": [c.0]})).unwrap(); + s.execute("stack.setTop", &json!({})).unwrap(); + s.execute("library.select", &json!({"ids": [a.0]})).unwrap(); + s.execute("stack.toggle", &json!({})).unwrap(); + assert_eq!(s.selection.active, Some(c)); + // remove from stack; undo + s.execute("stack.remove", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(s.catalog.stack_of(c).unwrap().photos, vec![c, b]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.stack_of(c).unwrap().photos.len(), 3); + // auto-stack the rest of the view: a huge gap groups everything unstacked + let pre = s.execute("stack.auto", &json!({"gap": 1e9, "preview": true})).unwrap(); + assert_eq!(pre["stacks"], 1); + assert_eq!(s.catalog.stacks().count(), 1, "preview changes nothing"); + s.execute("stack.auto", &json!({"gap": 1e9})).unwrap(); + assert_eq!(s.catalog.stacks().count(), 2); + assert_eq!(s.visible_cloned().len(), 2); + s.execute("stack.expandAll", &json!({})).unwrap(); + assert_eq!(s.visible_cloned().len(), n); + let expect = s.catalog.to_snapshot(); + drop(s); + let mut s2 = open(&dir); + assert_eq!(s2.catalog.to_snapshot(), expect); + s2.execute("library.select", &json!({"ids": [b.0]})).unwrap(); + s2.execute("stack.ungroup", &json!({})).unwrap(); + assert_eq!(s2.catalog.stacks().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 439f15ad..a871b0d1 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -46,6 +46,7 @@ pub enum Icon { Minus, Album, SmartAlbum, + Stack, Folder, Photos, Clock, @@ -303,6 +304,11 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.rect(3.0, 5.0, 15.0, 17.0, 1.0); pen.line(&[(6.0, 2.5), (17.5, 2.5), (17.5, 14.0)]); } + Stack => { + pen.rect(2.5, 7.0, 13.5, 17.5, 1.0); + pen.line(&[(5.0, 4.5), (16.0, 4.5), (16.0, 14.5)]); + pen.line(&[(7.5, 2.0), (18.5, 2.0), (18.5, 11.5)]); + } SmartAlbum => { pen.rect(3.0, 5.0, 15.0, 17.0, 1.0); pen.line(&[(6.0, 2.5), (17.5, 2.5), (17.5, 14.0)]); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6916f80f..8048fa06 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -54,6 +54,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), + ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Photo"), ("dialog.export", "Export…", Some("Cmd+Shift+E"), "File"), ("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"), @@ -267,6 +268,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.dialog = Some(Dialog::AutoStack { gap: p.get("gap").and_then(Value::as_f64).unwrap_or(60.0) as f32 }); + Ok(Value::Null) + } "dialog.newSmartAlbum" => { app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() }); Ok(Value::Null) @@ -389,7 +394,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec { .map(|(id, label, sc, m)| MenuEntry { id: id.to_string(), label: label.to_string(), - menu: vec![m.to_string()], + menu: m.split('>').map(str::to_string).collect(), shortcut: sc.map(str::to_string), enabled: ui_enabled(app, id), }) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 32cc8ea1..222ad21d 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -702,6 +702,15 @@ fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { if sel { p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside); } + if let Some(st) = app.session.catalog.stack_of(*id) { + let text = + if st.collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) }; + let g = p.layout_no_wrap(text, t.semibold(9.5), Color32::WHITE); + let br = Rect::from_min_size(fr.min + vec2(3.0, 3.0), vec2(g.size().x + 22.0, 15.0)); + p.rect_filled(br, 7.5, Color32::from_black_alpha(170)); + crate::icons::paint(p, Rect::from_min_size(br.min + vec2(4.0, 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Stack, Color32::WHITE); + p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + } } if resp.clicked() { let m = ui.input(|i| i.modifiers); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 340556a5..bf0536cb 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -23,6 +23,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::NewAlbum { .. } => "Create Album", Dialog::RenameAlbum { .. } => "Rename Album", Dialog::NewSmartAlbum { .. } => "Create Smart Album", + Dialog::AutoStack { .. } => "Auto-Stack by Capture Time", Dialog::CreatePreset { .. } => "Create Preset", Dialog::CopySettings { .. } => "Choose Edit Settings to Copy", Dialog::Export { .. } => "Export", @@ -39,6 +40,21 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .show(ctx, |ui| { ui.spacing_mut().item_spacing.y = 8.0; match &mut dlg { + Dialog::AutoStack { gap } => { + ui.label(egui::RichText::new("Stack photos taken within this time of each other:").color(t.text_label)); + ui.add( + egui::Slider::new(gap, 0.0..=86400.0) + .logarithmic(true) + .smallest_positive(1.0) + .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) + .custom_parser(|s| s.trim().trim_end_matches('s').parse().ok()), + ); + let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default(); + let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" }; + ui.label( + egui::RichText::new(format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"])).color(t.text_dim), + ); + } Dialog::NewSmartAlbum { name } => { let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY)); r.request_focus(); @@ -228,10 +244,24 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } /// Apply a dialog's action (also used by `ui.dialog.confirm`). +/// `90` → `1 min 30 s`. +pub fn fmt_gap(v: f64) -> String { + let v = v.round() as u64; + match v { + 0..60 => format!("{v} s"), + 60..3600 if v.is_multiple_of(60) => format!("{} min", v / 60), + 60..3600 => format!("{} min {} s", v / 60, v % 60), + 3600..86400 if v.is_multiple_of(3600) => format!("{} h", v / 3600), + 3600..86400 => format!("{} h {} min", v / 3600, v % 3600 / 60), + _ => "1 day".into(), + } +} + pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { match dlg { Dialog::NewAlbum { name, folder } => app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})), Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), + Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})), Dialog::CreatePreset { name, group } => { app.run("preset.create", json!({"name": if name.is_empty() { "My Preset" } else { name }, "group": group})) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index dc8b83da..8bad7394 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -106,6 +106,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(key, active)); if last != active { active.and_then(|a| cells.iter().find(|c| c.id == a).map(|c| c.rect)) } else { None } }; + let stacks = app.session.catalog.stack_index(); let mut visible_ids = HashSet::new(); egui::ScrollArea::vertical().id_salt("grid-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total_h), Sense::hover()); @@ -122,6 +123,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let r = c.rect.translate(origin.to_vec2()); let onscreen = c.rect.intersects(viewport); cell(app, ui, c.id, r, square, onscreen, ppp); + if let Some((sid, pos)) = stacks.get(&c.id) { + stack_badge(app, ui, c.id, *sid, *pos, r, square); + } } }); app.renderer.evict_thumbs(&visible_ids, 600); @@ -216,6 +220,36 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square resp.context_menu(|ui| context_menu(app, ui, id)); } +/// Stack badge at the cell's top-left: the photo count on a collapsed stack's top, `i/n` on the +/// members of an expanded stack. Clicking it expands/collapses the stack. +pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: lightcraft_catalog::StackId, pos: usize, r: Rect, square: bool) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.catalog.stack(sid) else { return }; + let n = st.photos.len(); + let collapsed = st.collapsed; + let text = if collapsed { n.to_string() } else { format!("{}/{n}", pos + 1) }; + let p = ui.painter(); + let g = p.layout_no_wrap(text, t.semibold(11.0), Color32::WHITE); + let origin = if square { r.min + vec2(10.0, 26.0) } else { r.min + vec2(5.0, 5.0) }; + let br = Rect::from_min_size(origin, vec2(g.size().x + 30.0, 20.0)); + let resp = ui.interact(br, egui::Id::new(("stack-badge", id.0)), Sense::click()).on_hover_text(if collapsed { + "Stack — click to expand (S)" + } else { + "Stack — click to collapse (S)" + }); + register(ui.ctx(), format!("stack:{}", id.0), br); + let fill = if resp.hovered() { Color32::from_black_alpha(220) } else { Color32::from_black_alpha(165) }; + p.rect_filled(br, 10.0, fill); + if pos == 0 && !collapsed { + p.rect_stroke(br, 10.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } + paint(p, Rect::from_min_size(br.min + vec2(6.0, 3.0), vec2(14.0, 14.0)), Icon::Stack, Color32::WHITE); + p.galley(pos2(br.min.x + 24.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + if resp.clicked() { + let _ = app.run("stack.toggle", json!({"ids": [id.0]})); + } +} + pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if !app.session.selection.contains(id) { let _ = app.run("library.select", json!({"ids": [id.0]})); @@ -246,6 +280,25 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } }); + ui.menu_button("Stack", |ui| { + let stacked = app.session.catalog.stack_of(id).is_some(); + let several = app.session.selection.ids.len() > 1; + for (label, cmd, on) in [ + ("Group into Stack", "stack.group", several), + ("Ungroup Stack", "stack.ungroup", stacked), + ("Remove from Stack", "stack.remove", stacked), + ("Set as Top of Stack", "stack.setTop", stacked), + ("Expand/Collapse Stack", "stack.toggle", stacked), + ] { + if ui.add_enabled(on, egui::Button::new(label)).clicked() { + let _ = app.run(cmd, json!({})); + } + } + ui.separator(); + if ui.button("Auto-Stack by Capture Time…").clicked() { + let _ = app.run("dialog.autoStack", json!({})); + } + }); ui.separator(); if ui.button("Copy Edit Settings").clicked() { let _ = app.run("develop.copy", json!({})); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8e3dfc4e..c2cf3c71 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -127,6 +127,10 @@ pub enum Dialog { id: u64, name: String, }, + /// Auto-stack by capture time: the largest gap between consecutive shots, in seconds. + AutoStack { + gap: f32, + }, /// Save the current view (source + filter) as a smart album. NewSmartAlbum { name: String, From 0571d8e43830a0dbb5c25d4ed2ea964174e2b6fb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:58:36 -0400 Subject: [PATCH 3/6] =?UTF-8?q?M9.7:=20virtual=20copies=20=E2=80=94=20new?= =?UTF-8?q?=20catalog=20entries=20sharing=20the=20original=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photo.copy_of / copy_name. photo.virtualCopy (Cmd+') clones the photo with independent settings and history, names it "Copy N" (numbered per master), adds it to the original's albums and stacks it (expanded) right after the original; one undo step for several copies (Session::merge_undo). Copies never write XMP sidecars. "Copy N" badge in the grid, Info panel line, copy:yes search token. Also: Info panel stars laid out in a row. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/model.rs | 9 ++++ crates/catalog/src/query.rs | 1 + crates/catalog/src/tests_journal.rs | 14 +++++- crates/engine/src/cmd/organize.rs | 69 ++++++++++++++++++++++++++++- crates/engine/src/cmd/query.rs | 2 + crates/engine/src/lib.rs | 11 +++++ crates/engine/src/sidecar.rs | 5 ++- crates/engine/src/tests_organize.rs | 46 +++++++++++++++++++ crates/ui-egui/src/panels/grid.rs | 20 +++++++++ crates/ui-egui/src/panels/right.rs | 6 ++- crates/ui-egui/src/shortcuts.rs | 1 + 11 files changed, 179 insertions(+), 5 deletions(-) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index c27eefa9..99d35294 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -174,6 +174,13 @@ pub struct Photo { /// "Enable Profile Corrections" is on. #[serde(default, skip_serializing_if = "Option::is_none")] pub embedded_lens: Option, + /// A virtual copy: the photo it was copied from (it shares that photo's file but has its own + /// settings, metadata and history). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub copy_of: Option, + /// Virtual copies' name ("Copy 1"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub copy_name: Option, } impl Photo { @@ -202,6 +209,8 @@ impl Photo { as_shot_wb: None, content_hash: None, embedded_lens: None, + copy_of: None, + copy_name: None, } } pub fn is_edited(&self) -> bool { diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 416b996a..389c01e3 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -97,6 +97,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool { "type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val), "edited" => (val == "true" || val == "yes") == p.is_edited(), "date" => p.date().starts_with(val), + "copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(), "name" | "file" => p.file_name.to_lowercase().contains(val), _ => false, }; diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index d04427ba..01d1f03d 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -26,7 +26,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { text: if b.is_multiple_of(5) { format!("{}", b % 10) } else { String::new() }, ..Default::default() }; - match kind % 15 { + match kind % 16 { 0 | 1 => { let id = c.alloc_photo_id(); let mut p = Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, &format!("{}.jpg", id.0), "JPEG", 60, 40, "2026-01-01"); @@ -67,6 +67,18 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { } 13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) }, 14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }), + 15 => match c.photo(pid).cloned() { + // a virtual copy: same source, own id, copy_of/copy_name set + Some(src) => { + let id = c.alloc_photo_id(); + let mut v = (*src).clone(); + v.id = id; + v.copy_of = Some(pid); + v.copy_name = Some(format!("Copy {b}")); + Op::AddPhoto { photo: Box::new(v) } + } + None => Op::Batch { ops: vec![] }, + }, _ => c.delete_permanently_ops(pid), } } diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs index 8b4b6f73..255fc088 100644 --- a/crates/engine/src/cmd/organize.rs +++ b/crates/engine/src/cmd/organize.rs @@ -1,7 +1,7 @@ //! Organizing commands: stacks (group, ungroup, set top, expand/collapse, auto-stack by capture -//! time). +//! time) and virtual copies. -use lightcraft_catalog::{Op, PhotoId, StackId}; +use lightcraft_catalog::{HistoryStep, Op, PhotoId, Stack, StackId}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection}; @@ -58,8 +58,73 @@ fn set_collapsed(s: &mut Session, stacks: Vec, collapsed: bool, label: Ok(json!({"changed": n})) } +/// "Copy 3" → 3. +fn copy_number(name: Option<&str>) -> u32 { + name.and_then(|n| n.strip_prefix("Copy ")).and_then(|n| n.trim().parse().ok()).unwrap_or(0) +} + +/// Create one virtual copy of `src`: a new photo sharing its file, with the same settings and +/// metadata, added to the same albums and stacked right after it (the stack is expanded so the +/// copy is visible). +fn virtual_copy(s: &mut Session, src: PhotoId) -> Result> { + let Some(orig) = s.catalog.photo(src).cloned() else { return Ok(None) }; + let master = orig.copy_of.unwrap_or(src); + let n = 1 + s.catalog.photos().filter(|p| p.copy_of == Some(master)).map(|p| copy_number(p.copy_name.as_deref())).max().unwrap_or(0); + let id = s.catalog.alloc_photo_id(); + let mut c = (*orig).clone(); + c.id = id; + c.copy_of = Some(master); + c.copy_name = Some(format!("Copy {n}")); + c.versions.clear(); + c.history = vec![HistoryStep { label: "Virtual Copy".into(), settings: c.develop.clone() }]; + c.deleted = false; + let mut ops = vec![Op::AddPhoto { photo: Box::new(c) }]; + for a in s.catalog.albums().filter(|a| !a.is_smart() && a.photos.contains(&src)) { + let mut photos = a.photos.clone(); + let at = photos.iter().position(|p| *p == src).map_or(photos.len(), |i| i + 1); + photos.insert(at, id); + ops.push(Op::SetAlbumPhotos { id: a.id, photos }); + } + match s.catalog.stack_of(src).cloned() { + Some(st) => { + let mut photos = st.photos.clone(); + let at = st.position(src).map_or(photos.len(), |i| i + 1); + photos.insert(at, id); + ops.push(Op::SetStack { id: st.id, photos, collapsed: false }); + } + None => { + let sid = s.catalog.alloc_stack_id(); + ops.push(Op::AddStack { stack: Stack { id: sid, photos: vec![src, id], collapsed: false } }); + } + } + s.commit("Create Virtual Copy", Op::Batch { ops })?; + Ok(Some(id)) +} + pub fn specs() -> Vec { vec![ + cmd!( + "photo.virtualCopy", + "Create Virtual Copy", + ["Photo"], + Some("Cmd+'"), + "{ids?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named \"Copy N\"", + has_selection, + |s, p| { + let targets = s.targets(p); + let mut made = Vec::new(); + for t in targets { + if let Some(id) = virtual_copy(s, t)? { + made.push(id); + } + } + s.merge_undo(made.len(), "Create Virtual Copy"); + if let Some(last) = made.last() { + s.selection = Selection { ids: made.clone(), active: Some(*last) }; + } + Ok(json!({"ids": made.iter().map(|i| i.0).collect::>()})) + } + ), cmd!( "stack.group", "Group into Stack", diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index db4be842..bed6883d 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -24,6 +24,8 @@ pub fn photo_summary(p: &Photo) -> Value { "keywords": p.meta.keywords, "camera": p.meta.camera, "deleted": p.deleted, + "copyOf": p.copy_of.map(|c| c.0), + "copyName": p.copy_name, }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 39b1dc74..71f2fe37 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -182,6 +182,17 @@ impl Session { Ok(()) } + /// Fold the last `n` undo steps into one (commands that commit step by step because each op + /// depends on the state the previous one left). + pub fn merge_undo(&mut self, n: usize, label: &str) { + if n < 2 || n > self.undo.len() { + return; + } + let tail = self.undo.split_off(self.undo.len() - n); + let ops = tail.into_iter().rev().map(|e| e.op).collect(); + self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops } }); + } + /// Apply without recording undo (interactive previews). fn apply_silent(&mut self, op: Op) -> Result<()> { self.catalog.apply(op)?; diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index c84c3eb1..e62d2b09 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -241,6 +241,9 @@ impl Session { /// Write the photo's XMP sidecar; returns its path. pub fn save_sidecar(&self, id: PhotoId) -> Result { let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?; + if p.copy_of.is_some() { + return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name))); + } let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?; let path = sidecar_path(orig, self.xmp.naming); write_atomic(&path, sidecar_packet(p).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; @@ -272,7 +275,7 @@ impl Session { let mut ids = Vec::new(); ops.iter().for_each(|o| op_photos(o, &mut ids)); for id in ids { - if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some()) + if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none()) && let Err(e) = self.save_sidecar(id) { log::warn!("auto-write XMP: {e}"); diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index 2930ec68..9c0b73cf 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -79,6 +79,52 @@ fn smart_album_from_smart_album_view_keeps_its_rules() { assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id))); } +#[test] +fn virtual_copies_are_independent_and_persist() { + let dir = temp_dir("vc"); + let mut s = open(&dir); + let src = s.visible_cloned()[1]; + let album = s.execute("album.create", &json!({"name": "Keep", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.addPhotos", &json!({"id": album, "ids": [src.0]})).unwrap(); + s.execute("library.select", &json!({"ids": [src.0]})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap(); + let undo_before = s.undo.len(); + let r = s.execute("photo.virtualCopy", &json!({})).unwrap(); + assert_eq!(s.undo.len(), undo_before + 1); + let c1 = lightcraft_catalog::PhotoId(r["ids"][0].as_u64().unwrap()); + let r2 = s.execute("photo.virtualCopy", &json!({"ids": [src.0]})).unwrap(); + let c2 = lightcraft_catalog::PhotoId(r2["ids"][0].as_u64().unwrap()); + let (p, a, b) = (s.catalog.photo(src).unwrap().clone(), s.catalog.photo(c1).unwrap().clone(), s.catalog.photo(c2).unwrap().clone()); + assert_eq!((a.copy_of, a.copy_name.as_deref()), (Some(src), Some("Copy 1"))); + assert_eq!(b.copy_name.as_deref(), Some("Copy 2")); + assert_eq!(a.source, p.source, "shares the original file"); + assert_eq!(a.develop.light.exposure, 0.5, "starts from the current settings"); + // a copy of a copy names itself after the master + let r3 = s.execute("photo.virtualCopy", &json!({"ids": [c1.0]})).unwrap(); + let c3 = s.catalog.photo(lightcraft_catalog::PhotoId(r3["ids"][0].as_u64().unwrap())).unwrap().clone(); + assert_eq!((c3.copy_of, c3.copy_name.as_deref()), (Some(src), Some("Copy 3"))); + // independent settings + s.execute("library.select", &json!({"ids": [c1.0]})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap(); + assert_eq!(s.catalog.photo(src).unwrap().develop.light.exposure, 0.5); + assert_eq!(s.catalog.photo(c1).unwrap().develop.light.exposure, -1.0); + // same album, stacked (expanded) with the original, visible in the grid + assert!(s.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.starts_with(&[src, c2, c1, c3.id])); + assert_eq!(s.catalog.stack_of(c1).unwrap().photos, vec![src, c2, c1, c3.id]); + assert!(s.visible_cloned().contains(&c2)); + let found = s.execute("catalog.query", &json!({"filter": {"text": "copy:yes"}})).unwrap(); + assert_eq!(found["total"], 3); + // undo removes a copy completely + s.execute("edit.undo", &json!({})).unwrap(); // exposure + s.execute("edit.undo", &json!({})).unwrap(); // third copy + assert!(s.catalog.photo(c3.id).is_none()); + let expect = s.catalog.to_snapshot(); + drop(s); + let s2 = open(&dir); + assert_eq!(s2.catalog.to_snapshot(), expect); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn stacks_group_collapse_and_replay() { let dir = temp_dir("stacks"); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 8bad7394..309254fe 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -198,6 +198,23 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square paint(p, Rect::from_min_size(pos2(bar.right() - 20.0, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::Sliders, t.text_label); } } + if let Some(name) = &photo.copy_name { + // virtual copy: a folded-corner tag at the top right + let g = p.layout_no_wrap(name.clone(), t.semibold(10.5), Color32::WHITE); + let tr = if square { img_rect.right_top() + vec2(-6.0, 6.0) } else { img_rect.right_top() + vec2(-5.0, 5.0) }; + let br = Rect::from_min_max(pos2(tr.x - g.size().x - 26.0, tr.y), pos2(tr.x, tr.y + 20.0)); + p.rect_filled(br, 10.0, Color32::from_black_alpha(165)); + let c = pos2(br.left() + 13.0, br.center().y); + let s = 5.0; + p.add(egui::Shape::convex_polygon( + vec![c + vec2(-s, -s), c + vec2(s * 0.3, -s), c + vec2(s, -s * 0.3), c + vec2(s, s), c + vec2(-s, s)], + Color32::TRANSPARENT, + Stroke::new(1.2, Color32::WHITE), + )); + p.line_segment([c + vec2(s * 0.3, -s), c + vec2(s * 0.3, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); + p.line_segment([c + vec2(s * 0.3, -s * 0.3), c + vec2(s, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); + p.galley(pos2(br.left() + 22.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + } if photo.flag == Flag::Reject { p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110)); } @@ -280,6 +297,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } }); + if ui.button("Create Virtual Copy").clicked() { + let _ = app.run("photo.virtualCopy", json!({})); + } ui.menu_button("Stack", |ui| { let stacked = app.session.catalog.stack_of(id).is_some(); let several = app.session.selection.ids.len() > 1; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 45e65291..cf0321d5 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -243,8 +243,12 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { padded(ui, |ui| { ui.label(egui::RichText::new(&p.file_name).font(t.semibold(14.0)).color(t.text)); ui.label(egui::RichText::new(format!("{} × {} · {}", p.width, p.height, p.format)).color(t.text_dim)); + if let Some(name) = &p.copy_name { + let of = p.copy_of.and_then(|m| app.session.catalog.photo(m)).map(|m| m.file_name.clone()).unwrap_or_else(|| "a removed photo".into()); + ui.label(egui::RichText::new(format!("Virtual copy “{name}” of {of}")).color(t.text_label)); + } ui.add_space(8.0); - if let Some(r) = crate::widgets::stars(ui, "info", p.rating, 20.0) { + if let Some(r) = ui.horizontal(|ui| crate::widgets::stars(ui, "info", p.rating, 20.0)).inner { let _ = app.run("photo.rate", json!({"rating": r})); } }); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index af53a4bd..193a36c5 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -27,6 +27,7 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { "-" => Some(Key::Minus), "[" => Some(Key::OpenBracket), "]" => Some(Key::CloseBracket), + "'" => Some(Key::Quote), _ => None, }) } From 1e52736703e8ea8a5e0d27e20471834bf4fbd57c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:05:24 -0400 Subject: [PATCH 4/6] =?UTF-8?q?M1.15:=20keyboard-first=20culling=20?= =?UTF-8?q?=E2=80=94=20Compare,=20Survey,=20Auto=20Advance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare view (Shift+C): select | candidate side by side with synced zoom and pan; arrows move the candidate, rating/flag/label keys act on the active (candidate) photo, swap / make-select. Survey view (N): the selection tiled, keys act on the active photo, hover x removes from the survey. Auto Advance setting (Photo menu, bottom bar toggle) and Shift+P/X/U flag-and-advance. Escape returns to Detail. Headless keyboard test. Fix: render-job keys include the photo id, so a view slot never shows one photo's result as current for another with identical settings/size. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/media.rs | 13 +- crates/ui-egui/src/headless.rs | 54 ++++ crates/ui-egui/src/icons.rs | 11 +- crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/src/menus.rs | 21 ++ crates/ui-egui/src/panels/bottombar.rs | 28 +- crates/ui-egui/src/panels/compare.rs | 339 +++++++++++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 4 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/shortcuts.rs | 32 ++- crates/ui-egui/src/state.rs | 10 + 11 files changed, 508 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/panels/compare.rs diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 1c0a4070..869357ea 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -320,7 +320,14 @@ impl crate::Session { let source = self.media.source_ref(&p, level); let settings = if before { Arc::new(lightcraft_pipeline::before_settings(&p.develop)) } else { p.develop.clone() }; let request = RenderRequest { max_w, max_h, quality: Quality::Full, apply_crop }; - let key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (level as u64) << 60; + // the photo id is part of the key: two photos with the same settings and size must not + // share a result (a view slot showing photo A would otherwise look current for photo B) + let key = settings.hash64() + ^ ((max_w as u64) << 40) + ^ ((max_h as u64) << 20) + ^ (apply_crop as u64) + ^ (level as u64) << 60 + ^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15); let cache = thumb_bucket.map(|b| { let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish(); (self.media.rendered.clone(), k) @@ -396,5 +403,9 @@ mod tests { let loupe = s.render_job(p.id, 1600, 1600, false, true).unwrap(); assert_eq!(loupe.level, SourceLevel::Preview); assert_ne!(loupe.key, s.render_job(p.id, 1600, 1600, false, true).unwrap().draft().key); + // two photos with identical settings and size never share a result key + let plain: Vec<_> = s.catalog.photos().filter(|p| !p.is_edited()).map(|p| p.id).take(2).collect(); + let (a, b) = (s.render_job(plain[0], 1600, 1600, false, true).unwrap(), s.render_job(plain[1], 1600, 1600, false, true).unwrap()); + assert_ne!(a.key, b.key); } } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index ccac06f2..9def0930 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -283,6 +283,60 @@ mod tests { assert_eq!(diff, 0, "{diff} pixels differ between two runs"); } + /// Keyboard culling: Compare (rating keys hit the candidate, arrows move it, auto-advance), + /// Survey (keys hit the active photo only), and auto-advance in Detail. + #[test] + fn compare_survey_and_auto_advance_by_keyboard() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().rating; + let flag = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().flag; + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]]}}), t); + let r = h.request("engine.execute", json!({"command": "view.compare"}), t); + assert_eq!(r["result"], json!({"select": vis[0], "candidate": vis[1]}), "{r}"); + assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare); + // rating applies to the candidate (active) only + let before = rating(&h, vis[0]); + h.request("ui.key", json!({"key": "2"}), t); + assert_eq!((rating(&h, vis[0]), rating(&h, vis[1])), (before, 2)); + // arrows move the candidate; the select stays + h.request("ui.key", json!({"key": "right"}), t); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); + h.request("ui.key", json!({"key": "left"}), t); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[1]))); + // Shift+X: reject and advance to the next candidate + h.request("ui.key", json!({"key": "x", "shift": true}), t); + assert_eq!(flag(&h, vis[1]), lightcraft_catalog::Flag::Reject); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); + h.request("engine.execute", json!({"command": "compare.swap"}), t); + assert_eq!(h.app.ui.compare, Some((vis[2], vis[0]))); + h.request("engine.execute", json!({"command": "compare.makeSelect"}), t); + assert_eq!(h.app.ui.compare.map(|c| c.0), Some(vis[0])); + // Survey: the selection tiled; keys hit the active photo, auto-advance walks the survey + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[3], vis[4], vis[5]], "active": vis[3]}}), t); + let r = h.request("engine.execute", json!({"command": "view.survey"}), t); + assert_eq!(r["result"]["photos"], 3); + h.request("engine.execute", json!({"command": "view.autoAdvance"}), t); + assert!(h.app.ui.auto_advance); + h.request("ui.key", json!({"key": "5"}), t); + h.request("ui.key", json!({"key": "p"}), t); + assert_eq!((rating(&h, vis[3]), flag(&h, vis[4])), (5, lightcraft_catalog::Flag::Pick)); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[5])); + assert_eq!(h.app.session.selection.ids.len(), 3, "the survey keeps its selection"); + let img = h.snapshot(Duration::from_secs(20)); + assert_eq!(img.size, [1000, 700]); + // Detail: auto-advance moves to the next photo + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[6]]}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("ui.key", json!({"key": "u"}), t); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[7])); + // Escape leaves the culling views for Detail + h.request("ui.set", json!({"view": "survey"}), t); + h.request("ui.key", json!({"key": "escape"}), t); + assert_eq!(h.app.ui.view, crate::state::ViewMode::Detail); + } + #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a871b0d1..3980861d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -29,6 +29,7 @@ pub enum Icon { GridSquare, Single, Compare, + Survey, Sort, Star, StarFilled, @@ -242,9 +243,15 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.rect_fill(10.5, 10.5, 16.5, 16.0, 0.5); } Single => pen.rect_fill(3.0, 5.0, 17.0, 15.0, 0.5), + Survey => { + pen.rect(2.5, 3.5, 9.0, 9.0, 1.0); + pen.rect(11.0, 3.5, 17.5, 9.0, 1.0); + pen.rect(2.5, 11.0, 9.0, 16.5, 1.0); + pen.rect(11.0, 11.0, 17.5, 16.5, 1.0); + } Compare => { - pen.rect_fill(3.0, 5.0, 9.5, 15.0, 0.5); - pen.rect_fill(10.5, 5.0, 17.0, 15.0, 0.5); + pen.rect_fill(2.0, 5.0, 8.8, 15.0, 0.5); + pen.rect_fill(11.2, 5.0, 18.0, 15.0, 0.5); } Sort => { pen.line(&[(3.0, 5.0), (17.0, 5.0)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 05013ce1..a0309967 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -329,10 +329,13 @@ impl LightcraftApp { } panels::bottombar::show(self, ui); let t = theme::Tokens::get(&ctx); - let bg = if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare) { t.canvas } else { t.grid_bg }; + let bg = + if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare | state::ViewMode::Survey) { t.canvas } else { t.grid_bg }; egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match self.ui.view { state::ViewMode::PhotoGrid | state::ViewMode::SquareGrid => panels::grid::show(self, ui), - state::ViewMode::Detail | state::ViewMode::Compare => panels::detail::show(self, ui), + state::ViewMode::Detail => panels::detail::show(self, ui), + state::ViewMode::Compare => panels::compare::show_compare(self, ui), + state::ViewMode::Survey => panels::compare::show_survey(self, ui), }); panels::dialogs::show(self, &ctx); panels::toast(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8048fa06..35785e70 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -14,6 +14,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.photoGrid", "Photo Grid", Some("G"), "View"), ("view.squareGrid", "Square Grid", Some("Shift+G"), "View"), ("view.detail", "Detail", Some("D"), "View"), + ("view.compare", "Compare", Some("Shift+C"), "View"), + ("view.survey", "Survey", Some("N"), "View"), + ("compare.swap", "Swap Compare Photos", None, "View"), + ("compare.makeSelect", "Make Candidate the Select", None, "View"), + ("view.autoAdvance", "Auto Advance", None, "Photo"), ("view.filmstrip", "Filmstrip", Some("/"), "View"), ("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"), ("view.beforeAfter", "Compare Before and After", Some("Y"), "View"), @@ -98,11 +103,25 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::enter_compare(app), + "view.survey" => { + app.ui.view = ViewMode::Survey; + Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()})) + } + "compare.swap" => crate::panels::compare::swap(app), + "compare.makeSelect" => crate::panels::compare::make_select(app), + "view.autoAdvance" => { + app.ui.auto_advance = !app.ui.auto_advance; + app.toast(&ctx, if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" }); + Ok(json!({"autoAdvance": app.ui.auto_advance})) + } "view.back" => { if app.ui.dialog.is_some() { app.ui.dialog = None; } else if !app.ui.tool.is_empty() { app.ui.tool.clear(); + } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) { + app.ui.view = ViewMode::Detail; } else if app.ui.view == ViewMode::Detail { app.ui.view = ViewMode::PhotoGrid; } @@ -373,6 +392,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.copySettings" => app.session.active().is_some(), "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), + "view.compare" => app.session.catalog.len() > 1, + "compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare, _ => true, } } diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index eb508ff2..3c09bf8d 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -23,6 +23,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"), ("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (Shift+G)"), ("detail", Icon::Single, ViewMode::Detail, "Detail (D)"), + ("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"), + ("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"), ] { if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() { let _ = app.run(&format!("view.{id}"), json!({})); @@ -45,7 +47,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { pill_ui.spacing_mut().item_spacing.x = 3.0; let rating = active.as_ref().map(|p| p.rating).unwrap_or(0); if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) { - let _ = app.run("photo.rate", json!({"rating": r})); + let mut params = json!({"rating": r}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.rate", params); } pill_ui.add_space(6.0); let flag = active.as_ref().map(|p| p.flag).unwrap_or_default(); @@ -64,7 +68,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }; paint(pill_ui.painter(), r.shrink(3.0), icon, c); if resp.clicked() { - let _ = app.run("photo.flag", json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }})); + let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.flag", params); } } // copy / paste settings @@ -102,7 +108,23 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .layout(egui::Layout::right_to_left(egui::Align::Center)), ); child.spacing_mut().item_spacing.x = 6.0; - if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { + if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) + && icon_button( + &mut child, + "autoAdvance", + Icon::ChevronRight, + vec2(30.0, 30.0), + app.ui.auto_advance, + true, + "Auto Advance after rating or flagging", + ) + .clicked() + { + let _ = app.run("view.autoAdvance", json!({})); + } + if app.ui.view == ViewMode::Survey { + // no zoom or thumbnail size in Survey + } else if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { if icon_button( &mut child, "beforeAfter", diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs new file mode 100644 index 00000000..ef38fb1f --- /dev/null +++ b/crates/ui-egui/src/panels/compare.rs @@ -0,0 +1,339 @@ +//! Culling views: **Compare** (the "select" and a "candidate" side by side, with synced zoom and +//! pan) and **Survey** (the selected photos tiled). Rating, flag and label keys act on the active +//! photo only here, and with Auto Advance on they move to the next candidate / photo. + +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use lightcraft_catalog::{Flag, PhotoId}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::icons::{Icon, paint}; +use crate::render::Slot; +use crate::state::{ViewMode, Zoom}; +use crate::theme::Tokens; +use crate::widgets::register; + +/// Most photos a survey shows. +pub const SURVEY_MAX: usize = 48; + +/// Compare or Survey. +pub fn culling(app: &LightcraftApp) -> bool { + matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) +} + +/// The (select, candidate) pair, repaired or chosen if needed: the active photo against the next +/// selected photo, else against its neighbour in the view. +pub fn compare_pair(app: &mut LightcraftApp) -> Option<(PhotoId, PhotoId)> { + let exists = |app: &LightcraftApp, id: PhotoId| app.session.catalog.photo(id).is_some_and(|p| !p.deleted); + if let Some((a, b)) = app.ui.compare { + let (a, b) = (PhotoId(a), PhotoId(b)); + if a != b && exists(app, a) && exists(app, b) { + return Some((a, b)); + } + } + let vis = app.session.visible_cloned(); + let sel = app.session.active().or(vis.first().copied())?; + let cand = app.session.selection.ids.iter().copied().find(|x| *x != sel).or_else(|| { + let i = vis.iter().position(|x| *x == sel)?; + vis.get(i + 1).or(i.checked_sub(1).and_then(|j| vis.get(j))).copied() + })?; + app.ui.compare = Some((sel.0, cand.0)); + Some((sel, cand)) +} + +/// Enter Compare with the current selection (the active photo becomes the select). +pub fn enter_compare(app: &mut LightcraftApp) -> Result { + app.ui.compare = None; + let (a, b) = compare_pair(app).ok_or("Compare needs at least two photos")?; + app.ui.view = ViewMode::Compare; + // the candidate is active: rating/flag keys judge it against the select + select_pair(app, a, b, b); + Ok(json!({"select": a.0, "candidate": b.0})) +} + +fn select_pair(app: &mut LightcraftApp, a: PhotoId, b: PhotoId, active: PhotoId) { + app.ui.compare = Some((a.0, b.0)); + let _ = app.session.execute("library.select", &json!({"ids": [a.0, b.0], "active": active.0})); +} + +/// Move the candidate `d` photos through the view (skipping the select); it becomes active. +pub fn compare_step(app: &mut LightcraftApp, d: isize) -> Result { + let (sel, cand) = compare_pair(app).ok_or("nothing to compare")?; + let vis: Vec = app.session.visible_cloned().into_iter().filter(|x| *x != sel).collect(); + let Some(i) = vis.iter().position(|x| *x == cand).or(if vis.is_empty() { None } else { Some(0) }) else { return Ok(Value::Null) }; + let j = (i as isize + d).clamp(0, vis.len() as isize - 1) as usize; + select_pair(app, sel, vis[j], vis[j]); + Ok(json!({"candidate": vis[j].0})) +} + +pub fn swap(app: &mut LightcraftApp) -> Result { + let (a, b) = compare_pair(app).ok_or("nothing to compare")?; + let active = app.session.active().unwrap_or(b); + select_pair(app, b, a, active); + Ok(Value::Null) +} + +/// The candidate becomes the select; the next photo becomes the candidate. +pub fn make_select(app: &mut LightcraftApp) -> Result { + let (_, b) = compare_pair(app).ok_or("nothing to compare")?; + let vis = app.session.visible_cloned(); + let i = vis.iter().position(|x| *x == b).unwrap_or(0); + let next = vis.iter().skip(i + 1).chain(vis.iter().take(i).rev()).copied().find(|x| *x != b).ok_or("no next photo")?; + select_pair(app, b, next, next); + Ok(json!({"select": b.0, "candidate": next.0})) +} + +/// The photos a survey shows: the selection (in view order), or the active photo alone. +pub fn survey_photos(app: &mut LightcraftApp) -> Vec { + let vis = app.session.visible_cloned(); + let sel = &app.session.selection; + let mut v: Vec = vis.iter().copied().filter(|x| sel.contains(*x)).collect(); + // selected photos hidden in the view (e.g. inside a collapsed stack) come last + let hidden: Vec = sel.ids.iter().copied().filter(|x| !v.contains(x) && app.session.catalog.photo(*x).is_some()).collect(); + v.extend(hidden); + if v.is_empty() { + v.extend(app.session.active()); + } + v.truncate(SURVEY_MAX); + v +} + +/// Move the active photo `d` steps within the survey. +pub fn survey_step(app: &mut LightcraftApp, d: isize) -> Result { + let v = survey_photos(app); + if v.is_empty() { + return Ok(Value::Null); + } + let i = app.session.selection.active.and_then(|a| v.iter().position(|x| *x == a)).unwrap_or(0); + let j = (i as isize + d).clamp(0, v.len() as isize - 1) as usize; + app.session.selection.active = Some(v[j]); + Ok(json!({"active": v[j].0})) +} + +/// After rating/flagging with Auto Advance: next candidate (Compare), next photo in the survey, +/// else the next photo in the view. +pub fn advance(app: &mut LightcraftApp) { + let _ = match app.ui.view { + ViewMode::Compare => compare_step(app, 1), + ViewMode::Survey => survey_step(app, 1), + _ => app.session.execute("library.next", &json!({})).map_err(|e| e.to_string()), + }; +} + +/// In the culling views, point a photo command at the active photo only (not the whole selection). +pub fn target_active(app: &LightcraftApp, params: &mut Value) { + if culling(app) + && params.get("ids").is_none() + && let Some(a) = app.session.active() + { + params["ids"] = json!([a.0]); + } +} + +fn area_and_filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui) -> Rect { + let t = Tokens::get(ui.ctx()); + let full = ui.max_rect(); + let film_h = if app.ui.filmstrip { t.film_h } else { 0.0 }; + let canvas = Rect::from_min_max(full.min, pos2(full.right(), full.bottom() - film_h)); + app.canvas_rect = Some(canvas); + if app.ui.filmstrip { + super::detail::filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max)); + } + canvas +} + +/// Draw one photo fitted into `area` (rendered at its display size into `slot`), with its caption +/// strip below. Returns the image rect and the click/drag response. +fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slot, area: Rect, label: &str, zoom: Zoom) -> (Rect, egui::Response) { + let t = Tokens::get(ui.ctx()); + let ppp = ui.ctx().pixels_per_point(); + let resp = ui.interact(area, egui::Id::new(("cull-tile", slot_index(slot), id.0)), Sense::click_and_drag()); + let Some(photo) = app.session.catalog.photo(id).cloned() else { return (area, resp) }; + let caption_h = 26.0; + let img_area = Rect::from_min_max(area.min, pos2(area.right(), area.bottom() - caption_h)); + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + photo.width.max(1) as usize, + photo.height.max(1) as usize, + &photo.develop, + true, + photo.embedded_lens.as_ref(), + ); + let aspect = frame.aspect() as f32; + let native = [photo.width.max(1) as usize, photo.height.max(1) as usize]; + let img = super::detail::fit_rect(img_area, aspect, zoom, native, ppp, app.ui.pan); + let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).min(2560.0) as usize; + let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; + if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) { + app.renderer.request(slot, job, 60); + } + if let Some(job) = app.session.thumb_job(id, 256) { + app.renderer.request(Slot::Thumb(id), job, 30); + } + let p = ui.painter_at(img_area); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + if let Some(tex) = app.renderer.textures.get(&slot).filter(|x| x.photo == id) { + p.image(tex.tex.id(), img, uv, Color32::WHITE); + } else if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) { + p.image(tex.tex.id(), img, uv, Color32::WHITE); + } else { + p.rect_filled(img.intersect(img_area), 0.0, Color32::from_gray(38)); + } + if photo.flag == Flag::Reject { + p.rect_filled(img, 0.0, Color32::from_black_alpha(110)); + } + let active = app.session.selection.active == Some(id); + if active { + p.rect_stroke(img.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); + } + // caption: label · file name · stars · flag + // caption right under the image (at the bottom when zoomed in) + let cap_top = (img.bottom() + 2.0).min(img_area.bottom()); + let cap = Rect::from_min_max(pos2(img.left().max(area.left()), cap_top), pos2(area.right(), cap_top + caption_h)); + let pt = ui.painter(); + let mut x = cap.left() + 4.0; + if !label.is_empty() { + let g = pt.layout_no_wrap(label.to_string(), t.semibold(12.0), if active { t.text } else { t.text_label }); + pt.galley(pos2(x, cap.center().y - g.size().y / 2.0), g.clone(), t.text); + x += g.size().x + 10.0; + } + let name = photo.copy_name.as_ref().map(|c| format!("{} ({c})", photo.file_name)).unwrap_or(photo.file_name.clone()); + let g = pt.layout_no_wrap(name, t.font(11.5), t.text_dim); + let name_w = g.size().x; + if x + name_w + 110.0 < cap.right() { + pt.galley(pos2(x, cap.center().y - g.size().y / 2.0), g, t.text_dim); + x += name_w + 10.0; + } + for i in 0..5u8 { + let filled = i < photo.rating; + let r = Rect::from_min_size(pos2(x + i as f32 * 13.0, cap.center().y - 6.0), vec2(12.0, 12.0)); + if r.right() < cap.right() { + paint(pt, r, if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon.gamma_multiply(0.6) }); + } + } + x += 5.0 * 13.0 + 4.0; + let fr = Rect::from_min_size(pos2(x, cap.center().y - 7.0), vec2(14.0, 14.0)); + match photo.flag { + Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPick, t.pick), + Flag::Reject if fr.right() < cap.right() => paint(pt, fr, Icon::FlagReject, t.reject), + _ => {} + } + register(ui.ctx(), format!("cull:{}", id.0), img); + (img, resp) +} + +fn slot_index(s: Slot) -> u8 { + match s { + Slot::Compare(i) => i, + _ => 255, + } +} + +/// Drag-to-pan when zoomed in (shared by both compare panes: zoom and pan are synced). +fn pan(app: &mut LightcraftApp, resp: &egui::Response, img: Rect) { + if app.ui.zoom != Zoom::Fit && resp.dragged() { + let d = resp.drag_delta(); + let (px, py) = app.ui.pan; + app.ui.pan = ((px - d.x / img.width().max(1.0)).clamp(0.0, 1.0), (py - d.y / img.height().max(1.0)).clamp(0.0, 1.0)); + } +} + +pub fn show_compare(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let canvas = area_and_filmstrip(app, ui); + let Some((sel, cand)) = compare_pair(app) else { + super::empty_message(ui, canvas, "Nothing to compare", "Select two photos, then choose View → Compare (Shift+C)"); + return; + }; + let area = canvas.shrink(18.0); + let half = (area.width() - 16.0) / 2.0; + let panes = [ + (sel, Rect::from_min_size(area.min, vec2(half, area.height())), "Select", 0u8), + (cand, Rect::from_min_size(pos2(area.right() - half, area.top()), vec2(half, area.height())), "Candidate", 1u8), + ]; + let zoom = app.ui.zoom; + for (id, r, label, i) in panes { + let (img, resp) = photo_tile(app, ui, id, Slot::Compare(i), r, label, zoom); + if i == 1 { + app.image_rect = Some(img); + } + pan(app, &resp, img); + if resp.clicked() { + select_pair(app, sel, cand, id); + } + if resp.double_clicked() { + app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100) } else { Zoom::Fit }; + } + resp.context_menu(|ui| { + if ui.button("Swap").clicked() { + let _ = swap(app); + } + if ui.button("Make Candidate the Select").clicked() { + let _ = make_select(app); + } + ui.separator(); + super::grid::context_menu(app, ui, id); + }); + } + let mid = area.center().x; + ui.painter().line_segment([pos2(mid, area.top()), pos2(mid, area.bottom())], Stroke::new(1.0, Tokens::get(ui.ctx()).divider)); +} + +/// Columns for `n` tiles of aspect ~3:2 in `area`, maximizing the tile size. +pub fn survey_columns(n: usize, area: Rect) -> usize { + let mut best = (1, 0.0f32); + for cols in 1..=n.max(1) { + let rows = n.div_ceil(cols); + let w = area.width() / cols as f32; + let h = area.height() / rows as f32; + let s = w.min(h * 1.5); + if s > best.1 { + best = (cols, s); + } + } + best.0 +} + +pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let canvas = area_and_filmstrip(app, ui); + let photos = survey_photos(app); + if photos.is_empty() { + super::empty_message(ui, canvas, "Nothing to survey", "Select photos, then choose View → Survey (N)"); + return; + } + let area = canvas.shrink(14.0); + let cols = survey_columns(photos.len(), area); + let rows = photos.len().div_ceil(cols); + let (cw, ch) = (area.width() / cols as f32, area.height() / rows as f32); + for (i, id) in photos.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let cell = Rect::from_min_size(pos2(area.left() + c as f32 * cw, area.top() + r as f32 * ch), vec2(cw, ch)).shrink(6.0); + let (img, resp) = photo_tile(app, ui, *id, Slot::Compare(i as u8), cell, "", Zoom::Fit); + if app.session.selection.active == Some(*id) { + app.image_rect = Some(img); + } + if resp.clicked() { + app.session.selection.active = Some(*id); + } + if resp.double_clicked() { + let _ = app.run("library.select", json!({"ids": [id.0]})); + app.ui.view = ViewMode::Detail; + } + // remove from the survey (deselect) on hover + if resp.hovered() && photos.len() > 1 { + let xr = Rect::from_min_size(pos2(img.right() - 26.0, img.top() + 6.0), vec2(20.0, 20.0)); + let xresp = ui.interact(xr, egui::Id::new(("survey-x", id.0)), Sense::click()).on_hover_text("Remove from survey"); + ui.painter().circle_filled(xr.center(), 10.0, Color32::from_black_alpha(if xresp.hovered() { 230 } else { 160 })); + paint(ui.painter(), xr.shrink(4.0), Icon::Close, t.text); + if xresp.clicked() { + let _ = app.run("library.select", json!({"ids": [id.0], "mode": "toggle"})); + } + } + resp.context_menu(|ui| super::grid::context_menu(app, ui, *id)); + } + let n = photos.len(); + let msg = + if app.session.selection.ids.len() > SURVEY_MAX { format!("Showing {n} of {}", app.session.selection.ids.len()) } else { String::new() }; + if !msg.is_empty() { + ui.painter().text(pos2(canvas.right() - 16.0, canvas.top() + 10.0), Align2::RIGHT_TOP, msg, t.font(12.0), t.text_dim); + } +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 222ad21d..818dca64 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -71,7 +71,7 @@ impl CanvasMap { } } -fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect { +pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect { let (aw, ah) = (area.width(), area.height()); let (w, h) = match zoom { Zoom::Fit => { @@ -652,7 +652,7 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo // ------------------------------------------------------------------------ filmstrip -fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { +pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let t = Tokens::get(ui.ctx()); ui.painter().rect_filled(r, 0.0, t.canvas); ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 4.0)), 0.0, Color32::from_gray(0x20)); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 66a6eb07..daab0083 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,6 +1,7 @@ //! Window regions and panels. pub mod bottombar; +pub mod compare; pub mod detail; pub mod dialogs; pub mod edit; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 193a36c5..a3487c3b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -80,15 +80,43 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { fire.push(format!("label:{label}")); } } + // Shift+P/X/U: flag and advance + for (id, key) in [("photo.pick", Key::P), ("photo.reject", Key::X), ("photo.unflag", Key::U)] { + if matches(i, Modifiers::SHIFT, key) { + fire.push(format!("flagadv:{id}")); + } + } }); + use crate::panels::compare; + // rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or + // Auto Advance then moves on (next candidate in Compare, next photo elsewhere) + let cull = |app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool| { + compare::target_active(app, &mut params); + let ok = app.run(id, params).is_ok(); + if ok && (advance || app.ui.auto_advance) { + compare::advance(app); + } + }; for f in fire { if let Some(rest) = f.strip_prefix("rate:") { let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); - let _ = app.run("photo.rate", json!({"rating": n.parse::().unwrap_or(0), "advance": adv == "1"})); + cull(app, "photo.rate", json!({"rating": n.parse::().unwrap_or(0)}), adv == "1"); let label = if n == "0" { "Rating cleared".to_string() } else { format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) }; app.toast(ctx, label); } else if let Some(l) = f.strip_prefix("label:") { - let _ = app.run("photo.label", json!({"label": l})); + cull(app, "photo.label", json!({"label": l}), false); + } else if let Some(id) = f.strip_prefix("flagadv:") { + cull(app, id, json!({}), true); + } 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.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop { + cull(app, &f, json!({}), false); + match f.as_str() { + "photo.pick" => app.toast(ctx, "Flagged as Pick"), + "photo.reject" => app.toast(ctx, "Flagged as Reject"), + _ => app.toast(ctx, "Unflagged"), + } } else { // X is both reject (library) and swap crop aspect (crop tool) if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c2cf3c71..52367fa4 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -10,7 +10,10 @@ pub enum ViewMode { PhotoGrid, SquareGrid, Detail, + /// Two photos side by side (select | candidate), synced zoom. Compare, + /// The selected photos tiled. + Survey, } /// The right-hand tool/panel shown next to the tool strip. @@ -107,6 +110,11 @@ pub struct UiState { pub brush_flow: f32, pub brush_erase: bool, pub remove_size: f32, + /// Culling: after a rating, flag or colour-label key, move to the next photo. + pub auto_advance: bool, + /// Compare view: (select, candidate) photo ids. + #[serde(skip)] + pub compare: Option<(u64, u64)>, /// Transient toast text and its expiry (seconds of app time). #[serde(skip)] pub toast: Option<(String, f64)>, @@ -183,6 +191,8 @@ impl Default for UiState { brush_flow: 60.0, brush_erase: false, remove_size: 0.02, + auto_advance: false, + compare: None, toast: None, status: String::new(), dialog: None, From 673e1bf6cbb29fce56899ad3b0d7089e15b1c1e7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:18:02 -0400 Subject: [PATCH 5/6] M16.1: native macOS menu bar + in-window menus from the command registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit menubar.rs builds File/Edit/View/Photo/Window/Help from engine + UI commands (layout table for order and separators, parameterized submenus for rating, flag, label, sort, add to album), with live labels ("Undo Exposure"), enabled and checked state; ui.menu.tree exposes it. The desktop app installs it natively on macOS with muda (pure Rust over AppKit; no GTK — muda is a macOS-only dependency): app menu (About, Services, Hide, Quit), Window menu (Minimize/Zoom/Bring All to Front), Help menu; per-frame state sync; accelerators without Cmd and the text editing ones step aside while a text field has focus; natively handled shortcuts are skipped by the egui handler. Other hosts (web, Windows, Linux) get the same menus in the top bar. Panels/sections/tools move to the Window menu. Verified with System Events: menus listed with live enabled state, View > Survey dispatches. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 39 ++ Cargo.toml | 1 + apps/lightcraft/Cargo.toml | 3 + apps/lightcraft/src/main.rs | 18 +- apps/lightcraft/src/native_menu.rs | 389 +++++++++++++++++++ crates/ui-egui/src/control.rs | 3 + crates/ui-egui/src/headless.rs | 2 + crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/src/menubar.rs | 576 ++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 53 +-- crates/ui-egui/src/panels/topbar.rs | 14 +- crates/ui-egui/src/shortcuts.rs | 12 +- docs/control-protocol.md | 3 +- 13 files changed, 1086 insertions(+), 34 deletions(-) create mode 100644 apps/lightcraft/src/native_menu.rs create mode 100644 crates/ui-egui/src/menubar.rs diff --git a/Cargo.lock b/Cargo.lock index 01e13890..f22c48fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -853,6 +853,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -1927,6 +1936,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2011,6 +2030,7 @@ dependencies = [ "lightcraft-raster", "lightcraft-ui-egui", "log", + "muda", "rfd", "serde_json", ] @@ -2392,6 +2412,25 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + [[package]] name = "naga" version = "30.0.1" diff --git a/Cargo.toml b/Cargo.toml index 76abfa4c..9e2bb135 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ egui_extras = { version = "0.36", default-features = false } eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" +muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK) web-time = "1" # web (apps/lightcraft-web) diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index 96d7e8ab..21de0243 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -21,3 +21,6 @@ log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +muda = { workspace = true } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index baac940f..7200568e 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -13,14 +13,20 @@ //! See `lightcraft_ui_egui::control` for the methods. mod control_server; +#[cfg(target_os = "macos")] +mod native_menu; use lightcraft_engine::Session; use lightcraft_ui_egui::{LightcraftApp, Services, UiState}; -struct App(LightcraftApp); +struct App(LightcraftApp, #[cfg(target_os = "macos")] Option); impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + #[cfg(target_os = "macos")] + if let Some(m) = self.1.as_mut() { + m.update(&mut self.0, ctx); + } self.0.logic(ctx); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { @@ -221,7 +227,15 @@ fn main() -> eframe::Result { let _ = app.run("library.import", serde_json::json!({"paths": files})); app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid; } - Ok(Box::new(App(app))) + // native menu bar generated from the command registry (macOS; elsewhere the menus are + // drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus. + #[cfg(target_os = "macos")] + let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx)); + Ok(Box::new(App( + app, + #[cfg(target_os = "macos")] + menu, + ))) }), ) } diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs new file mode 100644 index 00000000..cfb1bd4e --- /dev/null +++ b/apps/lightcraft/src/native_menu.rs @@ -0,0 +1,389 @@ +//! The native macOS menu bar (via `muda`, pure Rust over AppKit), generated from the same model as +//! the in-window menus ([`lightcraft_ui_egui::menubar`]): the registry's menu paths, shortcuts, +//! enabled and checked state, and live labels ("Undo Exposure"). +//! +//! Key handling: a native key equivalent consumes the key press before egui sees it, so +//! - shortcuts installed natively are listed in `LightcraftApp::native_shortcuts` and skipped by +//! the egui shortcut handler (nothing fires twice); +//! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the +//! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come +//! back when the field loses focus. + +use std::collections::{HashMap, HashSet}; +use std::sync::mpsc::{Receiver, channel}; + +use lightcraft_ui_egui::LightcraftApp; +use lightcraft_ui_egui::menubar::{MenuNode, menu_bar}; +use muda::accelerator::{Accelerator, Code, Modifiers}; +use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use serde_json::Value; + +const QUIT: &str = "app.quit"; +/// 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"]; + +enum Handle { + Plain(MenuItem), + Check(CheckMenuItem), +} + +struct Item { + handle: Handle, + cmd: String, + params: Value, + shortcut: Option, + accel: Option, + label: String, + enabled: bool, + checked: Option, +} + +pub struct NativeMenu { + menu: Menu, + items: HashMap, + rx: Receiver, + /// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it. + structure: String, + text_focus: bool, +} + +/// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape). +fn accelerator(sc: &str) -> Option { + let mut mods = Modifiers::empty(); + let mut key = None; + for part in sc.split('+') { + match part { + "Cmd" => mods |= Modifiers::META, + "Shift" => mods |= Modifiers::SHIFT, + "Alt" => mods |= Modifiers::ALT, + "Ctrl" => mods |= Modifiers::CONTROL, + k => key = Some(k), + } + } + let code = match key? { + k if k.len() == 1 && k.as_bytes()[0].is_ascii_alphabetic() => { + const LETTERS: [Code; 26] = [ + Code::KeyA, + Code::KeyB, + Code::KeyC, + Code::KeyD, + Code::KeyE, + Code::KeyF, + Code::KeyG, + Code::KeyH, + Code::KeyI, + Code::KeyJ, + Code::KeyK, + Code::KeyL, + Code::KeyM, + Code::KeyN, + Code::KeyO, + Code::KeyP, + Code::KeyQ, + Code::KeyR, + Code::KeyS, + Code::KeyT, + Code::KeyU, + Code::KeyV, + Code::KeyW, + Code::KeyX, + Code::KeyY, + Code::KeyZ, + ]; + LETTERS[(k.as_bytes()[0].to_ascii_uppercase() - b'A') as usize] + } + k if k.len() == 1 && k.as_bytes()[0].is_ascii_digit() => { + const DIGITS: [Code; 10] = [ + Code::Digit0, + Code::Digit1, + Code::Digit2, + Code::Digit3, + Code::Digit4, + Code::Digit5, + Code::Digit6, + Code::Digit7, + Code::Digit8, + Code::Digit9, + ]; + DIGITS[(k.as_bytes()[0] - b'0') as usize] + } + "[" => Code::BracketLeft, + "]" => Code::BracketRight, + "\\" => Code::Backslash, + "/" => Code::Slash, + "=" => Code::Equal, + "-" => Code::Minus, + "'" => Code::Quote, + "Delete" => Code::Backspace, + _ => return None, + }; + Some(Accelerator::new(mods, code)) +} + +/// Accelerators that must step aside while a text field has focus. +fn yields_to_text(sc: &str) -> bool { + !sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc) +} + +/// `&` marks a mnemonic in muda labels. +fn label_text(s: &str) -> String { + s.replace('&', "&&") +} + +fn structure_of(bar: &[(String, Vec)]) -> String { + fn walk(n: &[MenuNode], out: &mut String) { + for x in n { + match x { + MenuNode::Item { id, params, checked, shortcut, .. } => { + out.push_str(&MenuNode::key(id, params)); + out.push(if checked.is_some() { 'c' } else { 'i' }); + out.push_str(shortcut.as_deref().unwrap_or("")); + } + MenuNode::Separator => out.push('-'), + MenuNode::Submenu { label, children } => { + out.push('['); + out.push_str(label); + walk(children, out); + out.push(']'); + } + } + out.push(';'); + } + } + let mut s = String::new(); + for (t, items) in bar { + s.push_str(t); + walk(items, &mut s); + } + s +} + +impl NativeMenu { + /// Build the menu bar and install it as the application menu (main thread, after launch). + pub fn install(app: &mut LightcraftApp, ctx: &egui::Context) -> NativeMenu { + let (tx, rx) = channel::(); + let repaint = ctx.clone(); + MenuEvent::set_event_handler(Some(move |e: MenuEvent| { + let _ = tx.send(e.id.0); + repaint.request_repaint(); + })); + let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false }; + m.rebuild(app); + app.native_menu = true; + m + } + + fn rebuild(&mut self, app: &mut LightcraftApp) { + let bar = menu_bar(app); + self.structure = structure_of(&bar); + self.menu = Menu::new(); + self.items.clear(); + + // the application menu + let app_menu = Submenu::new("LightCraft", true); + let about = MenuItem::with_id("app.about", "About LightCraft", true, None); + let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q")); + let _ = app_menu.append_items(&[ + &about, + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::services(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::hide(Some("Hide LightCraft")), + &PredefinedMenuItem::hide_others(None), + &PredefinedMenuItem::show_all(None), + &PredefinedMenuItem::separator(), + &quit, + ]); + let _ = self.menu.append(&app_menu); + + for (title, nodes) in &bar { + let sub = Submenu::new(title, true); + if title == "Window" { + let _ = sub.append_items(&[ + &PredefinedMenuItem::minimize(None), + &PredefinedMenuItem::maximize(Some("Zoom")), + &PredefinedMenuItem::separator(), + ]); + } + // About lives in the app menu on macOS + let nodes: Vec = nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about")).cloned().collect(); + self.append_nodes(&sub, &nodes); + if title == "Window" { + let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]); + sub.set_as_windows_menu_for_nsapp(); + } + if title == "Help" { + sub.set_as_help_menu_for_nsapp(); + } + let _ = self.menu.append(&sub); + } + self.menu.init_for_nsapp(); + self.text_focus = false; + self.publish_shortcuts(app); + } + + fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode]) { + for n in nodes { + match n { + MenuNode::Separator => { + let _ = sub.append(&PredefinedMenuItem::separator()); + } + MenuNode::Submenu { label, children } => { + let s = Submenu::new(label_text(label), true); + self.append_nodes(&s, children); + let _ = sub.append(&s); + } + MenuNode::Item { id, params, label, shortcut, enabled, checked } => { + let key = MenuNode::key(id, params); + if self.items.contains_key(&key) { + continue; // muda ids must be unique + } + let accel = shortcut.as_deref().and_then(accelerator); + let handle = match checked { + Some(c) => { + let it = CheckMenuItem::with_id(key.clone(), label_text(label), *enabled, *c, accel); + let _ = sub.append(&it); + Handle::Check(it) + } + None => { + let it = MenuItem::with_id(key.clone(), label_text(label), *enabled, accel); + let _ = sub.append(&it); + Handle::Plain(it) + } + }; + self.items.insert( + key, + Item { + handle, + cmd: id.clone(), + params: params.clone(), + shortcut: shortcut.clone(), + accel, + label: label.clone(), + enabled: *enabled, + checked: *checked, + }, + ); + } + } + } + } + + /// Tell the egui shortcut handler which shortcuts the menu bar currently owns. + fn publish_shortcuts(&self, app: &mut LightcraftApp) { + app.native_shortcuts = self + .items + .values() + .filter(|i| i.accel.is_some()) + .filter_map(|i| i.shortcut.clone()) + .filter(|sc| !(self.text_focus && yields_to_text(sc))) + .collect::>(); + } + + /// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus + /// accelerators with the app state. + pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) { + while let Ok(key) = self.rx.try_recv() { + if key == QUIT { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + continue; + } + if key == "app.about" { + let _ = app.run("app.about", serde_json::json!({})); + continue; + } + if let Some(it) = self.items.get(&key) { + let (cmd, params) = (it.cmd.clone(), it.params.clone()); + let _ = lightcraft_ui_egui::menubar::run_item(app, &cmd, params); + } + } + let bar = menu_bar(app); + if structure_of(&bar) != self.structure { + self.rebuild(app); + return; + } + fn walk(nodes: &[MenuNode], items: &mut HashMap) { + for n in nodes { + match n { + MenuNode::Submenu { children, .. } => walk(children, items), + MenuNode::Item { id, params, label, enabled, checked, .. } => { + let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue }; + if it.label != *label { + match &it.handle { + Handle::Plain(h) => h.set_text(label_text(label)), + Handle::Check(h) => h.set_text(label_text(label)), + } + it.label = label.clone(); + } + if it.enabled != *enabled { + match &it.handle { + Handle::Plain(h) => h.set_enabled(*enabled), + Handle::Check(h) => h.set_enabled(*enabled), + } + it.enabled = *enabled; + } + if let (Handle::Check(h), Some(c)) = (&it.handle, checked) { + // also resyncs after AppKit toggled it on click + if h.is_checked() != *c { + h.set_checked(*c); + } + it.checked = Some(*c); + } + } + MenuNode::Separator => {} + } + } + } + walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::>(), &mut self.items); + + let focus = ctx.egui_wants_keyboard_input(); + if focus != self.text_focus { + self.text_focus = focus; + for it in self.items.values() { + let Some(sc) = it.shortcut.as_deref() else { continue }; + if it.accel.is_none() || !yields_to_text(sc) { + continue; + } + let accel = if focus { None } else { it.accel }; + let _ = match &it.handle { + Handle::Plain(h) => h.set_accelerator(accel), + Handle::Check(h) => h.set_accelerator(accel), + }; + } + self.publish_shortcuts(app); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shortcuts_map_to_accelerators() { + assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ))); + assert_eq!(accelerator("G"), Some(Accelerator::new(Modifiers::empty(), Code::KeyG))); + assert_eq!(accelerator("Cmd+'"), Some(Accelerator::new(Modifiers::META, Code::Quote))); + assert_eq!(accelerator("Escape"), None); + assert!(yields_to_text("G") && yields_to_text("Delete") && yields_to_text("Cmd+V")); + assert!(!yields_to_text("Cmd+Shift+E")); + // every registry shortcut that a menu shows maps (or is deliberately left to egui) + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + fn all(n: &[MenuNode], out: &mut Vec) { + for x in n { + match x { + MenuNode::Item { shortcut: Some(s), .. } => out.push(s.clone()), + MenuNode::Submenu { children, .. } => all(children, out), + _ => {} + } + } + } + let mut scs = Vec::new(); + for (_, v) in menu_bar(&app) { + all(&v, &mut scs); + } + for sc in scs { + assert!(accelerator(&sc).is_some(), "{sc}"); + } + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8d6cbfae..27451daf 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -132,6 +132,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest } "engine.commands" => ok(all_commands(app)), "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), + "ui.menu.tree" => { + ok(Value::Array(crate::menubar::menu_bar(app).into_iter().map(|(title, items)| json!({"label": title, "children": items})).collect())) + } "ui.inspect" => ok(inspect(app, ctx)), "ui.widgets" => { let filter = s("filter").unwrap_or(""); diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 9def0930..96aa5a3b 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -335,6 +335,8 @@ mod tests { h.request("ui.set", json!({"view": "survey"}), t); h.request("ui.key", json!({"key": "escape"}), t); assert_eq!(h.app.ui.view, crate::state::ViewMode::Detail); + // let in-flight renders finish: worker threads must not outlive the test process' TLS + h.settle(Duration::from_secs(20)); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a0309967..3a525716 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod control; pub mod headless; pub mod icons; +pub mod menubar; pub mod menus; pub mod panels; pub mod render; @@ -59,8 +60,11 @@ pub struct LightcraftApp { pub perf: Perf, /// macOS: the host draws the traffic lights over our top bar. pub integrated_titlebar: bool, - /// The host installed a native menu bar. + /// The host installed a native menu bar (no in-window menus then). pub native_menu: bool, + /// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler + /// leaves them alone so nothing fires twice. + pub native_shortcuts: std::collections::HashSet, /// The host is [`headless::Headless`] (it answers viewport screenshot commands itself). pub headless_host: bool, control_rx: Option>, @@ -92,6 +96,7 @@ impl LightcraftApp { perf: Perf::default(), integrated_titlebar: false, native_menu: false, + native_shortcuts: Default::default(), headless_host: false, control_rx: None, pending_screenshots: vec![], diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs new file mode 100644 index 00000000..0e74e2da --- /dev/null +++ b/crates/ui-egui/src/menubar.rs @@ -0,0 +1,576 @@ +//! The menu bar model: File, Edit, View, Photo, Window, Help, generated from the command registry +//! (engine commands with menu paths + [`crate::menus::UI_COMMANDS`]) with live labels, shortcuts, +//! enabled and checked state. +//! +//! One model drives every menu surface: the native macOS menu bar (built by the desktop host), the +//! in-window menu bar (web, Windows, Linux — [`show_in_window`]) and the control channel +//! (`ui.menu.tree`). Items are a command id plus parameters, run through [`run_item`] so they +//! behave exactly like their keyboard shortcuts. + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::menus::MenuEntry; +use crate::state::{RightPanel, ViewMode}; + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum MenuNode { + Item { + id: String, + #[serde(skip_serializing_if = "Value::is_null")] + params: Value, + label: String, + #[serde(skip_serializing_if = "Option::is_none")] + shortcut: Option, + enabled: bool, + /// `Some` for toggles and radio-style choices. + #[serde(skip_serializing_if = "Option::is_none")] + checked: Option, + }, + Separator, + Submenu { + label: String, + children: Vec, + }, +} + +impl MenuNode { + /// `id|params` — a stable key for one item (native menu ids). + pub fn key(id: &str, params: &Value) -> String { + if params.is_null() { id.to_string() } else { format!("{id}|{params}") } + } +} + +/// Top-level menus in order (the macOS app menu is added by the host). +pub const MENUS: &[&str] = &["File", "Edit", "View", "Photo", "Window", "Help"]; + +/// Order and grouping per menu: command ids, `---` separators and `@Submenu` placeholders. +/// Entries with a menu path that aren't listed are appended at the end of their menu. +const LAYOUT: &[(&str, &[&str])] = &[ + ( + "File", + &[ + "file.addPhotos", + "---", + "dialog.newAlbum", + "dialog.newFolder", + "dialog.newSmartAlbum", + "---", + "file.importPresets", + "file.exportPresets", + "---", + "dialog.export", + "---", + "library.toggleAutoWriteXmp", + "---", + ], + ), + ( + "Edit", + &["edit.undo", "edit.redo", "---", "develop.copy", "dialog.copySettings", "develop.paste", "---", "library.selectAll", "library.selectNone"], + ), + ( + "View", + &[ + "view.photoGrid", + "view.squareGrid", + "view.detail", + "view.compare", + "view.survey", + "---", + "view.leftPanel", + "view.filmstrip", + "view.histogram", + "---", + "view.showOriginal", + "view.beforeAfter", + "view.beforeAfterSplit", + "---", + "view.zoomIn", + "view.zoomOut", + "view.zoomToggle", + "view.zoomFit", + "view.zoom100", + "---", + "view.clipping", + "view.maskOverlay", + "view.cropOverlay", + "---", + "@Sort", + "@Stacks", + "view.filterBar", + "library.clearFilter", + "---", + "compare.swap", + "compare.makeSelect", + ], + ), + ( + "Photo", + &[ + "@Add to Album", + "---", + "@Set Rating", + "@Set Flag", + "@Set Color Label", + "view.autoAdvance", + "---", + "photo.rotateLeft", + "photo.rotateRight", + "photo.flipHorizontal", + "photo.flipVertical", + "---", + "version.create", + "photo.virtualCopy", + "@Stack", + "---", + "develop.auto", + "develop.treatment", + "develop.reset", + "dialog.createPreset", + "---", + "photo.saveMetadataToFile", + "photo.readMetadataFromFile", + "---", + "photo.delete", + ], + ), + ( + "Window", + &[ + "panel.edit", + "panel.crop", + "panel.remove", + "panel.masking", + "panel.redeye", + "---", + "panel.presets", + "panel.versions", + "panel.activity", + "panel.info", + "panel.keywords", + "---", + "@Edit Sections", + "@Tools", + ], + ), + ("Help", &["app.shortcuts", "---", "app.about"]), +]; + +/// Registry entries that are reached another way (parameterized commands are expanded into +/// submenus below; others duplicate a dialog command). +const HIDDEN: &[&str] = &[ + "photo.rate", + "photo.flag", + "photo.pick", + "photo.reject", + "photo.unflag", + "photo.label", + "library.sort", + "album.addPhotos", + "album.create", + "library.import", + "preset.create", +]; + +fn item(id: &str, params: Value, label: impl Into, shortcut: Option<&str>, enabled: bool, checked: Option) -> MenuNode { + MenuNode::Item { id: id.into(), params, label: label.into(), shortcut: shortcut.map(str::to_string), enabled, checked } +} + +/// Checked state of toggles and radio items. +pub fn checked(app: &LightcraftApp, id: &str) -> Option { + let u = &app.ui; + let panel = |p: RightPanel| Some(u.right == p); + match id { + "view.photoGrid" => Some(u.view == ViewMode::PhotoGrid), + "view.squareGrid" => Some(u.view == ViewMode::SquareGrid), + "view.detail" => Some(u.view == ViewMode::Detail), + "view.compare" => Some(u.view == ViewMode::Compare), + "view.survey" => Some(u.view == ViewMode::Survey), + "view.leftPanel" => Some(u.left_panel), + "view.filmstrip" => Some(u.filmstrip), + "view.histogram" => Some(u.histogram), + "view.clipping" => Some(u.show_clipping), + "view.maskOverlay" => Some(u.mask_overlay), + "view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original), + "view.beforeAfter" => Some(u.before_after == crate::state::BeforeAfter::SideBySide), + "view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split), + "view.autoAdvance" => Some(u.auto_advance), + "panel.edit" => panel(RightPanel::Edit), + "panel.crop" => panel(RightPanel::Crop), + "panel.remove" => panel(RightPanel::Remove), + "panel.masking" => panel(RightPanel::Masking), + "panel.redeye" => panel(RightPanel::RedEye), + "panel.versions" => panel(RightPanel::Versions), + "panel.activity" => panel(RightPanel::Activity), + "panel.info" => panel(RightPanel::Info), + "panel.keywords" => panel(RightPanel::Keywords), + "panel.presets" => Some(u.presets), + "library.toggleAutoWriteXmp" => Some(app.session.xmp.auto_write), + _ => None, + } +} + +/// Labels that follow the state ("Undo Exposure", "Delete 3 Photos"). +fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String { + let n = app.session.selection.ids.len(); + match id { + "edit.undo" => app.session.undo.last().map(|e| format!("Undo {}", e.label)).unwrap_or_else(|| "Undo".into()), + "edit.redo" => app.session.redo.last().map(|e| format!("Redo {}", e.label)).unwrap_or_else(|| "Redo".into()), + "photo.delete" if n > 1 => format!("Delete {n} Photos"), + "photo.virtualCopy" if n > 1 => format!("Create {n} Virtual Copies"), + _ => label.to_string(), + } +} + +/// The parameterized submenus. +fn expanded(app: &LightcraftApp, name: &str) -> Option> { + let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned()); + let has = active.is_some(); + Some(match name { + "Set Rating" => (0..=5u8) + .map(|r| { + let label = if r == 0 { "None".to_string() } else { "★".repeat(r as usize) }; + let sc = ["0", "1", "2", "3", "4", "5"][r as usize]; + item("photo.rate", json!({"rating": r}), label, Some(sc), has, Some(active.as_ref().is_some_and(|p| p.rating == r))) + }) + .collect(), + "Set Flag" => [ + ("pick", "Pick", "P", lightcraft_catalog::Flag::Pick), + ("reject", "Reject", "X", lightcraft_catalog::Flag::Reject), + ("none", "Unflagged", "U", lightcraft_catalog::Flag::None), + ] + .into_iter() + .map(|(f, label, sc, flag)| { + item("photo.flag", json!({"flag": f}), label, Some(sc), has, Some(active.as_ref().is_some_and(|p| p.flag == flag))) + }) + .collect(), + "Set Color Label" => { + let mut v: Vec = lightcraft_catalog::ColorLabel::ALL + .iter() + .zip([Some("6"), Some("7"), Some("8"), Some("9"), None]) + .map(|(l, sc)| { + let name = format!("{l:?}"); + item( + "photo.label", + json!({"label": name.to_lowercase()}), + name, + sc, + has, + Some(active.as_ref().is_some_and(|p| p.label == Some(*l))), + ) + }) + .collect(); + v.push(MenuNode::Separator); + v.push(item("photo.label", json!({"label": "none"}), "None", None, has, Some(active.as_ref().is_some_and(|p| p.label.is_none())))); + v + } + "Sort" => { + use lightcraft_catalog::SortKey::*; + let cur = app.session.sort; + let mut v: Vec = [ + ("Capture Date", CaptureDate, "captureDate"), + ("Import Date", ImportDate, "importDate"), + ("Modified Date", EditDate, "editDate"), + ("File Name", FileName, "fileName"), + ("Rating", Rating, "rating"), + ("File Size", FileSize, "fileSize"), + ] + .into_iter() + .map(|(label, key, k)| item("library.sort", json!({"key": k}), label, None, true, Some(cur.key == key))) + .collect(); + v.push(MenuNode::Separator); + v.push(item("library.sort", json!({"ascending": true}), "Ascending", None, true, Some(cur.ascending))); + v.push(item("library.sort", json!({"ascending": false}), "Descending", None, true, Some(!cur.ascending))); + v + } + "Add to Album" => { + let sel = !app.session.selection.ids.is_empty() || has; + let mut albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.name.clone(), a.id.0)).collect(); + albums.sort_by_key(|(n, _)| n.to_lowercase()); + let mut v: Vec = + albums.into_iter().map(|(name, id)| item("album.addPhotos", json!({"id": id}), name, None, sel, None)).collect(); + if !v.is_empty() { + v.push(MenuNode::Separator); + } + v.push(item("dialog.newAlbum", Value::Null, "New Album…", None, true, None)); + v + } + _ => return None, + }) +} + +fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode { + item(&e.id, Value::Null, live_label(app, &e.id, &e.label), e.shortcut.as_deref(), e.enabled, checked(app, &e.id)) +} + +/// Drop leading, trailing and doubled separators (also inside submenus) and empty submenus. +fn tidy(v: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(v.len()); + for n in v { + let n = match n { + MenuNode::Submenu { label, children } => { + let children = tidy(children); + if children.is_empty() { + continue; + } + MenuNode::Submenu { label, children } + } + n => n, + }; + if n == MenuNode::Separator && out.last().is_none_or(|l| *l == MenuNode::Separator) { + continue; + } + out.push(n); + } + while out.last() == Some(&MenuNode::Separator) { + out.pop(); + } + out +} + +/// The whole menu bar: (title, items) per menu in [`MENUS`] order. +pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec)> { + let entries: Vec = crate::menus::menu_entries(app).into_iter().filter(|e| !HIDDEN.contains(&e.id.as_str())).collect(); + let mut used = vec![false; entries.len()]; + let mut bar = Vec::new(); + for title in MENUS { + let layout = LAYOUT.iter().find(|(t, _)| t == title).map(|(_, l)| *l).unwrap_or(&[]); + let mut items = Vec::new(); + let sub = |name: &str, used: &mut [bool]| -> Vec { + let mut children = expanded(app, name).unwrap_or_default(); + let mut dialogs = Vec::new(); + for (i, e) in entries.iter().enumerate() { + if !used[i] && e.menu.len() == 2 && e.menu[0] == *title && e.menu[1] == name { + used[i] = true; + // commands that open a dialog go last, after a separator + if e.label.ends_with('…') { dialogs.push(node(app, e)) } else { children.push(node(app, e)) } + } + } + if !dialogs.is_empty() { + children.push(MenuNode::Separator); + children.extend(dialogs); + } + children + }; + for slot in layout { + if *slot == "---" { + items.push(MenuNode::Separator); + } else if let Some(name) = slot.strip_prefix('@') { + let children = sub(name, &mut used); + items.push(MenuNode::Submenu { label: name.to_string(), children }); + } else if let Some(i) = entries.iter().position(|e| e.id == *slot && e.menu.first().map(String::as_str) == Some(title)) { + used[i] = true; + items.push(node(app, &entries[i])); + } + } + // everything else that names this menu + items.push(MenuNode::Separator); + let mut extra_subs: Vec = Vec::new(); + for (i, e) in entries.iter().enumerate() { + if used[i] || e.menu.first().map(String::as_str) != Some(title) { + continue; + } + if e.menu.len() == 1 { + used[i] = true; + items.push(node(app, e)); + } else if !extra_subs.contains(&e.menu[1]) { + extra_subs.push(e.menu[1].clone()); + } + } + for name in extra_subs { + let children = sub(&name, &mut used); + items.push(MenuNode::Submenu { label: name, children }); + } + bar.push((title.to_string(), tidy(items))); + } + bar +} + +/// Run a menu item. Rating, flag and label items behave like their keys (the active photo only in +/// Compare/Survey; Auto Advance moves on). +pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result { + 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"); + if culling_cmd { + crate::panels::compare::target_active(app, &mut params); + } + let r = app.run(id, params); + if culling_cmd && r.is_ok() && app.ui.auto_advance { + crate::panels::compare::advance(app); + } + r +} + +/// Human-readable shortcut text for menus: `Cmd+Shift+Z` → `⌘⇧Z` on macOS, `Ctrl+Shift+Z` elsewhere. +pub fn shortcut_text(sc: &str, mac: bool) -> String { + if mac { + let mut mods = String::new(); + let mut key = ""; + for part in sc.split('+') { + match part { + "Ctrl" => mods.push('⌃'), + "Alt" => mods.push('⌥'), + "Shift" => mods.push('⇧'), + "Cmd" => mods.push('⌘'), + k => key = k, + } + } + let key = match key { + "Delete" => "⌫", + "Escape" => "⎋", + "Left" => "←", + "Right" => "→", + k => k, + }; + format!("{mods}{key}") + } else { + sc.replace("Cmd", "Ctrl") + } +} + +/// Width of the in-window menu bar's titles. +pub fn bar_width(ui: &egui::Ui) -> f32 { + let t = crate::theme::Tokens::get(ui.ctx()); + MENUS.iter().map(|m| ui.painter().layout_no_wrap(m.to_string(), t.font(13.0), t.text).size().x + 16.0).sum::() + + ui.spacing().item_spacing.x * MENUS.len() as f32 +} + +/// The in-window menu bar (hosts without a native one): one dropdown per menu, or a single +/// "Menu" button when the space is too narrow. Returns the width used. +pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32) -> f32 { + let t = crate::theme::Tokens::get(ui.ctx()); + let bar = menu_bar(app); + let font = t.font(13.0); + let widths: Vec = bar.iter().map(|(title, _)| ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x + 16.0).collect(); + let total: f32 = widths.iter().sum(); + let mut clicked: Option<(String, Value)> = None; + let start = ui.cursor().left(); + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + if total <= max_width { + for (title, items) in &bar { + let r = ui.add(egui::Button::new(egui::RichText::new(title).font(font.clone()).color(t.text_label)).frame(false)); + crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect); + egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked)); + } + } else { + let r = ui.add(egui::Button::new(egui::RichText::new("Menu").font(font.clone()).color(t.text_label)).frame(false)); + crate::widgets::register(ui.ctx(), "menu:all", r.rect); + egui::Popup::menu(&r).show(|ui| { + for (title, items) in &bar { + ui.menu_button(title, |ui| nodes_ui(ui, items, mac, &mut clicked)); + } + }); + } + if let Some((id, params)) = clicked { + let _ = run_item(app, &id, params); + } + ui.cursor().left() - start +} + +fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Option<(String, Value)>) { + ui.set_min_width(220.0); + for n in nodes { + match n { + MenuNode::Separator => { + ui.separator(); + } + MenuNode::Submenu { label, children } => { + ui.menu_button(format!(" {label}"), |ui| nodes_ui(ui, children, mac, clicked)); + } + MenuNode::Item { id, params, label, shortcut, enabled, checked } => { + // a gutter for check marks, like native menus + let mut b = egui::Button::new(format!(" {label}")); + if let Some(sc) = shortcut { + b = b.shortcut_text(shortcut_text(sc, mac)); + } + let r = ui.add_enabled(*enabled, b); + if *checked == Some(true) { + let t = crate::theme::Tokens::get(ui.ctx()); + let c = egui::Rect::from_center_size(egui::pos2(r.rect.left() + 11.0, r.rect.center().y), egui::vec2(13.0, 13.0)); + crate::icons::paint(ui.painter(), c, crate::icons::Icon::Check, if *enabled { t.text } else { t.text_disabled }); + } + if r.clicked() { + *clicked = Some((id.clone(), params.clone())); + ui.close(); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> LightcraftApp { + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()) + } + + fn find<'a>(nodes: &'a [MenuNode], id: &str) -> Option<&'a MenuNode> { + nodes.iter().find_map(|n| match n { + MenuNode::Item { id: i, .. } if i == id => Some(n), + MenuNode::Submenu { children, .. } => find(children, id), + _ => None, + }) + } + + #[test] + fn bar_follows_the_registry_and_state() { + let mut app = app(); + let bar = menu_bar(&app); + let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(titles, MENUS); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + // every engine command with a menu path is reachable (parameterized ones via submenus) + for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id)) { + assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id); + } + for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] { + assert!(find(&all, id).is_some(), "{id}"); + } + // no doubled or dangling separators + fn check(v: &[MenuNode]) { + assert_ne!(v.first(), Some(&MenuNode::Separator)); + assert_ne!(v.last(), Some(&MenuNode::Separator)); + for w in v.windows(2) { + assert!(!(w[0] == MenuNode::Separator && w[1] == MenuNode::Separator)); + } + for n in v { + if let MenuNode::Submenu { children, .. } = n { + check(children); + } + } + } + bar.iter().for_each(|(_, v)| check(v)); + // live state: undo label, enabled, checked + let Some(MenuNode::Item { enabled, .. }) = find(&all, "edit.undo") else { panic!() }; + assert!(!enabled); + run_item(&mut app, "photo.rate", json!({"rating": 3})).unwrap(); + let bar = menu_bar(&app); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let Some(MenuNode::Item { label, enabled, .. }) = find(&all, "edit.undo") else { panic!() }; + assert_eq!((label.as_str(), *enabled), ("Undo Set Rating", true)); + let rated: Vec<_> = all + .iter() + .filter_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Set Rating" => Some(children.clone()), + _ => None, + }) + .flatten() + .filter(|n| matches!(n, MenuNode::Item { checked: Some(true), .. })) + .collect(); + assert!(matches!(&rated[..], [MenuNode::Item { params, .. }] if params["rating"] == 3)); + let Some(MenuNode::Item { checked, .. }) = find(&all, "view.filmstrip") else { panic!() }; + assert_eq!(*checked, Some(app.ui.filmstrip)); + } + + #[test] + fn shortcut_text_per_platform() { + assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z"); + assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z"); + assert_eq!(shortcut_text("Delete", true), "⌫"); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 35785e70..a43c9885 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -33,39 +33,40 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), - ("view.back", "Back to Grid", Some("Escape"), "View"), + ("view.back", "Back to Grid", Some("Escape"), ""), ("view.filterBar", "Filter", None, "View"), - ("panel.edit", "Edit", Some("E"), "View"), - ("panel.crop", "Crop & Rotate", Some("C"), "View"), - ("panel.remove", "Remove", Some("H"), "View"), - ("panel.masking", "Masking", Some("M"), "View"), - ("panel.redeye", "Red Eye", None, "View"), - ("panel.presets", "Presets", Some("Shift+P"), "View"), - ("panel.info", "Info", Some("I"), "View"), - ("panel.keywords", "Keywords", Some("K"), "View"), - ("panel.versions", "Versions", Some("Shift+V"), "View"), - ("panel.activity", "History", Some("Shift+Y"), "View"), - ("panel.close", "Close Panel", None, "View"), - ("section.light", "Light", Some("Cmd+Alt+1"), "View"), - ("section.color", "Color", Some("Cmd+Alt+2"), "View"), - ("section.effects", "Effects", Some("Cmd+Alt+3"), "View"), - ("section.detail", "Detail", Some("Cmd+Alt+4"), "View"), - ("section.optics", "Optics", Some("Cmd+Alt+5"), "View"), - ("tool.brush", "Brush", Some("B"), "View"), - ("tool.linear", "Linear Gradient", Some("L"), "View"), - ("tool.radial", "Radial Gradient", Some("R"), "View"), - ("tool.wbPicker", "White Balance Selector", Some("W"), "View"), - ("tool.none", "No Tool", None, "View"), + ("panel.edit", "Edit", Some("E"), "Window"), + ("panel.crop", "Crop & Rotate", Some("C"), "Window"), + ("panel.remove", "Remove", Some("H"), "Window"), + ("panel.masking", "Masking", Some("M"), "Window"), + ("panel.redeye", "Red Eye", None, "Window"), + ("panel.presets", "Presets", Some("Shift+P"), "Window"), + ("panel.info", "Info", Some("I"), "Window"), + ("panel.keywords", "Keywords", Some("K"), "Window"), + ("panel.versions", "Versions", Some("Shift+V"), "Window"), + ("panel.activity", "History", None, "Window"), + ("panel.close", "Close Panel", None, ""), + ("section.light", "Light", Some("Cmd+Alt+1"), "Window>Edit Sections"), + ("section.color", "Color", Some("Cmd+Alt+2"), "Window>Edit Sections"), + ("section.effects", "Effects", Some("Cmd+Alt+3"), "Window>Edit Sections"), + ("section.detail", "Detail", Some("Cmd+Alt+4"), "Window>Edit Sections"), + ("section.optics", "Optics", Some("Cmd+Alt+5"), "Window>Edit Sections"), + ("tool.brush", "Brush", Some("B"), "Window>Tools"), + ("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"), + ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), + ("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"), + ("tool.none", "No Tool", None, ""), ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), + ("dialog.newFolder", "New Folder…", None, "File"), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), - ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Photo"), + ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Edit"), ("dialog.export", "Export…", Some("Cmd+Shift+E"), "File"), ("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"), ("file.importPresets", "Import Presets…", None, "File"), ("file.exportPresets", "Export Presets…", None, "File"), - ("app.about", "About LightCraft", None, "LightCraft"), + ("app.about", "About LightCraft", None, "Help"), ("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"), ("app.export", "Export Now", None, ""), ]; @@ -295,6 +296,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.dialog = Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: true }); + Ok(Value::Null) + } "dialog.createPreset" => { app.ui.dialog = Some(Dialog::CreatePreset { name: String::new(), group: "User Presets".into() }); Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 7429a05d..979f9128 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -16,6 +16,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); + let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0); + if !app.native_menu { + // leave room for the in-window menus left of the (centred) search field + let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; + sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0); + } + let search_left = full.center().x - sw / 2.0; ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, "Show/hide My Photos panel").clicked() { @@ -26,9 +33,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("view.back", json!({})); } icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, "Forward"); + if !app.native_menu { + // no native menu bar (web, Windows, Linux): menus in the top bar + ui.add_space(10.0); + let room = search_left - ui.cursor().left() - 16.0; + crate::menubar::show_in_window(app, ui, room); + } }); // search field (centred on the window) - let sw = 640.0f32.min(full.width() - 460.0).max(200.0); let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0)); let id = egui::Id::new("search-field"); let focused = ui.memory(|m| m.has_focus(id)); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index a3487c3b..cce9defd 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -51,16 +51,18 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { return; } let mut fire: Vec = Vec::new(); + // shortcuts the native menu bar handles (it consumes those key presses itself) + let native = |sc: &str| app.native_shortcuts.contains(sc); ctx.input(|i| { for (id, _, sc, _) in crate::menus::UI_COMMANDS { - if let Some((m, k)) = sc.and_then(parse) + if let Some((m, k)) = sc.filter(|s| !native(s)).and_then(parse) && matches(i, m, k) { fire.push(id.to_string()); } } for c in lightcraft_engine::command_specs() { - if let Some((m, k)) = c.shortcut.and_then(parse) + if let Some((m, k)) = c.shortcut.filter(|s| !native(s)).and_then(parse) && matches(i, m, k) { fire.push(c.id.to_string()); @@ -68,15 +70,15 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } // rating 0-5, colour labels 6-9 (with Shift: and advance) for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() { - if matches(i, Modifiers::NONE, *key) { + if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) { fire.push(format!("rate:{n}:0")); } if matches(i, Modifiers::SHIFT, *key) { fire.push(format!("rate:{n}:1")); } } - for (label, key) in [("red", Key::Num6), ("yellow", Key::Num7), ("green", Key::Num8), ("blue", Key::Num9)] { - if matches(i, Modifiers::NONE, key) { + for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] { + if matches(i, Modifiers::NONE, key) && !native(sc) { fire.push(format!("label:{label}")); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c3cb8103..71d5ee05 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -19,7 +19,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: |---|---|---| | `engine.execute` (alias `ui.menu.invoke`) | `{command, params?}` | Run any engine or UI command (see `engine.commands`) | | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | -| `ui.menu.list` | — | Menu entries | +| `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | +| `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | | `ui.inspect` | — | UI state, window, canvas/image rects, active photo, selection, perf, status | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` | Real egui input on a widget | From 8d5cff2e699afa22a54e216c532dfde85e1155ef Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:31:58 -0400 Subject: [PATCH 6/6] M1.16: library filter bar (LR-FILT-*) + parity rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter bar above the grid (View > Filter Bar, ⇧F, or the top-bar filter icon): rating with ≥ / = / ≤, picked / rejected / unflagged, colour labels, kind, edited/unedited, camera / lens / keyword pickers, Clear and "Save as Smart Album…" — every control drives library.filter. Two rows when the grid is narrow. Empty grid under a filter says so. Headless test clicks the bar's widgets and saves a smart album. docs/parity.md: rows for smart albums, stacks, virtual copies, compare, survey, filter bar, menu bar updated; finished top gaps removed. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/headless.rs | 28 +++ crates/ui-egui/src/menubar.rs | 1 + crates/ui-egui/src/menus.rs | 9 +- crates/ui-egui/src/panels/filterbar.rs | 255 +++++++++++++++++++++++++ crates/ui-egui/src/panels/grid.rs | 9 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/topbar.rs | 2 +- crates/ui-egui/src/state.rs | 3 + docs/parity.md | 108 +++++------ 9 files changed, 354 insertions(+), 62 deletions(-) create mode 100644 crates/ui-egui/src/panels/filterbar.rs diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 96aa5a3b..0ae29c29 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -339,6 +339,34 @@ mod tests { h.settle(Duration::from_secs(20)); } + /// The filter bar drives `library.filter` and saves the view as a smart album. + #[test] + fn filter_bar_filters_and_saves_a_smart_album() { + let mut h = demo([1400.0, 800.0]); + let t = Duration::from_secs(10); + let all = h.app.session.visible_cloned().len(); + h.request("engine.execute", json!({"command": "view.filterBar"}), t); + assert!(h.app.ui.filter_bar); + for w in ["filter:star3", "filter:pick", "filter:label-red", "filter:label-red"] { + let r = h.request("ui.clickWidget", json!({"id": w}), t); + assert_eq!(r["ok"], true, "{w}: {r}"); + } + let f = h.app.session.filter.clone(); + assert_eq!((f.rating, f.flag, f.label), (3, Some(lightcraft_catalog::Flag::Pick), None), "a second click clears the label"); + let n = h.app.session.visible_cloned().len(); + assert!(n > 0 && n < all); + h.request("ui.clickWidget", json!({"id": "button:filterSave"}), t); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::NewSmartAlbum { .. }))); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let smart = h.app.session.catalog.albums().find(|a| a.is_smart()).expect("smart album").id; + assert_eq!(h.app.session.catalog.album_count(smart), n); + h.request("ui.clickWidget", json!({"id": "button:filterClear"}), t); + assert_eq!(h.app.session.filter, Default::default()); + assert_eq!(h.app.session.visible_cloned().len(), all); + h.settle(Duration::from_secs(20)); + } + #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 02b63d61..19d19050 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -200,6 +200,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.beforeAfter" => Some(u.before_after == crate::state::BeforeAfter::SideBySide), "view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split), "view.autoAdvance" => Some(u.auto_advance), + "view.filterBar" => Some(u.filter_bar), "panel.edit" => panel(RightPanel::Edit), "panel.crop" => panel(RightPanel::Crop), "panel.remove" => panel(RightPanel::Remove), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e2e3f6f2..fffb9b92 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -36,7 +36,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), ("view.back", "Back to Grid", Some("Escape"), ""), - ("view.filterBar", "Filter", None, "View"), + ("view.filterBar", "Filter Bar", Some("Shift+F"), "View"), ("panel.edit", "Edit", Some("E"), "Window"), ("panel.crop", "Crop & Rotate", Some("C"), "Window"), ("panel.remove", "Remove", Some("H"), "Window"), @@ -210,8 +210,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.left_panel = true; - Ok(Value::Null) + app.ui.filter_bar = !app.ui.filter_bar; + if app.ui.filter_bar && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { + app.ui.view = ViewMode::PhotoGrid; + } + Ok(json!({"filterBar": app.ui.filter_bar})) } "panel.edit" => { panel(app, &ctx, RightPanel::Edit, "Edit"); diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs new file mode 100644 index 00000000..7dcea37b --- /dev/null +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -0,0 +1,255 @@ +//! The library filter bar above the grid: rating (≥ / = / ≤), flags, colour labels, kind, +//! edited state and camera / lens / keyword pickers — every control drives `library.filter`. +//! "Save as Smart Album…" keeps the current view as a live album; "Clear" resets the filter. + +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, vec2}; +use lightcraft_catalog::{ColorLabel, Flag, MediaKind, RatingOp}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::icons::{Icon, paint}; +use crate::theme::Tokens; +use crate::widgets::register; + +pub const HEIGHT: f32 = 40.0; +/// Bar width (points) below which the controls take two rows. +const WIDE: f32 = 1240.0; + +/// Colour-label swatches (UI colours, our own choice). +pub fn label_color(l: ColorLabel) -> Color32 { + match l { + ColorLabel::Red => Color32::from_rgb(222, 72, 72), + ColorLabel::Yellow => Color32::from_rgb(232, 196, 58), + ColorLabel::Green => Color32::from_rgb(88, 176, 92), + ColorLabel::Blue => Color32::from_rgb(72, 130, 222), + ColorLabel::Purple => Color32::from_rgb(158, 100, 210), + } +} + +fn filter(app: &mut LightcraftApp, patch: Value) { + let _ = app.run("library.filter", patch); +} + +/// A small label before a group of controls. +fn caption(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).font(t.font(12.0)).color(t.text_dim)); +} + +/// A square toggle with an icon or a colour swatch. +fn toggle(ui: &mut egui::Ui, id: &str, on: bool, tip: &str, draw: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + register(ui.ctx(), format!("filter:{id}"), r); + let p = ui.painter(); + if on { + p.rect_filled(r, 5.0, t.tool_active); + p.rect_stroke(r, 5.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } else if resp.hovered() { + p.rect_filled(r, 5.0, t.hover); + } + draw( + p, + r.shrink(5.0), + if on { + t.text + } else if resp.hovered() { + t.text_label + } else { + t.icon + }, + ); + resp.on_hover_text(tip) +} + +/// A dropdown showing `current`; `items` are (label, filter patch, selected). +fn picker(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, current: &str, active: bool, items: Vec<(String, Value, bool)>) { + let t = Tokens::get(ui.ctx()); + let r = crate::widgets::dropdown(ui, &format!("filter-{id}"), current, t.font(12.5), if active { t.text } else { t.text_label }); + if active { + ui.painter().rect_stroke(r.rect.expand(2.0), 4.0, Stroke::new(1.0, t.accent.gamma_multiply(0.7)), StrokeKind::Outside); + } + let mut chosen = None; + egui::Popup::menu(&r).show(|ui| { + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (label, patch, sel) in &items { + if ui.selectable_label(*sel, label).clicked() { + chosen = Some(patch.clone()); + } + } + }); + }); + if let Some(p) = chosen { + filter(app, p); + } +} + +fn distinct(mut v: Vec) -> Vec { + v.retain(|s| !s.trim().is_empty()); + v.sort_by_key(|s| s.to_lowercase()); + v.dedup(); + v +} + +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + // one row when there is room, else the metadata pickers and actions go to a second row + let two_rows = ui.available_width() < WIDE; + let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), if two_rows { 2.0 * HEIGHT - 8.0 } else { HEIGHT }), Sense::hover()); + ui.painter().rect_filled(bar, 0.0, t.chrome); + ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.divider)); + register(ui.ctx(), "filterbar", bar); + let f = app.session.filter.clone(); + let row = |ui: &mut egui::Ui, i: f32| { + let r = Rect::from_min_size(bar.min + vec2(16.0, 4.0 + i * (HEIGHT - 8.0)), vec2(bar.width() - 32.0, HEIGHT - 8.0)); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(r).layout(egui::Layout::left_to_right(egui::Align::Center))); + c.spacing_mut().item_spacing.x = 4.0; + c + }; + let mut row1 = row(ui, 0.0); + let mut row2 = row(ui, 1.0); + let ui = &mut row1; + + // rating: operator + stars + caption(ui, "Rating"); + let op_label = match f.rating_op { + RatingOp::AtLeast => "≥", + RatingOp::Exactly => "=", + RatingOp::AtMost => "≤", + }; + picker( + app, + ui, + "ratingOp", + op_label, + false, + [("≥ at least", "atLeast", RatingOp::AtLeast), ("= exactly", "exactly", RatingOp::Exactly), ("≤ at most", "atMost", RatingOp::AtMost)] + .into_iter() + .map(|(l, k, op)| (l.to_string(), json!({"ratingOp": k}), f.rating_op == op)) + .collect(), + ); + for i in 1..=5u8 { + let (r, resp) = ui.allocate_exact_size(vec2(17.0, 26.0), Sense::click()); + register(ui.ctx(), format!("filter:star{i}"), r); + let filled = i <= f.rating; + let c = if filled { + t.star + } else if resp.hovered() { + t.text_label + } else { + t.icon + }; + paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), if filled { Icon::StarFilled } else { Icon::Star }, c); + if resp.clicked() { + filter(app, json!({"rating": if f.rating == i { 0 } else { i }})); + } + } + ui.add_space(14.0); + + // flags (one at a time; click again to clear) + caption(ui, "Flag"); + for (id, icon, flag, tip) in [ + ("pick", Icon::FlagPick, Flag::Pick, "Picked"), + ("reject", Icon::FlagReject, Flag::Reject, "Rejected"), + ("none", Icon::Circle, Flag::None, "Unflagged"), + ] { + let on = f.flag == Some(flag); + let color = match flag { + Flag::Pick if on => t.pick, + Flag::Reject if on => t.reject, + _ => Color32::TRANSPARENT, + }; + let resp = toggle(ui, id, on, tip, |p, r, c| paint(p, r, icon, if color == Color32::TRANSPARENT { c } else { color })); + if resp.clicked() { + filter(app, json!({"flag": if on { Value::Null } else { json!(id) }})); + } + } + ui.add_space(14.0); + + // colour labels + caption(ui, "Label"); + for l in ColorLabel::ALL { + let on = f.label == Some(l); + let name = format!("{l:?}").to_lowercase(); + let resp = toggle(ui, &format!("label-{name}"), on, &format!("{l:?} label"), |p, r, _| { + p.circle_filled(r.center(), 5.5, label_color(l)); + }); + if resp.clicked() { + filter(app, json!({"label": if on { Value::Null } else { json!(name) }})); + } + } + ui.add_space(14.0); + + // kind + edited + let kind_label = match f.kind { + None => "All types", + Some(MediaKind::Image) => "Photos", + Some(MediaKind::Raw) => "Raw", + Some(MediaKind::Video) => "Videos", + }; + picker( + app, + ui, + "kind", + kind_label, + f.kind.is_some(), + [ + ("All types", Value::Null, None), + ("Photos", json!("image"), Some(MediaKind::Image)), + ("Raw", json!("raw"), Some(MediaKind::Raw)), + ("Videos", json!("video"), Some(MediaKind::Video)), + ] + .into_iter() + .map(|(l, v, k)| (l.to_string(), json!({"kind": v}), f.kind == k)) + .collect(), + ); + let edited_label = match f.edited { + None => "Edited or not", + Some(true) => "Edited", + Some(false) => "Unedited", + }; + picker( + app, + ui, + "edited", + edited_label, + f.edited.is_some(), + [("Edited or not", Value::Null, None), ("Edited", json!(true), Some(true)), ("Unedited", json!(false), Some(false))] + .into_iter() + .map(|(l, v, e)| (l.to_string(), json!({"edited": v}), f.edited == e)) + .collect(), + ); + + // metadata pickers + let ui: &mut egui::Ui = if two_rows { &mut row2 } else { &mut row1 }; + let photos: Vec<_> = app.session.catalog.photos().filter(|p| !p.deleted).cloned().collect(); + let cameras = distinct(photos.iter().map(|p| p.meta.camera.clone()).collect()); + let lenses = distinct(photos.iter().map(|p| p.meta.lens.clone()).collect()); + let keywords: Vec = app.session.catalog.keywords().into_iter().map(|(k, _)| k).collect(); + for (id, key, all_label, current, values) in [ + ("camera", "camera", "Camera", f.camera.clone(), cameras), + ("lens", "lens", "Lens", f.lens.clone(), lenses), + ("keyword", "keyword", "Keyword", f.keyword.clone(), keywords), + ] { + let mut items = vec![(format!("Any {}", all_label.to_lowercase()), json!({key: Value::Null}), current.is_none())]; + items.extend(values.into_iter().map(|v| (v.clone(), json!({key: v.clone()}), current.as_deref() == Some(v.as_str())))); + let shown = current.clone().unwrap_or_else(|| all_label.to_string()); + let shown = if shown.chars().count() > 22 { format!("{}…", shown.chars().take(21).collect::()) } else { shown }; + picker(app, ui, id, &shown, current.is_some(), items); + } + + // save / clear + ui.add_space(14.0); + let filtering = app.session.filter != Default::default(); + let save = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new("Save as Smart Album…").font(t.font(12.5)))); + register(ui.ctx(), "button:filterSave", save.rect); + if save.clicked() { + let _ = app.run("dialog.newSmartAlbum", json!({})); + } + let clear = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new("Clear").font(t.font(12.5)))); + register(ui.ctx(), "button:filterClear", clear.rect); + if clear.clicked() { + app.ui.search.clear(); + let _ = app.run("library.clearFilter", json!({})); + } +} diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 03534a2c..e20121be 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -36,9 +36,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let sel_n = app.session.selection.ids.len(); let cnt = if sel_n > 1 { format!("{sel_n} of {} photos", ids.len()) } else { format!("{} photos", ids.len()) }; ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim); + if app.ui.filter_bar { + super::filterbar::show(app, ui); + } app.canvas_rect = Some(ui.max_rect()); if ids.is_empty() { - super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here"); + if app.session.filter != Default::default() { + super::empty_message(ui, ui.max_rect(), "No matching photos", "Change the filter, or clear it (View → Clear Filters)"); + } else { + super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here"); + } return; } let ppp = ui.ctx().pixels_per_point(); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index daab0083..76cbb011 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -5,6 +5,7 @@ pub mod compare; pub mod detail; pub mod dialogs; pub mod edit; +pub mod filterbar; pub mod grid; pub mod left; pub mod masking; diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 979f9128..64899ee6 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -78,7 +78,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0)); let fresp = ui.interact(fr, egui::Id::new("filter-btn"), Sense::click()); register(ui.ctx(), "icon:filter", fr); - let filtering = app.session.filter != Default::default(); + let filtering = app.session.filter != Default::default() || app.ui.filter_bar; paint( ui.painter(), fr.shrink(6.0), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d2614f47..d38a0ad6 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -114,6 +114,8 @@ pub struct UiState { pub brush_flow: f32, pub brush_erase: bool, pub remove_size: f32, + /// The library filter bar above the grid. + pub filter_bar: bool, /// Culling: after a rating, flag or colour-label key, move to the next photo. pub auto_advance: bool, /// Compare view: (select, candidate) photo ids. @@ -196,6 +198,7 @@ impl Default for UiState { brush_erase: false, remove_size: 0.02, auto_advance: false, + filter_bar: false, compare: None, toast: None, status: String::new(), diff --git a/docs/parity.md b/docs/parity.md index edeead7f..a522e01f 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -22,9 +22,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 2 | 4 | 7 | 1 | 1/4 (25%) | 1/4 (25%) | -| B. Library management (LIB) | 9 | 4 | 12 | 2 | 7/9 (78%) | 1/9 (11%) | -| C. Views & navigation (VIEW) | 7 | 2 | 8 | 0 | 7/9 (78%) | 0/4 (0%) | -| D. Search & filter (FILT) | 2 | 7 | 4 | 0 | 2/4 (50%) | 0/4 (0%) | +| B. Library management (LIB) | 12 | 4 | 9 | 2 | 7/9 (78%) | 4/9 (44%) | +| C. Views & navigation (VIEW) | 9 | 2 | 6 | 0 | 7/9 (78%) | 1/4 (25%) | +| D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) | | E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) | | F. Edit panel — global adjustments (EDIT) | 35 | 3 | 10 | 1 | 27/28 (96%) | 8/14 (57%) | | G. Profiles (PROF) | 3 | 1 | 6 | 0 | 2/3 (67%) | 1/3 (33%) | @@ -45,10 +45,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | V. Preferences (PREF) | 0 | 3 | 5 | 3 | 0/1 (0%) | 0/4 (0%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 10 | 2 | 5 | 1 | 7/8 (88%) | 3/5 (60%) | -| Y. Menus | 42 | 19 | 24 | 8 | 37/47 (79%) | 3/23 (13%) | -| Z. Keyboard shortcuts (desktop) | 45 | 16 | 19 | 1 | 39/52 (75%) | 6/23 (26%) | -| Lightroom Classic extras | 1 | 17 | 61 | 9 | — | 1/21 (5%) | -| **Total** | 184 | 110 | 206 | 37 | 148/198 (75%) | 33/144 (23%) | +| Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) | +| Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) | +| Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) | +| **Total** | 202 | 106 | 192 | 37 | 150/198 (76%) | 47/144 (33%) | ## Top gaps @@ -57,45 +57,39 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i 1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible / Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort. -2. **LR-FILT-RATING / LR-FILT-FLAG / LR-FILT-LABEL / LR-FILT-TYPE** (P0/P1) — the engine filters already exist - (`cmd:library.filter`); there is no filter-bar UI (stars ≥/=/≤, flags, labels, kind, edited). High value, low effort. -3. **LR-EXP-PREVIOUS + LR-EXP-DIALOG presets** (P0) — remember the last export settings (⌘E "export with previous") +2. **LR-EXP-PREVIOUS + LR-EXP-DIALOG presets** (P0) — remember the last export settings (⌘E "export with previous") and offer built-in export presets (small JPEG, full JPEG, TIFF). Medium value, low effort. -4. **LR-LIB-SHOWFINDER** (P0) — reveal the original in Finder/Explorer/file manager. Medium value, low effort. -5. **LR-EDIT-EFFECTS-VIGNETTE** (P0) — the vignette style (highlight priority / colour priority / paint overlay) is +3. **LR-LIB-SHOWFINDER** (P0) — reveal the original in Finder/Explorer/file manager. Medium value, low effort. +4. **LR-EDIT-EFFECTS-VIGNETTE** (P0) — the vignette style (highlight priority / colour priority / paint overlay) is rendered but has no UI. Low effort. -6. **LR-VIEW-BEFOREAFTER** (P0) — add top/bottom and split top/bottom layouts (⌥Y). Low effort. -7. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask +5. **LR-VIEW-BEFOREAFTER** (P0) — add top/bottom and split top/bottom layouts (⌥Y). Low effort. +6. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask kinds, colour/opacity choice), not just brush dabs and outlines. High value, medium effort. -8. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local +7. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort. -9. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, +8. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, feather/opacity sliders, `[`/`]` size keys. Medium effort. -10. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. -11. **LR-CROP-STRAIGHTEN** (P0) — draw-along-the-horizon straighten tool (+ double-click auto). Low effort. -12. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset; +9. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. +10. **LR-CROP-STRAIGHTEN** (P0) — draw-along-the-horizon straighten tool (+ double-click auto). Low effort. +11. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset; live preview while hovering presets/profiles/versions. Medium effort. -13. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). -14. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. -15. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), +12. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). +13. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. +14. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), XMP prefs, cache, GPU. Medium effort. -16. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination +15. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination album; browse folders before adding. Medium effort. -17. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in +16. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in `crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi. -18. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no +17. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no permissive pure-Rust decoder yet). High value, high effort (clean sources needed). -19. **MENU-BAR** (P1) — render the existing menu model (`ui.menu.list`) as a native macOS menu / in-window menu bar. - High value, medium effort. -20. **LR-LIB-SMARTALBUM** (P1) — smart albums from the existing filter + fielded search tokens. High value, medium effort. -21. **LR-LIB-STACK + LR-LIB-VIRTUALCOPY** (P1) — stacks (⌘G) and virtual copies. Medium effort. -22. **LR-VIEW-FULLSCREEN + LR-VIEW-COMPARE + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. -23. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. -24. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render). -25. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) — +18. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. +19. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. +20. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render). +21. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) — missing develop controls (calibration matters for XMP interop). -26. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. -27. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). +22. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. +23. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). ## Shortcuts: conflicts and missing bindings @@ -160,7 +154,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-BYDATE | Browse by date | P1 | 🟡 | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` | years only; no month/day tree | | LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) | -| LR-LIB-SMARTALBUM | Smart albums | P1 | ⬜ | `crates/catalog/src/query.rs` | building blocks exist (filter + fielded search tokens) | +| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs` | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`) | | 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 | ⬜ | | | @@ -169,7 +163,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-LABEL | Colour labels | P1 | 🟡 | `cmd:photo.label`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | no label menu/buttons, no purple key, label names not editable | | LR-LIB-KEYWORD | Keywords | P0 | 🟡 | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`) | no library-wide rename/delete, no keyword browser in the left panel | | LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | | -| LR-LIB-STACK | Stacks | P1 | ⬜ | | | +| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | @@ -181,7 +175,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-CULL | Assisted culling | P2 | ⬜ | | | | LR-LIB-ACTIVITY | Comments & likes | OOS | 🚫 | | | | LR-LIB-QUICKCOLL | Quick collection [Classic] | P2 | ⬜ | | | -| LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ⬜ | | | +| LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ✅ | `cmd:photo.virtualCopy`, `crates/engine/src/cmd/organize.rs` | “Copy N” badge, stacked with the original, same albums, no XMP writes; no “Set Copy as Master” | ## C. Views & navigation (VIEW) @@ -196,8 +190,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ⬜ | | | | LR-VIEW-BEFOREAFTER | Before / after | P0 | 🟡 | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | all four layouts; "before" is always the original (no before-state from history) | -| LR-VIEW-COMPARE | Compare two photos | P1 | ⬜ | | | -| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ⬜ | | | +| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | +| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ⬜ | | | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ⬜ | | | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ⬜ | | | @@ -211,12 +205,12 @@ under the UI command that wraps it (a no-op error); the UI command now wins. |---|---|---|---|---|---| | LR-FILT-SEARCH-META | Text search | P0 | ✅ | `cmd:library.filter` (`text`), `crates/ui-egui/src/panels/topbar.rs`, `crates/catalog/src/query.rs` | fielded tokens (`rating:3`, `iso:>800`, `camera:…`); no suggestions dropdown | | LR-FILT-SEARCH-AI | Natural-language search | P2 | ⬜ | | | -| LR-FILT-RATING | Rating filter | P0 | 🟡 | `cmd:library.filter` (`rating`, `ratingOp`) | engine + search token only; no filter-bar UI | -| LR-FILT-FLAG | Flag filter | P0 | 🟡 | `cmd:library.filter` (`flag`), `cmd:library.source` (`picks`) | no filter-bar UI (Picks source only) | -| LR-FILT-LABEL | Colour-label filter | P1 | 🟡 | `cmd:library.filter` (`label`) | no UI | -| LR-FILT-TYPE | Type / edited filter | P1 | 🟡 | `cmd:library.filter` (`kind`, `edited`) | no UI; no HDR/panorama/depth kinds | -| LR-FILT-KEYWORD | Keyword filter | P1 | 🟡 | `cmd:library.filter` (`keyword`) | no UI | -| LR-FILT-CAMERA | Camera / lens filter | P1 | 🟡 | `cmd:library.filter` (`camera`) | no UI; lens via text search only | +| LR-FILT-RATING | Rating filter | P0 | ✅ | `cmd:library.filter` (`rating`, `ratingOp`), `crates/ui-egui/src/panels/filterbar.rs` | ≥ / = / ≤ stars in the filter bar (`cmd:view.filterBar`) | +| LR-FILT-FLAG | Flag filter | P0 | ✅ | `cmd:library.filter` (`flag`), `crates/ui-egui/src/panels/filterbar.rs` | picked / rejected / unflagged (one at a time) | +| LR-FILT-LABEL | Colour-label filter | P1 | ✅ | `cmd:library.filter` (`label`), `crates/ui-egui/src/panels/filterbar.rs` | one label at a time; no “no label” choice | +| LR-FILT-TYPE | Type / edited filter | P1 | 🟡 | `cmd:library.filter` (`kind`, `edited`), `crates/ui-egui/src/panels/filterbar.rs` | photos / raw / videos, edited / unedited; no HDR/panorama/depth kinds | +| LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | `cmd:library.filter` (`keyword`), `crates/ui-egui/src/panels/filterbar.rs` | keyword picker | +| LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | `cmd:library.filter` (`camera`, `lens`), `crates/ui-egui/src/panels/filterbar.rs` | camera and lens pickers | | LR-FILT-LOCATION | Location filter | P2 | 🟡 | `cmd:library.filter` (`text`) | free-text match on the location field only | | LR-FILT-PEOPLE | People filter | P2 | ⬜ | | | | LR-FILT-CULL | Culling-score filters | P2 | ⬜ | | | @@ -534,7 +528,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| MENU-BAR | Menu bar rendering | P1 | 🟡 | `crates/ui-egui/src/menus.rs` (`menu_entries`) | model only; no native macOS menu or in-window menu bar | +| MENU-BAR | Menu bar rendering | P1 | ✅ | `crates/ui-egui/src/menubar.rs`, `apps/lightcraft/src/native_menu.rs` | native macOS menu bar (muda) with live labels/enabled/checked; in-window menus on web/Windows/Linux; ⌫ and X stay egui-handled (contextual), so they show no key in the native menu | | MENU-APP-ABOUT | About | P2 | ✅ | `cmd:app.about` | | | MENU-APP-SETTINGS | Settings… | P0 | ⬜ | | see LR-PREF-GENERAL | | MENU-APP-UPDATES | Check for updates | P2 | ⬜ | | | @@ -547,7 +541,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-FILE-MIGRATE | Migrate photos | OOS | 🚫 | | | | MENU-FILE-NEWALBUM | New Album… | P0 | ✅ | `cmd:dialog.newAlbum` | | | MENU-FILE-NEWFOLDER | New Folder… | P0 | ✅ | `cmd:dialog.newFolder` | ⇧⌘N | -| MENU-FILE-NEWSMART | New Smart Album… | P1 | ⬜ | | | +| MENU-FILE-NEWSMART | New Smart Album… | P1 | ✅ | `cmd:dialog.newSmartAlbum` | saves the current view (source + filter) | | MENU-FILE-IMPORTPROFILES | Import Profiles & Presets… | P1 | 🟡 | `cmd:file.importPresets` | presets only | | MENU-FILE-EXPORT | Export… | P0 | ✅ | `cmd:dialog.export` | | | MENU-FILE-EXPORTPREV | Export with Previous | P0 | ✅ | `cmd:app.exportPrevious` | ⌥⇧⌘E | @@ -587,7 +581,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-MASKOVERLAY | Mask overlay / cycle colour | P0 | 🟡 | `cmd:view.maskOverlay` | no colour cycle | | MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | 🟡 | `cmd:library.import` | folder import is always recursive | | MENU-VIEW-SORT | Sort submenu | P0 | ✅ | `cmd:library.sort` | no colour-label key | -| MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ⬜ | | | +| MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ✅ | `cmd:stack.expandAll`, `cmd:stack.collapseAll` | | | MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | 🟡 | `crates/ui-egui/src/panels/left.rs` | counts always shown; no toggle | | MENU-VIEW-HDR | HDR display options | P2 | ⬜ | | | | MENU-PHOTO-ADDTOALBUM | Add to album | P0 | ✅ | `cmd:album.addPhotos` | | @@ -598,7 +592,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-PHOTO-ROTATE | Rotate left / right | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | | MENU-PHOTO-FLIP | Flip horizontal / vertical | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | | | MENU-PHOTO-CREATEVERSION | Create Version… | P1 | ✅ | `cmd:version.create` | no name prompt | -| MENU-PHOTO-STACK | Stack submenu | P1 | ⬜ | | | +| MENU-PHOTO-STACK | Stack submenu | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:dialog.autoStack` | | | MENU-PHOTO-MERGE | Photo merge submenu | P2 | ⬜ | | | | MENU-PHOTO-ENHANCE | Enhance… | P2 | ⬜ | | | | MENU-PHOTO-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto` | | @@ -668,7 +662,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-EXPORTPREV | Export with previous — ⌘E | P0 | 🟡 | `cmd:app.exportPrevious` | bound to ⌥⇧⌘E (Classic); ⌘E not bound | | KEY-EXPORTDIALOG | Export dialog — ⇧E | P0 | ✅ | `cmd:dialog.export` | secondary binding (primary ⌘⇧E) | | KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ⬜ | | | -| KEY-STACK | Group / ungroup stack — ⌘G / ⇧⌘G | P1 | ⬜ | | | +| KEY-STACK | Group / ungroup stack — ⌘G / ⇧⌘G | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup` | also S expand/collapse, ⇧S top of stack | | KEY-GUIDEDUPRIGHT | Guided Upright — ⇧G | P1 | 🟡 | `cmd:geometry.upright` | button in the crop panel; ⇧G = Square Grid | | KEY-HIDE | Hide / hide others — ⌘H / ⌥⌘H | P1 | 🟡 | | platform default (unverified) | | KEY-QUIT | Quit — ⌘Q | P0 | 🟡 | | platform default (unverified) | @@ -727,17 +721,17 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ⬜ | | | | LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | | LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.detail` | no compare, survey, people; no grid cell styles beyond filenames | -| LRC-LIB-COMPARE | Compare view | P1 | ⬜ | | | -| LRC-LIB-SURVEY | Survey view | P2 | ⬜ | | | +| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; zoom always linked | +| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ⬜ | | | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | 🟡 | `cmd:library.source` | all, recently added, picks, recently deleted; no missing/problem sets | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ⬜ | | | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | 🟡 | `cmd:album.create` | albums + folders; no smart / quick / target collections | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ⬜ | `crates/catalog/src/query.rs` | fielded search tokens are a start | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | 🟡 | `cmd:album.createSmart`, `crates/catalog/src/query.rs` | the filter fields + date range; no rule editor, any/none groups or operators beyond ≥/=/≤ | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | -| LRC-LIB-FILTERBAR | Library filter bar | P1 | 🟡 | `cmd:library.filter` | engine filter + text tokens; no filter bar, presets, lock | -| LRC-LIB-STACKS | Stacks (full) | P1 | ⬜ | | | -| LRC-LIB-VC | Virtual copies | P1 | ⬜ | | | +| LRC-LIB-FILTERBAR | Library filter bar | P1 | 🟡 | `cmd:view.filterBar`, `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label/kind/edited/camera/lens/keyword, clear, save as smart album; no filter presets, lock, or multi-select columns | +| LRC-LIB-STACKS | Stacks (full) | P1 | 🟡 | `crates/catalog/src/stacks.rs` | group/ungroup/toggle/top/remove/auto by time; no split stack or move up/down | +| LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ⬜ | | | | LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | ⬜ | | flat keywords only (LR-LIB-KEYWORD) | | LRC-LIB-METADATA | Metadata panel & presets | P1 | 🟡 | `cmd:photo.setMeta`, `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile` | no metadata presets, capture-time edit, copyright status |