mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 03:57:57 -06:00
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:
co-authored by
Claude Opus 5.5
parent
25ef65c81e
commit
edb3cbfd8a
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user