From a8744c07e7f16797496f74fbdf29374cd9c38084 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:54:09 -0400 Subject: [PATCH] MENU-CTX-ALBUM + MENU-CTX-GRID: export / move albums from their menu, set album cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Album rows: Export Album… (shows and selects the album, opens Export), Export Album with Preset (background), Move to (a folder or Top Level; never into itself or a subfolder). - Photo context menu inside an album: Set as Album Cover. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/grid.rs | 10 ++++-- crates/ui-egui/src/panels/left.rs | 56 +++++++++++++++++++++++++++++++ docs/parity.md | 8 ++--- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 3558d8d04..951998198 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -467,12 +467,16 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } }); - // in a (non-smart) album: take the selection out of it + // in a (non-smart) album: take the selection out of it, or make this photo its cover if let lightcraft_engine::LibrarySource::Album(aid) = app.session.source && app.session.catalog.album(aid).is_some_and(|a| !a.folder && !a.is_smart()) - && ui.button("Remove from Album").clicked() { - let _ = app.run("album.removePhotos", json!({"id": aid.0})); + if ui.button("Remove from Album").clicked() { + let _ = app.run("album.removePhotos", json!({"id": aid.0})); + } + if ui.button("Set as Album Cover").clicked() { + let _ = app.run("album.setCover", json!({"id": aid.0, "photo": id.0})); + } } if ui.button("Rename…").clicked() { let _ = app.run("dialog.rename", json!({})); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 08093504c..0970cbf39 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -158,6 +158,45 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { 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 !a.folder { + // export: show the album, select its photos, then the dialog / a preset + let show_all = |app: &mut LightcraftApp| { + let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0})); + let _ = app.run("library.selectAll", json!({})); + }; + let has_photos = app.session.catalog.album_count(a.id) > 0; + if ui.add_enabled(has_photos, egui::Button::new("Export Album…")).clicked() { + show_all(app); + let _ = app.run("dialog.export", json!({})); + } + ui.add_enabled_ui(has_photos, |ui| { + ui.menu_button("Export Album with Preset", |ui| { + for (p, _) in app.session.all_export_presets() { + if ui.button(&p.name).clicked() { + show_all(app); + if let Err(e) = app.run("app.export", json!({"preset": p.name, "background": true})) { + app.toast(ui.ctx(), e); + } + } + } + }); + }); + ui.separator(); + } + // move into another folder (not into itself or one of its own subfolders) + let mut folders: Vec<(u64, String)> = + app.session.catalog.albums().filter(|f| f.folder && !is_within(app, f.id, a.id)).map(|f| (f.id.0, f.name.clone())).collect(); + folders.sort_by_key(|(_, n)| n.to_lowercase()); + ui.menu_button("Move to", |ui| { + if ui.add_enabled(a.parent.is_some(), egui::Button::new("Top Level")).clicked() { + let _ = app.run("album.move", json!({"id": a.id.0, "parent": null})); + } + for (fid, name) in &folders { + if ui.add_enabled(a.parent.map(|p| p.0) != Some(*fid), egui::Button::new(name)).clicked() { + let _ = app.run("album.move", json!({"id": a.id.0, "parent": fid})); + } + } + }); if ui.button("Rename…").clicked() { app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() }); } @@ -167,6 +206,23 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { }); } +/// Whether `id` is `ancestor` or lies inside it. +fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lightcraft_catalog::AlbumId) -> bool { + let mut cur = Some(id); + let mut guard = 0; + while let Some(c) = cur { + if c == ancestor { + return true; + } + cur = app.session.catalog.album(c).and_then(|x| x.parent); + guard += 1; + if guard > 64 { + break; + } + } + false +} + /// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click /// filters the grid by the keyword (children included), the triangle opens a level, and the /// context menu renames, merges or deletes the keyword across the library. diff --git a/docs/parity.md b/docs/parity.md index be22b1ef5..10ef6b939 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -45,10 +45,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | V. Preferences (PREF) | 4 | 1 | 3 | 3 | 1/1 (100%) | 3/4 (75%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 11 | 2 | 4 | 1 | 8/8 (100%) | 3/5 (60%) | -| Y. Menus | 57 | 15 | 13 | 8 | 42/47 (89%) | 13/23 (57%) | +| Y. Menus | 59 | 13 | 13 | 8 | 44/47 (94%) | 13/23 (57%) | | Z. Keyboard shortcuts (desktop) | 56 | 13 | 11 | 1 | 47/52 (90%) | 9/23 (39%) | | Lightroom Classic extras | 4 | 20 | 55 | 9 | — | 3/21 (14%) | -| **Total** | 267 | 77 | 156 | 37 | 185/198 (93%) | 72/144 (50%) | +| **Total** | 269 | 75 | 156 | 37 | 187/198 (94%) | 72/144 (50%) | ## Top gaps @@ -579,9 +579,9 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | | | MENU-HELP-FEEDBACK | Send feedback | P2 | ⬜ | | | | MENU-HELP-SYSINFO | System info | P2 | ⬜ | `cmd:library.info` | library info only | -| MENU-CTX-GRID | Photo context menu | P0 | 🟡 | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, delete; no set-as-album-cover | +| MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete | | MENU-CTX-DETAIL | Loupe context menu | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | same as grid; no zoom submenu | -| MENU-CTX-ALBUM | Album / folder row menu | P0 | 🟡 | `crates/ui-egui/src/panels/left.rs` | add selected, rename, delete; no move-to, export album | +| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), move to a folder or the top level, rename, delete; smart albums: update rules | | MENU-CTX-MASK | Mask / component menu | P0 | 🟡 | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`) | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; components: add/subtract only (no per-component menu) | | MENU-CTX-PRESET | Preset menu | P1 | 🟡 | `crates/ui-egui/src/panels/presets.rs` | favourite, delete, export group; no rename/update/move | | MENU-CTX-PROFILE | Profile favourites | P2 | ⬜ | | |