commit
261994acbc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::<f32>(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<R>(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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue