Files
lightcraft/crates/ui-egui/src/import.rs
T
Brandon Thomas ab96593df5 Merge PR #1298
# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/src/panels/dialogs.rs
#	crates/ui-egui/src/panels/faces.rs
#	crates/ui-egui/src/shortcuts.rs
2026-10-15 00:29:27 -04:00

1719 lines
80 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The import review dialog (File → Import Photos… / Import from Folder… / Import from Device):
//! the source it scanned (a scanned folder is *not* added to Local), the files found under it as
//! a grid of thumbnails with checkboxes (duplicates marked and unchecked; Shift-click sets a
//! range), the destination (add in place / copy or move into the library's `Originals/` or a
//! chosen folder, filed by day, by month, into one folder or by a custom folder template,
//! optionally renamed), an album (existing or new), a preset and keywords to apply. Importing
//! runs on a worker thread (files are probed, copied or moved there) and its batches join the
//! catalog between frames, with a row and ✕ in the activity stack (issue #345); the whole import is
//! one undo step.
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Vec2, pos2, vec2};
use lightcraft_engine::import::{
ImportCandidate, ImportUprightMode, ImportWarning, ImportWarningCode, ScanInput, ScanOutput, ScanProgress, scan_with,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::atomic::Ordering;
use crate::LightcraftApp;
use crate::render::Slot;
use crate::theme::Tokens;
use crate::widgets::register;
/// Files per batch in the browser build (each batch joins the catalog as it is ready, so the
/// progress window updates); the desktop's worker uses [`lightcraft_engine::import::batch_size`].
#[cfg(target_arch = "wasm32")]
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 {
/// The files / folders that were scanned (shown as the source).
pub sources: Vec<String>,
pub candidates: Vec<ImportCandidate>,
pub checked: Vec<bool>,
/// Copy into the library (else add in place).
pub copy: bool,
/// With `copy`: move instead — the originals are removed from the source once each copy is
/// verified and catalogued (`library.import` mode `move`).
pub move_files: bool,
/// Existing album to add to.
pub album: Option<u64>,
/// …or a new album with this name.
pub new_album: String,
/// Preset id ("" = none).
pub preset: String,
/// Comma-separated keywords.
pub keywords: String,
/// Copy: destination folder ("" = the library's Originals/).
pub destination: String,
/// Copy: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or `custom` ([`ImportDialog::folder_template`]).
pub organize: String,
/// Copy, `custom`: the folder template, e.g. `{date:%Y}/{date:%Y%m%d}`.
pub folder_template: String,
/// Copy: file-name template for the copies ("" = keep the names).
pub rename: String,
/// Metadata preset name ("" = none).
pub metadata_preset: String,
/// Copy: raws are copied as DNG.
pub dng: bool,
/// Per candidate: the library already has it in Recently Deleted ([`ImportDialog::set_trashed`]).
pub trashed: Vec<bool>,
/// What to do with those: `restore` (the default: the photo comes back with its edits), `fresh`
/// (imported as a new photo, the old record deleted) or "" (leave them) — the `onDeleted` param.
pub on_deleted: String,
/// The candidate clicked last: where a Shift-click range starts ([`ImportDialog::click`]).
#[serde(skip)]
pub last_clicked: Option<usize>,
}
/// A candidate's file type, as the review groups them: its format, or its extension when the
/// probe named none (`JPEG` and `JPG` are one type).
fn file_type(c: &ImportCandidate) -> String {
let f = if c.format.trim().is_empty() {
std::path::Path::new(&c.path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default()
} else {
c.format.clone()
};
match f.trim().to_uppercase().as_str() {
"JPEG" => "JPG".into(),
"TIFF" => "TIF".into(),
"" => "?".into(),
other => other.to_string(),
}
}
impl ImportDialog {
pub fn new(candidates: Vec<ImportCandidate>) -> Self {
let checked = candidates.iter().map(|c| c.duplicate.is_none() && c.error.is_none()).collect();
ImportDialog { candidates, checked, on_deleted: "restore".into(), ..Default::default() }
}
/// Mark the candidates whose photo is in Recently Deleted. They are checked when the choice
/// for them (`on_deleted`) imports them — by default it restores them, so a photo deleted by
/// mistake comes back by importing its file again (issue #706).
pub fn set_trashed(&mut self, trashed: Vec<bool>) {
self.trashed = trashed;
for i in 0..self.candidates.len() {
if !self.is_trashed(i) {
continue;
}
let on = self.checked_by_default(i);
if let Some(c) = self.checked.get_mut(i) {
*c = on;
}
}
}
/// Candidate `i` can be checked: a readable file the library doesn't have, or one whose photo
/// is in Recently Deleted — even with "Leave them", a click picks it (issue #1285).
pub fn importable(&self, i: usize) -> bool {
self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i)))
}
/// Candidate `i` is checked by Check All and the file type toggles: with "Leave them", a file in
/// Recently Deleted is only checked by hand.
pub fn checked_by_default(&self, i: usize) -> bool {
self.importable(i) && !(self.is_trashed(i) && self.on_deleted.is_empty())
}
/// The file types among the candidates (`ARW`, `JPG`…, upper case, sorted) and how many of each.
pub fn file_types(&self) -> Vec<(String, usize)> {
let mut types: std::collections::BTreeMap<String, usize> = Default::default();
for c in &self.candidates {
*types.entry(file_type(c)).or_default() += 1;
}
types.into_iter().collect()
}
/// Any file of type `ty` is checked.
pub fn type_checked(&self, ty: &str) -> bool {
self.candidates.iter().zip(&self.checked).any(|(c, on)| *on && file_type(c) == ty)
}
/// Check (the importable ones) or uncheck every file of type `ty`: the raws only, say, and not
/// the JPEGs kept beside them (issue #344).
pub fn check_type(&mut self, ty: &str, on: bool) {
for i in 0..self.candidates.len() {
let same = self.candidates.get(i).is_some_and(|c| file_type(c) == ty);
let importable = self.checked_by_default(i);
if same && let Some(c) = self.checked.get_mut(i) {
*c = on && importable;
}
}
}
/// Candidate `i` is a file whose photo is in Recently Deleted.
pub fn is_trashed(&self, i: usize) -> bool {
self.trashed.get(i).copied().unwrap_or(false)
}
/// Choose what happens to the files in Recently Deleted (`""`, `restore` or `fresh`); they are
/// checked once there is a choice and unchecked again without one.
pub fn set_on_deleted(&mut self, choice: &str) {
// switching between restore and fresh keeps the user's per-cell choices
let changed = self.on_deleted.is_empty() != choice.is_empty();
self.on_deleted = choice.to_string();
for i in (0..self.candidates.len()).filter(|_| changed) {
if self.is_trashed(i)
&& let Some(c) = self.checked.get_mut(i)
{
*c = !choice.is_empty();
}
}
}
/// A click on candidate `i` toggles its checkbox. With `shift`, every importable candidate
/// from the last clicked one to `i` takes `i`'s new state, as in a file manager: click the
/// first, Shift-click the last, and the whole range is checked (or unchecked).
pub fn click(&mut self, i: usize, shift: bool) {
if !self.importable(i) {
return;
}
let Some(on) = self.checked.get(i).map(|c| !c) else { return };
let from = if shift { self.last_clicked.filter(|a| *a < self.checked.len()).unwrap_or(i) } else { i };
for k in from.min(i)..=from.max(i) {
if self.importable(k)
&& let Some(c) = self.checked.get_mut(k)
{
*c = on;
}
}
self.last_clicked = Some(i);
}
pub fn selected_paths(&self) -> Vec<String> {
self.candidates
.iter()
.zip(&self.checked)
.enumerate()
.filter(|(i, (_, on))| **on && self.importable(*i))
.map(|(_, (c, _))| c.path.clone())
.collect()
}
/// The `organize` param of `library.import` (`None` = the default, by day); an unusable
/// custom folder template is an error.
pub fn organize_param(&self) -> Result<Option<String>, String> {
match self.organize.as_str() {
"" => Ok(None),
"custom" => {
let t = self.folder_template.trim();
if let Some(e) = lightcraft_engine::rename::folder_template_error(t) {
return Err(e);
}
// a plain folder name ("Imports") is a one-level template too
Ok(Some(if t.contains(['{', '/', '\\']) { t.to_string() } else { format!("{t}/") }))
}
o => Ok(Some(o.to_string())),
}
}
fn keywords(&self) -> Vec<String> {
self.keywords.split(',').map(str::trim).filter(|k| !k.is_empty()).map(str::to_string).collect()
}
}
/// A running import (see the module docs). The file-system work — expanding folders, probing,
/// reading sidecars, copying or placing files — runs on a worker thread
/// ([`lightcraft_engine::import::ImportJob`]); each batch it readies is added to the catalog on the
/// UI thread between frames, so a slow drive never stalls the window.
#[derive(Debug, Default)]
pub struct ImportTask {
/// Files and folders to import (handed to the worker when the task starts).
queue: Vec<String>,
pub total: usize,
pub done: usize,
params: Value,
pub imported: usize,
/// Brought back from Recently Deleted (`onDeleted: restore`).
pub restored: usize,
pub duplicates: usize,
/// The library photos the skipped duplicates match (shown when nothing new was added).
existing: Vec<u64>,
pub failed: usize,
/// Move: originals moved, and sources left in place (reported by the engine with a reason).
pub moved: usize,
pub kept: usize,
/// Structured diagnostics from foreign XMP develop settings.
pub warnings: Vec<ImportWarning>,
undo0: usize,
first: Option<u64>,
/// Reading a folder for the Local view: the photos stay out of the library, and nothing is
/// selected or announced as added.
browse: bool,
/// Cancel was pressed: no further files are started; batches already readied are added.
pub cancelled: bool,
/// Auto Import (the watched folder): the selection stays as it is, a short toast when done.
auto: bool,
/// Auto Import: the selection to keep.
keep_selection: Option<lightcraft_engine::Selection>,
/// The selection and source as the import last left them: a change between frames is the
/// user choosing another photo or view, which the end of the import doesn't take away.
left: Option<(lightcraft_engine::Selection, lightcraft_engine::LibrarySource)>,
/// The user chose another photo or view while the import ran.
moved_on: bool,
run: Option<ImportRun>,
/// The import's row in the activity stack (its ✕ and `activity.cancel` set the run's cancel flag).
guard: Option<lightcraft_engine::activity::TaskGuard>,
}
impl ImportTask {
/// An import of `paths` (files or folders) with `library.import` params (without `paths`).
pub fn new(paths: Vec<String>, params: Value, undo0: usize, browse: bool) -> Self {
ImportTask { total: paths.len(), queue: paths, params, undo0, browse, ..Default::default() }
}
/// An Auto Import (see [`ImportTask::auto`]).
pub fn auto(mut self) -> Self {
self.auto = true;
self
}
/// `{done, total, imported, cancelled}` for `ui.inspect`.
pub fn status(&self) -> Value {
json!({"done": self.done, "total": self.total, "imported": self.imported, "cancelled": self.cancelled})
}
}
/// Where an import's batches are readied: a worker thread, or (browser build: no threads, and no
/// slow drives) inline, one batch per frame.
enum Runner {
#[cfg(not(target_arch = "wasm32"))]
Thread(std::sync::mpsc::Receiver<lightcraft_engine::import::Prepared>),
#[cfg(target_arch = "wasm32")]
Inline { job: Box<lightcraft_engine::import::ImportJob>, queue: Vec<String>, files: Option<std::collections::VecDeque<String>> },
}
struct ImportRun {
runner: Runner,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
opts: lightcraft_engine::import::ImportOptions,
now: String,
album: Option<u64>,
album_name: Option<String>,
}
impl std::fmt::Debug for ImportRun {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ImportRun").field("cancel", &self.cancel.load(Ordering::Relaxed)).finish()
}
}
impl Drop for ImportRun {
/// A dropped import (replaced, or the app closing) stops its worker at the next file.
fn drop(&mut self) {
self.cancel.store(true, Ordering::Relaxed);
}
}
impl ImportRun {
fn start(
job: lightcraft_engine::import::ImportJob,
queue: Vec<String>,
album: Option<u64>,
album_name: Option<String>,
ctx: &egui::Context,
) -> Result<Self, String> {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let (total, done) = (job.total.clone(), job.done.clone());
let (opts, now) = (job.opts.clone(), job.now().to_string());
let runner = Self::spawn(job, queue, cancel.clone(), ctx)?;
Ok(ImportRun { runner, cancel, total, done, opts, now, album, album_name })
}
#[cfg(not(target_arch = "wasm32"))]
fn spawn(
mut job: lightcraft_engine::import::ImportJob,
queue: Vec<String>,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
ctx: &egui::Context,
) -> Result<Runner, String> {
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ctx.clone();
let work = move || {
let files = job.expand(&queue);
for chunk in files.chunks(lightcraft_engine::import::batch_size()) {
if cancel.load(Ordering::Relaxed) {
break;
}
let prepared = match lightcraft_engine::guard::catch("import", || job.prepare_files(chunk.to_vec(), &cancel)) {
Ok(p) => p,
Err(_) => break, // logged; the files readied so far are added
};
if let Err(unsent) = tx.send(prepared) {
// the import was dropped: nothing will add these, so take back what a Move placed
unsent.0.rollback();
break;
}
ctx.request_repaint();
}
ctx.request_repaint();
};
std::thread::Builder::new().name("lc-import".into()).spawn(work).map(|_| Runner::Thread(rx)).map_err(|e| format!("could not start: {e}"))
}
#[cfg(target_arch = "wasm32")]
fn spawn(
job: lightcraft_engine::import::ImportJob,
queue: Vec<String>,
_cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
_ctx: &egui::Context,
) -> Result<Runner, String> {
Ok(Runner::Inline { job: Box::new(job), queue, files: None })
}
/// Readied batches (at most a few per frame). `None` once the worker is done.
fn next_batches(&mut self) -> Option<Vec<lightcraft_engine::import::Prepared>> {
match &mut self.runner {
#[cfg(not(target_arch = "wasm32"))]
Runner::Thread(rx) => {
let mut out = Vec::new();
while out.len() < 4 {
match rx.try_recv() {
Ok(p) => out.push(p),
Err(std::sync::mpsc::TryRecvError::Empty) => return Some(out),
Err(std::sync::mpsc::TryRecvError::Disconnected) => return if out.is_empty() { None } else { Some(out) },
}
}
Some(out)
}
#[cfg(target_arch = "wasm32")]
Runner::Inline { job, queue, files } => {
if self.cancel.load(Ordering::Relaxed) {
return None;
}
if files.is_none() {
*files = Some(job.expand(queue).into());
return Some(Vec::new());
}
let Some(files) = files.as_mut() else { return None };
if files.is_empty() {
return None;
}
let n = files.len().min(BATCH);
let batch: Vec<String> = files.drain(..n).collect();
Some(vec![job.prepare_files(batch, &self.cancel)])
}
}
}
}
/// A folder scan running on a worker thread (a network share can take minutes to read; the
/// window must keep answering meanwhile).
pub struct ScanTask {
progress: std::sync::Arc<ScanProgress>,
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).
sources: Vec<String>,
/// The scan's row in the activity stack (its ✕ and `activity.cancel` set `progress.cancel`).
guard: lightcraft_engine::activity::TaskGuard,
}
/// The activity row of a scan: the review's ("Scanning folder") or the Local view's ("Reading folder").
fn scan_guard(app: &LightcraftApp, progress: &ScanProgress, browse: bool) -> lightcraft_engine::activity::TaskGuard {
let label = if browse { "Reading folder" } else { "Scanning folder" };
app.session.activity.start("scan", label, lightcraft_engine::activity::Cancel::Flag(progress.cancel.clone()))
}
/// An import and a Synchronize Folder never run at once: each readies its files against the
/// library as it was when it started, so both could add the same file.
pub(crate) fn busy_synchronizing(app: &LightcraftApp) -> Result<(), String> {
if app.sync_run.is_some() { Err(crate::i18n::tr("A folder is being synchronized").to_string()) } else { Ok(()) }
}
/// Settings → Import → "Show the import review when a camera or card is connected", called with
/// each device scan while it's on. The first look only notes the cards already in. A card that
/// shows up after that opens Import from Device once nothing else is open, and a card that is
/// taken out and put back in asks again.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn cards_seen(app: &mut LightcraftApp, now: Vec<lightcraft_engine::devices::Device>) {
let Some(known) = app.ui.known_cards.as_mut() else {
app.ui.known_cards = Some(now.into_iter().map(|d| d.root).collect());
return;
};
known.retain(|root| now.iter().any(|d| d.root == *root));
let Some(card) = now.into_iter().find(|d| !known.contains(&d.root)) else { return };
// busy: ask when the window is free (the card isn't noted until then)
if app.import.is_some() || app.scan.is_some() || app.sync_run.is_some() || app.lightroom.is_some() || app.ui.dialog.is_some() {
return;
}
known.push(card.root);
if let Err(e) = app.run("file.addFromDevice", json!({"path": card.path})) {
log::warn!("import from {}: {e}", card.name);
}
}
/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]).
pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String> {
if app.scan.is_some() {
return Err("a scan is already running".into());
}
let sources = paths.clone();
let (input, paths) = ScanInput::new(&mut app.session, &paths);
let progress = std::sync::Arc::new(ScanProgress::default());
let (tx, rx) = std::sync::mpsc::channel();
let p = progress.clone();
let job = move || {
let _ = tx.send(scan_with(input, &paths, &p));
};
#[cfg(not(target_arch = "wasm32"))]
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
job();
let guard = scan_guard(app, &progress, false);
app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: false, sources, guard });
Ok(json!({"scanning": true}))
}
/// Show a folder's photos in the Local view (`library.browse` from the UI): the view switches at
/// once, the folder is listed and read on a worker thread (a network share can take minutes),
/// and the photos then join the view in small batches under a progress window. A browse already
/// running is replaced; the import review's scan is not.
pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) -> Result<Value, String> {
let dir = std::path::absolute(std::path::Path::new(path)).map_err(|e| e.to_string())?;
if !dir.is_dir() {
return Err(format!("{path}: not a folder"));
}
if app.scan.as_ref().is_some_and(|t| !t.browse) || app.import.as_ref().is_some_and(|t| !t.browse) {
return Err("an import is running".into());
}
let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string();
let subfolders = subfolders.unwrap_or_else(|| app.session.browse.as_ref().is_some_and(|b| b.subfolders));
let running = app.scan.as_ref().is_some_and(|t| t.browse) || app.import.as_ref().is_some_and(|t| t.browse);
if running && app.session.browse.as_ref().is_some_and(|b| b.path == dir_s && b.subfolders == subfolders) {
// already reading this folder: clicking it again must not restart the progress
app.session.source = lightcraft_engine::LibrarySource::Folder;
app.session.keep_active_in_view();
return Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true}));
}
if let Some(t) = app.scan.take() {
t.progress.cancel.store(true, Ordering::Relaxed);
}
app.import = None;
app.session.browse = Some(lightcraft_engine::Browse { path: dir_s.clone(), subfolders });
app.session.source = lightcraft_engine::LibrarySource::Folder;
// the photos the library already knows from this folder show at once: keep or pick the active one
app.session.keep_active_in_view();
let (input, _) = ScanInput::new(&mut app.session, std::slice::from_ref(&dir_s));
let progress = std::sync::Arc::new(ScanProgress::default());
let (tx, rx) = std::sync::mpsc::channel();
let p = progress.clone();
let root = dir_s.clone();
let job = move || {
let files: Vec<String> = if subfolders {
lightcraft_engine::import::expand(&[root], None)
} else {
let mut v: Vec<String> = std::fs::read_dir(&root)
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|f| {
f.is_file()
&& lightcraft_engine::import::is_supported(f)
&& !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
})
.map(|f| f.to_string_lossy().to_string())
.collect()
})
.unwrap_or_default();
v.sort();
v
};
let _ = tx.send(scan_with(input, &files, &p));
};
#[cfg(not(target_arch = "wasm32"))]
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
job();
let guard = scan_guard(app, &progress, true);
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}))
}
/// Collect a finished scan and open the review (called every frame).
pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(task) = app.scan.as_ref() else { return };
ctx.request_repaint_after(std::time::Duration::from_millis(100));
let out = match task.rx.try_recv() {
Ok(o) => o,
Err(std::sync::mpsc::TryRecvError::Empty) => {
if task.guard.is_cancelled() {
// ✕: the worker stops at its next file; don't wait for it (a NAS read can take a while)
app.scan = None;
return;
}
let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed));
task.guard.progress(done as u64, total as u64);
// while the folders are listed the total isn't known: say what is happening instead
task.guard.detail(if total == 0 { crate::i18n::tr("Looking for photos…") } else { "" });
return;
}
Err(_) => {
app.scan = None;
app.toast(ctx, crate::i18n::tr("Scan failed"));
return;
}
};
let Some(task) = app.scan.take() else { return };
if task.progress.cancel.load(Ordering::Relaxed) {
return;
}
app.session.import_probes = out.probes;
if task.browse {
// records of files gone from the folder (deleted or moved by another app) that nothing was changed on go,
// as `library.browse` forgets them (issue #1392)
if let Some(b) = app.session.browse.clone() {
let listed: Vec<String> = out.candidates.iter().map(|c| c.path.clone()).collect();
app.session.forget_gone_from(&b.path, b.subfolders, &listed);
}
// what the library doesn't know yet joins the Local view
let queue: Vec<String> =
out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect();
if !queue.is_empty() {
let undo0 = app.session.undo.len();
let total = queue.len();
let _ = total;
app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true));
}
return;
}
if out.candidates.is_empty() {
app.toast(ctx, crate::i18n::tr("No photos found"));
return;
}
app.renderer.forget_imports();
let mut d = ImportDialog::new(out.candidates);
let trashed = d
.candidates
.iter()
.map(|c| {
c.duplicate.is_some()
&& c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted))
})
.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) });
}
impl ScanTask {
/// `{done, total}` for `ui.inspect` (total is 0 while the folders are still being listed).
pub fn status(&self) -> Value {
json!({"done": self.progress.done.load(Ordering::Relaxed), "total": self.progress.total.load(Ordering::Relaxed)})
}
}
/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`).
pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String> {
busy_synchronizing(app)?;
let queue = d.selected_paths();
if queue.is_empty() {
return Err("no photos selected".into());
}
let undo0 = app.session.undo.len();
let mut album = d.album;
if album.is_none() && !d.new_album.trim().is_empty() {
let r = app.session.execute("album.create", &json!({"name": d.new_album.trim()})).map_err(|e| e.to_string())?;
album = r["id"].as_u64();
}
let mode = match (d.copy, d.move_files) {
(false, _) => "add",
(true, false) => "copy",
(true, true) => "move",
};
let mut params = json!({"mode": mode, "keywords": d.keywords()});
if !d.on_deleted.is_empty() {
params["onDeleted"] = json!(d.on_deleted);
}
if let Some(a) = album {
params["album"] = json!(a);
}
if !d.preset.is_empty() {
params["preset"] = json!(d.preset);
}
if !d.metadata_preset.is_empty() {
params["metadataPreset"] = json!(d.metadata_preset);
}
if d.copy {
if !d.destination.trim().is_empty() {
params["destination"] = json!(d.destination.trim());
}
if let Some(o) = d.organize_param()? {
params["organize"] = json!(o);
}
if !d.rename.trim().is_empty() {
params["rename"] = json!(d.rename.trim());
params["renameStart"] = json!(1);
}
if d.dng && !d.move_files {
params["dng"] = json!(true);
}
}
let total = queue.len();
app.import = Some(ImportTask::new(queue, params, undo0, false));
app.renderer.forget_imports();
Ok(json!({"importing": total}))
}
/// Start importing `paths` (files or folders) in the background, e.g. dropped on the window.
pub fn start_paths(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String> {
busy_synchronizing(app)?;
if app.import.is_some() || app.scan.as_ref().is_some_and(|t| !t.browse) {
return Err("an import is running".into());
}
let undo0 = app.session.undo.len();
let total = paths.len();
app.import = Some(ImportTask::new(paths, json!({"mode": "add"}), undo0, false));
Ok(json!({"importing": total}))
}
/// Advance the import in progress (called every frame): start its worker, then add the batches it
/// has readied to the catalog.
pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(mut task) = app.import.take() else { return };
if task.run.is_none() {
let mut p = task.params.clone();
p["paths"] = json!(task.queue);
let started = lightcraft_engine::cmd::library::import_params(&app.session, &p)
.and_then(|req| Ok((lightcraft_engine::import::ImportJob::new(&mut app.session, req.opts)?, req.album, req.album_name)))
.map_err(|e| e.to_string())
.and_then(|(job, album, name)| ImportRun::start(job, std::mem::take(&mut task.queue), album, name, ctx));
if task.auto {
task.keep_selection = Some(app.session.selection.clone());
}
match started {
Ok(run) => {
let label = if task.browse { "Reading folder" } else { "Importing" };
task.guard = Some(app.session.activity.start("import", label, lightcraft_engine::activity::Cancel::Flag(run.cancel.clone())));
task.run = Some(run);
}
Err(e) => {
log::warn!("import: {e}");
app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e));
return;
}
}
}
ctx.request_repaint_after(std::time::Duration::from_millis(100));
let Some(run) = task.run.as_mut() else { return };
let batches = run.next_batches();
let finished = batches.is_none();
if task.left.as_ref().is_some_and(|(sel, source)| *sel != app.session.selection || *source != app.session.source) {
task.moved_on = true;
}
for prepared in batches.into_iter().flatten() {
commit_batch(app, &mut task, prepared);
// arriving photos don't take over the selection
if let Some(sel) = &task.keep_selection {
app.session.selection = sel.clone();
}
// a Local folder still on screen: its first photo becomes active once there is one (#1447)
if task.browse && app.session.source == lightcraft_engine::LibrarySource::Folder {
app.session.keep_active_in_view();
}
}
task.left = Some((app.session.selection.clone(), app.session.source));
if let Some(run) = &task.run {
task.total = run.total.load(Ordering::Relaxed).max(task.total);
task.done = run.done.load(Ordering::Relaxed);
}
if let Some(guard) = &task.guard {
guard.progress(task.done as u64, task.total as u64);
// ✕ in the stack (or `activity.cancel`): no further files are started
if guard.is_cancelled() {
task.cancelled = true;
}
}
if !finished {
app.import = Some(task);
return;
}
finish(app, ctx, task);
}
/// Add one readied batch to the catalog (an undo step merged into the import's at the end).
fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightcraft_engine::import::Prepared) {
let Some(run) = task.run.as_ref() else { return };
let n = prepared.len();
let (opts, now, album, album_name) = (&run.opts, run.now.as_str(), run.album, run.album_name.clone());
let r = app.session.execute_fn("library.import", |s| {
let report = lightcraft_engine::import::commit_prepared(s, opts, now, prepared)?;
lightcraft_engine::cmd::library::import_batch_done(s, report, album, album_name.as_deref())
});
match r {
Ok(v) => {
// a new album (named in the params) is created with the first photos; later batches join it
if let (Some(a), Some(run)) = (v["album"].as_u64(), task.run.as_mut()) {
run.album = Some(a);
}
let len = |k: &str| v[k].as_array().map_or(0, Vec::len);
task.imported += len("imported");
task.restored += len("restored");
task.duplicates += len("duplicates");
task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64()));
task.failed += len("failed");
task.moved += len("moved");
task.kept += len("kept");
task.warnings.extend(v["warnings"].as_array().into_iter().flatten().filter_map(|warning| serde_json::from_value(warning.clone()).ok()));
for k in v["kept"].as_array().into_iter().flatten() {
log::warn!("import: kept {}: {}", k["path"].as_str().unwrap_or(""), k["reason"].as_str().unwrap_or(""));
}
if task.first.is_none() {
task.first = ["imported", "restored"].iter().find_map(|k| v[*k].as_array().and_then(|a| a.first()).and_then(Value::as_u64));
}
}
Err(e) => {
log::warn!("import: {e}");
task.failed += n;
}
}
}
fn xmp_warning_text(warning: &ImportWarning) -> String {
let path = &warning.path;
match warning.code {
ImportWarningCode::CameraProfile => crate::i18n::tr_format!(
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.",
profile = warning.profile.as_deref().unwrap_or_default(),
path = path,
),
ImportWarningCode::UprightGeometry => match warning.mode.unwrap_or(ImportUprightMode::Unknown) {
ImportUprightMode::Off => crate::i18n::tr_format!(
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.",
path = path,
),
ImportUprightMode::Guided => crate::i18n::tr_format!(
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.",
path = path,
),
ImportUprightMode::Auto => crate::i18n::tr_format!(
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.",
path = path,
mode = crate::i18n::tr("Auto")
),
ImportUprightMode::Level => crate::i18n::tr_format!(
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.",
path = path,
mode = crate::i18n::tr("Level")
),
ImportUprightMode::Vertical => crate::i18n::tr_format!(
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.",
path = path,
mode = crate::i18n::tr("Vertical")
),
ImportUprightMode::Full => crate::i18n::tr_format!(
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.",
path = path,
mode = crate::i18n::tr("Full")
),
ImportUprightMode::Unknown => {
crate::i18n::tr_format!("Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.", path = path,)
}
},
ImportWarningCode::UnmappedXmp => {
crate::i18n::tr_format!("XMP adjustments were not imported for {path}: {fields}.", path = path, fields = warning.fields.join(", "),)
}
}
}
fn xmp_warnings_text(warnings: &[ImportWarning]) -> String {
crate::i18n::tr_format!(
"Import warnings ({n}):\n{warnings}",
n = warnings.len(),
warnings = warnings.iter().map(xmp_warning_text).collect::<Vec<_>>().join("\n"),
)
}
/// The import is done (or cancelled): one undo step, show and select the first photo (unless the
/// user chose another meanwhile), say what happened.
fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
let steps = app.session.undo.len().saturating_sub(task.undo0);
let plural = |n: usize| if n == 1 { "" } else { "s" };
let label = if task.imported == 0 && task.restored > 0 {
crate::i18n::tr_format!("Restore {} Photo{}", task.restored, plural(task.restored))
} else if task.restored > 0 {
crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, plural(task.imported), task.restored)
} else {
crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" })
};
app.session.merge_undo(steps, &label);
if task.auto {
if !task.warnings.is_empty() {
app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0);
} else if task.imported > 0 {
app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }));
}
return;
}
if task.browse {
if !task.warnings.is_empty() {
app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0);
} else if task.failed > 0 {
app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" }));
}
return;
}
if let Some(f) = task.first.filter(|_| !task.moved_on) {
// a view the new photos aren't in (an album, Picks, a Local folder) would leave the active
// photo off screen: show All Photos, as Lightroom shows the import's own collection (#1446)
if !app.session.visible().contains(&lightcraft_catalog::PhotoId(f)) {
let _ = app.run("library.source", json!({"kind": "all"}));
}
let _ = app.run("library.select", json!({"ids": [f]}));
}
let plural = |n: usize| if n == 1 { "" } else { "s" };
// nothing new, only files the library already has: show where they are (Recently Deleted is
// easy to miss, and the side panel that lists it starts collapsed)
if task.imported == 0
&& task.restored == 0
&& task.failed == 0
&& task.warnings.is_empty()
&& !task.cancelled
&& show_existing(app, ctx, &task.existing)
{
return;
}
let mut msg = if task.cancelled {
crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported))
} else if task.imported == 0 && task.restored > 0 {
crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, plural(task.restored))
} else {
crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported))
};
if task.restored > 0 && (task.imported > 0 || task.cancelled) {
msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored));
}
if task.params["mode"] == "move" {
msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved));
if task.kept > 0 {
msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, plural(task.kept)));
}
}
if task.duplicates > 0 {
msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" }));
let deleted = deleted_of(app, &task.existing).len();
if deleted > 0 {
msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted));
}
}
if task.failed > 0 {
msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed));
}
if task.warnings.is_empty() {
app.toast(ctx, msg);
} else {
msg.push_str("\n\n");
msg.push_str(&xmp_warnings_text(&task.warnings));
app.toast_for(ctx, msg, 8.0);
}
}
/// The photos among `ids` that are in Recently Deleted (each once).
fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec<u64> {
let mut v: Vec<u64> =
ids.iter().copied().filter(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)).collect();
v.sort_unstable();
v.dedup();
v
}
/// Select the library photos an import skipped as duplicates of, in Recently Deleted (side panel
/// opened, with how to get them back) or in All Photos, and say so. `false` when there are none.
fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) -> bool {
let deleted = deleted_of(app, existing);
let (kind, mut ids) = if deleted.is_empty() { ("all", existing.to_vec()) } else { ("recentlyDeleted", deleted) };
ids.sort_unstable();
ids.dedup();
ids.retain(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some());
if ids.is_empty() || app.run("library.source", json!({"kind": kind})).is_err() {
return false;
}
let _ = app.run("library.select", json!({"ids": ids}));
app.ui.left_panel = true;
let n = ids.len();
let plural = if n == 1 { "" } else { "s" };
let msg = if kind == "all" {
crate::i18n::tr_format!("Already in the library: {} photo{} selected in All Photos", n, plural)
} else {
crate::i18n::tr_format!(
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh",
n,
plural
)
};
app.toast_for(ctx, msg, 6.0);
true
}
/// Skipped candidates: (already in the library, identical copies within the source, in Recently
/// Deleted). Files of photos in Recently Deleted are not "already in the library", and a file with
/// the same bytes as one earlier in the list (no library photo) is a copy in the source.
fn dup_counts(d: &ImportDialog) -> (usize, usize, usize) {
let trashed = d.trashed.iter().filter(|t| **t).count();
let in_source = d.candidates.iter().filter(|c| c.duplicate.as_deref() == Some("content") && c.existing.is_none()).count();
let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed).saturating_sub(in_source);
(dups, in_source, trashed)
}
/// The dialog body: options, then the candidate grid.
pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
let t = Tokens::get(ui.ctx());
let n = d.candidates.len();
let (dups, in_source, trashed) = dup_counts(d);
let sel = d.selected_paths().len();
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text));
if dups > 0 {
ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim));
}
if in_source > 0 {
ui.label(egui::RichText::new(crate::i18n::tr_format!("· {in_source} identical copies", in_source = in_source)).color(t.text_dim))
.on_hover_text(crate::i18n::tr("Files with the same content as another file in this import: only the first is imported."));
}
if trashed > 0 {
ui.label(egui::RichText::new(crate::i18n::tr_format!("· {trashed} in Recently Deleted", trashed = trashed)).color(t.text_dim));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::text_button(ui, "importNone", crate::i18n::tr("Uncheck All"), false).clicked() {
d.checked.iter_mut().for_each(|c| *c = false);
}
if crate::widgets::text_button(ui, "importAll", crate::i18n::tr("Check All"), false).clicked() {
for i in 0..n {
d.checked[i] = d.checked_by_default(i);
}
}
});
});
// with more than one file type: a toggle per type checks or unchecks all of its files
let types = d.file_types();
if types.len() > 1 {
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr("File types:")).color(t.text_dim));
for (ty, count) in &types {
let on = d.type_checked(ty);
let r = crate::widgets::text_button(ui, &format!("importType:{ty}"), &format!("{ty} · {count}"), on)
.on_hover_text(crate::i18n::tr(if on { "Uncheck the files of this type" } else { "Check the files of this type" }));
if r.clicked() {
d.check_type(ty, !on);
}
}
});
}
// candidate grid
let cell = 116.0;
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);
// 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);
let local = Rect::from_min_size(pos2(c as f32 * (cell + 6.0), r as f32 * (cell + 26.0)), vec2(cell, cell + 20.0));
if !local.intersects(viewport.expand(cell)) {
continue;
}
let rect = local.translate(area.min.to_vec2());
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() {
field(ui, "Source", |ui| {
// (whether each source is a folder is checked off the UI thread, once)
let joined = d.sources.join("\n");
let summary = crate::panels::left::fs_cached(ui, "import-source", &joined, f64::INFINITY, |s| {
inspect_sources(&s.split('\n').map(str::to_string).collect::<Vec<_>>())
})
.map(|summary| summary.display())
.unwrap_or_else(|| crate::i18n::tr_format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" }));
let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined);
register(ui.ctx(), "label:importSource", r.rect);
});
ui.label(
egui::RichText::new(crate::i18n::tr("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing)."))
.color(t.text_dim)
.small(),
);
}
field(ui, "Transfer", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if crate::widgets::text_button(ui, "importAdd", crate::i18n::tr("Add in place"), !d.copy)
.on_hover_text(crate::i18n::tr("Reference the files where they are"))
.clicked()
{
d.copy = false;
d.move_files = false;
}
let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some();
let r =
ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", crate::i18n::tr("Copy"), d.copy && !d.move_files)).inner;
if r.on_hover_text(crate::i18n::tr("Copy the files (into the library's Originals/, or a folder you choose)")).clicked() {
d.copy = true;
d.move_files = false;
}
let r =
ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importMove", crate::i18n::tr("Move"), d.copy && d.move_files)).inner;
if r.on_hover_text(crate::i18n::tr("Move the files (into the library's Originals/, or a folder you choose), removing them from the source"))
.clicked()
{
d.copy = true;
d.move_files = true;
}
});
let r = ui.label(
egui::RichText::new(crate::i18n::tr(match (d.copy, d.move_files) {
(true, false) => {
"Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are."
}
(true, true) => {
"Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are."
}
_ => "Add in place: the library references the files where they are; nothing is copied or moved.",
}))
.color(if d.copy && d.move_files { t.caution } else { t.text_dim })
.small(),
);
register(ui.ctx(), "label:importModeHelp", r.rect);
if d.copy {
field(ui, if d.move_files { "Move to" } else { "Copy to" }, |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let shown = if d.destination.trim().is_empty() { crate::i18n::tr("Library Originals").to_string() } else { d.destination.clone() };
ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label));
if app.services.pick_folder.is_some()
&& crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked()
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f())
{
d.destination = f;
}
if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", crate::i18n::tr("Library"), false).clicked() {
d.destination.clear();
}
});
field(ui, "Folders", |ui| {
let opts = [
("date", "By day (YYYY/YYYY-MM-DD)"),
("month", "By month (YYYY/YYYY-MM)"),
("flat", "Into one folder"),
("custom", "Custom template…"),
];
let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() };
let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1);
let combo = crate::widgets::combo_box("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
for (k, label) in opts {
let r = ui.selectable_label(key == k, crate::i18n::tr(label));
register(ui.ctx(), format!("button:importOrganize-{k}"), r.rect);
if r.clicked() {
d.organize = k.to_string();
if k == "custom" && d.folder_template.trim().is_empty() {
d.folder_template = DEFAULT_FOLDER_TEMPLATE.into();
}
}
}
});
register(ui.ctx(), "combo:importOrganize", combo.response.rect);
});
if d.organize == "custom" {
let folders_id = egui::Id::new("import-folder-template");
let tags_open = field(ui, "Template", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = tag_field_width(ui);
let r = ui.add(egui::TextEdit::singleline(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w));
register(ui.ctx(), "field:importFolderTemplate", r.rect);
tag_toggle(ui, "importFolders")
});
if tags_open {
tag_help(ui, "importFolders", &mut d.folder_template, folders_id);
}
let t = Tokens::get(ui.ctx());
match lightcraft_engine::rename::folder_template_error(&d.folder_template) {
Some(e) => {
ui.label(egui::RichText::new(e).color(t.caution));
}
None => unknown_tags_warning(ui, &d.folder_template),
}
ui.label(
egui::RichText::new(crate::i18n::tr(
"Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").",
))
.color(t.text_dim)
.small(),
);
}
if !d.move_files {
field(ui, "Raw files", |ui| {
let r = ui.checkbox(&mut d.dng, crate::i18n::tr("Copy as DNG"));
register(ui.ctx(), "check:importDng", r.rect);
});
}
let rename_id = egui::Id::new("import-rename");
let tags_open = field(ui, "Rename", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = tag_field_width(ui);
let r = ui.add(
egui::TextEdit::singleline(&mut d.rename)
.id(rename_id)
.hint_text(crate::i18n::tr("keep names — or e.g. {date}_{seq:3}"))
.desired_width(w),
);
register(ui.ctx(), "field:importRename", r.rect);
tag_toggle(ui, "importRename")
});
if tags_open {
tag_help(ui, "importRename", &mut d.rename, rename_id);
}
unknown_tags_warning(ui, &d.rename);
if let Some(example) = example_destination(app, d) {
let t = Tokens::get(ui.ctx());
let r = ui.label(egui::RichText::new(crate::i18n::tr_format!("Example: {example}", example = example)).color(t.text_dim));
register(ui.ctx(), "label:importExample", r.rect);
ui.label(
egui::RichText::new(crate::i18n::tr("Folders and {date} use the capture time; a photo without one uses today's date."))
.color(t.text_dim)
.small(),
);
}
}
if d.trashed.iter().any(|t| *t) {
field(ui, "In Recently Deleted", |ui| {
let cur = match d.on_deleted.as_str() {
"restore" => "Restore them",
"fresh" => "Import as new",
_ => "Leave them",
};
let mut pick = None;
crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] {
if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() {
pick = Some(v);
}
}
});
if let Some(v) = pick {
d.set_on_deleted(v);
}
let t = Tokens::get(ui.ctx());
ui.label(
egui::RichText::new(crate::i18n::tr("Restore keeps their edits; Import as new deletes the old record first."))
.color(t.text_dim)
.small(),
);
});
}
let albums: Vec<(u64, String)> = {
let mut v: Vec<(u64, String)> =
app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect();
v.sort_by_key(|(_, n)| n.to_lowercase());
v
};
field(ui, "Album", |ui| {
let cur = match d.album {
Some(a) => albums.iter().find(|x| x.0 == a).map(|x| x.1.clone()).unwrap_or_default(),
None if !d.new_album.is_empty() => crate::i18n::tr("New album").into(),
None => crate::i18n::tr("None").into(),
};
crate::widgets::combo_box("import-album").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() {
d.album = None;
d.new_album.clear();
}
if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")).clicked() {
d.album = None;
if d.new_album.is_empty() {
d.new_album = crate::i18n::tr("Imported Photos").into();
}
}
for (id, name) in &albums {
if ui.selectable_label(d.album == Some(*id), name).clicked() {
d.album = Some(*id);
d.new_album.clear();
}
}
});
if d.album.is_none() && !d.new_album.is_empty() {
let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY));
register(ui.ctx(), "field:importAlbumName", r.rect);
}
});
field(ui, "Preset", |ui| {
let cur = app
.session
.presets
.iter()
.find(|p| p.id == d.preset)
.map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string())
.unwrap_or_else(|| crate::i18n::tr("None").into());
crate::widgets::combo_box("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| {
if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() {
d.preset.clear();
}
let selected = (!d.preset.is_empty()).then_some(d.preset.as_str());
if let Some(pid) = crate::panels::presets::picker_rows(ui, "import-preset", &app.session.presets, selected) {
d.preset = pid;
}
});
});
if !app.session.metadata_presets.is_empty() {
field(ui, "Metadata", |ui| {
let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() };
crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() {
d.metadata_preset.clear();
}
for m in &app.session.metadata_presets {
if ui.selectable_label(d.metadata_preset == m.name, &m.name).clicked() {
d.metadata_preset = m.name.clone();
}
}
});
});
}
field(ui, "Keywords", |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text(crate::i18n::tr("comma, separated")).desired_width(f32::INFINITY));
register(ui.ctx(), "field:importKeywords", r.rect);
});
}
fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog, i: usize, rect: Rect) {
let t = Tokens::get(ui.ctx());
let c = d.candidates[i].clone();
let ok = d.importable(i);
let img = Rect::from_min_size(rect.min, vec2(rect.width(), rect.width()));
let resp = ui.interact(img, egui::Id::new(("import-cell", i)), Sense::click());
crate::widgets::announce(&resp, c.name.clone());
register(ui.ctx(), format!("import:{i}"), img);
let p = ui.painter();
p.rect_filled(img, 3.0, Color32::from_gray(30));
// thumbnail (background job; the grid only asks for the cells in view)
let slot = Slot::Import(i as u32);
if !app.renderer.textures.contains_key(&slot)
&& let Some(job) = app.session.candidate_thumb_job(&c, 192, i as u64)
{
app.renderer.request_quick(slot, job, 4);
}
if let Some(tex) = app.renderer.textures.get(&slot) {
let [tw, th] = tex.size;
let s = ((img.width() - 8.0) / tw as f32).min((img.height() - 8.0) / th as f32);
let fit = Rect::from_center_size(img.center(), vec2(tw as f32 * s, th as f32 * s));
let tint = if ok { Color32::WHITE } else { Color32::from_gray(110) };
p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint);
}
let on = d.checked[i] && ok;
if on {
p.rect_stroke(img, 3.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
}
// checkbox
let cb = Rect::from_min_size(img.min + vec2(6.0, 6.0), vec2(16.0, 16.0));
p.rect(cb, 3.0, if on { t.accent } else { Color32::from_black_alpha(150) }, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside);
if on {
p.line_segment([cb.left_center() + vec2(3.5, 0.5), cb.center_bottom() + vec2(-1.0, -4.0)], Stroke::new(2.0, Color32::WHITE));
p.line_segment([cb.center_bottom() + vec2(-1.0, -4.0), cb.right_top() + vec2(-3.5, 4.0)], Stroke::new(2.0, Color32::WHITE));
}
let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted));
let badge = match (&c.duplicate, &c.error) {
// checked with "Leave them", the file goes without `onDeleted`: the engine restores it
(Some(_), _) if in_trash && on => Some(if d.on_deleted == "fresh" { "Will be replaced" } else { "Will be restored" }),
(Some(_), _) if in_trash => Some("In Recently Deleted"),
(Some(r), _) if r == "path" => Some("In library"),
(Some(_), _) => Some("Duplicate"),
(None, Some(_)) => Some("Unreadable"),
_ => None,
};
if let Some(b) = badge {
let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE);
let br = badge_rect(img, cb, g.size());
p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220));
p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE);
}
let name = if c.name.chars().count() > 18 { format!("{}…", c.name.chars().take(17).collect::<String>()) } else { c.name.clone() };
p.text(pos2(rect.left() + 2.0, img.bottom() + 9.0), Align2::LEFT_CENTER, name, t.font(10.5), if ok { t.text_label } else { t.text_dim });
let tip = format!(
"{}\n{} × {} · {} · {:.1} MB{}",
c.path,
c.width,
c.height,
c.format,
c.file_size as f64 / 1e6,
c.captured.as_deref().map(|d| format!("\n{}", d.replace('T', " "))).unwrap_or_default()
);
let resp = resp.on_hover_text(tip);
if resp.clicked() && ok {
let shift = ui.input(|input| input.modifiers.shift);
d.click(i, shift);
}
}
/// Where a cell's badge goes: top right, or under the checkbox when the text is too wide to sit
/// beside it — drawn over the checkbox it hid it (issue #1285).
fn badge_rect(img: Rect, cb: Rect, text: Vec2) -> Rect {
let size = text + vec2(8.0, 4.0);
let left = img.right() - size.x - 4.0;
let top = if left < cb.right() + 4.0 { cb.bottom() + 4.0 } else { img.top() + 6.0 };
Rect::from_min_size(pos2(left, top), size)
}
/// Reserve the actual translated tag button width beside a template field.
pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(crate::i18n::tr("Tags").to_string(), t.semibold(11.5), t.text);
(ui.available_width() - (galley.size().x + 18.0).max(37.0) - ui.spacing().item_spacing.x).max(80.0)
}
/// The "Tags" toggle beside a template field (`button:<key>Tags`); returns whether the tag help
/// ([`tag_help`]) is open.
pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool {
let id = egui::Id::new((key, "tags-open"));
let mut open = ui.ctx().data(|d| d.get_temp::<bool>(id)).unwrap_or(false);
let r = crate::widgets::text_button(ui, &format!("{key}Tags"), crate::i18n::tr("Tags"), open)
.on_hover_text(crate::i18n::tr("Show the template tags; click one to insert it"));
if r.clicked() {
open = !open;
ui.ctx().data_mut(|d| d.insert_temp(id, open));
}
open
}
/// "Unknown tag {x} stays as typed" under a template field, when it has one.
pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) {
let unknown = lightcraft_engine::rename::unknown_tokens(template);
if !unknown.is_empty() {
let s = if unknown.len() == 1 { "" } else { "s" };
let text = crate::i18n::tr_format!(
"Unknown tag{s} {} stay{} as typed (see Tags)",
unknown.join(" "),
if unknown.len() == 1 { "s" } else { "" },
s = s
);
ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution));
}
}
/// Insert `tag` into `text` at the text cursor of the field `edit_id` (replacing its selection);
/// appended when the field never had a cursor. The cursor ends up after the tag.
pub(crate) fn insert_at_cursor(ctx: &egui::Context, edit_id: egui::Id, text: &mut String, tag: &str) {
use egui::text::{CCursor, CCursorRange};
let mut state = egui::text_edit::TextEditState::load(ctx, edit_id).unwrap_or_default();
let n = text.chars().count();
let (a, b) = state.cursor.char_range().map_or((n, n), |r| {
let (x, y) = (r.primary.index.0.min(n), r.secondary.index.0.min(n));
(x.min(y), x.max(y))
});
let byte = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i);
let (ba, bb) = (byte(a), byte(b));
text.replace_range(ba..bb, tag);
state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(a + tag.chars().count()))));
state.store(ctx, edit_id);
ctx.memory_mut(|m| m.request_focus(edit_id));
}
/// The template tag help: every tag (from the engine's one list, `rename::TOKENS`) with its
/// meaning and an example, the `{date:…}` directives and how templates behave. Clicking a tag
/// (`button:<key>Tag-<i>`) inserts it at the cursor of the field `edit_id`.
pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: egui::Id) {
use lightcraft_engine::rename::{DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, token_example};
let t = Tokens::get(ui.ctx());
egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| {
ui.label(
egui::RichText::new(crate::i18n::tr("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48."))
.color(t.text_dim)
.small(),
);
egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| {
egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| {
for (i, tok) in TOKENS.iter().enumerate() {
let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text(
if tok.aliases.is_empty() {
crate::i18n::tr("Insert at the cursor").to_string()
} else {
crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", "))
},
);
register(ui.ctx(), format!("button:{key}Tag-{i}"), r.rect);
if r.clicked() {
insert_at_cursor(ui.ctx(), edit_id, text, tok.tag);
}
ui.label(egui::RichText::new(crate::i18n::tr(tok.meaning)).color(t.text_label));
ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim));
ui.end_row();
}
});
let directives: Vec<String> = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {}", crate::i18n::tr(m))).collect();
ui.label(egui::RichText::new(crate::i18n::tr_format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small());
for n in TEMPLATE_NOTES {
ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(n))).color(t.text_dim).small());
}
});
});
}
/// The folder template the Custom choice starts with (`2026/20260114/`).
pub const DEFAULT_FOLDER_TEMPLATE: &str = "{date:%Y}/{date:%Y%m%d}";
/// Where the first selected photo would be copied to (destination, folders, name), for the
/// dialog's example line; `None` without a photo or with an unusable folder template.
pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option<String> {
let c = d.candidates.iter().zip(&d.checked).enumerate().find(|(i, (_, on))| **on && d.importable(*i)).map(|(_, (c, _))| c)?;
let organize = match d.organize_param().ok()? {
Some(o) => lightcraft_engine::import::Organize::parse(&o)?,
None => Default::default(),
};
let mut q = lightcraft_catalog::Photo::new(
lightcraft_catalog::PhotoId(0),
lightcraft_catalog::Source::File { path: c.path.clone() },
&c.name,
&c.format,
0,
0,
// a photo without a capture time is dated by the import (now)
&(app.session.clock)(),
);
q.captured = c.captured.clone();
// the probe's metadata, so {camera}, {title}… preview as they will import
if let Some(info) = app.session.import_probes.get(&c.path) {
q.meta = info.meta.clone();
}
let name = if d.rename.trim().is_empty() { c.name.clone() } else { lightcraft_engine::rename::expand(d.rename.trim(), &q, 1) };
let root = if d.destination.trim().is_empty() {
app.session.library.as_ref().map_or_else(|| "Originals".to_string(), |l| l.dir.join("Originals").to_string_lossy().to_string())
} else {
d.destination.trim().trim_end_matches(['/', '\\']).to_string()
};
let sep = std::path::MAIN_SEPARATOR_STR;
let mut parts = vec![root];
parts.extend(organize.folders(&q));
parts.push(name);
Some(parts.join(sep))
}
/// The import source in a few words: a folder's name, a file's name, or "N files and folders".
pub fn source_summary(sources: &[String]) -> String {
inspect_sources(sources).display()
}
/// Facts cached by the filesystem worker, formatted in the UI thread's active language.
/// Caching a translated String on the worker would always use its default language (English)
/// and also retain that language after the user changes the UI language.
#[derive(Clone)]
enum SourceSummary {
Folder(String),
File(String),
Many { files: usize, folders: usize },
}
impl SourceSummary {
fn display(&self) -> String {
match self {
Self::Folder(name) => crate::i18n::tr_format!("Folder “{}” (and its subfolders)", name),
Self::File(name) => name.clone(),
Self::Many { files, folders } => match (files, folders) {
(_, 0) => crate::i18n::tr_format!("{} files", files),
(0, f) => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f),
(_, f) => crate::i18n::tr_format!("{} files and {f} folder{}", files, if *f == 1 { "" } else { "s" }, f = f),
},
}
}
}
fn inspect_sources(sources: &[String]) -> SourceSummary {
let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |n| n.to_string_lossy().to_string());
match sources {
[one] if std::path::Path::new(one).is_dir() => SourceSummary::Folder(name(one)),
[one] => SourceSummary::File(name(one)),
many => {
let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count();
SourceSummary::Many { files: many.len().saturating_sub(folders), folders }
}
}
}
/// A labelled row (fixed label column).
fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(vec2(78.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(78.0);
ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label));
});
add(ui)
})
.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);
}
/// Six candidates, all checked; candidate 3 is a duplicate (unchecked, not importable).
fn dialog() -> ImportDialog {
let mut candidates: Vec<ImportCandidate> = (0..6).map(|i| ImportCandidate { path: format!("img{i}.jpg"), ..Default::default() }).collect();
candidates[3].duplicate = Some("content".into());
ImportDialog::new(candidates)
}
/// Identical copies inside the source folder are not "already in the library" (a fresh library
/// said "8 already in the library").
#[test]
fn copies_within_the_source_are_not_counted_as_in_the_library() {
let mut d = dialog();
assert_eq!(dup_counts(&d), (0, 1, 0), "same bytes as an earlier file, no library photo");
d.candidates[4].duplicate = Some("content".into());
d.candidates[4].existing = Some(7);
d.candidates[5].duplicate = Some("path".into());
d.candidates[5].existing = Some(8);
assert_eq!(dup_counts(&d), (2, 1, 0));
}
/// A toggle per file type: the raws only, without the JPEGs beside them (issue #344).
#[test]
fn file_types_check_and_uncheck_together() {
let mut candidates: Vec<ImportCandidate> =
["a.ARW", "a.JPG", "b.arw", "b.jpeg", "c.png"].iter().map(|p| ImportCandidate { path: p.to_string(), ..Default::default() }).collect();
candidates[2].format = "ARW".into();
candidates[3].format = "JPEG".into();
candidates[2].duplicate = Some("content".into());
let mut d = ImportDialog::new(candidates);
assert_eq!(d.file_types(), [("ARW".to_string(), 2), ("JPG".to_string(), 2), ("PNG".to_string(), 1)]);
assert!(d.type_checked("JPG"));
d.check_type("JPG", false);
assert_eq!(d.checked, [true, false, false, false, true]);
assert!(!d.type_checked("JPG"));
d.check_type("PNG", false);
d.check_type("ARW", true);
// the duplicate stays unchecked: it isn't importable
assert_eq!(d.checked, [true, false, false, false, false]);
d.check_type("JPG", true);
assert_eq!(d.checked, [true, true, false, true, false]);
assert_eq!(d.selected_paths(), ["a.ARW", "a.JPG", "b.jpeg"]);
// an unknown type changes nothing
d.check_type("CR3", false);
assert_eq!(d.checked, [true, true, false, true, false]);
}
#[test]
fn click_toggles_one_candidate() {
let mut d = dialog();
d.click(1, false);
assert_eq!(d.checked, [true, false, true, false, true, true]);
d.click(1, false);
assert_eq!(d.checked, [true, true, true, false, true, true]);
d.click(3, false);
assert_eq!(d.checked, [true, true, true, false, true, true], "a duplicate can't be checked");
}
#[test]
fn shift_click_sets_the_range_to_the_clicked_state() {
let mut d = dialog();
d.click(1, false);
d.click(4, true);
assert_eq!(d.checked, [true, false, false, false, false, true], "1..=4 unchecked, the duplicate left alone");
assert_eq!(d.selected_paths(), ["img0.jpg", "img5.jpg"]);
// backwards from the new anchor (4) re-checks 2..=4
d.click(2, true);
assert_eq!(d.checked, [true, false, true, false, true, true]);
}
#[test]
fn shift_click_without_an_earlier_click_toggles_one() {
let mut d = dialog();
d.click(2, true);
assert_eq!(d.checked, [true, true, false, false, true, true]);
}
/// With "Leave them", a file in Recently Deleted stays out of Check All and the type toggles,
/// but a click still checks it: it is imported (restored) on its own (issue #1285).
#[test]
fn leave_them_keeps_a_trashed_file_out_until_it_is_clicked() {
let mut d = dialog();
d.set_trashed(vec![false, false, false, true, false, false]);
assert!(d.checked[3], "restored by default");
d.set_on_deleted("");
assert!(!d.checked[3]);
d.check_type("JPG", true);
assert!(!d.checked[3], "the type toggle leaves it out");
d.click(3, false);
assert!(d.checked[3], "a click checks it");
assert!(d.selected_paths().contains(&"img3.jpg".to_string()));
d.click(3, false);
assert!(!d.checked[3]);
// a plain duplicate (not in Recently Deleted) still can't be checked
d.set_trashed(vec![false; 6]);
d.click(3, false);
assert!(!d.checked[3]);
}
/// A badge too wide to fit beside the checkbox goes under it instead of covering it (issue #1285).
#[test]
fn a_badge_never_covers_the_checkbox() {
let img = Rect::from_min_size(pos2(100.0, 50.0), vec2(116.0, 116.0));
let cb = Rect::from_min_size(img.min + vec2(6.0, 6.0), vec2(16.0, 16.0));
// "Will be restored" fits beside it, top right
let short = badge_rect(img, cb, vec2(76.0, 12.0));
assert_eq!(short.right(), img.right() - 4.0);
assert_eq!(short.top(), img.top() + 6.0);
// "In Recently Deleted" doesn't
let long = badge_rect(img, cb, vec2(96.0, 12.0));
assert!(!long.intersects(cb), "{long:?} covers {cb:?}");
assert_eq!(long.right(), img.right() - 4.0);
assert!(img.contains_rect(long));
}
#[test]
fn shift_click_with_a_stale_anchor_toggles_one() {
let mut d = dialog();
d.last_clicked = Some(99);
d.click(2, true);
assert_eq!(d.checked, [true, true, false, false, true, true]);
}
}