diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index ab82f29c..a60051bb 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1421,6 +1421,55 @@ mod tests { } let _ = std::fs::remove_dir_all(&dir); } + /// The import review opens bigger than the old fixed 6 × 2.5 grid and can be resized by its + /// corner: the photo grid takes the new height, and the window then keeps its size (issue #337). + #[test] + fn import_review_dialog_resizes() { + let mut h = demo([1400.0, 1000.0]); + let t = Duration::from_secs(10); + let candidates = (0..60) + .map(|i| lightcraft_engine::import::ImportCandidate { + path: format!("/lc-test/img{i}.png"), + name: format!("img{i}.png"), + error: Some("not read in this test".into()), + ..Default::default() + }) + .collect(); + h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: crate::import::ImportDialog::new(candidates) }); + let rect = |h: &mut Headless| { + let r = h.request("ui.widgets", json!({}), t); + let w = r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == "dialog:window")).expect("dialog on screen"); + [0usize, 1, 2, 3].map(|i| w["rect"][i].as_f64().unwrap()) + }; + for _ in 0..10 { + h.step(); + } + let r0 = rect(&mut h); + assert!(r0[3] > 600.0, "{r0:?}"); + // the Copy options add rows below the grid: the grid makes room, the window doesn't grow + for copy in [true, false] { + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + opts.copy = copy; + } + for _ in 0..10 { + h.step(); + } + let r = rect(&mut h); + assert!((r[3] - r0[3]).abs() < 0.5, "copy {copy}: {r0:?} → {r:?}"); + } + let (x, y) = (r0[0] + r0[2] - 3.0, r0[1] + r0[3] - 3.0); + h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 160.0, "toY": y + 120.0, "steps": 12}), t); + for _ in 0..10 { + h.step(); + } + let r1 = rect(&mut h); + assert!(r1[2] > r0[2] + 100.0 && r1[3] > r0[3] + 80.0, "dragging the corner resized it: {r0:?} → {r1:?}"); + for _ in 0..60 { + h.step(); + } + let r2 = rect(&mut h); + assert!((r2[2] - r1[2]).abs() < 0.5 && (r2[3] - r1[3]).abs() < 0.5, "the dialog kept its size: {r1:?} → {r2:?}"); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })), "still open"); } /// Adding a file again that is in Recently Deleted shows it there (side panel opened, photo diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 922d08fe..993c67d2 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -21,6 +21,31 @@ use crate::widgets::register; /// Files per batch (each batch joins the catalog as it is ready, so the progress window updates). pub(crate) const BATCH: usize = 8; +/// The review dialog's first size (points). It can be resized; the photo grid takes the height. +pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0]; +/// Memory keys: where the photo grid ended and where the dialog's content (its buttons) ended, last +/// frame. Their difference is the room the grid leaves below it. +const GRID_BOTTOM: &str = "import-grid-bottom"; +const CONTENT_BOTTOM: &str = "import-content-bottom"; +/// Room below the grid before it has been measured (more than the options and buttons take). +const DEFAULT_BELOW_GRID: f32 = 360.0; + +/// Height of the photo grid: what is `available` once `below` (options and buttons) is left free, +/// in whole points so the window never grows by a fraction each frame, and at least one `row`. +pub(crate) fn grid_height(available: f32, below: f32, row: f32) -> f32 { + let h = (available - below.max(0.0)).floor(); + if h.is_finite() { h.max(row) } else { (DIALOG_SIZE[1] - DEFAULT_BELOW_GRID).max(row) } +} + +/// Remember where the review dialog's content ends this frame (after its buttons), for +/// [`grid_height`] on the next. +pub(crate) fn note_dialog_bottom(ui: &egui::Ui) { + if !ui.is_sizing_pass() { + let y = ui.min_rect().bottom(); + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(CONTENT_BOTTOM), y)); + } +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct ImportDialog { @@ -778,7 +803,14 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { let avail = ui.available_width(); let cols = ((avail + 6.0) / (cell + 6.0)).floor().max(1.0) as usize; let rows = n.div_ceil(cols); - egui::ScrollArea::vertical().id_salt("import-grid").max_height(330.0).auto_shrink([false, true]).show_viewport(ui, |ui, viewport| { + // as tall as the window leaves after the options and buttons below it (measured last frame), + // so resizing the dialog resizes the grid + let below = ui.ctx().data(|m| { + let y = |key: &str| m.get_temp::(egui::Id::new(key)); + y(CONTENT_BOTTOM).zip(y(GRID_BOTTOM)).map(|(content, grid)| content - grid) + }); + let grid_h = grid_height(ui.available_height(), below.unwrap_or(DEFAULT_BELOW_GRID), cell + 26.0); + let grid = egui::ScrollArea::vertical().id_salt("import-grid").max_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { let (area, _) = ui.allocate_exact_size(vec2(avail, rows as f32 * (cell + 26.0)), Sense::hover()); for i in 0..n { let (c, r) = (i % cols, i / cols); @@ -790,6 +822,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { candidate_cell(app, ui, d, i, rect); } }); + if !ui.is_sizing_pass() { + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(GRID_BOTTOM), grid.inner_rect.bottom())); + } ui.add_space(4.0); // options if !d.sources.is_empty() { @@ -1253,3 +1288,21 @@ fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R }) .inner } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grid_height_follows_the_window() { + // the grid takes what the options and buttons leave, in whole points + assert_eq!(grid_height(700.0, 300.0, 142.0), 400.0); + assert_eq!(grid_height(1000.5, 300.0, 142.0), 700.0); + // never less than one row of thumbnails + assert_eq!(grid_height(200.0, 300.0, 142.0), 142.0); + // a bad measurement doesn't take the grid away or make it endless + assert_eq!(grid_height(700.0, -50.0, 142.0), 700.0); + assert_eq!(grid_height(700.0, f32::NAN, 142.0), 700.0); + assert_eq!(grid_height(f32::INFINITY, 300.0, 142.0), DIALOG_SIZE[1] - DEFAULT_BELOW_GRID); + } +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 231ab0c1..0c53929b 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -105,13 +105,20 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } .to_string(); let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12)); - let shown = egui::Window::new(crate::i18n::tr(&title)).id(egui::Id::new("lightcraft-dialog")) + // The import review can be resized (its photo grid takes the room); it keeps a window id of its + // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls + // rather than growing the window when the options below the grid get taller (e.g. Copy). + let import = matches!(dlg, Dialog::Import { .. }); + let window_id = egui::Id::new(if import { "lightcraft-import-dialog" } else { "lightcraft-dialog" }); + let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id) .collapsible(false) - .resizable(false) + .resizable(import) + .vscroll(import) + .resize(|r| if import { r.default_height(crate::import::DIALOG_SIZE[1]) } else { r }) .frame(frame) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .default_width(match dlg { - Dialog::Import { .. } => 760.0, + Dialog::Import { .. } => crate::import::DIALOG_SIZE[0], Dialog::SmartRules { .. } => 680.0, Dialog::AllMetadata { .. } => 620.0, _ => 380.0, @@ -867,6 +874,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } }); + if import { + crate::import::note_dialog_bottom(ui); + } }); if let Some(w) = shown { ctx.move_to_top(w.response.layer_id); diff --git a/docs/parity.md b/docs/parity.md index 88cb11ae..2580ec92 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -123,7 +123,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, with a progress window and Cancel; one undo step. Drag-and-drop goes through the same background import | +| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, with a progress window and Cancel; one undo step. Drag-and-drop goes through the same background import | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | | LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk) |