diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index b53c198a..8bbd0888 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -235,6 +235,11 @@ impl Catalog { pub fn albums(&self) -> impl Iterator { self.albums.values() } + /// The Quick Collection, once something was added to it. + pub fn quick_collection(&self) -> Option { + self.albums.values().find(|a| a.quick).map(|a| a.id) + } + /// Albums (regular and smart) that contain the photo. pub fn albums_of(&self, id: PhotoId) -> Vec { let Some(p) = self.photos.get(&id) else { return Vec::new() }; diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 184a9380..e1c87bf1 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -283,12 +283,15 @@ pub struct Album { /// live; `photos` stays empty. #[serde(default, skip_serializing_if = "Option::is_none")] pub smart: Option>, + /// The Quick Collection: a regular album that B adds to (one per library). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub quick: bool, } 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 } + Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false } } pub fn is_smart(&self) -> bool { self.smart.is_some() diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 7eb7544b..778b46db 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -57,11 +57,15 @@ 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, smart: None } }) - .unwrap(); + c.apply(Op::AddAlbum { + album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false }, + }) + .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, smart: 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, quick: false }, + }) + .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"); diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 5fa4ecf4..ec38af57 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -50,7 +50,16 @@ 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, smart: None }, + album: Album { + id, + name: format!("A{b}"), + parent: None, + folder: b.is_multiple_of(4), + photos: vec![], + cover: None, + smart: None, + quick: false, + }, } } 6 => Op::SetAlbumPhotos { id: aid, photos: photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(3)).collect() }, diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index ce64a6aa..cb7c5729 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -432,7 +432,7 @@ pub fn specs() -> Vec { let cover = photos.first().copied(); s.commit( if folder { "New Folder" } else { "New Album" }, - Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None } }, + Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false } }, )?; Ok(json!({"id": id.0})) }), @@ -539,6 +539,63 @@ pub fn specs() -> Vec { s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?; Ok(json!({"added": added})) }), + cmd!( + "album.toggleTarget", + "Add to Target Album", + [], + None, + "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added}", + has_selection, + |s, p| { + let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); + let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)); + let id = match target.or_else(|| s.catalog.quick_collection()) { + Some(id) => id, + None => { + let id = s.catalog.alloc_album_id(); + let album = Album { quick: true, ..Album::new(id, "Quick Collection") }; + s.commit("New Quick Collection", Op::AddAlbum { album })?; + id + } + }; + let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?; + let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t)); + let photos: Vec = if all_in { + al.photos.iter().copied().filter(|x| !targets.contains(x)).collect() + } else { + al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect() + }; + let name = al.name.clone(); + let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied()); + s.commit( + if all_in { "Remove from Target Album" } else { "Add to Target Album" }, + Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] }, + )?; + Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id)})) + } + ), + cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| { + s.target_album = match p.get("id").and_then(Value::as_u64) { + Some(a) => { + let id = AlbumId(a); + let al = s.catalog.album(id).ok_or_else(|| bad("album.setTarget", "no such album"))?; + if al.is_smart() || al.folder { + return Err(bad("album.setTarget", "smart albums and folders can't hold photos")); + } + Some(id) + } + None => None, + }; + Ok(json!({"target": s.target_album.map(|a| a.0)})) + }), + cmd!("album.clearQuick", "Clear Quick Collection", [], None, "{}", always, |s, _| { + let Some(id) = s.catalog.quick_collection() else { return ok() }; + s.commit( + "Clear Quick Collection", + Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos: vec![] }, Op::SetAlbumCover { id, cover: None }] }, + )?; + ok() + }), cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| { let id = album_param(p, "id", "album.removePhotos")?; let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 7b4951c7..2f392cb7 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -62,7 +62,7 @@ pub fn load(s: &mut Session) { } 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, smart: None }, + album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false }, }); 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 [ @@ -75,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, smart: None } }); + ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None, quick: false } }); } for op in ops { let _ = s.catalog.apply(op); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 75d2444b..4211c0b9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -125,6 +125,8 @@ pub struct Session { pub filter_presets: Vec, /// Saved colour-label name sets. pub label_sets: Vec, + /// The target album B adds to (`None` = the Quick Collection). + pub target_album: Option, /// Keyword sets (⌥1–⌥9 apply the current set's keywords), the one in use (`None` = Recent /// Keywords) and the recently added keywords, newest first. pub keyword_sets: Vec, @@ -185,6 +187,7 @@ impl Session { metadata_presets: Vec::new(), filter_presets: Vec::new(), label_sets: Vec::new(), + target_album: None, keyword_sets: Vec::new(), keyword_set: None, recent_keywords: Vec::new(), diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index ecc791e9..67bc9564 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -209,3 +209,31 @@ fn filter_any_of_several_labels() { s.execute("library.filter", &serde_json::json!({"labels": []})).unwrap(); assert!(s.visible_cloned().len() > want); } + +/// Quick Collection / target album: B toggles the selection in it (created on first use); +/// another album can be the target; clear empties it; all undoable. +#[test] +fn quick_collection_and_target_album() { + let mut s = crate::Session::with_demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + assert!(s.catalog.quick_collection().is_none()); + s.execute("library.select", &serde_json::json!({"ids": [ids[0], ids[1]]})).unwrap(); + let r = s.execute("album.toggleTarget", &serde_json::json!({})).unwrap(); + assert_eq!((r["added"].as_bool(), r["count"].as_u64(), r["name"].as_str()), (Some(true), Some(2), Some("Quick Collection"))); + let quick = s.catalog.quick_collection().expect("created"); + // again: both are in it, so they come out + assert_eq!(s.execute("album.toggleTarget", &serde_json::json!({})).unwrap()["count"], 0); + s.execute("edit.undo", &serde_json::json!({})).unwrap(); + assert_eq!(s.catalog.album_count(quick), 2); + // a regular album as the target + let alb = s.execute("album.create", &serde_json::json!({"name": "Picks"})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.setTarget", &serde_json::json!({"id": alb})).unwrap(); + s.execute("album.toggleTarget", &serde_json::json!({"ids": [ids[2]]})).unwrap(); + assert_eq!(s.catalog.album_count(lightcraft_catalog::AlbumId(alb)), 1); + assert_eq!(s.catalog.album_count(quick), 2, "the Quick Collection is untouched"); + let smart = s.execute("album.createSmart", &serde_json::json!({"name": "S", "rules": {"rating": 3}})).unwrap()["id"].as_u64().unwrap(); + assert!(s.execute("album.setTarget", &serde_json::json!({"id": smart})).is_err()); + s.execute("album.setTarget", &serde_json::json!({"id": null})).unwrap(); + s.execute("album.clearQuick", &serde_json::json!({})).unwrap(); + assert_eq!(s.catalog.album_count(quick), 0); +} diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 1e3cb4b9..34bf9475 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -254,7 +254,11 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent let sel = app.session.source == LibrarySource::Album(a.id); 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); + // the album B adds to is marked "+" + let target = + app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection()); + let label = if target == Some(a.id) { format!("{} +", a.name) } else { a.name.clone() }; + let mut resp = row(app, ui, &format!("album:{}", a.id.0), icon, &label, Some(n), sel, indent); if !a.is_smart() { drop_target(app, ui, &resp, a); } @@ -294,6 +298,18 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { if !a.folder && !a.is_smart() && ui.button("Add Selected Photos").clicked() { let _ = app.run("album.addPhotos", json!({"id": a.id.0})); } + if !a.folder && !a.is_smart() { + let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick); + if !is_target && ui.button("Set as Target Album (B adds to it)").clicked() { + let _ = app.run("album.setTarget", json!({"id": if a.quick { serde_json::Value::Null } else { json!(a.id.0) }})); + } + if is_target && !a.quick && ui.button("Stop Using as Target Album").clicked() { + let _ = app.run("album.setTarget", json!({"id": null})); + } + } + if a.quick && ui.button("Clear Quick Collection").clicked() { + let _ = app.run("album.clearQuick", json!({})); + } if a.is_smart() && ui.button("Edit Smart Album…").clicked() { // older smart albums keep their filter fields; the editor works on the rule set let rules = a.smart.as_ref().and_then(|f| f.rule_set.clone()).unwrap_or_default(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index be281339..4ede1ad0 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -201,6 +201,16 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { continue; } } + // B: the brush while editing; in the grids, add to the target album (Quick Collection) + if f == "tool.brush" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { + if let Ok(r) = app.run("album.toggleTarget", json!({})) { + let n = app.session.targets(&json!({})).len(); + let what = if n == 1 { "photo".to_string() } else { format!("{n} photos") }; + let name = r["name"].as_str().unwrap_or("Quick Collection").to_string(); + app.toast(ctx, if r["added"] == true { format!("Added {what} to {name}") } else { format!("Removed {what} from {name}") }); + } + continue; + } // X is both reject (library) and swap crop aspect (crop tool) if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop { let _ = app.run("crop.rotateAspect", json!({})); diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 303a37a8..5aee602b 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -328,3 +328,20 @@ fn luminance_range_controls_and_map() { assert_ne!(h.app.ui.mask_overlay_mode, "colorOnBw"); h.settle(SETTLE); } + +#[test] +fn b_adds_to_quick_collection_in_the_grid_and_brushes_in_edit() { + let mut h = detail("panel.edit"); + // in the loupe B is the masking brush + let r = h.request("ui.key", json!({"key": "b"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.tool, "brush"); + assert!(h.app.session.catalog.quick_collection().is_none()); + // in the grid it adds the selection to the Quick Collection + exec(&mut h, "view.photoGrid", json!({})); + let r = h.request("ui.key", json!({"key": "b"}), T); + assert_eq!(r["ok"], true, "{r}"); + let q = h.app.session.catalog.quick_collection().expect("quick collection"); + assert_eq!(h.app.session.catalog.album_count(q), 1); + h.settle(SETTLE); +} diff --git a/docs/parity.md b/docs/parity.md index abbe9fbd..e2f4968c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | X. Cross-cutting behaviours (BEHAV) | 13 | 0 | 4 | 1 | 8/8 (100%) | 5/5 (100%) | | Y. Menus | 73 | 4 | 8 | 8 | 46/47 (98%) | 22/23 (96%) | | Z. Keyboard shortcuts (desktop) | 73 | 3 | 4 | 1 | 49/52 (94%) | 22/23 (96%) | -| Lightroom Classic extras | 18 | 12 | 49 | 9 | — | 17/21 (81%) | -| **Total** | 344 | 32 | 124 | 37 | 193/198 (97%) | 131/144 (91%) | +| Lightroom Classic extras | 19 | 11 | 49 | 9 | — | 18/21 (86%) | +| **Total** | 345 | 31 | 124 | 37 | 193/198 (97%) | 132/144 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **72.0%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.1% of 144 · P2 18.4% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **72.1%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.4% of 144 · P2 18.4% of 158. ## Top gaps @@ -694,7 +694,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-REFVIEW | Reference view | P2 | ⬜ | | | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | 🟡 | `cmd:library.browse`, `crates/ui-egui/src/panels/left.rs` (`local_section`) | standard places + the browsed folder + Browse Folder…, breadcrumb navigation upwards; no expandable folder tree, no folder rename/move | -| LRC-LIB-COLLECTIONS | Collections & sets | P1 | 🟡 | `cmd:album.create` | albums + folders; no smart / quick / target collections | +| LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 26 fields (rating, flag, label, type, edits, keywords, any text, filename, format, title, caption, camera, lens, location, creator, copyright, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor |