LRC-LIB-COLLECTIONS: Quick Collection and target album (B)
Albums gain a `quick` flag: the Quick Collection is created the first time B adds to it. album.toggleTarget adds the selection to the target album (the Quick Collection unless album.setTarget names another regular album) or removes it when all of it is already there; album.clearQuick empties it. B in the photo / square grids does this (with a toast); in the loupe and edit views B stays the masking brush. Album menu: Set as Target Album / Stop Using as Target Album / Clear Quick Collection; the target is marked + in the list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
def15b0346
commit
b163d0e16e
|
|
@ -235,6 +235,11 @@ impl Catalog {
|
|||
pub fn albums(&self) -> impl Iterator<Item = &Album> {
|
||||
self.albums.values()
|
||||
}
|
||||
/// The Quick Collection, once something was added to it.
|
||||
pub fn quick_collection(&self) -> Option<AlbumId> {
|
||||
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<AlbumId> {
|
||||
let Some(p) = self.photos.get(&id) else { return Vec::new() };
|
||||
|
|
|
|||
|
|
@ -283,12 +283,15 @@ pub struct Album {
|
|||
/// live; `photos` stays empty.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub smart: Option<Box<crate::Filter>>,
|
||||
/// 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<String>) -> 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()
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -432,7 +432,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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<CommandSpec> {
|
|||
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<PhotoId> = 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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -125,6 +125,8 @@ pub struct Session {
|
|||
pub filter_presets: Vec<cmd::filters::FilterPreset>,
|
||||
/// Saved colour-label name sets.
|
||||
pub label_sets: Vec<cmd::manage::LabelSet>,
|
||||
/// The target album B adds to (`None` = the Quick Collection).
|
||||
pub target_album: Option<lightcraft_catalog::AlbumId>,
|
||||
/// 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<cmd::keywords::KeywordSet>,
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<u64> = 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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!({}));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## 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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue