From edb3cbfd8a7907044b63504e1ad692c5010535e6 Mon Sep 17 00:00:00 2001 From: Markus Friedel <20045158+MarkusFriedel@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:45:14 +0200 Subject: [PATCH] =?UTF-8?q?MENU-CTX-ALBUM:=20Import=20=E2=96=B8=20submenu?= =?UTF-8?q?=20on=20an=20album=20opens=20the=20review=20with=20that=20album?= =?UTF-8?q?=20chosen=20(#1267)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An album's context menu gets Import ▸ with File ▸ Import's ways in (Import Photos…, Import from Folder…, Import from Device). file.addPhotos, file.addFolder and file.addFromDevice take {album}; the scan task carries it and the review chooses it when the scan is done, if it still offers that album (not deleted, not a folder or smart album). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/import.rs | 8 +++- crates/ui-egui/src/menus.rs | 22 +++++++-- crates/ui-egui/src/panels/left.rs | 18 ++++++++ crates/ui-egui/src/tests_panels.rs | 72 ++++++++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- 5 files changed, 116 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a18357eca..6fa311e52 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -410,6 +410,8 @@ pub struct ScanTask { rx: std::sync::mpsc::Receiver, /// Open the review with "copy into the library" checked (a camera / card). pub copy: bool, + /// Open the review with this album chosen (an album's context menu ▸ Import…). + pub album: Option, /// Browsing a folder (Local): the photos are read in place instead of opening the review. browse: bool, /// What is being scanned (the review's source). @@ -448,7 +450,7 @@ pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result) -> #[cfg(target_arch = "wasm32")] job(); let guard = scan_guard(app, &progress, true); - app.scan = Some(ScanTask { progress, rx, copy: false, browse: true, sources: Vec::new(), guard }); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: true, sources: Vec::new(), guard }); app.renderer.forget_imports(); Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) } @@ -572,6 +574,8 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { .collect(); d.set_trashed(trashed); d.copy = task.copy; + // only an album the review offers (it may have been deleted while the scan ran) + d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart())); d.sources = task.sources; app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) }); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..07545fe02 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -265,6 +265,18 @@ fn parent_param(p: &Value) -> Option { p.get("parent").and_then(Value::as_u64) } +/// `file.addPhotos` / `file.addFolder` / `file.addFromDevice` `{album?}`: the review of the scan +/// just started opens with that album chosen (an album's context menu ▸ Import ▸ …). An error +/// means another scan is running. +fn import_into_album(app: &mut LightcraftApp, p: &Value, r: Result) -> Result { + if r.is_ok() + && let Some(t) = &mut app.scan + { + t.album = p.get("album").and_then(Value::as_u64); + } + r +} + /// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { if let Some(s) = v.as_str() { @@ -1307,7 +1319,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { let path = match p.get("path").and_then(Value::as_str).map(str::to_string) { @@ -1476,7 +1489,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { // a folder (searched recursively) into the import review @@ -1490,7 +1503,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::import::open(app, vec![path]), + Some(path) => { + let r = crate::import::open(app, vec![path]); + import_into_album(app, p, r) + } None => Ok(Value::Null), } } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a14332c82..144fcc1cd 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1527,6 +1527,24 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { Err(e) => app.toast(ui.ctx(), e), } } + // File ▸ Import's ways in; the import review opens with this album chosen + let menu = ui.menu_button(crate::i18n::tr("Import"), |ui| { + for (label, command, widget) in [ + ("Import Photos…", "file.addPhotos", "button:albumImportPhotos"), + ("Import from Folder…", "file.addFolder", "button:albumImportFolder"), + ("Import from Device", "file.addFromDevice", "button:albumImportDevice"), + ] { + let r = ui.button(crate::i18n::tr(label)); + crate::widgets::register(ui.ctx(), widget, r.rect); + if r.clicked() { + if let Err(e) = app.run(command, json!({"album": a.id.0})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + } + }); + crate::widgets::register(ui.ctx(), "button:albumImport", menu.response.rect); } if !a.folder && !a.is_smart() { let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 25edf3bfa..2fa9c10c4 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2141,3 +2141,75 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() { assert!(trip.photos.contains(&photo), "the right-clicked photo is in it: {:?}", trip.photos); assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); } + +/// An album's context menu ▸ Import ▸ Import from Folder… (and Import Photos…, Import from Device) +/// opens the import review with that album chosen; a folder row doesn't offer it, and an `album` +/// the review can't offer (a folder, or an album deleted while the scan ran) leaves its album empty. +#[test] +fn an_album_menu_imports_into_that_album() { + let dir = std::env::temp_dir().join(format!("lc-ui-album-import-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..2u8 { + let (w, h) = (40usize, 30usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|k| [(k % w * 6) as u8, i * 90, (k / w * 8) as u8, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(format!("card{i}.png")), png).unwrap(); + } + let picked = dir.to_string_lossy().to_string(); + let services = Services { png: None, pick_folder: Some(Box::new(move || Some(picked.clone()))), ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + // the demo's top-level album and album folder, both shown in the sidebar + let id_of = |name: &str| h.app.session.catalog.albums().find(|a| a.name == name).map(|a| a.id.0).expect(name); + let (album, folder) = (id_of("Garden"), id_of("Travel 2026")); + let right_click = |h: &mut Headless, id: &str| { + let row = widget(h, id); + let r = h.request("ui.click", json!({"x": row.center().x, "y": row.center().y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + }; + let review_album = |h: &mut Headless| { + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review: {:?}", h.app.ui.dialog) }; + let chosen = opts.album; + h.app.ui.dialog = None; + chosen + }; + + // a folder row has no Import + right_click(&mut h, &format!("source:folder:{folder}")); + assert!(!has(&h, "button:albumImport"), "folders hold no photos"); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + + // an album's menu: Import ▸ Import from Folder… → the (stubbed) folder dialog → the review, album chosen + right_click(&mut h, &format!("source:album:{album}")); + let r = h.request("ui.hoverWidget", json!({"id": "button:albumImport"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + for way in ["button:albumImportPhotos", "button:albumImportFolder", "button:albumImportDevice"] { + assert!(has(&h, way), "{way} is offered"); + } + let r = h.request("ui.clickWidget", json!({"id": "button:albumImportFolder"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(review_album(&mut h), Some(album)); + + // Import Photos… passes the album the same way + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()], "album": album}}), T); + assert_eq!(r["result"]["scanning"], true, "{r}"); + assert_eq!(review_album(&mut h), Some(album)); + + // an album the review can't offer (here a folder) is not chosen + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()], "album": folder}}), T); + assert_eq!(r["result"]["scanning"], true, "{r}"); + assert_eq!(review_album(&mut h), None); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..838431bf4 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -577,7 +577,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; one Close button, like About (#782); JSON for agents (`open: false`) | | 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 (restore / delete permanently in Recently Deleted) | | MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu | -| 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), New ▸ Create Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules | +| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export`, `cmd:file.addPhotos`, `cmd:file.addFolder`, `cmd:file.addFromDevice` | add selected, Import ▸ Import Photos… / Import from Folder… / Import from Device (the import review opens with that album chosen: `{album}` on those commands; `button:albumImport`), export album (dialog / preset), New ▸ Create Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), 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`, `component_row_menu`), `cmd:mask.component` | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; component rows (right-click or "…"): invert, duplicate, mode (add / subtract / intersect), intersect with / subtract a new component, rename (also double-click), delete (the last one deletes the mask) | | MENU-CTX-PRESET | Preset menu | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs` | favourite, update with current settings, rename, move to group, delete; group: export | | MENU-CTX-PROFILE | Profile favourites | P2 | ✅ | `cmd:profile.favorite`, `crates/ui-egui/src/panels/profiles.rs` | star in the profile browser; Favorites group first |