This commit is contained in:
bflatastic committed 2026-10-13 11:01:01 +07:00
commit 1543fef701
5 files changed
+130 -6

No files matched your search

+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),
}
}
+32
View File
@@ -1531,6 +1531,38 @@ 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| {
let mut entry = |ui: &mut egui::Ui, label: String, widget: String, command: &str, params: serde_json::Value| {
let r = ui.button(label);
crate::widgets::register(ui.ctx(), widget, r.rect);
if r.clicked() {
if let Err(e) = app.run(command, params) {
app.toast(ui.ctx(), e);
}
ui.close();
}
};
for (label, command, widget) in [
("Import Photos…", "file.addPhotos", "button:albumImportPhotos"),
("Import from Folder…", "file.addFolder", "button:albumImportFolder"),
] {
entry(ui, crate::i18n::tr(label).into(), widget.into(), command, json!({"album": a.id.0}));
}
// like File ▸ Import from Device: one entry per camera or card found
let device = ui.menu_button(crate::i18n::tr("Import from Device"), |ui| {
let devices = lightcraft_engine::devices::devices();
if devices.is_empty() {
ui.add_enabled(false, egui::Button::new(crate::i18n::tr("No Camera or Card Found")));
}
for (i, d) in devices.into_iter().enumerate() {
let params = json!({"path": d.path, "album": a.id.0});
entry(ui, format!("{}…", d.name), format!("button:albumImportDevice:{i}"), "file.addFromDevice", params);
}
});
crate::widgets::register(ui.ctx(), "button:albumImportDevice", device.response.rect);
});
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 ▸ (one entry per camera or card, as in File ▸ 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 |