MENU-CTX-ALBUM: Import ▸ submenu on an album opens the review with that album chosen (#1267)

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 <noreply@anthropic.com>
This commit is contained in:
Markus Friedel
2026-10-12 16:45:14 +02:00
co-authored by Claude Opus 5.5
parent 25ef65c81e
commit edb3cbfd8a
5 changed files with 116 additions and 6 deletions
+6 -2
View File
@@ -410,6 +410,8 @@ pub struct ScanTask {
rx: std::sync::mpsc::Receiver<ScanOutput>,
/// 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<u64>,
/// 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<String>) -> Result<Value, String
#[cfg(target_arch = "wasm32")]
job();
let guard = scan_guard(app, &progress, false);
app.scan = Some(ScanTask { progress, rx, copy: false, browse: false, sources, guard });
app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: false, sources, guard });
Ok(json!({"scanning": true}))
}
@@ -510,7 +512,7 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) ->
#[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) });
}
+19 -3
View File
@@ -265,6 +265,18 @@ fn parent_param(p: &Value) -> Option<u64> {
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<Value, String>) -> Result<Value, String> {
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<Re
return Some(Ok(Value::Null));
}
// review first: the import dialog lists what was found
crate::import::open(app, paths)
let r = crate::import::open(app, paths);
import_into_album(app, p, r)
}
"file.importLightroom" => {
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<Re
{
t.copy = true;
}
r
import_into_album(app, p, r)
}
"file.addFolder" => {
// 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<Re
},
};
match path {
Some(path) => 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),
}
}
+18
View File
@@ -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);
+72
View File
@@ -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);
}
+1 -1
View File
@@ -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 |