diff --git a/AGENTS.md b/AGENTS.md index 8252ed1b..190c759b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,30 @@ branches can still break each other (e.g. a new struct field vs. a new construct `git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small and often so a crash loses minutes, not hours. +## Never crash (outranks feature work) +People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad +command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or +agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top +of it. Full standard: `../craftrules/standards/never-crash.md` +([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)). +- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and + no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a + fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an + "unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]` + + `.expect("why it can't fail")`. +- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or + arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative + casts to `usize`; cap allocations sized by input; slice strings only at char boundaries. +- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:** + `lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s. +- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped + panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native. +- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix. +- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate + root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, + clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit + integration tests, examples and benches. New crates start with the attribute. + ## Non-negotiables - **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…). - **Pure Rust** in the product. No C/C++ dependencies. diff --git a/CLAUDE.md b/CLAUDE.md index 8252ed1b..190c759b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,30 @@ branches can still break each other (e.g. a new struct field vs. a new construct `git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small and often so a crash loses minutes, not hours. +## Never crash (outranks feature work) +People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad +command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or +agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top +of it. Full standard: `../craftrules/standards/never-crash.md` +([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)). +- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and + no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a + fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an + "unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]` + + `.expect("why it can't fail")`. +- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or + arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative + casts to `usize`; cap allocations sized by input; slice strings only at char boundaries. +- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:** + `lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s. +- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped + panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native. +- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix. +- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate + root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, + clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit + integration tests, examples and benches. New crates start with the attribute. + ## Non-negotiables - **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…). - **Pure Rust** in the product. No C/C++ dependencies. diff --git a/README.md b/README.md index 0a8d94a1..7e6c3173 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,9 @@ Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. T an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit. - **Pure Rust**, enforced crate layering, everything is a command, and `cargo xtask ci` green before every commit (one task id per commit). +- **Never crash.** Non-test code returns errors instead of panicking: no `unwrap()`, `expect()`, `panic!` or + `unsafe`, checked indexing on anything derived from input, and a regression test with every crash fix. Details in + [AGENTS.md](AGENTS.md#never-crash-outranks-feature-work). Questions, ideas or a bug you'd like to talk through first? Bring them to [Discord](https://discord.gg/artcraft). diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 617aa071..1c2b7bf8 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -10,6 +10,7 @@ //! lightcraft-cli commands [--json] //! lightcraft-cli controls [--json] //! ``` +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod alloc_release; @@ -283,14 +284,18 @@ fn synth_merge(args: &[String]) -> Result<(), String> { let mut written = Vec::new(); match kind { "hdr" => { - for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).into_iter().enumerate() { + for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).map_err(|e| e.to_string())?.into_iter().enumerate() { let p = Path::new(&dir).join(format!("bracket-{k}.dng")); std::fs::write(&p, b).map_err(|e| e.to_string())?; written.push(p); } } "panorama" | "pano" => { - for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0]).into_iter().enumerate() { + for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0]) + .map_err(|e| e.to_string())? + .into_iter() + .enumerate() + { let p = Path::new(&dir).join(format!("view-{k}.png")); let img = v.to_srgb8(); let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()) diff --git a/apps/lightcraft-web/src/lib.rs b/apps/lightcraft-web/src/lib.rs index 3b19987f..3cbb27b3 100644 --- a/apps/lightcraft-web/src/lib.rs +++ b/apps/lightcraft-web/src/lib.rs @@ -14,6 +14,7 @@ //! console (see [`bench`]); `?store=idb` forces the IndexedDB backend, `?store=memory` disables //! persistence and `?workers=0` renders on the main thread. #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod bench; pub mod files; diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 63308367..0d50ebe8 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -29,17 +29,18 @@ const LIBRARY_DIR: &str = "library"; /// How often view state, UI prefs and the thumbnail index are saved (ms). const SAVE_EVERY_MS: f64 = 1000.0; -fn window() -> web_sys::Window { - web_sys::window().expect("no window") +/// The page's window (`None` only outside a browser page, e.g. in a worker). +fn window() -> Option { + web_sys::window() } /// Milliseconds since navigation start. fn perf_now() -> f64 { - window().performance().map(|p| p.now()).unwrap_or(0.0) + window().and_then(|w| w.performance()).map(|p| p.now()).unwrap_or(0.0) } fn set_status(text: &str) { - if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_status")) { + if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_status")) { el.set_text_content(Some(text)); } } @@ -59,7 +60,7 @@ struct Options { impl Options { fn from_url() -> Options { - let q = window().location().search().unwrap_or_default(); + let q = window().and_then(|w| w.location().search().ok()).unwrap_or_default(); let params: Vec<(String, String)> = q .trim_start_matches('?') .split('&') @@ -102,7 +103,7 @@ async fn read_file(originals: Originals, backend: Option, file: web_sys /// Show the browser's open dialog; picked files are imported asynchronously. fn open_picker(originals: Originals, backend: Option, ctx: egui::Context) { - let Some(doc) = window().document() else { return }; + let Some(doc) = window().and_then(|w| w.document()) else { return }; let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::()) else { return }; input.set_type("file"); input.set_multiple(true); @@ -131,7 +132,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { opts.set_type(crate::store::mime_for(name)); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&arr, &opts).map_err(e)?; let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(e)?; - let doc = window().document().ok_or("no document")?; + let doc = window().and_then(|w| w.document()).ok_or("no document")?; let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into(); a.set_href(&url); a.set_download(name); @@ -140,7 +141,9 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { let revoke = Closure::once_into_js(move || { let _ = web_sys::Url::revoke_object_url(&url); }); - let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000); + if let Some(w) = window() { + let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000); + } log::info!("exported {name} ({} bytes)", bytes.len()); Ok(()) } @@ -315,7 +318,7 @@ impl WebApp { } app.ui = app.ui.sanitized(); let n = opts.workers.unwrap_or_else(|| { - let cores = window().navigator().hardware_concurrency() as usize; + let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize); cores.saturating_sub(1).clamp(1, 4) }); let workers = @@ -516,8 +519,10 @@ pub fn start() { log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now()); let opts = Options::from_url(); wasm_bindgen_futures::spawn_local(async move { - let Some(canvas) = - window().document().and_then(|d| d.get_element_by_id("lightcraft_canvas")).and_then(|e| e.dyn_into::().ok()) + let Some(canvas) = window() + .and_then(|w| w.document()) + .and_then(|d| d.get_element_by_id("lightcraft_canvas")) + .and_then(|e| e.dyn_into::().ok()) else { log::error!("missing "); return; @@ -527,7 +532,7 @@ pub fn start() { let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await; match r { Ok(()) => { - if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_loading")) { + if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) { el.remove(); } } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 975283af..7f744330 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -17,6 +17,7 @@ //! window (issue #7). Their `--help` / `--version` output and diagnostics then have no console to //! go to; use `lightcraft-cli` (a console program) from a terminal, or a debug build. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod alloc_release; mod control_server; @@ -263,6 +264,7 @@ OPTIONS: "; fn main() -> eframe::Result { + lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log")); alloc_release::install(); let mut control_port: Option = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..4b6c79f5 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-indexing-slicing-in-tests = true diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs index 200d80be..06d5c2c0 100644 --- a/crates/catalog/src/dates.rs +++ b/crates/catalog/src/dates.rs @@ -128,13 +128,15 @@ pub fn display_time(iso: &str) -> String { /// Header text for a group key. pub fn group_label(key: &str) -> String { - let month = |k: &str| k.get(5..7).and_then(|m| m.parse::().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]); + let month = |k: &str| k.get(5..7).and_then(|m| m.parse::().ok()).and_then(|m| MONTHS.get(m.wrapping_sub(1))); + // `get` rather than `[..]`: a non-ASCII key must not split a character + let year = key.get(..4).unwrap_or(key); match key.len() { - 10 => match (weekday(key), month(key), key[8..10].parse::().ok()) { - (Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {}", &key[..4]), + 10 => match (weekday(key), month(key), key.get(8..10).and_then(|d| d.parse::().ok())) { + (Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {year}"), _ => key.to_string(), }, - 7 => month(key).map(|m| format!("{m} {}", &key[..4])).unwrap_or_else(|| key.to_string()), + 7 => month(key).map(|m| format!("{m} {year}")).unwrap_or_else(|| key.to_string()), 4 => key.to_string(), _ => "Unknown Date".into(), } @@ -196,6 +198,9 @@ mod tests { assert_eq!(group_label("2026-02"), "February 2026"); assert_eq!(group_label("2026"), "2026"); assert_eq!(group_label(""), "Unknown Date"); + // non-ASCII keys of a date's length used to be sliced mid-character + assert_eq!(group_label("2024-01é0"), "2024-01é0"); + assert_eq!(group_label("202é01"), "January 202é01"); assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00")); assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00")); assert_eq!(shift_iso("nope", 5), None); diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 79318235..8241372e 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -6,6 +6,7 @@ //! - **undo/redo**: the engine keeps inverse ops; //! - **determinism**: replaying the log reproduces the state exactly (property-tested). #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod dates; pub mod journal; @@ -289,7 +290,8 @@ impl Catalog { /// Number of photos shown for an album in the sources list (excludes deleted photos). pub fn album_count(&self, id: AlbumId) -> usize { match self.albums.get(&id) { - Some(Album { smart: Some(_), .. }) => self.album_photos(id).len(), + // counted in place: no id list is built just for its length + Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(), Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(), None => 0, } diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index bac31f48..72c7ffb1 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -174,6 +174,11 @@ impl Filter { if v.is_empty() { "all photos".into() } else { v.join(", ") } } + /// Whether matches depend on the clock (only "in the last…" rules do). + pub fn depends_on_now(&self) -> bool { + self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now) + } + pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool { if p.deleted != self.deleted { return false; @@ -346,6 +351,52 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool { !rest.is_empty() && (deep || !rest.contains(['/', '\\'])) } +/// A folder path's identity, for telling whether two spellings name the same folder: `/` and +/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped, +/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive +/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased. +/// No file-system access: the paths compared should already be absolute. +pub fn folder_key(path: &str) -> String { + let mut s = path.replace('\\', "/"); + if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) { + s = match rest.strip_prefix("UNC/") { + Some(unc) => format!("//{unc}"), + None => rest.to_string(), + }; + } + // the part `..` can't climb above: `//server/share`, `c:` or `/` + let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) { + let mut it = unc.splitn(3, '/'); + let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or("")); + (format!("//{server}/{share}"), rest.to_string()) + } else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() { + (s[..2].to_ascii_lowercase(), s[2..].to_string()) + } else { + (String::new(), s.clone()) + }; + let absolute = rest.starts_with('/') || !prefix.is_empty(); + let mut parts: Vec<&str> = Vec::new(); + for c in rest.split('/') { + match c { + "" | "." => {} + ".." if parts.last().is_some_and(|p| *p != "..") => { + parts.pop(); + } + ".." if absolute => {} + c => parts.push(c), + } + } + let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") }; + if cfg!(windows) { key.to_lowercase() } else { key } +} + +/// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled +/// (see [`folder_key`]). +pub fn folder_within(path: &str, root: &str) -> bool { + let (p, r) = (folder_key(path), folder_key(root)); + p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/'))) +} + /// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`, /// `IMG_1-Pano.dng`, `IMG_1-HDR-Pano.dng` — also the names other raw editors use): /// `hdr`, `panorama` or `hdrPanorama`. diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 4b6060f7..26e3926d 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -346,6 +346,15 @@ impl RuleSet { } } + /// Whether matches depend on the clock ("in the last…" rules, nested groups included): the + /// same photos can enter or leave the set without any catalog change. + pub fn depends_on_now(&self) -> bool { + self.rules.iter().any(|r| match r { + Rule::Group { group } => group.depends_on_now(), + Rule::Field { op, .. } => op == "inLast" || op == "notInLast", + }) + } + /// Unknown fields or operators (for command validation), as readable messages. pub fn problems(&self) -> Vec { let mut out = Vec::new(); diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs index ecc97a82..304106ae 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -277,7 +277,7 @@ mod tests { #[test] fn group_merge_set_top_remove_and_arrange() { let (mut c, ids) = cat(6, &[]); - let [a, b, x, d, e, f] = ids[..] else { unreachable!() }; + let [a, b, x, d, e, f] = ids[..] else { panic!("six ids") }; let op = c.group_ops(b, &[a, b, x], false).unwrap(); let inv = c.apply(op).unwrap(); let s = c.stack_of(a).unwrap().clone(); diff --git a/crates/catalog/src/store.rs b/crates/catalog/src/store.rs index 98e690b9..6207173a 100644 --- a/crates/catalog/src/store.rs +++ b/crates/catalog/src/store.rs @@ -87,7 +87,7 @@ impl Store for FsStore { } self.appender = Some((name.to_string(), f)); } - let (_, f) = self.appender.as_mut().expect("appender just opened"); + let Some((_, f)) = self.appender.as_mut() else { return Err(io::Error::other("appender not open")) }; f.write_all(data)?; f.sync_data() } diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 94004eb7..86442d2a 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -264,3 +264,36 @@ fn preview_only_survives_serde_and_set_content_undo() { assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF")); assert_eq!(c.photo(a).unwrap().width, 6000); } + +/// Equivalent spellings of one folder (separators, trailing or doubled separators, `.`/`..`, +/// drive-letter case, verbatim and UNC prefixes) share an identity; different folders don't. +#[test] +fn folder_identity_ignores_spelling() { + use crate::query::{folder_key, folder_within}; + let same = |a: &str, b: &str| assert_eq!(folder_key(a), folder_key(b), "{a} vs {b}"); + let differ = |a: &str, b: &str| assert_ne!(folder_key(a), folder_key(b), "{a} vs {b}"); + for (a, b) in [ + ("D:/Example/Photos", "D:\\Example\\Photos"), + ("D:/Example/Photos", "d:\\Example\\Photos\\"), + ("D:\\Example/Photos", "D:/Example//Photos/."), + ("D:/Example/Photos", "D:/Example/Other/../Photos"), + ("D:/Example/Photos", "\\\\?\\D:\\Example\\Photos"), + ("\\\\server\\share\\Photos", "//server/share/Photos/"), + ("\\\\?\\UNC\\server\\share\\Photos", "//server/share/Photos"), + ("/home/me/Photos", "/home/me/Photos/"), + ("/home/me/Photos", "/home//me/./Photos"), + ("/", "//"), + ("C:\\", "c:/"), + ] { + same(a, b); + } + differ("D:/Example/Photos", "D:/Example/Photos2"); + differ("D:/Example/Photos", "E:/Example/Photos"); + differ("/home/me/Photos", "/home/me"); + assert_eq!(folder_key("/a/../../b"), "/b", "`..` stops at the root"); + assert!(folder_within("D:\\Example\\Photos\\2026", "D:/Example/Photos/")); + assert!(folder_within("D:/Example/Photos", "d:\\Example\\Photos")); + assert!(!folder_within("D:/Example/Photos2", "D:/Example/Photos")); + assert!(folder_within("/a/b", "/")); + assert!(folder_within("C:\\x", "c:\\")); +} diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs index 93023486..02652db5 100644 --- a/crates/codecs/src/convert.rs +++ b/crates/codecs/src/convert.rs @@ -323,7 +323,9 @@ fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccIn } // Naive: RGB = (1 - C)(1 - K) … treated as sRGB-encoded. let px = samples - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .map(|c| { let k = 1.0 - c[3]; [(1.0 - c[0]) * k, (1.0 - c[1]) * k, (1.0 - c[2]) * k].map(lightcraft_color::transfer::srgb_to_linear) diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 0e56dfe9..59622073 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -91,7 +91,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin let n = img.width as usize * img.height as usize; let (data, ct): (std::borrow::Cow<[u8]>, jpeg_encoder::ColorType) = match img.channels { 1 => (data[..n].into(), jpeg_encoder::ColorType::Luma), - 2 => (data.chunks_exact(2).take(n).map(|c| c[0]).collect::>().into(), jpeg_encoder::ColorType::Luma), + 2 => (data.as_chunks::<2>().0.iter().take(n).map(|c| c[0]).collect::>().into(), jpeg_encoder::ColorType::Luma), 3 => (data[..n * 3].into(), jpeg_encoder::ColorType::Rgb), _ => (data[..n * 4].into(), jpeg_encoder::ColorType::Rgba), }; @@ -254,7 +254,7 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod }}; } fn expand_ga(s: &[T], n: usize) -> Vec { - s.chunks_exact(2).take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect() + s.as_chunks::<2>().0.iter().take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect() } match img.samples { Samples::U8(s) => match ch { diff --git a/crates/codecs/src/icc.rs b/crates/codecs/src/icc.rs index 7850e61b..65112259 100644 --- a/crates/codecs/src/icc.rs +++ b/crates/codecs/src/icc.rs @@ -236,7 +236,7 @@ pub(crate) fn cms_to_linear_rec2020(icc: &[u8], samples: &[f32], channels: usize for (s, d) in src_chunks.zip(dst_chunks) { t.transform(s, d).ok()?; } - Some(out.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect()) + Some(out.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect()) }; std::panic::catch_unwind(run).ok().flatten() } diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index 988065eb..0096f905 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -159,7 +159,7 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) let (model, buf, depth) = match info.pixel_format { PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8), PixelFormat::L16 => { - let v = px.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + let v = px.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); (Model::Gray, Buf::U16(v), 16) } PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8), @@ -226,7 +226,7 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> { let Some(e) = entries.iter().find(|e| e.tag == 0xB002) else { return vec![] }; let Some(list) = t.bytes(e) else { return vec![] }; let mut out = Vec::new(); - for (i, rec) in list.chunks_exact(16).enumerate().take(16) { + for (i, rec) in list.as_chunks::<16>().0.iter().enumerate().take(16) { if i == 0 { continue; } diff --git a/crates/codecs/src/jpeg_par.rs b/crates/codecs/src/jpeg_par.rs index 1dfe5ecd..6ec9b6a8 100644 --- a/crates/codecs/src/jpeg_par.rs +++ b/crates/codecs/src/jpeg_par.rs @@ -274,7 +274,7 @@ pub fn encode( { let rows: Vec<(usize, Vec<&mut [f32]>)> = { let mut its: Vec<_> = full.iter_mut().map(|p| p.chunks_mut(width)).collect(); - (0..height).map(|y| (y, its.iter_mut().map(|it| it.next().unwrap()).collect())).collect() + (0..height).map_while(|y| Some((y, its.iter_mut().map(|it| it.next()).collect::>()?))).collect() }; let conv = |(y, mut out): (usize, Vec<&mut [f32]>)| { let src = &data[y * width * channels..(y + 1) * width * channels]; diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 2c98ea7c..3e8929e1 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -12,6 +12,7 @@ //! //! No decoder panics on malformed input (property-tested). #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod convert; pub mod encode; diff --git a/crates/codecs/src/png_codec.rs b/crates/codecs/src/png_codec.rs index a6704495..529d120d 100644 --- a/crates/codecs/src/png_codec.rs +++ b/crates/codecs/src/png_codec.rs @@ -37,7 +37,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { }; buf.truncate(out.buffer_size()); let (samples, depth) = match out.bit_depth { - png::BitDepth::Sixteen => (Buf::U16(buf.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16), + png::BitDepth::Sixteen => (Buf::U16(buf.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16), _ => (Buf::U8(buf), info.bit_depth as u8), }; let raw = Raw { width: out.width as usize, height: out.height as usize, model, alpha, premultiplied: false, buf: samples, bit_depth: depth }; diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index da9ea700..7d137d56 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -9,6 +9,7 @@ //! //! The pipeline's working space is **linear Rec.2020, D65** ([`WORKING`]). #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod cct; pub mod perceptual; @@ -125,14 +126,17 @@ pub const WORKING: RgbSpace = REC2020; impl RgbSpace { /// Matrix taking linear RGB in this space to XYZ (white Y = 1) relative to its own white. + /// Degenerate (collinear) primaries, which readers reject before building a space from a + /// file, leave the primaries unscaled instead of panicking. pub fn to_xyz(&self) -> Mat3 { let p = [self.r.to_xyz(), self.g.to_xyz(), self.b.to_xyz()]; let m = Mat3([[p[0][0], p[1][0], p[2][0]], [p[0][1], p[1][1], p[2][1]], [p[0][2], p[1][2], p[2][2]]]); - let s = m.inverse().expect("primaries are independent").apply(self.white.to_xyz()); + let s = m.inverse().map_or([1.0; 3], |inv| inv.apply(self.white.to_xyz())); m.mul(&Mat3::diag(s[0], s[1], s[2])) } + /// Inverse of [`to_xyz`](Self::to_xyz); the identity for degenerate primaries. pub fn from_xyz(&self) -> Mat3 { - self.to_xyz().inverse().expect("invertible") + self.to_xyz().inverse().unwrap_or(Mat3::IDENTITY) } /// Luminance weights (the Y row of `to_xyz`). pub fn luma(&self) -> [f64; 3] { @@ -154,7 +158,8 @@ pub fn bradford(src: Xy, dst: Xy) -> Mat3 { } let s = BRADFORD.apply(src.to_xyz()); let d = BRADFORD.apply(dst.to_xyz()); - let inv = BRADFORD.inverse().expect("invertible"); + // a constant, invertible matrix: the fallback (no adaptation) can't happen + let Some(inv) = BRADFORD.inverse() else { return Mat3::IDENTITY }; inv.mul(&Mat3::diag(d[0] / s[0], d[1] / s[1], d[2] / s[2])).mul(&BRADFORD) } diff --git a/crates/color/src/perceptual.rs b/crates/color/src/perceptual.rs index aa3ffbfa..78824321 100644 --- a/crates/color/src/perceptual.rs +++ b/crates/color/src/perceptual.rs @@ -28,9 +28,10 @@ fn mats() -> &'static OkMats { let to_lms = XYZ_TO_LMS.mul(&REC2020.to_xyz()); OkMats { to_lms: to_lms.to_f32(), - from_lms: to_lms.inverse().expect("invertible").to_f32(), + // constant, invertible matrices (round trips tested below): the identity fallback can't happen + from_lms: to_lms.inverse().unwrap_or(Mat3::IDENTITY).to_f32(), to_lab: LMS_TO_LAB.to_f32(), - from_lab: LMS_TO_LAB.inverse().expect("invertible").to_f32(), + from_lab: LMS_TO_LAB.inverse().unwrap_or(Mat3::IDENTITY).to_f32(), } }) } diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index f0ffffd2..72660afd 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -4,6 +4,7 @@ //! The pipeline evaluates it; the catalog stores it; presets, copy/paste, sync, versions and history //! are operations on it. Numeric sliders are addressed by id through [`controls`]. #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod controls; pub mod presets; diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 666477a2..35c54eff 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -250,7 +250,7 @@ pub fn specs() -> Vec { Ok(json!({"selected": s.selection.ids.len()})) }), cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| { - Ok(serde_json::to_value(crate::devices::devices()).unwrap_or_default()) + Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default()) }), cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| { let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?; @@ -745,10 +745,10 @@ pub fn specs() -> Vec { ), cmd!( "library.import", - "Add Photos…", + "Import Photos", ["File"], Some("Cmd+Shift+I"), - "{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), local?: bool (browsing: the photos stay out of the library, like library.browse), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}", + "{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), local?: bool (browsing: the photos stay out of the library, like library.browse), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}", always, |s, p| { let paths = strs(p, "paths"); @@ -773,11 +773,19 @@ pub fn specs() -> Vec { return Err(bad("library.import", "album must be a regular album")); } let organize = match str_param(p, "organize") { - Some(o) => { - crate::import::Organize::parse(o).ok_or_else(|| bad("library.import", format!("unknown organize `{o}` (date|month|flat)")))? - } + Some(o) => crate::import::Organize::parse(o).ok_or_else(|| { + bad( + "library.import", + format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"), + ) + })?, None => Default::default(), }; + if let crate::import::Organize::Template(t) = &organize + && let Some(e) = crate::rename::folder_template_error(t) + { + return Err(bad("library.import", e)); + } let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); if let Some(n) = &metadata_preset && !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n)) diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index 84b46ccc..8b9ee8a2 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -61,7 +61,10 @@ fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec { pub fn specs() -> Vec { vec![ - cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| { + cmd!(query "photo.renameTokens", "Rename Template Tags", [], None, "{} → {tokens: [{tag, aliases, meaning, example}], dateDirectives: [{directive, meaning}], notes: [..], sample} — the file-name template tags shared by photo.rename, library.import (rename) and app.export (naming); examples are for a sample photo", always, |_, _| { + Ok(crate::rename::token_help_json()) + }), + cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| { let (ids, template, start) = rename_args(s, p, "photo.renamePreview")?; Ok(serde_json::to_value(s.plan_rename(&ids, &template, start)).unwrap_or_default()) }), @@ -70,7 +73,7 @@ pub fn specs() -> Vec { "Rename Photos", [], None, - "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", + "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| { let (ids, template, start) = rename_args(s, p, "photo.rename")?; diff --git a/crates/engine/src/cmd/missing.rs b/crates/engine/src/cmd/missing.rs index 8ec96355..05bea65f 100644 --- a/crates/engine/src/cmd/missing.rs +++ b/crates/engine/src/cmd/missing.rs @@ -5,24 +5,48 @@ use std::collections::HashMap; use std::path::Path; -use lightcraft_catalog::{Op, PhotoId, Source}; +use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, str_param}; use crate::{Result, Session}; +/// The file Missing Photos checks for `p`, if it is in scope: a library photo (not in Recently +/// Deleted, not a Local-only record seen while browsing a folder) whose original is a file. +/// +/// Local browse records are out of scope on purpose: browsing can leave tens of thousands of +/// them in the catalog, they never show in library views (Missing Photos included), and a +/// folder that is gone is simply not browsed again. The sidebar count, the Missing Photos view, +/// `library.missing` and Find Missing Photos all use this one rule. +pub fn checked_path(p: &Photo) -> Option<&str> { + match &p.source { + Source::File { path } if p.in_library() => Some(path), + _ => None, + } +} + +/// Every in-scope photo's file ([`checked_path`]), without touching the disk. +pub fn candidates(cat: &Catalog) -> Vec { + cat.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect() +} + +/// [`missing`] with the existence check supplied (tests count the file-system calls). +pub fn missing_with(cat: &Catalog, mut exists: impl FnMut(&str) -> bool) -> Vec<(PhotoId, String)> { + cat.photos().filter_map(|p| checked_path(p).filter(|f| !exists(f)).map(|f| (p.id, f.to_string()))).collect() +} + /// Library photos whose original file can't be found: (id, path). pub fn missing(s: &Session) -> Vec<(PhotoId, String)> { if cfg!(target_arch = "wasm32") { return Vec::new(); } - s.catalog - .photos() - .filter_map(|p| match &p.source { - Source::File { path } if !p.deleted && !Path::new(path).exists() => Some((p.id, path.clone())), - _ => None, - }) - .collect() + missing_with(&s.catalog, |f| Path::new(f).exists()) +} + +/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only +/// the photos its query already narrowed to). +pub fn is_missing(cat: &Catalog, id: PhotoId) -> bool { + !cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| !Path::new(f).exists()) } fn relink_op(id: PhotoId, path: &str) -> Op { @@ -50,7 +74,11 @@ fn relink(s: &mut Session, p: &Value) -> Result { { ops.extend(crate::cmd::convert::content_op(id, ph, info)); } - s.commit("Relink Photo", if ops.len() == 1 { ops.pop().expect("one op") } else { Op::Batch { ops } })?; + let op = match <[Op; 1]>::try_from(ops) { + Ok([op]) => op, + Err(ops) => Op::Batch { ops }, + }; + s.commit("Relink Photo", op)?; s.media.forget(id); Ok(json!({"id": id.0, "path": abs})) } diff --git a/crates/engine/src/devices.rs b/crates/engine/src/devices.rs index 0ad26922..825ed5f6 100644 --- a/crates/engine/src/devices.rs +++ b/crates/engine/src/devices.rs @@ -1,6 +1,15 @@ //! Cameras and memory cards: mounted volumes with a `DCIM` folder (the DCF layout every camera -//! writes). Listed by `library.devices`; the app offers them under File → Add from Device, and +//! writes). Listed by `library.devices`; the app offers them under File → Import from Device, and //! the import review copies from them into the library. +//! +//! Menus are rebuilt every frame (even with their popups closed), so [`devices`] never touches the +//! disk: it serves the last scan and, when that is older than [`MAX_AGE`], starts one background +//! rescan (coalesced; no lock is held while it runs). A changed result calls the [`on_change`] +//! hook, which the UI uses to repaint, so hot-plugged cards still appear. [`devices_now`] scans +//! synchronously for explicit requests (`library.devices`, Add from Device without a path). + +use std::sync::{Arc, Mutex}; +use std::time::Duration; use serde::Serialize; @@ -14,6 +23,9 @@ pub struct Device { pub root: String, } +/// How long a scan is served before [`devices`] starts another one. +pub const MAX_AGE: Duration = Duration::from_secs(2); + /// Folders whose children are mounted volumes on this platform. `LIGHTCRAFT_DEVICE_ROOTS` /// (paths joined like `PATH`) replaces them (tests, unusual mounts). fn mount_parents() -> Vec { @@ -34,21 +46,114 @@ fn mount_parents() -> Vec { out } -/// Mounted volumes that look like a camera or a memory card. Cached for a couple of seconds: -/// menus ask often, and listing mounts can be slow (network volumes). -pub fn devices() -> Vec { - use std::sync::Mutex; - use std::time::{Duration, Instant}; - static CACHE: Mutex)>> = Mutex::new(None); - let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some((t, d)) = c.as_ref() - && t.elapsed() < Duration::from_secs(2) - { - return d.clone(); +type Scanner = dyn Fn() -> Vec + Send + Sync; +type Notify = dyn Fn() + Send + Sync; + +#[derive(Default)] +struct WatchState { + list: Vec, + /// When the served list was scanned (`None`: never). No clock (and no threads) on wasm, where + /// there are no mounted volumes either. + #[cfg(not(target_arch = "wasm32"))] + scanned: Option, + /// A background scan is running. + refreshing: bool, +} + +/// A cached device list, refreshed off the calling thread. +pub struct DeviceWatch { + scanner: Box, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + max_age: Duration, + state: Mutex, + notify: Mutex>>, +} + +impl DeviceWatch { + pub fn new(scanner: impl Fn() -> Vec + Send + Sync + 'static, max_age: Duration) -> Arc { + Arc::new(DeviceWatch { scanner: Box::new(scanner), max_age, state: Mutex::default(), notify: Mutex::new(None) }) } - let d = scan(); - *c = Some((Instant::now(), d.clone())); - d + + fn state(&self) -> std::sync::MutexGuard<'_, WatchState> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Called (on the scanning thread) whenever a scan changes the list. + pub fn on_change(&self, f: impl Fn() + Send + Sync + 'static) { + *self.notify.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(f)); + } + + /// The last scan, immediately; starts a background rescan when it is stale and none is running. + pub fn get(self: &Arc) -> Vec { + let mut s = self.state(); + #[cfg(not(target_arch = "wasm32"))] + if !s.refreshing && s.scanned.is_none_or(|t| t.elapsed() >= self.max_age) { + s.refreshing = true; + let me = self.clone(); + // the scan runs without the state lock; `get` only ever waits for a list clone + let spawned = std::thread::Builder::new().name("lightcraft-devices".into()).spawn(move || { + let list = (me.scanner)(); + me.store(list); + }); + if spawned.is_err() { + s.refreshing = false; + } + } + s.list.clone() + } + + /// Scan now on this thread, and serve the result to later [`DeviceWatch::get`] calls. + pub fn scan_now(&self) -> Vec { + let list = (self.scanner)(); + self.store(list.clone()); + list + } + + fn store(&self, list: Vec) { + let changed = { + let mut s = self.state(); + s.refreshing = false; + #[cfg(not(target_arch = "wasm32"))] + { + s.scanned = Some(std::time::Instant::now()); + } + let changed = s.list != list; + s.list = list; + changed + }; + if changed { + let f = self.notify.lock().unwrap_or_else(|e| e.into_inner()).clone(); + if let Some(f) = f { + f(); + } + } + } + + /// A background scan is running. + pub fn refreshing(&self) -> bool { + self.state().refreshing + } +} + +fn global() -> &'static Arc { + static WATCH: std::sync::OnceLock> = std::sync::OnceLock::new(); + WATCH.get_or_init(|| DeviceWatch::new(scan, MAX_AGE)) +} + +/// Mounted volumes that look like a camera or a memory card, without blocking: the last scan, +/// refreshed in the background when older than [`MAX_AGE`] (empty until the first scan lands). +pub fn devices() -> Vec { + global().get() +} + +/// Scan the mounted volumes now (blocking), for explicit requests. +pub fn devices_now() -> Vec { + global().scan_now() +} + +/// Run `f` whenever a scan changes the device list (the UI requests a repaint). +pub fn on_change(f: impl Fn() + Send + Sync + 'static) { + global().on_change(f); } fn scan() -> Vec { @@ -73,10 +178,7 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec roots.extend(extra); let mut out: Vec = Vec::new(); for root in roots { - let Ok(rd) = std::fs::read_dir(&root) else { continue }; - let Some(dcim) = rd.flatten().map(|e| e.path()).find(|p| p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM"))) else { - continue; - }; + let Some(dcim) = dcim_in(&root) else { continue }; let name = root.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| root.to_string_lossy().to_string()); if !out.iter().any(|d| d.root == root.to_string_lossy()) { out.push(Device { name, path: dcim.to_string_lossy().to_string(), root: root.to_string_lossy().to_string() }); @@ -85,9 +187,22 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec out } +/// The volume's `DCIM` folder: the usual spellings are checked directly, so a volume's root is +/// only listed (other spellings on a case-sensitive volume) off Windows, whose drives aren't. +fn dcim_in(root: &std::path::Path) -> Option { + if let Some(p) = ["DCIM", "dcim"].iter().map(|n| root.join(n)).find(|p| p.is_dir()) { + return Some(p); + } + if cfg!(windows) { + return None; + } + std::fs::read_dir(root).ok()?.flatten().map(|e| e.path()).find(|p| p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")) && p.is_dir()) +} + #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; #[test] fn volumes_with_a_dcim_folder_are_devices() { @@ -96,10 +211,76 @@ mod tests { std::fs::create_dir_all(base.join("EOS_DIGITAL/DCIM/100CANON")).unwrap(); std::fs::create_dir_all(base.join("Backup/Photos")).unwrap(); std::fs::create_dir_all(base.join("SD/dcim")).unwrap(); + std::fs::create_dir_all(base.join("X/Dcim")).unwrap(); let d = devices_in(std::slice::from_ref(&base), Vec::new()); let names: Vec<&str> = d.iter().map(|x| x.name.as_str()).collect(); - assert_eq!(names, ["EOS_DIGITAL", "SD"], "{d:?}"); - assert!(d[0].path.ends_with("DCIM") && d[1].path.ends_with("dcim")); + assert_eq!(names, ["EOS_DIGITAL", "SD", "X"], "{d:?}"); + // the DCIM folder itself, in a spelling that opens on that volume + for x in &d { + let p = std::path::Path::new(&x.path); + assert!(p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")), "{x:?}"); + } let _ = std::fs::remove_dir_all(&base); } + + fn card(name: &str) -> Device { + Device { name: name.into(), path: format!("/v/{name}/DCIM"), root: format!("/v/{name}") } + } + + /// Wait (bounded) for `f`. + fn until(f: impl Fn() -> bool) -> bool { + let t = std::time::Instant::now(); + while t.elapsed() < Duration::from_secs(20) { + if f() { + return true; + } + std::thread::sleep(Duration::from_millis(5)); + } + false + } + + #[test] + fn slow_scans_never_block_and_hot_plug_still_arrives() { + // a scanner that takes 1.5 s (a sleeping network drive) and counts its runs + const SLOW: Duration = Duration::from_millis(1500); + let mounted = Arc::new(Mutex::new(Vec::::new())); + let runs = Arc::new(AtomicUsize::new(0)); + let (m, r) = (mounted.clone(), runs.clone()); + let watch = DeviceWatch::new( + move || { + r.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(SLOW); + m.lock().unwrap().clone() + }, + Duration::from_millis(20), + ); + let changes = Arc::new(AtomicUsize::new(0)); + let c = changes.clone(); + watch.on_change(move || { + c.fetch_add(1, Ordering::SeqCst); + }); + // cold: served at once (empty) while one scan runs in the background + let t = std::time::Instant::now(); + for _ in 0..50 { + assert!(watch.get().is_empty()); + } + assert!(t.elapsed() < SLOW / 2, "get() waited for the scan: {:?}", t.elapsed()); + assert!(watch.refreshing()); + assert!(until(|| !watch.refreshing())); + assert_eq!(runs.load(Ordering::SeqCst), 1, "requests while scanning are coalesced"); + assert_eq!(changes.load(Ordering::SeqCst), 0, "nothing changed"); + // a card is plugged in: the next stale request starts a rescan, still without waiting + mounted.lock().unwrap().push(card("EOS_DIGITAL")); + std::thread::sleep(Duration::from_millis(40)); + let t = std::time::Instant::now(); + assert!(watch.get().is_empty(), "the old list is served while rescanning"); + assert!(t.elapsed() < SLOW / 2, "warm get() waited: {:?}", t.elapsed()); + assert!(until(|| changes.load(Ordering::SeqCst) == 1), "a changed scan notifies"); + assert_eq!(watch.get(), [card("EOS_DIGITAL")]); + // explicit requests scan synchronously + assert!(until(|| !watch.refreshing())); + mounted.lock().unwrap().clear(); + assert!(watch.scan_now().is_empty()); + assert_eq!(changes.load(Ordering::SeqCst), 2); + } } diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 9c3c49a6..70f89a7a 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -553,10 +553,9 @@ impl ExportOptions { r(ResizeMode::ShortEdge, v) } else if let Some(v) = f("megapixels") { r(ResizeMode::Megapixels, v) - } else if let Some(v) = f("percent") { - r(ResizeMode::Percent, v) } else { - return None; + let v = f("percent")?; + r(ResizeMode::Percent, v) } } }) @@ -659,7 +658,7 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar let (w, h) = (img.width, img.height); match &mut img.samples { DeepSamples::U16(v) => { - let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect(); + let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect(); if let Some(out) = unsharp(w, h, &src, target, amount) { for (d, s) in v.iter_mut().zip(out.as_flattened()) { *d = s.round().clamp(0.0, 65535.0) as u16; @@ -667,7 +666,7 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar } } DeepSamples::F32(v) => { - let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(); + let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect(); if let Some(out) = unsharp(w, h, &src, target, amount) { for (d, s) in v.iter_mut().zip(out.as_flattened()) { *d = s.max(0.0); diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs new file mode 100644 index 00000000..4ab53179 --- /dev/null +++ b/crates/engine/src/guard.rs @@ -0,0 +1,55 @@ +//! Last-resort panic guard (never-crash standard, `AGENTS.md`): a panic that escapes a command +//! becomes that command's error and the session (catalog, library, undo history) stays usable. +//! It's a safety net, not a way to report errors: commands return `Err` themselves. + +use std::any::Any; +use std::io::Write; +use std::panic::{self, AssertUnwindSafe}; +use std::path::PathBuf; + +/// The text of a panic payload (`panic!("…")` gives a `&str` or a `String`). +pub fn panic_message(payload: &(dyn Any + Send)) -> String { + match (payload.downcast_ref::<&str>(), payload.downcast_ref::()) { + (Some(s), _) => (*s).to_string(), + (_, Some(s)) => s.clone(), + _ => "unknown panic".into(), + } +} + +/// Run `f`; a panic inside it becomes `Err(" failed unexpectedly: ")`. On wasm32 +/// (built with `panic = "abort"`) nothing can be caught and this just runs `f`. +pub fn catch(what: &str, f: impl FnOnce() -> T) -> Result { + panic::catch_unwind(AssertUnwindSafe(f)).map_err(|p| { + let msg = format!("{what} failed unexpectedly: {}", panic_message(p.as_ref())); + log::error!("{msg}"); + msg + }) +} + +/// Install a panic hook that keeps the default report (stderr) and also appends the panic's +/// location and message to `log` (best effort), so a panic in a GUI session leaves a trace. +pub fn install_hook(log: PathBuf) { + let default = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + default(info); + let at = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default(); + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let thread = std::thread::current().name().unwrap_or("unnamed").to_string(); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log) { + let _ = writeln!(f, "{secs} [{thread}] panic at {at}: {}", panic_message(info.payload())); + } + })); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_panic_becomes_an_error() { + assert_eq!(catch("ok", || 7), Ok(7)); + let e = catch("`demo.cmd`", || -> u32 { panic!("boom {}", 1) }).unwrap_err(); + assert_eq!(e, "`demo.cmd` failed unexpectedly: boom 1"); + assert!(catch("x", || std::panic::panic_any(5u8)).unwrap_err().ends_with("unknown panic")); + } +} diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 5b9211d3..b6a08adf 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -8,8 +8,9 @@ //! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch). //! 5. *Add* in place (the photo points at the original file) or *copy* into the library's //! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy. -//! 6. Read each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for -//! metadata and develop settings are restored (see [`crate::sidecar`]). +//! 6. Apply each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for +//! metadata and develop settings are restored (see [`crate::sidecar`]). Its capture time fills +//! in when the file has none (read before step 5, so it also files and names the copy). //! 7. Optionally apply a preset and add keywords (the import dialog's options). //! 8. Commit all new photos as one undoable op. //! @@ -66,8 +67,9 @@ pub struct ImportOptions { pub convert_dng: bool, } -/// How copies are filed in the destination. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +/// How copies are filed in the destination. The date is the capture time, else the time of the +/// import. +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub enum Organize { /// `YYYY/YYYY-MM-DD/` by capture date. #[default] @@ -76,17 +78,36 @@ pub enum Organize { ByMonth, /// All in the destination itself. Flat, + /// A folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → `2026/20260114/`: the template's own + /// `/` make the levels, each expanded with the file-name tokens ([`crate::rename::expand_folder`]; + /// never outside the destination). + Template(String), } impl Organize { + /// `date` | `month` | `flat`, or a folder template (anything with a `{`, `/` or `\`). pub fn parse(s: &str) -> Option { match s { "date" | "day" | "byDay" => Some(Organize::ByDay), "month" | "byMonth" => Some(Organize::ByMonth), "flat" | "none" | "intoOneFolder" => Some(Organize::Flat), + t if t.contains(['{', '/', '\\']) => Some(Organize::Template(t.trim().to_string())), _ => None, } } + + /// The folders (inside the destination) a copy of `p` goes to. + pub fn folders(&self, p: &Photo) -> Vec { + let date = p.date(); + let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated"); + let year = day.get(..4).unwrap_or("undated"); + match self { + Organize::ByDay => vec![year.into(), day.into()], + Organize::ByMonth => vec![year.into(), day.get(..7).unwrap_or("undated").into()], + Organize::Flat => Vec::new(), + Organize::Template(t) => crate::rename::expand_folder(t, p, 1), + } + } } /// A file found by [`scan`], for the import review. @@ -322,16 +343,10 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress .collect() } -/// Copy `src` into `root` (filed per `organize` by `date`) as `name` (default: its own name), -/// made unique with -1, -2…; returns the new path. -fn copy_into(root: &Path, src: &str, date: &str, organize: Organize, name: Option<&str>) -> Result { - let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated"); - let year = day.get(..4).unwrap_or("undated"); - let dir = match organize { - Organize::ByDay => root.join(year).join(day), - Organize::ByMonth => root.join(year).join(day.get(..7).unwrap_or("undated")), - Organize::Flat => root.to_path_buf(), - }; +/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…; +/// returns the new path. +fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>) -> Result { + let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; let name = name .map(str::to_string) @@ -553,21 +568,38 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" }); continue; } + // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files + // and names the copy when the file itself has none + let raw = info.kind == MediaKind::Raw; + let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming) + .and_then(|f| std::fs::read_to_string(f).ok()) + .or_else(|| info.xmp.clone().filter(|_| raw)); + let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) { + Ok(sc) => (sc != crate::sidecar::SidecarData::default()).then_some(sc), + Err(e) => { + log::warn!("import {path}: XMP: {e}"); + None + } + }); + let mut info = info; + if info.captured.is_none() { + info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone()); + } let stored = match (mode, ©_root) { (ImportMode::Copy, Some(root)) if !Path::new(&path).starts_with(root) && !lib_dir.as_ref().is_some_and(|l| Path::new(&path).starts_with(l)) => { + // the templates see the photo as it will be catalogued (undated: the import time) + let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now); + q.captured = info.captured.clone(); + q.meta = info.meta.clone(); let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| { - // the template sees the photo as it will be catalogued - let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now); - q.captured = info.captured.clone(); - q.meta = info.meta.clone(); let n = crate::rename::expand(t, &q, seq); seq += 1; n }); - match copy_into(root, &path, info.captured.as_deref().unwrap_or(&now), opts.organize, name.as_deref()) { + match copy_into(root, &path, &opts.organize.folders(&q), name.as_deref()) { Ok(p) => p, Err(e) => { report.failed.push((path, e)); @@ -583,7 +615,6 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c } // Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace) let mut stored = stored; - let mut info = info; if opts.convert_dng && mode == ImportMode::Copy && stored != path && info.kind == MediaKind::Raw && !info.format.eq_ignore_ascii_case("DNG") { match crate::cmd::convert::write_dng_for(s, &stored, String::new()) { Ok(dng) => { @@ -606,19 +637,9 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c p.embedded_lens = info.embedded_lens; p.preview_only = info.preview_only.clone(); apply_import_defaults(s, &mut p); - let raw = p.kind == lightcraft_catalog::MediaKind::Raw; - let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming) - .and_then(|f| std::fs::read_to_string(f).ok()) - .or_else(|| info.xmp.clone().filter(|_| raw)); - if let Some(x) = packet { - match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) { - Ok(sc) if sc != crate::sidecar::SidecarData::default() => { - crate::sidecar::merge_into(&mut p, &sc, &now); - report.sidecars += 1; - } - Ok(_) => {} - Err(e) => log::warn!("import {path}: XMP: {e}"), - } + if let Some(sc) = &sidecar { + crate::sidecar::merge_into(&mut p, sc, &now); + report.sidecars += 1; } if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) { crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ce0404f2..3e7ddf1e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -17,6 +17,7 @@ pub mod demo; pub mod devices; pub mod export; pub mod files; +pub mod guard; pub mod import; pub mod library; pub mod media; @@ -237,7 +238,8 @@ impl Session { let log_start = self.pending_log.len(); let was_active = self.active(); self.depth += 1; - let r = (spec.run)(self, params); + // last-resort guard: a panic in a command is that command's error, not a crash + let r = guard::catch(&format!("`{id}`"), || (spec.run)(self, params)).unwrap_or_else(|e| Err(EngineError::Other(e))); self.depth -= 1; if self.depth == 0 && was_active.is_some() && self.active() != was_active { self.previous_active = was_active; @@ -507,8 +509,9 @@ impl Session { }); } if self.source == LibrarySource::Missing { - let missing: std::collections::HashSet = cmd::missing::missing(self).into_iter().map(|m| m.0).collect(); - self.visible.retain(|id| missing.contains(id)); + // only the photos the query kept (library photos, not Local browse records) are checked + let cat = &self.catalog; + self.visible.retain(|id| cmd::missing::is_missing(cat, *id)); } if self.source != LibrarySource::RecentlyDeleted { self.visible = self.catalog.arrange_stacks(&self.visible); diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 35d46580..6644efa3 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -228,8 +228,8 @@ impl Session { self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes()); } let view_written = self.view_json(); - self.library = Some(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written }); - Ok(&self.library.as_ref().expect("just set").report) + let library = self.library.insert(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written }); + Ok(&library.report) } /// Write pending ops to the log (fsynced), compact when due, and save changed presets. diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index c7bab82f..24c65489 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -309,14 +309,10 @@ fn output_path(first: &str, suffix: &str) -> String { let p = std::path::Path::new(first); let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default(); let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Merge".into()); - for k in 1.. { - let name = if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") }; - let cand = dir.join(name); - if !cand.exists() { - return cand.to_string_lossy().to_string(); - } - } - unreachable!() + let cand = |k: u64| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") }); + // (`u64::MAX` taken names can't exist; the last candidate is the fallback) + let free = (1..u64::MAX).map(cand).find(|c| !c.exists()).unwrap_or_else(|| cand(u64::MAX)); + free.to_string_lossy().to_string() } /// Parse `merge.*` command parameters into a kind and finishing options. diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 2b540c85..4b956c84 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -82,6 +82,148 @@ fn format_date(iso: &str, fmt: &str) -> String { out } +/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and +/// command description shows; a test checks that [`expand_tokens`] knows each of them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct TokenHelp { + /// The token as typed, e.g. `{seq:3}`. + pub tag: &'static str, + /// Other spellings with the same meaning. + pub aliases: &'static [&'static str], + pub meaning: &'static str, +} + +/// Every template token, in the order the help shows them. +pub const TOKENS: &[TokenHelp] = &[ + TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" }, + TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" }, + TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" }, + TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" }, + TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" }, + TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" }, + TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" }, + TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" }, + TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" }, + TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" }, + TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" }, + TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" }, + TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" }, + TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" }, +]; + +/// The `%` directives of `{date:…}`. +pub const DATE_DIRECTIVES: &[(&str, &str)] = &[ + ("%Y", "year, 4 digits"), + ("%y", "year, 2 digits"), + ("%m", "month 01–12"), + ("%d", "day 01–31"), + ("%H", "hour 00–23"), + ("%M", "minute"), + ("%S", "second"), + ("%%", "a literal %"), +]; + +/// How templates behave, one sentence each (shown under the token list). +pub const TEMPLATE_NOTES: &[&str] = &[ + "The original extension is always added; {ext} only puts it inside the name as well.", + "A blank template keeps the original names.", + "{date} is the capture time; a photo without one uses the time it was imported.", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.", + "Unknown tags stay as typed — check the preview for a {typo}.", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….", +]; + +/// The tokens as one line (`{name} {num} {seq} …`), for compact hints. +pub fn token_summary() -> String { + TOKENS.iter().map(|t| t.tag).collect::>().join(" ") +} + +/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48). +pub fn sample_photo() -> Photo { + let mut p = + Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00"); + p.captured = Some("2026-01-14T05:58:48".into()); + p.rating = 4; + p.meta.camera = "Canon EOS R5".into(); + p.meta.lens = "RF24-70mm F2.8".into(); + p.meta.iso = Some(400); + p.meta.title = "Harbour".into(); + p.meta.creator = "Ann Lee".into(); + p +} + +/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column. +pub fn token_example(tag: &str) -> String { + expand_tokens(tag, &sample_photo(), 1, 1) +} + +/// Why a folder template (`{date:%Y}/{date:%Y%m%d}`) can't be used, if it can't: it must be +/// relative (no leading `/` or `\`, drive letter or `~`) and have no `.` / `..` levels. +pub fn folder_template_error(template: &str) -> Option { + let t = template.trim(); + if t.is_empty() { + return Some("the folder template is empty".into()); + } + let b = t.as_bytes(); + if t.starts_with(['/', '\\', '~']) || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') { + return Some("the folder template must be relative to the destination (no leading /, \\, ~ or drive)".into()); + } + if t.split(['/', '\\']).any(|s| matches!(s.trim(), "." | "..")) { + return Some("the folder template may not contain . or .. folders".into()); + } + None +} + +/// The folders (relative to the destination) a folder template gives `p`: the template's own `/` +/// (or `\`) separate levels; each level's tokens are expanded and the result made a safe folder +/// name — a value can never add a level or climb out (`/`, `\`, `:`… become `-`, leading and +/// trailing dots are dropped). A level that comes out empty (missing metadata) is `unknown`; empty +/// levels in the template (`a//b`) are skipped. `{date}` is the capture time, else [`Photo::date`]'s +/// fallback (the import time). Check [`folder_template_error`] first; levels it rejects are skipped. +pub fn expand_folder(template: &str, p: &Photo, seq: usize) -> Vec { + template + .split(['/', '\\']) + .map(str::trim) + .filter(|s| !s.is_empty() && !matches!(*s, "." | "..")) + .map(|s| { + let v = sanitize(&expand_tokens(s, p, seq, 1)); + if v.is_empty() { "unknown".to_string() } else { v } + }) + .collect() +} + +/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning. +pub fn unknown_tokens(template: &str) -> Vec { + let p = sample_photo(); + let mut out = Vec::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + let Some(j) = rest[i..].find('}') else { break }; + let tag = &rest[i..i + j + 1]; + if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) { + out.push(tag.to_string()); + } + rest = &rest[i + j + 1..]; + } + out +} + +/// Every token with its meaning and example, the date directives and the notes, as JSON (the +/// `photo.renameTokens` command). +pub fn token_help_json() -> serde_json::Value { + let tokens: Vec = TOKENS + .iter() + .map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)})) + .collect(); + let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect(); + serde_json::json!({ + "tokens": tokens, + "dateDirectives": directives, + "notes": TEMPLATE_NOTES, + "sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48", + }) +} + /// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension. pub fn expand(template: &str, p: &Photo, seq: usize) -> String { let (stem, ext) = split_ext(&p.file_name); @@ -358,6 +500,66 @@ impl Session { mod tests { use super::*; + #[test] + fn folder_templates_stay_inside_the_destination() { + let mut p = sample_photo(); + assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]); + assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too"); + // metadata can't add levels or climb out + p.meta.camera = "../../etc/x".into(); + p.meta.title = "..".into(); + assert_eq!(expand_folder("{camera}/{title}/{date:%Y}", &p, 1), vec!["-..-etc-x", "unknown", "2026"]); + p.meta.camera = "C:\\Windows".into(); + assert_eq!(expand_folder("{camera}", &p, 1), vec!["C--Windows"]); + // missing metadata → `unknown`; missing capture time → the import time + p.meta.camera.clear(); + p.captured = None; + assert_eq!(expand_folder("{camera}//{date:%Y%m%d}", &p, 1), vec!["unknown", "20260120"]); + for bad in ["", " ", "/abs/{date}", "\\\\server\\x", "C:/x", "c:x", "~/x", "../{date}", "{date}/../x", "a/./b", "{date:%Y}/.."] { + assert!(folder_template_error(bad).is_some(), "{bad:?} should be rejected"); + } + for ok in ["{date:%Y}/{date:%Y%m%d}", "Trips/{camera}", "{date}", "a//b"] { + assert_eq!(folder_template_error(ok), None, "{ok:?}"); + } + // even unchecked, rejected levels are skipped rather than followed + assert_eq!(expand_folder("/../{date:%Y}/./x", &p, 1), vec!["2026", "x"]); + } + + /// The help lists exactly what `expand_tokens` understands: every listed tag (and alias) is + /// replaced, never left literal, and the help's examples match the documented behaviour. + #[test] + fn token_help_matches_the_implementation() { + let p = sample_photo(); + for t in TOKENS { + for tag in std::iter::once(&t.tag).chain(t.aliases) { + let v = expand_tokens(tag, &p, 1, 1); + assert!(!v.contains('{'), "{tag} is not a known token (expanded to {v:?})"); + assert!(!v.is_empty(), "{tag}: the sample photo should give an example"); + } + } + assert_eq!(token_example("{seq:3}"), "001"); + assert_eq!(token_example("{date}"), "20260114"); + assert_eq!(token_example("{date:%Y%m%d_%H%M%S}"), "20260114_055848"); + assert_eq!(token_example("{ext}"), "CR3"); + assert_eq!(expand("{date:%Y%m%d_%H%M%S}_{seq:3}", &p, 1), "20260114_055848_001.CR3"); + for (d, _) in DATE_DIRECTIVES { + let v = format_date("2026-01-14T05:58:48", d); + assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive"); + } + assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]); + assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty()); + let json = token_help_json(); + assert_eq!(json["tokens"].as_array().unwrap().len(), TOKENS.len()); + assert_eq!(json["tokens"][0]["example"], "IMG_0042"); + // the command descriptions that list the tokens list all of them + for id in ["photo.rename", "photo.renamePreview", "library.import"] { + let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap(); + for t in TOKENS.iter().filter(|t| !t.tag.contains(':')) { + assert!(spec.params.contains(t.tag), "{id} does not mention {}", t.tag); + } + } + } + #[test] fn templates() { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "IMG_0042.CR2", "CR2", 1, 1, "2026-01-01T00:00:00"); diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 030f79eb..a49e5fee 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -5,7 +5,9 @@ //! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop //! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`). //! -//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata; develop +//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except +//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`, +//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop //! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:` //! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used. @@ -84,6 +86,9 @@ pub struct SidecarData { pub alt_text: Option, pub extended_description: Option, pub keywords: Option>, + /// Capture time (ISO 8601) from `exif:DateTimeOriginal`, `photoshop:DateCreated` or + /// `xmp:CreateDate` (first found). Used only when the file itself has no capture time. + pub captured: Option, pub develop: Option, } @@ -115,6 +120,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result std::result::Result bool { + if p.captured.is_none() { + p.captured = sc.captured.clone(); + } if let Some(r) = sc.rating { p.rating = r; } @@ -321,6 +331,9 @@ impl Session { Op::SetLabel { id, label: q.label }, Op::SetMeta { id, meta: Box::new(q.meta.clone()) }, ]; + if q.captured != p.captured { + ops.push(Op::SetCaptured { id, captured: q.captured.clone() }); + } if develop_changed { ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File")); } diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 2a1480ba..6be7ce9a 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -257,6 +257,37 @@ fn copy_with_destination_organize_rename_and_metadata_preset() { } } +/// Copy with a custom folder template: `{date:%Y}/{date:%Y%m%d}` files a photo under +/// `2026/20260114/` (an undated file by the import time); templates that would leave the +/// destination are refused. +#[test] +fn copy_with_a_folder_template() { + let src = temp_dir("tplsrc"); + let dest = temp_dir("tpldest"); + write_png(&src.join("a.png"), 1); + let mut s = Session::new().with_fs(); + s.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); + for bad in ["../{date}", "/tmp/{date}", "{date:%Y}/../x", "C:/x"] { + let r = s.execute( + "library.import", + &json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": bad}), + ); + assert!(r.is_err(), "{bad} should be refused"); + } + assert_eq!(s.catalog.len(), 0); + let r = s + .execute( + "library.import", + &json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "{date:%Y}/{date:%Y%m%d}"}), + ) + .unwrap(); + assert_eq!(ids(&r, "imported"), 1, "{r}"); + assert!(dest.join("2026").join("20260114").join("a.png").is_file()); + for d in [&src, &dest] { + let _ = std::fs::remove_dir_all(d); + } +} + /// Auto import: files in the watched folder are added once their size held between two scans, /// into the named album; non-photos are tried once; the selection stays put. #[test] @@ -585,3 +616,31 @@ fn smart_previews_folder_is_chosen_per_library() { assert_eq!(r["custom"], false, "{r}"); assert_eq!(r["path"], lib.join("Smart Previews").to_string_lossy().as_ref()); } + +/// Missing Photos covers library photos only: a small library next to many Local browse +/// records (and a deleted photo) checks — and lists, and counts — the library files alone. +#[test] +fn missing_photos_skip_local_browse_records() { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let gone = std::env::temp_dir().join(format!("lc-missing-scope-{}", std::process::id())); + let mut s = Session::new(); + let mut ops = Vec::new(); + for i in 0..205u64 { + let path = gone.join(format!("IMG_{i:04}.jpg")).to_string_lossy().to_string(); + let mut p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:04}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); + p.local = i >= 5; // 5 library photos, 200 seen while browsing + p.deleted = i == 4; + ops.push(Op::AddPhoto { photo: Box::new(p) }); + } + s.commit("Add", Op::Batch { ops }).unwrap(); + let mut checked = 0; + let lost = crate::cmd::missing::missing_with(&s.catalog, |_| { + checked += 1; + false + }); + assert_eq!((checked, lost.len()), (4, 4), "only the non-deleted library files are checked"); + assert_eq!(crate::cmd::missing::candidates(&s.catalog).len(), 4, "the sidebar count checks the same files"); + assert_eq!(s.execute("library.missing", &json!({})).unwrap().as_array().map(Vec::len), Some(4)); + s.execute("library.source", &json!({"kind": "missing"})).unwrap(); + assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts"); +} diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index 44a904a6..a0784dd1 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -21,7 +21,7 @@ fn import(s: &mut Session, paths: Vec) -> Vec { fn hdr_merge_command_creates_and_imports_a_dng() { let dir = temp_dir("hdr"); let mut paths = Vec::new(); - for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).into_iter().enumerate() { + for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() { let p = dir.join(format!("IMG_{i}.dng")); std::fs::write(&p, b).unwrap(); paths.push(p.to_string_lossy().to_string()); @@ -71,7 +71,7 @@ fn hdr_merge_command_creates_and_imports_a_dng() { fn panorama_command_with_auto_crop() { let dir = temp_dir("pano"); let mut paths = Vec::new(); - for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).into_iter().enumerate() { + for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).unwrap().into_iter().enumerate() { let img = v.to_srgb8(); let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); let p = dir.join(format!("P_{i}.png")); diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 6545cdb0..00b8e3b0 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -446,6 +446,58 @@ fn dng_export_compression_choices() { assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}"); } +fn write_jpeg(path: &Path, capture: Option<&str>) { + let (w, h) = (32usize, 24usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 7) as u8, (i / w * 9) as u8, 90, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let exif = capture.map(|c| { + lightcraft_meta::write_exif(&lightcraft_meta::Metadata { capture_time: lightcraft_meta::DateTime::parse_iso(c), ..Default::default() }) + }); + let meta = lightcraft_codecs::EncodeMeta { exif: exif.as_deref(), ..Default::default() }; + let bytes = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +const DATED_SIDECAR: &str = r#" + + + +"#; + +#[test] +fn sidecar_capture_time_fills_in_when_the_file_has_none() { + let src = temp_dir("sidecar-date"); + write_jpeg(&src.join("sample.jpg"), None); + std::fs::write(src.join("sample.jpg.xmp"), DATED_SIDECAR).unwrap(); + // the embedded capture time wins over the sidecar's + write_jpeg(&src.join("dated.jpg"), Some("2020-05-06T07:08:09")); + std::fs::write(src.join("dated.jpg.xmp"), DATED_SIDECAR).unwrap(); + let lib = temp_dir("sidecar-date-lib"); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + // copy: the sidecar's date also files the copy + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).unwrap(); + assert_eq!(r["sidecars"], 2, "{r}"); + let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().clone(); + let sample = by_name(&s, "sample.jpg"); + assert_eq!(sample.captured.as_deref(), Some("2026-01-14T05:58:48")); + let lightcraft_catalog::Source::File { path } = &sample.source else { panic!("not a file") }; + assert!(Path::new(path).starts_with(lib.join("Originals").join("2026").join("2026-01-14")), "{path}"); + assert_eq!(by_name(&s, "dated.jpg").captured.as_deref(), Some("2020-05-06T07:08:09")); + + // Read Metadata from File fills in a missing capture time too + let id = sample.id; + s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: None }).unwrap(); + std::fs::copy(src.join("sample.jpg.xmp"), format!("{path}.xmp")).unwrap(); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.readMetadataFromFile", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2026-01-14T05:58:48")); + let _ = std::fs::remove_dir_all(&src); + let _ = std::fs::remove_dir_all(&lib); +} + /// Copyright status, rights usage terms and copyright info URL: edited with `photo.setMeta` (one /// undo step), copied / pasted, kept in metadata presets, written to the sidecar as XMP Rights /// Management fields and read back on a fresh import. diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 530d089d..509285aa 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -3,6 +3,7 @@ //! Conventions: image coordinates are y-down. *Normalized* coordinates map the full (uncropped, //! oriented) image to `0..1` on both axes, so tools and settings are resolution independent. #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod crop; mod homography; diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index ad40d680..1424d6a7 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -86,6 +86,9 @@ pub struct Buf { impl Buf { pub fn raw(&self) -> &wgpu::Buffer { + // `buf` is `Some` from construction (`Gpu::buffer`/`upload`) until `Drop` takes it back + // for the pool, so a live `Buf` always has it. + #[allow(clippy::expect_used)] &self.buf.as_ref().expect("live buffer").0 } } @@ -442,7 +445,12 @@ impl Gpu { /// Record kernel `name` with parameters `p` over buffers `bufs` (None = unused binding). pub fn run(&self, enc: &mut wgpu::CommandEncoder, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) { - let k = self.kernels.get(name).unwrap_or_else(|| panic!("unknown kernel {name}")); + let Some(k) = self.kernels.get(name) else { + // a kernel name typo: fail this render over to the CPU instead of crashing + log::error!("gpu: unknown kernel {name}"); + crate::mark_broken(); + return; + }; assert_eq!(bufs.len(), k.nbuf, "{name}: binding count"); let params = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, @@ -479,11 +487,21 @@ impl Gpu { let _ = tx.send(r); }); let _ = self.device.poll(wgpu::PollType::wait_indefinitely()); + // A failed readback (device lost, out of memory) marks the GPU broken: the caller + // (`crate::render`) then discards this render's zeroed result and renders on the CPU. + let failed = |why: String| { + log::error!("gpu readback failed: {why}"); + crate::mark_broken(); + vec![::zeroed(); len] + }; match rx.recv() { Ok(Ok(())) => {} - other => panic!("gpu readback failed: {other:?}"), + other => return failed(format!("{other:?}")), } - let data = slice.get_mapped_range().expect("mapped"); + let data = match slice.get_mapped_range() { + Ok(d) => d, + Err(e) => return failed(format!("{e:?}")), + }; let out: Vec = bytemuck::cast_slice(&data).to_vec(); drop(data); staging.unmap(); diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 3342a0f8..fb08a913 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -11,6 +11,7 @@ //! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here //! compiles to the CPU fallback on wasm32. #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index e35c6d8d..d592ca9b 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -86,15 +86,17 @@ pub fn finish_fields() -> Vec<(&'static str, usize)> { .collect() } -fn index(name: &str) -> usize { +/// Offset of field `name`; `None` (and a debug assertion) for a name not in [`FIELDS`]. +fn index(name: &str) -> Option { let mut i = 0; for (n, len) in FIELDS { if *n == name { - return i; + return Some(i); } i += len; } - panic!("unknown finish field {name}") + debug_assert!(false, "unknown finish field {name}"); + None } /// A parameter block being filled by name. @@ -105,20 +107,20 @@ impl Block { Block(vec![0; FIELDS.iter().map(|f| f.1).sum()]) } fn f(&mut self, name: &str, v: f32) { - let i = index(name); - self.0[i] = v.to_bits(); + self.u(name, v.to_bits()); } fn u(&mut self, name: &str, v: u32) { - let i = index(name); - self.0[i] = v; + if let Some(slot) = index(name).and_then(|i| self.0.get_mut(i)) { + *slot = v; + } } fn b(&mut self, name: &str, v: bool) { self.u(name, v as u32); } fn fs(&mut self, name: &str, v: &[f32]) { - let i = index(name); - for (k, x) in v.iter().enumerate() { - self.0[i + k] = x.to_bits(); + let Some(i) = index(name) else { return }; + for (slot, x) in self.0.iter_mut().skip(i).zip(v) { + *slot = x.to_bits(); } } } diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index ff983a0b..1a80af13 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -402,17 +402,19 @@ fn sample(cx: &mut Cx<'_>, src: &Arc, src_buf: Arc, plan: &Plan<'_> Some(d) => (Arc::new(resize(cx, &oriented, (sp.ow, sp.oh), d, 3, Filter::Mitchell)), d), None => (oriented, (sp.ow, sp.oh)), }; - match &sp.mode { - SampleMode::Copy => base, - SampleMode::Affine(xf) => { - let out = cx.gpu.buffer(w * h * 3); - let mut p = vec![bw as u32, bh as u32, w as u32, h as u32]; - p.extend(affine_bits(xf)); - cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16])); - Arc::new(out) - } - SampleMode::Warp(o2t) => { - let wp = fr.warp.as_ref().expect("warp mode has a warp"); + let mut affine = |xf: &lightcraft_geom::Affine| { + let out = cx.gpu.buffer(w * h * 3); + let mut p = vec![bw as u32, bh as u32, w as u32, h as u32]; + p.extend(affine_bits(xf)); + cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16])); + Arc::new(out) + }; + match (&sp.mode, fr.warp.as_ref()) { + (SampleMode::Copy, _) => base, + (SampleMode::Affine(xf), _) => affine(xf), + // `sample_plan` plans a warp only when there is one; without it the same mapping is affine + (SampleMode::Warp(o2t), None) => affine(&(lightcraft_geom::Affine::scale(sp.sx, sp.sy) * *o2t)), + (SampleMode::Warp(o2t), Some(wp)) => { let out = cx.gpu.buffer(w * h * 3); let p = warp_params(wp, (bw, bh), (w, h), o2t, sp.sx, sp.sy); cx.run("sample_warp", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16])); diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 2b8fa10f..a1fb5510 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -13,6 +13,7 @@ //! //! Entry points: [`Server::serve`] (stdio loop) and [`Server::handle_line`] (one message). #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod backend; mod base64; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 0a7bbc57..0bf78908 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -72,6 +72,26 @@ fn tools_list_has_helpers_and_every_command() { assert!(r["result"]["tools"].as_array().unwrap().iter().all(|t| !t["name"].as_str().unwrap().starts_with("cmd_"))); } +/// The `import` helper passes the copy options through: a folder template files the copy. +#[test] +fn import_tool_copies_with_a_folder_template() { + let base = std::env::temp_dir().join(format!("lc-mcp-import-tpl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (src, dest) = (base.join("card"), base.join("out")); + std::fs::create_dir_all(&src).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[20, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(src.join("a.png"), png).unwrap(); + let mut b = Headless::demo(); + b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); + let args = + json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "{date:%Y}/{date:%Y%m%d}"}); + let r = call_tool(&mut b, "import", &args); + assert!(!r.is_error, "{r:?}"); + assert!(dest.join("2026").join("20260114").join("a.png").is_file()); + let _ = std::fs::remove_dir_all(&base); +} + #[test] fn command_tools_run_commands() { let mut b = Headless::demo(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index cbca4ed2..77f610ef 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -96,7 +96,14 @@ pub fn helper_tools(has_ui: bool) -> Vec { "import", "Import photos", "Add image/raw files to the library. Folders are scanned recursively for photos; relative paths resolve against the server's working directory. The first imported photo becomes active. Returns the new photo ids.", - json!({"paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"}, "album": {"type": "integer", "description": "Also add to this album id"}}), + json!({ + "paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"}, + "album": {"type": "integer", "description": "Also add to this album id"}, + "mode": {"type": "string", "enum": ["add", "copy"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/)"}, + "destination": {"type": "string", "description": "Copy: destination folder"}, + "organize": {"type": "string", "description": "Copy: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"}, + "rename": {"type": "string", "description": "Copy: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"} + }), &["paths"], ), tool( @@ -454,8 +461,10 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { return ToolResult::error(format!("no photos found in {paths:?}")); } let mut p = json!({"paths": files}); - if let Some(a) = args.get("album") { - p["album"] = a.clone(); + for k in ["album", "mode", "destination", "organize", "rename"] { + if let Some(v) = args.get(k).filter(|v| !v.is_null()) { + p[k] = v.clone(); + } } ToolResult::from(exec(b, "library.import", p).map(|mut r| { if let Some(o) = r.as_object_mut() { diff --git a/crates/merge/src/align.rs b/crates/merge/src/align.rs index 4298f810..c977c8b5 100644 --- a/crates/merge/src/align.rs +++ b/crates/merge/src/align.rs @@ -97,7 +97,8 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz } } let Some(most) = fits.iter().map(|(_, f)| f.inliers.len()).max() else { return Alignment::identity() }; - let (name, f) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97).expect("the best fit qualifies"); + // the fit with `most` inliers always qualifies + let Some((name, f)) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97) else { return Alignment::identity() }; Alignment { h: scale_homography(&f.h, scale), model: name, inliers: f.inliers.len(), rms: f.rms / scale } } diff --git a/crates/merge/src/features.rs b/crates/merge/src/features.rs index 353bed8f..9bf770a6 100644 --- a/crates/merge/src/features.rs +++ b/crates/merge/src/features.rs @@ -54,7 +54,7 @@ impl Features { pub fn detect(img: &Plane, max: usize) -> Features { let mut levels = vec![img.clone()]; while levels.len() < 4 { - let l = levels.last().expect("non-empty"); + let Some(l) = levels.last() else { break }; if l.width.min(l.height) < 96 { break; } diff --git a/crates/merge/src/lib.rs b/crates/merge/src/lib.rs index 65bd86c4..edfd1934 100644 --- a/crates/merge/src/lib.rs +++ b/crates/merge/src/lib.rs @@ -11,6 +11,7 @@ //! Every algorithm here is our own implementation from published papers (cited in each module); //! no code from GPL projects was consulted. #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod align; pub mod features; diff --git a/crates/merge/src/pano/blend.rs b/crates/merge/src/pano/blend.rs index e3147da1..dacd5b68 100644 --- a/crates/merge/src/pano/blend.rs +++ b/crates/merge/src/pano/blend.rs @@ -106,14 +106,14 @@ pub fn push_pull(vals: &mut Buf, wgt: &[f32]) { } pm.data[i * (c + 1) + c] = a; } - let mut levels = vec![pm]; - while levels.last().is_some_and(|l| l.w > 1 || l.h > 1) { - let d = down(levels.last().expect("level")); - levels.push(d); + // the finer levels, and the coarsest (1 × 1) one + let (mut levels, mut l) = (Vec::new(), pm); + while l.w > 1 || l.h > 1 { + let d = down(&l); + levels.push(std::mem::replace(&mut l, d)); } // normalise the coarsest, then fill each finer level's holes from the coarser one let mut filled = { - let l = levels.last().expect("level"); let mut f = Buf::new(l.w, l.h, c); for i in 0..l.w * l.h { let a = l.data[i * (c + 1) + c]; @@ -123,8 +123,7 @@ pub fn push_pull(vals: &mut Buf, wgt: &[f32]) { } f }; - for li in (0..levels.len() - 1).rev() { - let l = &levels[li]; + for l in levels.iter().rev() { let coarse = up(&filled, l.w, l.h); let mut f = Buf::new(l.w, l.h, c); for i in 0..l.w * l.h { @@ -235,7 +234,8 @@ impl Blender { } res = Some(b); } - res.expect("level 0") + // `0..=levels` always yields level 0 + res.unwrap_or_else(|| Buf::new(self.w, self.h, 3)) } } diff --git a/crates/merge/src/pano/camera.rs b/crates/merge/src/pano/camera.rs index 1257b952..7625170c 100644 --- a/crates/merge/src/pano/camera.rs +++ b/crates/merge/src/pano/camera.rs @@ -191,7 +191,7 @@ pub fn initial_cameras(ids: &[usize], sizes: &[(usize, usize)], pairs: &[PairMat let s = d.signum() * d.abs().cbrt(); let rel = rel.map(|r| r.map(|v| v / s)); let rel = linalg::orthonormalize(&rel); - let rk = cams[known].expect("known").r; + let Some(rk) = cams[known].map(|c| c.r) else { continue }; cams[new] = Some(Camera { f: f0, r: linalg::mul3(&rel, &rk), cx: c(new).0, cy: c(new).1 }); grew = true; } @@ -277,7 +277,9 @@ pub fn bundle_adjust(cams: &[Option], pairs: &[PairMatch], anchor: usize let cost = |cs: &[Option]| -> (f64, f64) { let (mut c, mut sq) = (0.0, 0.0); for o in &obs { - let r = residual(cs[o.i].as_ref().expect("cam"), cs[o.j].as_ref().expect("cam"), o.a, o.b); + // every observation joins two posed cameras (see `obs` above) + let (Some(ci), Some(cj)) = (cs[o.i].as_ref(), cs[o.j].as_ref()) else { continue }; + let r = residual(ci, cj, o.a, o.b); for v in r { let a = v.abs(); sq += v * v; @@ -298,11 +300,12 @@ pub fn bundle_adjust(cams: &[Option], pairs: &[PairMatch], anchor: usize .fold( || (vec![0.0f64; np * np], vec![0.0f64; np]), |(mut jtj, mut jtr), o| { - let (ci, cj) = (cur[o.i].as_ref().expect("cam"), cur[o.j].as_ref().expect("cam")); + let (Some(ci), Some(cj)) = (cur[o.i].as_ref(), cur[o.j].as_ref()) else { return (jtj, jtr) }; let r = residual(ci, cj, o.a, o.b); let mut cols: Vec<(usize, [f64; 4])> = Vec::with_capacity(8); for (cam, which) in [(o.i, 0), (o.j, 1)] { - let base = 4 * idx[cam].expect("idx"); + let Some(i) = idx[cam] else { continue }; + let base = 4 * i; for k in 0..4 { if k < 3 && cam == anchor { continue; diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano/mod.rs index d81abda9..30950561 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano/mod.rs @@ -223,7 +223,7 @@ pub fn stitch(frames: Vec, opts: &PanoOptions, progress: &Progress) -> Re Projection::Auto => choose_projection(&frames, &cams), p => p, }; - let fmed = median(used.iter().map(|&i| cams[i].expect("used").f).collect()); + let fmed = median(used.iter().filter_map(|&i| cams[i].map(|c| c.f)).collect()); let Some((bx0, by0, bx1, by1)) = bounds(&frames, &cams, projection, fmed) else { return Err(MergeError::NoOverlap(format!("the field of view ({:.0}°) is too wide for a perspective projection", fov.0))); }; diff --git a/crates/merge/src/synth.rs b/crates/merge/src/synth.rs index 1d8fd8aa..d15646ec 100644 --- a/crates/merge/src/synth.rs +++ b/crates/merge/src/synth.rs @@ -8,9 +8,13 @@ use lightcraft_raster::Rgb32f; use crate::frame::FrameColor; use crate::linalg::{self, M3}; use crate::output::{DngSamples, write_linear_dng}; +use crate::{MergeError, Result}; -fn scene(kind: lightcraft_scenes::Kind) -> lightcraft_scenes::Scene { - lightcraft_scenes::demo_library().into_iter().find(|s| s.kind == kind).expect("scene kind in the demo library") +fn scene(kind: lightcraft_scenes::Kind) -> Result { + lightcraft_scenes::demo_library() + .into_iter() + .find(|s| s.kind == kind) + .ok_or_else(|| MergeError::Mismatch(format!("no {kind:?} scene in the demo library"))) } fn srgb_color() -> FrameColor { @@ -19,12 +23,12 @@ fn srgb_color() -> FrameColor { /// Exposure brackets (`evs`, e.g. `[-2, 0, 2]`) of a sunset scene as 16-bit linear DNGs, each /// shifted by a few pixels (handheld) with EXIF exposure times; `w × h` pixels. -pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Vec> { +pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Result>> { let m = 12usize; - let truth = scene(lightcraft_scenes::Kind::OceanSunset).render(w + 2 * m, h + 2 * m); + let truth = scene(lightcraft_scenes::Kind::OceanSunset)?.render(w + 2 * m, h + 2 * m); let mut l: Vec = truth.data.iter().map(|p| p[1]).collect(); l.sort_by(|a, b| a.total_cmp(b)); - let s = 0.18 / l[l.len() / 2].max(1e-6); + let s = 0.18 / l.get(l.len() / 2).copied().unwrap_or(0.0).max(1e-6); let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32(); evs.iter() .enumerate() @@ -44,20 +48,21 @@ pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Vec> { iso: Some(100), ..Default::default() }; - write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16).expect("encode") + write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16) }) .collect() } /// `yaws.len()` overlapping views (degrees of yaw, small pitch/roll wobble) of a canyon scene used /// as a 200° × 70° spherical environment, `w × h` pixels with focal length `f`, as linear sRGB. -pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Vec { - let env = scene(lightcraft_scenes::Kind::Canyon).render(2400, 840); +pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Result> { + let env = scene(lightcraft_scenes::Kind::Canyon)?.render(2400, 840); let mut l: Vec = env.data.iter().map(|p| p[1]).collect(); l.sort_by(|a, b| a.total_cmp(b)); - let s = 0.25 / l[l.len() / 2].max(1e-6); + let s = 0.25 / l.get(l.len() / 2).copied().unwrap_or(0.0).max(1e-6); let (theta, phi) = (200.0f64, 70.0f64); - yaws.iter() + Ok(yaws + .iter() .enumerate() .map(|(i, yaw)| { let wob = (i as f64 * 1.3).sin(); @@ -77,5 +82,5 @@ pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Vec { env.sample_bilinear(px as f32, py as f32).map(|v| (v * s).min(1.0)) }) }) - .collect() + .collect()) } diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index e2f3fcc1..56b158e2 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -211,19 +211,22 @@ impl Frame { Some((nw, nh)) => std::borrow::Cow::Owned(resize(o, nw, nh, Filter::Mitchell)), None => std::borrow::Cow::Borrowed(o), }; - match plan.mode { - SampleMode::Copy => base.into_owned(), - SampleMode::Warp(o2t) => sample_warped(&base, plan.sx, plan.sy, self.warp.as_ref().expect("warp"), o2t, w, h), - SampleMode::Affine(xf) => { - let mut out = Rgb32f::new(w, h); - par_rows(&mut out.data, w, |y, row| { - for (x, px) in row.iter_mut().enumerate() { - let p = xf.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); - *px = base.sample_bilinear(p.x as f32, p.y as f32); - } - }); - out - } + let affine = |xf: Affine| { + let mut out = Rgb32f::new(w, h); + par_rows(&mut out.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + let p = xf.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + *px = base.sample_bilinear(p.x as f32, p.y as f32); + } + }); + out + }; + match (plan.mode, self.warp.as_ref()) { + (SampleMode::Copy, _) => base.into_owned(), + (SampleMode::Warp(o2t), Some(warp)) => sample_warped(&base, plan.sx, plan.sy, warp, o2t, w, h), + // `sample_plan` plans a warp only when there is one; without it the same mapping is affine + (SampleMode::Warp(o2t), None) => affine(Affine::scale(plan.sx, plan.sy) * o2t), + (SampleMode::Affine(xf), _) => affine(xf), } } diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index fb361c3c..875aca11 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -21,6 +21,7 @@ //! [`render_cached`] each stage's output is reused while its inputs are unchanged ([`StageCache`]): //! dragging a tone, colour or exposure slider re-runs only the per-pixel stage. #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod auto; pub mod colorops; diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs index 6f2e353c..5272b54d 100644 --- a/crates/pipeline/src/output.rs +++ b/crates/pipeline/src/output.rs @@ -217,12 +217,12 @@ impl DeepImage { let trc = self.space.trc(); match &self.samples { DeepSamples::U16(v) => { - for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) { + for (p, c) in out.data.iter_mut().zip(v.as_chunks::<3>().0) { *p = [c[0], c[1], c[2]].map(|x| ((x as u32 * 255 + 32767) / 65535) as u8).into_rgba(); } } DeepSamples::F32(v) => { - for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) { + for (p, c) in out.data.iter_mut().zip(v.as_chunks::<3>().0) { *p = [c[0], c[1], c[2]].map(|x| (trc.encode(x) * 255.0 + 0.5) as u8).into_rgba(); } } diff --git a/crates/preview/src/disk.rs b/crates/preview/src/disk.rs index 995709a0..7dbdda1e 100644 --- a/crates/preview/src/disk.rs +++ b/crates/preview/src/disk.rs @@ -173,6 +173,6 @@ pub fn decode_jpeg(bytes: &[u8]) -> Option { if px.len() < w * h * 3 { return None; } - let data = px.chunks_exact(3).take(w * h).map(|c| [c[0], c[1], c[2], 255]).collect(); + let data = px.as_chunks::<3>().0.iter().take(w * h).map(|c| [c[0], c[1], c[2], 255]).collect(); Some(Rgba8 { width: w, height: h, data }) } diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs index e36f1b41..9518983c 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -10,6 +10,7 @@ //! //! No UI dependencies (L3): the egui frontend, the CLI and the MCP server share it. #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod disk; pub mod hash; diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 50593f29..25571128 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -6,6 +6,7 @@ //! - Blur ([`blur`]): separable Gaussian via repeated box filters (O(1) per pixel in the radius). //! - [`Histogram`] of display-encoded RGB + luminance. #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod blur; pub mod histogram; @@ -243,7 +244,7 @@ impl Rgba8 { if bytes.len() != width * height * 4 { return None; } - Some(Self { width, height, data: bytes.chunks_exact(4).map(|c| [c[0], c[1], c[2], c[3]]).collect() }) + Some(Self { width, height, data: bytes.as_chunks::<4>().0.iter().map(|c| [c[0], c[1], c[2], c[3]]).collect() }) } /// Linear Rec.709/sRGB primaries float image from 8-bit sRGB. pub fn to_linear(&self) -> Rgb32f { diff --git a/crates/raw/src/demosaic/mod.rs b/crates/raw/src/demosaic/mod.rs index f00913c5..1ab3675c 100644 --- a/crates/raw/src/demosaic/mod.rs +++ b/crates/raw/src/demosaic/mod.rs @@ -70,7 +70,7 @@ pub fn demosaic(n: &Normalized, method: Method) -> Rgb32f { return Rgb32f::new(w, h); } match (&n.cfa, n.cpp) { - (_, 3) => Rgb32f { width: w, height: h, data: n.data.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect() }, + (_, 3) => Rgb32f { width: w, height: h, data: n.data.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect() }, (_, cpp) if cpp != 1 => Rgb32f { width: w, height: h, data: n.data.chunks_exact(cpp).map(|c| [c[0], c[1], c[2]]).collect() }, (None, _) => Rgb32f { width: w, height: h, data: n.data.iter().map(|&v| [v; 3]).collect() }, (Some(cfa), _) => { diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 10bb8ded..ac8ce799 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -185,9 +185,8 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { } } - match &raw.data { - RawData::F32(v) if matches!(opts.compression, DngCompression::Deflate { .. }) => { - let DngCompression::Deflate { tile, half } = opts.compression else { unreachable!() }; + match (&raw.data, opts.compression) { + (RawData::F32(v), DngCompression::Deflate { tile, half }) => { let tile = (tile.max(16) & !15) as usize; let bp = if half { 2 } else { 4 }; ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![(bp * 8) as u16; cpp])); @@ -222,7 +221,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { .collect(); ifd.set_image(ImageData::Tiles { tile_width: tile as u32, tile_height: tile as u32, tiles }); } - RawData::F32(v) => { + (RawData::F32(v), _) => { ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![32; cpp])); ifd.set(t::SAMPLE_FORMAT, Value::Short(vec![3; cpp])); ifd.set(t::COMPRESSION, Value::Short(vec![compression::NONE])); @@ -234,7 +233,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { } ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![bytes] }); } - RawData::U16(v) => { + (RawData::U16(v), _) => { let maxv = v.iter().copied().max().unwrap_or(0); let bits = (16 - maxv.leading_zeros()).max(raw.bits.min(16)).max(2) as u8; ifd.set( diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index be7e0fdc..554bc1f0 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -18,6 +18,7 @@ //! (public specifications, tag-name documentation, black-box analysis of CC0 samples) and gaps. Non-DNG files carry no //! colour matrix: [`color`] falls back to a documented neutral model. The decoders never panic on malformed input. #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod binned; pub mod color; @@ -266,6 +267,11 @@ impl Cfa { .collect::>()?; (p.len() == 4).then_some(Cfa { width: 2, height: 2, pattern: p }) } + /// A 2×2 Bayer layout named in code (`"RGGB"`, `"BGGR"`, `"GRBG"`, `"GBRG"`). Only for literal + /// names (tested below); anything else falls back to RGGB instead of failing. + pub(crate) fn bayer_static(s: &'static str) -> Cfa { + Cfa::bayer(s).unwrap_or(Cfa { width: 2, height: 2, pattern: vec![0, 1, 1, 2] }) + } /// The Fujifilm X-Trans 6×6 layout (as commonly documented), anchored at (0, 0). pub fn xtrans() -> Cfa { let rows = ["GGRGGB", "GGBGGR", "BRGRBG", "GGBGGR", "GGRGGB", "RBGBRG"]; @@ -575,6 +581,9 @@ mod tests { assert_eq!(c.shifted(1, 0).name(), "GRBG"); assert_eq!(c.shifted(1, 1).name(), "BGGR"); assert!(Cfa::bayer("RGGX").is_none()); + for name in ["RGGB", "BGGR", "GRBG", "GBRG"] { + assert_eq!(Cfa::bayer_static(name).name(), name); + } let x = Cfa::xtrans(); assert!(!x.is_bayer()); let greens = x.pattern.iter().filter(|&&p| p == 1).count(); diff --git a/crates/raw/src/ljpeg.rs b/crates/raw/src/ljpeg.rs index 000a2364..cb2d0467 100644 --- a/crates/raw/src/ljpeg.rs +++ b/crates/raw/src/ljpeg.rs @@ -359,7 +359,11 @@ pub fn decode(d: &[u8], max_samples: usize) -> Result { if (entropy.len() as u64 + 64) * 8 < total as u64 { return Err(err("entropy data too short for frame")); } - let tables: Vec<&Huffman> = h.table_for.iter().map(|&t| h.tables[t].as_ref().expect("checked")).collect(); + let tables: Vec<&Huffman> = h + .table_for + .iter() + .map(|&t| h.tables.get(t).and_then(Option::as_ref).ok_or_else(|| err("scan uses an undefined Huffman table"))) + .collect::>()?; let mut out = vec![0u16; total]; let mut br = BitReader::new(entropy); let init = 1i32 << (h.precision - h.pt - 1); diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index 03ceebc8..f63eaf17 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -511,7 +511,7 @@ pub fn apply_list3(list: &[Opcode], img: &mut Rgb32f) { other => { let mut flat: Vec = img.data.iter().flat_map(|p| *p).collect(); apply_list(std::slice::from_ref(other), &mut flat, w, h, 3, None, 1.0); - for (d, c) in img.data.iter_mut().zip(flat.chunks_exact(3)) { + for (d, c) in img.data.iter_mut().zip(flat.as_chunks::<3>().0) { *d = [c[0], c[1], c[2]]; } } diff --git a/crates/raw/src/unpack.rs b/crates/raw/src/unpack.rs index 6d86e02f..15451548 100644 --- a/crates/raw/src/unpack.rs +++ b/crates/raw/src/unpack.rs @@ -41,7 +41,7 @@ pub fn unpack_lsb(src: &[u8], bits: u32, out: &mut [u16]) { } pub fn read_u16s(src: &[u8], order: ByteOrder, out: &mut [u16]) { - for (o, c) in out.iter_mut().zip(src.chunks_exact(2)) { + for (o, c) in out.iter_mut().zip(src.as_chunks::<2>().0) { *o = order.u16([c[0], c[1]]); } } diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 2cd509ab..a9eb2c7d 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -60,7 +60,8 @@ pub(crate) fn decode_row(row: &[u8], out: &mut [u16]) { while x0 + 32 <= w && x0 + 32 <= row.len() { for half in 0..2 { let off = x0 + half * 16; - let block = u128::from_le_bytes(row[off..off + 16].try_into().expect("16 bytes")); + let Some(bytes) = row.get(off..off + 16).and_then(|b| <[u8; 16]>::try_from(b).ok()) else { return }; + let block = u128::from_le_bytes(bytes); let max = (block & 0x7ff) as u16; let min = ((block >> 11) & 0x7ff) as u16; let imax = ((block >> 22) & 0xf) as usize; @@ -217,7 +218,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, - _ => Cfa::bayer("RGGB").expect("static"), + _ => Cfa::bayer_static("RGGB"), }; let default_black = if out_bits >= 14 { 512.0 } else { 128.0 }; let black = match raw.f64s(BLACK_LEVEL).as_deref() { diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 8629f75c..fa15456a 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -117,7 +117,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { } } let wb = mn.as_ref().and_then(|m| m.ifd.u64s(COLOR_BALANCE)).and_then(|v| wb_from_color_balance(&v)); - let cfa = Cfa::bayer("RGGB").expect("static"); + let cfa = Cfa::bayer_static("RGGB"); let black = if active.x >= 8 { black_from_columns(&data, width, 2..active.x - 2, active.y..active.y + active.height, active) } else { diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 11700bd1..fb4b644a 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -84,7 +84,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { .map(|v| [v[0] as f32, 1.0, v[1] as f32]); let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, - _ => Cfa::bayer("RGGB").expect("static"), + _ => Cfa::bayer_static("RGGB"), }; let white = white_from_data(samples, bits); let active_w = trailing_masked_columns(samples, w, h, white); diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index 2e2eebb3..52f597d2 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -79,7 +79,7 @@ pub(crate) fn cfa_from_data(d: &[u16], w: usize, a: Rect) -> Cfa { anti += (p(1, 0) - p(0, 1)).unsigned_abs(); } } - Cfa::bayer(if main < anti { "GRBG" } else { "RGGB" }).expect("static") + Cfa::bayer_static(if main < anti { "GRBG" } else { "RGGB" }) } pub(crate) fn decode(bytes: &[u8]) -> Result { diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index a6fb8c57..78cfa87a 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -232,7 +232,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { }; let cfa = match (ifd0.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), ifd0.bytes(t::CFA_PATTERN_EP)) { (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, - _ => Cfa::bayer("BGGR").expect("static"), + _ => Cfa::bayer_static("BGGR"), }; let black = match mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_POINT)).as_deref() { Some(v @ [_, _, _, _]) => { diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index e8ee126e..2505db13 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -91,14 +91,14 @@ fn raw_ifd(raw: &[u8]) -> Result { fn cfa(h: &Header) -> Cfa { match record(h, XTRANS_LAYOUT) { Some(l) if l.len() == 36 && l.iter().all(|&c| c <= 2) => Cfa { width: 6, height: 6, pattern: l.iter().rev().copied().collect() }, - _ => Cfa::bayer("RGGB").expect("static"), + _ => Cfa::bayer_static("RGGB"), } } /// Unpack one row of `bits`-bit samples in Fujifilm's packing. pub(crate) fn unpack_row(src: &[u8], bits: u32, out: &mut [u16]) { match bits { - 16 => out.iter_mut().zip(src.chunks_exact(2)).for_each(|(o, c)| *o = u16::from_le_bytes([c[0], c[1]])), + 16 => out.iter_mut().zip(src.as_chunks::<2>().0).for_each(|(o, c)| *o = u16::from_le_bytes([c[0], c[1]])), 12 => unpack_lsb(src, 12, out), _ => { let swapped: Vec = src diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index d996d5b2..efd515f2 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -74,11 +74,11 @@ pub(crate) fn unpack(stream: &[u8], bits: u32, out: &mut [u16]) -> Result<()> { let first_block = ci * 1024; for (bi, px) in o.chunks_mut(per).enumerate() { let at = (first_block + bi) * 16; - let Some(b) = stream.get(at..at + 16) else { + let Some(b) = stream.get(at..at + 16).and_then(|b| <[u8; 16]>::try_from(b).ok()) else { px.fill(0); continue; }; - let word = u128::from_le_bytes(b.try_into().expect("16 bytes")); + let word = u128::from_le_bytes(b); for (i, p) in px.iter_mut().enumerate() { *p = ((word >> (start + i as u32 * bits)) & mask) as u16; } @@ -94,7 +94,7 @@ fn cfa(code: u16) -> Cfa { 4 => "BGGR", _ => "RGGB", }; - Cfa::bayer(s).expect("static") + Cfa::bayer_static(s) } pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { diff --git a/crates/raw/tests/vendor_robust.rs b/crates/raw/tests/vendor_robust.rs new file mode 100644 index 00000000..2db97c64 --- /dev/null +++ b/crates/raw/tests/vendor_robust.rs @@ -0,0 +1,220 @@ +//! Malformed vendor raw files (NEF, ARW, PEF, ORF, RW2, CR2, RAF) must decode to an error, never panic. + +use lightcraft_tiff::tags as t; +use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; +use proptest::prelude::*; + +const W: u32 = 32; +const H: u32 = 8; + +/// A CFA TIFF the way most vendors lay it out: IFD0 (make) + a SubIFD holding the raw strip. +fn cfa_tiff(make: &str, compression: u16, bits: u16, order: ByteOrder) -> Vec { + let px = (W * H) as usize; + // Sony's ARW2 (32767) packs a sample per byte + let len = if compression == 32767 { px } else { px * 2 }; + let strip: Vec = (0..len).map(|i| (i * 37 % 251) as u8).collect(); + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![W])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![H])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits])); + raw.set(t::COMPRESSION, Value::Short(vec![compression])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set_image(ImageData::Strips { rows_per_strip: H, strips: vec![strip] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii("TEST".into())); + ifd0.add_sub_ifd(raw); + TiffWriter::new(order, false).write(&[ifd0]).unwrap() +} + +/// An Olympus ORF (TIFF with an `IIRO` magic) or Panasonic RW2 (`IIU\0`), raw image in IFD0. +fn magic_tiff(magic: &[u8; 4], make: &str) -> Vec { + let px = (W * H) as usize; + let mut ifd = IfdBuilder::new(); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![W])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![H])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![1])); + ifd.set(t::MAKE, Value::Ascii(make.into())); + ifd.set_image(ImageData::Strips { rows_per_strip: H, strips: vec![(0..px * 2).map(|i| (i * 13) as u8).collect()] }); + let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap(); + b[..4].copy_from_slice(magic); + b +} + +/// A Panasonic RW2: sensor size, bit depth and raw format in IFD0, 12-bit packed blocks. +fn rw2() -> Vec { + let mut ifd = IfdBuilder::new(); + ifd.set(0x0002, Value::Short(vec![W as u16])); + ifd.set(0x0003, Value::Short(vec![H as u16])); + ifd.set(0x0004, Value::Short(vec![1])); + ifd.set(0x0005, Value::Short(vec![2])); + ifd.set(0x0006, Value::Short(vec![H as u16 - 1])); + ifd.set(0x0007, Value::Short(vec![W as u16 - 2])); + ifd.set(0x000a, Value::Short(vec![12])); + ifd.set(0x002d, Value::Short(vec![5])); + ifd.set(0x001c, Value::Short(vec![64])); + ifd.set(0x001d, Value::Short(vec![64])); + ifd.set(0x001e, Value::Short(vec![64])); + ifd.set(t::MAKE, Value::Ascii("Panasonic".into())); + let need = (W * H).div_ceil(10) as usize * 16; + ifd.set_image(ImageData::Strips { rows_per_strip: H, strips: vec![(0..need).map(|i| (i * 29) as u8).collect()] }); + let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap(); + b[..4].copy_from_slice(b"IIU\0"); + b +} + +/// A Pentax PEF: the raw image in IFD0, wide enough for the dark-border trim. +fn pef(compression: u16) -> Vec { + let (w, h) = (264u32, 4u32); + let mut ifd = IfdBuilder::new(); + ifd.set(t::MAKE, Value::Ascii("PENTAX".into())); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + let strip: Vec = (0..(w * h * 2) as usize).map(|i| if i % 528 < 8 { 0 } else { (i * 7 % 200) as u8 }).collect(); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] }); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd]).unwrap() +} + +/// A Canon CR2: three placeholder IFDs and IFD3 with a 2-component lossless JPEG frame. +fn cr2() -> Vec { + let img: Vec = (0..(W * H) as usize).map(|i| 1024 + (i * 7919 % 12000) as u16).collect(); + let enc = lightcraft_raw::ljpeg::encode(&img, W as usize / 2, H as usize, 2, 14, 1, 0); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Canon".into())); + let mut ifd3 = IfdBuilder::new(); + ifd3.set(t::COMPRESSION, Value::Short(vec![6])); + ifd3.set(0xc640, Value::Short(vec![1, 16, 16])); + ifd3.set_image(ImageData::Strips { rows_per_strip: H, strips: vec![enc] }); + let blank = || IfdBuilder::new().with(1, Value::Short(vec![0])); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0, blank(), blank(), ifd3]).unwrap() +} + +/// A Fujifilm RAF: header, records, raw block (LE TIFF whose IFD0 points to a 0xf000 raw IFD). +fn raf_file(w: u32, h: u32, bits: u32, strip: Vec, layout: Option<[u8; 36]>, jpeg: &[u8]) -> Vec { + let mut block = b"II*\0\x08\0\0\0".to_vec(); + let sub_off: u32 = 8 + 2 + 12 + 4; + block.extend_from_slice(&1u16.to_le_bytes()); + block.extend_from_slice(&[0x00, 0xf0, 13, 0, 1, 0, 0, 0]); + block.extend_from_slice(&sub_off.to_le_bytes()); + block.extend_from_slice(&0u32.to_le_bytes()); + let n = 7u32; + let wb_off = sub_off + 2 + 12 * n + 4; + let strip_off = wb_off + 12; + let entries: [(u16, u32, u32); 7] = [ + (0xf001, 1, w), + (0xf002, 1, h), + (0xf003, 1, bits), + (0xf007, 1, strip_off), + (0xf008, 1, strip.len() as u32), + (0xf00a, 1, 64), + (0xf00e, 3, wb_off), + ]; + block.extend_from_slice(&(n as u16).to_le_bytes()); + for (tag, count, v) in entries { + block.extend_from_slice(&tag.to_le_bytes()); + block.extend_from_slice(&4u16.to_le_bytes()); + block.extend_from_slice(&count.to_le_bytes()); + block.extend_from_slice(&v.to_le_bytes()); + } + block.extend_from_slice(&0u32.to_le_bytes()); + for v in [300u32, 600, 450] { + block.extend_from_slice(&v.to_le_bytes()); + } + block.extend_from_slice(&strip); + let mut recs: Vec = Vec::new(); + let mut nrec = 0u32; + let mut add = |tag: u16, d: &[u8]| { + recs.extend_from_slice(&tag.to_be_bytes()); + recs.extend_from_slice(&(d.len() as u16).to_be_bytes()); + recs.extend_from_slice(d); + nrec += 1; + }; + add(0x0100, &[(h >> 8) as u8, h as u8, (w >> 8) as u8, w as u8]); + add(0x0110, &[0, 2, 0, 4]); + add(0x0111, &[0, (h - 2) as u8, 0, (w - 4) as u8]); + if let Some(l) = layout { + add(0x0131, &l); + } + let mut dir = nrec.to_be_bytes().to_vec(); + dir.extend_from_slice(&recs); + let mut out = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec(); + out.resize(108, 0); + let jpeg_off = 160u32; + let dir_off = jpeg_off + jpeg.len() as u32; + let raw_off = dir_off + dir.len() as u32; + for (i, v) in [jpeg_off, jpeg.len() as u32, dir_off, dir.len() as u32, raw_off, block.len() as u32].iter().enumerate() { + out[84 + i * 4..88 + i * 4].copy_from_slice(&v.to_be_bytes()); + } + out.resize(160, 0); + out.extend_from_slice(jpeg); + out.extend_from_slice(&dir); + out.extend_from_slice(&block); + out +} + +fn raf() -> Vec { + let strip: Vec = (0..(W * H * 2) as usize).map(|i| (i * 11) as u8).collect(); + let layout: [u8; 36] = std::array::from_fn(|i| (i % 3) as u8); + raf_file(W, H, 16, strip, Some(layout), b"\xff\xd8\xff\xd9") +} + +fn samples() -> Vec> { + vec![ + cfa_tiff("NIKON CORPORATION", 1, 12, ByteOrder::Big), + cfa_tiff("NIKON CORPORATION", 34713, 14, ByteOrder::Big), + cfa_tiff("SONY", 32767, 12, ByteOrder::Little), + cfa_tiff("SONY", 1, 14, ByteOrder::Little), + pef(1), + pef(65535), + cfa_tiff("SAMSUNG", 1, 12, ByteOrder::Little), + cfa_tiff("ACME", 1, 16, ByteOrder::Little), + magic_tiff(b"IIRO", "OLYMPUS IMAGING CORP."), + rw2(), + cr2(), + raf(), + ] +} + +fn exercise(bytes: &[u8]) { + let _ = lightcraft_raw::probe(bytes); + let _ = lightcraft_raw::probe_info(bytes); + let _ = lightcraft_raw::embedded_preview(bytes); + if let Ok(img) = lightcraft_raw::decode(bytes) + && img.width * img.height <= 1 << 16 + { + let _ = img.develop(lightcraft_raw::Method::Ahd); + } +} + +#[test] +fn vendor_samples_truncated_at_every_length() { + for s in samples() { + exercise(&s); + for n in (0..s.len()).step_by(3) { + exercise(&s[..n]); + } + } +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 2000, .. ProptestConfig::default() })] + + #[test] + fn mutated_vendor_files_never_panic(kind in 0usize..12, flips in proptest::collection::vec((any::(), any::()), 1..16), cut in any::()) { + let mut data = samples().swap_remove(kind); + let n = data.len(); + for (i, v) in flips { + data[i % n] = v; + } + let keep = if cut % 3 == 0 { cut % n } else { n }; + exercise(&data[..keep]); + } +} diff --git a/crates/scenes/src/lib.rs b/crates/scenes/src/lib.rs index 69d80eb7..0b7ad016 100644 --- a/crates/scenes/src/lib.rs +++ b/crates/scenes/src/lib.rs @@ -5,6 +5,7 @@ //! develop pipeline's highlight recovery, tone mapping and colour tools have something real to do. //! Every scene is deterministic for its seed. No external assets: zero licensing or privacy risk. #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod noise; mod paint; diff --git a/crates/tiff/src/lib.rs b/crates/tiff/src/lib.rs index f7637fb7..bafa3eb5 100644 --- a/crates/tiff/src/lib.rs +++ b/crates/tiff/src/lib.rs @@ -17,6 +17,7 @@ //! Sources: TIFF 6.0 specification (Adobe, 1992), TIFF Technical Note / supplements, the BigTIFF //! design notes (AWare Systems), Adobe DNG specification 1.7 (tag numbers), CIPA DC-008 (Exif 2.32). #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod image; pub mod makernote; diff --git a/crates/tiff/src/reader.rs b/crates/tiff/src/reader.rs index a491f0dd..e1c6dd3a 100644 --- a/crates/tiff/src/reader.rs +++ b/crates/tiff/src/reader.rs @@ -220,17 +220,19 @@ pub(crate) fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { Value::Ascii(String::from_utf8_lossy(&b[..end]).into_owned()) } FieldType::SByte => Value::SByte(b.iter().map(|&x| x as i8).collect()), - FieldType::Short => Value::Short(b.chunks_exact(2).map(|c| o.u16(arr(c))).collect()), - FieldType::SShort => Value::SShort(b.chunks_exact(2).map(|c| o.u16(arr(c)) as i16).collect()), - FieldType::Long => Value::Long(b.chunks_exact(4).map(|c| o.u32(arr(c))).collect()), - FieldType::Ifd => Value::Ifd(b.chunks_exact(4).map(|c| o.u32(arr(c))).collect()), - FieldType::SLong => Value::SLong(b.chunks_exact(4).map(|c| o.u32(arr(c)) as i32).collect()), - FieldType::Float => Value::Float(b.chunks_exact(4).map(|c| f32::from_bits(o.u32(arr(c)))).collect()), - FieldType::Rational => Value::Rational(b.chunks_exact(8).map(|c| (o.u32(arr(&c[..4])), o.u32(arr(&c[4..])))).collect()), - FieldType::SRational => Value::SRational(b.chunks_exact(8).map(|c| (o.u32(arr(&c[..4])) as i32, o.u32(arr(&c[4..])) as i32)).collect()), - FieldType::Double => Value::Double(b.chunks_exact(8).map(|c| f64::from_bits(o.u64(arr(c)))).collect()), - FieldType::Long8 => Value::Long8(b.chunks_exact(8).map(|c| o.u64(arr(c))).collect()), - FieldType::Ifd8 => Value::Ifd8(b.chunks_exact(8).map(|c| o.u64(arr(c))).collect()), - FieldType::SLong8 => Value::SLong8(b.chunks_exact(8).map(|c| o.u64(arr(c)) as i64).collect()), + FieldType::Short => Value::Short(b.as_chunks::<2>().0.iter().map(|c| o.u16(arr(c))).collect()), + FieldType::SShort => Value::SShort(b.as_chunks::<2>().0.iter().map(|c| o.u16(arr(c)) as i16).collect()), + FieldType::Long => Value::Long(b.as_chunks::<4>().0.iter().map(|c| o.u32(arr(c))).collect()), + FieldType::Ifd => Value::Ifd(b.as_chunks::<4>().0.iter().map(|c| o.u32(arr(c))).collect()), + FieldType::SLong => Value::SLong(b.as_chunks::<4>().0.iter().map(|c| o.u32(arr(c)) as i32).collect()), + FieldType::Float => Value::Float(b.as_chunks::<4>().0.iter().map(|c| f32::from_bits(o.u32(arr(c)))).collect()), + FieldType::Rational => Value::Rational(b.as_chunks::<8>().0.iter().map(|c| (o.u32(arr(&c[..4])), o.u32(arr(&c[4..])))).collect()), + FieldType::SRational => { + Value::SRational(b.as_chunks::<8>().0.iter().map(|c| (o.u32(arr(&c[..4])) as i32, o.u32(arr(&c[4..])) as i32)).collect()) + } + FieldType::Double => Value::Double(b.as_chunks::<8>().0.iter().map(|c| f64::from_bits(o.u64(arr(c)))).collect()), + FieldType::Long8 => Value::Long8(b.as_chunks::<8>().0.iter().map(|c| o.u64(arr(c))).collect()), + FieldType::Ifd8 => Value::Ifd8(b.as_chunks::<8>().0.iter().map(|c| o.u64(arr(c))).collect()), + FieldType::SLong8 => Value::SLong8(b.as_chunks::<8>().0.iter().map(|c| o.u64(arr(c)) as i64).collect()), } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 33a7a497..33e64a04 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -268,7 +268,14 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest } } "ui.dialog.confirm" => match app.ui.dialog.take() { - Some(d) => wrap(crate::panels::dialogs::confirm_dialog(app, &d)), + Some(d) => { + let r = crate::panels::dialogs::confirm_dialog(app, &d); + // the import review stays open on an error, as with its button + if r.is_err() && matches!(d, crate::state::Dialog::Import { .. }) { + app.ui.dialog = Some(d); + } + wrap(r) + } None => err("no dialog open"), }, "ui.dialog.cancel" => { diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index a11bbe3c..ea1467af 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -831,6 +831,54 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Local: a kept folder stays a root while its subfolders (and other locations) are + /// browsed — the subfolder is highlighted inside its tree, its siblings stay listed — and + /// the kept folders survive a save/load of the UI state (a restart). + #[test] + fn kept_local_root_stays_while_browsing_below_and_elsewhere() { + let mut h = demo([1300.0, 1400.0]); + let t = Duration::from_secs(10); + let base = std::env::temp_dir().join(format!("lc-ui-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["Photos/2026/20260101", "Photos/2026/20260114", "Other"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let s = |p: std::path::PathBuf| p.to_string_lossy().to_string(); + let (photos, day1, day2, other) = + (s(base.join("Photos")), s(base.join("Photos/2026/20260101")), s(base.join("Photos/2026/20260114")), s(base.join("Other"))); + let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); + let rects = |h: &mut Headless| -> std::collections::HashMap { + let w = h.request("ui.widgets", json!({"filter": "lc-ui-roots-"}), t); + w["result"].as_array().unwrap().iter().map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"][0].as_f64().unwrap_or(0.0))).collect() + }; + h.request("ui.set", json!({"leftPanel": true}), t); + assert_eq!(exec(&mut h, "local.addRoot", json!({"path": photos}))["ok"], true); + assert_eq!(exec(&mut h, "local.addRoot", json!({"path": format!("{photos}/")}))["result"]["roots"].as_array().map(Vec::len), Some(1)); + exec(&mut h, "library.browse", json!({"path": day1})); + h.settle(SETTLE); + h.step(); + let r = rects(&mut h); + assert!(r.contains_key(&format!("source:local:{photos}")), "the kept root stays: {r:?}"); + assert!(r.contains_key(&format!("source:local:{day2}")), "the sibling stays reachable: {r:?}"); + let (root_x, child_x) = (r[&format!("folderToggle:{photos}")], r[&format!("folderToggle:{day1}")]); + assert!(child_x > root_x, "the browsed folder is inside the root's tree, not a root of its own ({child_x} vs {root_x})"); + assert_eq!(h.request("ui.clickWidget", json!({"id": format!("source:local:{day2}")}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.app.session.browse.as_ref().map(|b| b.path.clone()), Some(day2.clone()), "the sibling is browsed"); + // another location: the kept root stays listed + exec(&mut h, "library.browse", json!({"path": other})); + h.settle(SETTLE); + h.step(); + let r = rects(&mut h); + assert!(r.contains_key(&format!("source:local:{photos}")) && r.contains_key(&format!("source:local:{other}")), "{r:?}"); + // restart: the kept roots come back with the saved UI state + let saved = serde_json::to_string(&h.app.ui).unwrap(); + let back: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(back.local_roots, vec![photos.clone()]); + let _ = std::fs::remove_dir_all(&base); + h.settle(SETTLE); + } + /// Versions panel: resting on a version previews it in the loupe without changing the photo; /// a click restores it. #[test] @@ -898,7 +946,7 @@ mod tests { assert!(h.quit_requested()); } - /// File → Add Folder… opens the import review for a folder (searched recursively). + /// File → Import from Folder… opens the import review for a folder (searched recursively). #[test] fn add_folder_opens_the_import_review() { let mut h = demo([1200.0, 900.0]); @@ -920,6 +968,13 @@ mod tests { let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review: {:?}", h.app.ui.dialog) }; assert_eq!(opts.candidates.len(), 2, "both files, the subfolder's too"); assert!(!opts.copy, "a folder is added in place by default"); + // the review names its source; scanning doesn't save a Local location + assert_eq!(opts.sources, vec![dir.to_string_lossy().to_string()]); + let name = dir.file_name().unwrap().to_string_lossy().to_string(); + assert_eq!(crate::import::source_summary(&opts.sources), format!("Folder “{name}” (and its subfolders)")); + let r = h.request("ui.widgets", json!({}), t); + assert!(r.to_string().contains("label:importSource"), "source shown"); + assert!(h.app.ui.local_roots.is_empty(), "no Local shortcut saved"); // a camera / card folder: copied into the library by default h.app.ui.dialog = None; let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": sub.to_string_lossy()}}), t); @@ -1113,7 +1168,7 @@ mod tests { h.settle(SETTLE); } - /// File → Add Photos… opens the import review: candidates with thumbnails, the duplicate is + /// File → Import Photos… opens the import review: candidates with thumbnails, the duplicate is /// unchecked; unchecking a cell and confirming imports the rest in batches, into a new album /// with keywords, as one undo step. #[test] @@ -1215,6 +1270,105 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + /// Import review, Copy, Folders → Custom template…: the default `{date:%Y}/{date:%Y%m%d}` + /// files copies under `2026/20260114/`, the example line shows the full destination, and a + /// template that would climb out of the destination is refused. + #[test] + fn import_copy_into_a_custom_folder_template() { + let base = std::env::temp_dir().join(format!("lc-ui-import-tpl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (src, dest) = (base.join("card"), base.join("out")); + std::fs::create_dir_all(&src).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(src.join("IMG_01.png"), png).unwrap(); + let dest_s = dest.to_string_lossy().to_string(); + let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + // undated files are filed by the import time + session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); + let mut app = LightcraftApp::new(session, services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + for id in ["button:importCopy", "button:importDest"] { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + } + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + opts.organize = "custom".into(); + opts.folder_template = "../{date:%Y}".into(); + } + h.step(); + // refused: the dialog stays open, nothing is imported + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_ne!(r["ok"], true, "{r}"); + assert!(h.app.import.is_none()); + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("dialog closed") }; + opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into(); + let opts = opts.clone(); + let sep = std::path::MAIN_SEPARATOR; + let example = crate::import::example_destination(&h.app, &opts).expect("example"); + assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}IMG_01.png", dest.to_string_lossy())); + h.step(); + let r = h.request("ui.widgets", json!({}), t); + assert!(r.to_string().contains("label:importExample"), "example shown"); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(dest.join("2026").join("20260114").join("IMG_01.png").is_file()); + let _ = std::fs::remove_dir_all(&base); + } + + /// Import review, Copy: Tags beside the Rename field lists the template tags; clicking one + /// inserts it at the text cursor (not appended, not replacing the template). + #[test] + fn import_rename_tags_insert_at_the_cursor() { + let dir = std::env::temp_dir().join(format!("lc-ui-import-tags-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join("IMG_0007.png"), png).unwrap(); + let lib = dir.join("lib"); + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + session.open_library(&lib, false).unwrap(); + let mut app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() }); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); + let t = Duration::from_secs(10); + let r = + h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.join("IMG_0007.png").to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "button:importCopy"}), t); + assert_eq!(r["ok"], true, "{r}"); + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + opts.rename = "Trip-_x".into(); + } + // the cursor sits after "Trip-" + let id = egui::Id::new("import-rename"); + let mut st = egui::text_edit::TextEditState::default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(5)))); + st.store(&h.view.ctx, id); + let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTags"}), t); + assert_eq!(r["ok"], true, "{r}"); + let seq3 = lightcraft_engine::rename::TOKENS.iter().position(|x| x.tag == "{seq:3}").unwrap(); + let r = h.request("ui.clickWidget", json!({"id": format!("button:importRenameTag-{seq3}")}), t); + assert_eq!(r["ok"], true, "{r}"); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") }; + assert_eq!(opts.rename, "Trip-{seq:3}_x", "inserted at the cursor"); + // a second tag goes after the first (the cursor moved past it) + let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTag-0"}), t); + assert_eq!(r["ok"], true, "{r}"); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") }; + assert_eq!(opts.rename, "Trip-{seq:3}{name}_x"); + let _ = std::fs::remove_dir_all(&dir); + } + /// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through /// the engine; the delete confirmation guards ⌫. #[test] diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 719b235a..717cb727 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1,6 +1,8 @@ -//! The import review dialog (File → Add Photos…): the files found under the chosen files and -//! folders as a grid of thumbnails with checkboxes (duplicates marked and unchecked), the -//! destination (add in place / copy into the library's dated `Originals/` folders), an album +//! 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), the destination (add in +//! place / copy 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 in small batches, one per //! frame, with a progress window; the whole import is one undo step. @@ -21,6 +23,8 @@ pub(crate) const BATCH: usize = 8; #[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, pub candidates: Vec, pub checked: Vec, /// Copy into the library (else add in place). @@ -35,8 +39,10 @@ pub struct ImportDialog { pub keywords: String, /// Copy: destination folder ("" = the library's Originals/). pub destination: String, - /// Copy: `date` (YYYY/YYYY-MM-DD), `month` or `flat`. + /// 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). @@ -61,6 +67,22 @@ impl ImportDialog { .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, 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 { self.keywords.split(',').map(str::trim).filter(|k| !k.is_empty()).map(str::to_string).collect() } @@ -92,6 +114,8 @@ pub struct ScanTask { pub copy: bool, /// 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, } /// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]). @@ -99,6 +123,7 @@ pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result) -> Result) -> std::thread::spawn(job); #[cfg(target_arch = "wasm32")] job(); - app.scan = Some(ScanTask { progress, rx, copy: false, browse: true }); + app.scan = Some(ScanTask { progress, rx, copy: false, browse: true, sources: Vec::new() }); app.renderer.forget_imports(); Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) } @@ -189,7 +214,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { return; } }; - let task = app.scan.take().expect("scan task"); + let Some(task) = app.scan.take() else { return }; if task.progress.cancel.load(Ordering::Relaxed) { return; } @@ -212,6 +237,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { app.renderer.forget_imports(); let mut d = ImportDialog::new(out.candidates); d.copy = task.copy; + d.sources = task.sources; app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) }); } @@ -277,8 +303,8 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result if !d.destination.trim().is_empty() { params["destination"] = json!(d.destination.trim()); } - if !d.organize.is_empty() { - params["organize"] = json!(d.organize); + if let Some(o) = d.organize_param()? { + params["organize"] = json!(o); } if !d.rename.trim().is_empty() { params["rename"] = json!(d.rename.trim()); @@ -307,7 +333,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { p["renameStart"] = json!(1 + task.imported); } let r = app.session.execute("library.import", &p); - let task = app.import.as_mut().expect("import task"); + let Some(task) = app.import.as_mut() else { return }; task.done += n; match r { Ok(v) => { @@ -328,7 +354,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { if !task.queue.is_empty() { return; } - let task = app.import.take().expect("import task"); + let Some(task) = app.import.take() else { return }; let steps = app.session.undo.len().saturating_sub(task.undo0); let label = format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" }); app.session.merge_undo(steps, &label); @@ -341,7 +367,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(f) = task.first { let _ = app.run("library.select", json!({"ids": [f]})); } - let mut msg = format!("Added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }); + let mut msg = format!("Imported {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }); if task.duplicates > 0 { msg.push_str(&format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); } @@ -407,7 +433,18 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { }); ui.add_space(4.0); // options - field(ui, "Destination", |ui| { + if !d.sources.is_empty() { + field(ui, "Source", |ui| { + let r = ui.label(egui::RichText::new(source_summary(&d.sources)).color(t.text_label)).on_hover_text(d.sources.join("\n")); + register(ui.ctx(), "label:importSource", r.rect); + }); + ui.label( + egui::RichText::new("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", "Add in place", !d.copy).on_hover_text("Reference the files where they are").clicked() { d.copy = false; @@ -418,6 +455,15 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { d.copy = true; } }); + ui.label( + egui::RichText::new(if d.copy { + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are." + } else { + "Add in place: the library references the files where they are; nothing is copied or moved." + }) + .color(t.text_dim) + .small(), + ); if d.copy { field(ui, "Copy to", |ui| { ui.spacing_mut().item_spacing.x = 4.0; @@ -434,40 +480,76 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { } }); field(ui, "Folders", |ui| { - let opts = [("date", "By day (YYYY/YYYY-MM-DD)"), ("month", "By month (YYYY/YYYY-MM)"), ("flat", "Into one folder")]; + 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); - egui::ComboBox::from_id_salt("import-organize").selected_text(cur).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("import-organize").selected_text(cur).show_ui(ui, |ui| { for (k, label) in opts { - if ui.selectable_label(key == k, label).clicked() { + let r = ui.selectable_label(key == k, 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 = (ui.available_width() - 50.0).max(80.0); + 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("Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").") + .color(t.text_dim) + .small(), + ); + } field(ui, "Raw files", |ui| { let r = ui.checkbox(&mut d.dng, "Copy as DNG"); register(ui.ctx(), "check:importDng", r.rect); }); - field(ui, "Rename", |ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.rename).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(f32::INFINITY)); + 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 = (ui.available_width() - 50.0).max(80.0); + let r = ui.add(egui::TextEdit::singleline(&mut d.rename).id(rename_id).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(w)); register(ui.ctx(), "field:importRename", r.rect); + tag_toggle(ui, "importRename") }); - if !d.rename.trim().is_empty() - && let Some(c) = d.candidates.iter().zip(&d.checked).find(|(c, on)| **on && c.duplicate.is_none()).map(|(c, _)| c) - { - let mut q = lightcraft_catalog::Photo::new( - lightcraft_catalog::PhotoId(0), - lightcraft_catalog::Source::Demo { scene: 0 }, - &c.name, - &c.format, - 0, - 0, - "", + 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(format!("Example: {example}")).color(t.text_dim)); + register(ui.ctx(), "label:importExample", r.rect); + ui.label( + egui::RichText::new("Folders and {date} use the capture time; a photo without one uses today's date.").color(t.text_dim).small(), ); - q.captured = c.captured.clone(); - let example = lightcraft_engine::rename::expand(d.rename.trim(), &q, 1); - ui.label(egui::RichText::new(format!("{} → {example}", c.name)).color(Tokens::get(ui.ctx()).text_dim)); } } let albums: Vec<(u64, String)> = { @@ -602,6 +684,141 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial } } +/// The "Tags" toggle beside a template field (`button: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::(id)).unwrap_or(false); + let r = crate::widgets::text_button(ui, &format!("{key}Tags"), "Tags", open).on_hover_text("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 = format!("Unknown tag{s} {} stay{} as typed (see Tags)", unknown.join(" "), if unknown.len() == 1 { "s" } else { "" }); + 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:Tag-`) 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("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() { + "Insert at the cursor".to_string() + } else { + 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(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 = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {m}")).collect(); + ui.label(egui::RichText::new(format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small()); + for n in TEMPLATE_NOTES { + ui.label(egui::RichText::new(format!("• {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 { + let c = d.candidates.iter().zip(&d.checked).find(|(c, on)| **on && c.duplicate.is_none() && c.error.is_none()).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 { + 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() => format!("Folder “{}” (and its subfolders)", name(one)), + [one] => name(one), + many => { + let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count(); + match folders { + 0 => format!("{} files", many.len()), + f if f == many.len() => format!("{f} folders (and their subfolders)"), + f => format!("{} files and {f} folder{}", many.len() - f, if f == 1 { "" } else { "s" }), + } + } + } +} + /// A labelled row (fixed label column). fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e42ed5ea..aa60a80e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4,6 +4,7 @@ //! (views, panels, zoom — see [`menus::UI_COMMANDS`]) and forwards everything else to the engine. //! The same entry point serves menus, shortcuts, buttons and the control channel ([`control`]). #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod control; pub mod export_task; @@ -27,6 +28,8 @@ mod tests_curve; #[cfg(test)] mod tests_masking; #[cfg(test)] +mod tests_panels; +#[cfg(test)] mod tests_scroll; use std::sync::mpsc::{Receiver, Sender}; @@ -404,6 +407,9 @@ impl LightcraftApp { if !self.styled { theme::install_fonts(ctx); theme::apply(ctx); + // File → Add from Device lists cards scanned in the background: show hot-plugs + let repaint = ctx.clone(); + lightcraft_engine::devices::on_change(move || repaint.request_repaint()); self.styled = true; } else { self.fonts_ready = true; @@ -658,6 +664,9 @@ pub struct Caches { counts: Option<(u64, LibraryCounts)>, date_groups: Option<(u64, std::sync::Arc>)>, filter_values: Option<(u64, std::sync::Arc)>, + album_counts: Option<(u64, std::sync::Arc>)>, + /// How often the album counts were recomputed (tests check that unchanged frames don't). + pub album_count_scans: usize, /// The grid's layout (by photos, shapes, width, thumbnail size, grouping). pub grid_layout: Option<(u64, std::sync::Arc)>, } @@ -779,4 +788,95 @@ impl Caches { } } } + /// Every album's photo count for the sidebar (a smart album evaluates its rules, which + /// scans the catalog). Recomputed when the catalog changes (photos, metadata, album rules + /// all bump its revision) and — only while some smart album has an "in the last…" rule — + /// when `now` (the session clock, ISO) enters a new minute, so such counts follow the clock + /// within a minute without rescanning every frame. + pub fn album_counts( + &mut self, + cat: &lightcraft_catalog::Catalog, + now: &str, + ) -> std::sync::Arc> { + let relative = cat.albums().any(|a| a.smart.as_ref().is_some_and(|f| f.depends_on_now())); + let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; + let k = key_of((cat.revision, minute)); + match &self.album_counts { + Some((h, v)) if *h == k => v.clone(), + _ => { + if relative { + // "in the last N days" counts back from the session clock, as in the grid + lightcraft_catalog::rules::set_now(Some(now.to_string())); + } + let v: std::sync::Arc> = + std::sync::Arc::new(cat.albums().map(|a| (a.id, cat.album_count(a.id))).collect()); + self.album_count_scans += 1; + self.album_counts = Some((k, v.clone())); + v + } + } + } +} + +#[cfg(test)] +mod cache_tests { + use lightcraft_catalog::{Album, AlbumId, Catalog, Filter, Op, Photo, PhotoId, RuleSet, Source}; + + fn photo(id: u64, captured: &str, rating: u8) -> Op { + let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, &format!("p{id}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); + p.captured = Some(captured.into()); + p.rating = rating; + Op::AddPhoto { photo: Box::new(p) } + } + + fn smart(id: u64, rules: serde_json::Value) -> Op { + let mut a = Album::new(AlbumId(id), format!("smart {id}")); + let rules: RuleSet = serde_json::from_value(rules).unwrap(); + a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() })); + Op::AddAlbum { album: a } + } + + /// Smart-album counts: unchanged frames reuse them; metadata and rule changes and (for + /// "in the last…" rules) the clock crossing into a new minute recompute them. + #[test] + fn smart_album_counts_are_cached_until_something_changes() { + let mut cat = Catalog::default(); + let mut c = super::Caches::default(); + for (i, d) in ["2026-09-30T11:59:30", "2026-09-29T08:00:00", "2026-01-01T08:00:00"].iter().enumerate() { + cat.apply(photo(i as u64 + 1, d, if i == 0 { 5 } else { 1 })).unwrap(); + } + cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap(); + cat.apply(smart(11, serde_json::json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "hours"}}]}))).unwrap(); + let now = "2026-09-30T12:00:00"; + let n = c.album_counts(&cat, now); + assert_eq!((n[&AlbumId(10)], n[&AlbumId(11)]), (1, 1)); + assert_eq!(c.album_count_scans, 1); + for _ in 0..10 { + c.album_counts(&cat, "2026-09-30T12:00:40"); + } + assert_eq!(c.album_count_scans, 1, "unchanged frames in the same minute don't rescan"); + // metadata change + cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap(); + assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 2); + // rule change + let rs: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 5}]})).unwrap(); + cat.apply(Op::SetAlbumRules { id: AlbumId(10), rules: Box::new(Filter { rule_set: Some(rs), ..Default::default() }) }).unwrap(); + assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 1); + // an hour later the 11:59:30 photo has left "in the last hour", with no catalog change + assert_eq!(c.album_counts(&cat, "2026-09-30T13:00:10")[&AlbumId(11)], 0); + assert_eq!(c.album_count_scans, 4); + lightcraft_catalog::rules::set_now(None); + } + + /// Without "in the last…" rules the clock never causes a rescan. + #[test] + fn absolute_rules_ignore_the_clock() { + let mut cat = Catalog::default(); + let mut c = super::Caches::default(); + cat.apply(photo(1, "2026-09-30T11:59:30", 5)).unwrap(); + cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap(); + c.album_counts(&cat, "2026-09-30T12:00:00"); + c.album_counts(&cat, "2027-01-01T00:00:00"); + assert_eq!(c.album_count_scans, 1); + } } diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 051341da..16aa700e 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -47,14 +47,15 @@ impl MenuNode { pub const MENUS: &[&str] = &["File", "Edit", "View", "Photo", "Window", "Help"]; /// Order and grouping per menu: command ids, `---` separators and `@Submenu` placeholders. -/// Entries with a menu path that aren't listed are appended at the end of their menu. +/// Entries with a menu path that aren't listed are appended at the end of their menu, before any +/// [`LAST`] item. const LAYOUT: &[(&str, &[&str])] = &[ ( "File", &[ "file.addPhotos", "file.addFolder", - "@Add from Device", + "@Import from Device", "---", "app.openLibrary", "---", @@ -219,6 +220,10 @@ const LAYOUT: &[(&str, &[&str])] = &[ ), ]; +/// Items that always end their menu in their own separator group, after the entries appended +/// because the layout doesn't list them (Quit is the last item of the in-window File menu). +const LAST: &[&str] = &["app.quit"]; + /// Registry entries that are reached another way (parameterized commands are expanded into /// submenus below; others duplicate a dialog command). const HIDDEN: &[&str] = &[ @@ -428,7 +433,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.extend(groups.into_iter().map(|(label, g, k)| item("library.sort", json!({"group": k}), label, None, true, Some(cur.group == g)))); v } - "Add from Device" => { + "Import from Device" => { let devices = lightcraft_engine::devices::devices(); if devices.is_empty() { vec![item("file.addFromDevice", Value::Null, "No Camera or Card Found", None, false, None)] @@ -535,6 +540,7 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec)> { for title in MENUS { let layout = LAYOUT.iter().find(|(t, _)| t == title).map(|(_, l)| *l).unwrap_or(&[]); let mut items = Vec::new(); + let mut last = Vec::new(); let sub = |name: &str, used: &mut [bool]| -> Vec { let mut children = expanded(app, name).unwrap_or_default(); let mut dialogs = Vec::new(); @@ -559,7 +565,7 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec)> { items.push(MenuNode::Submenu { label: name.to_string(), children }); } else if let Some(i) = entries.iter().position(|e| e.id == *slot && e.menu.first().map(String::as_str) == Some(title)) { used[i] = true; - items.push(node(app, &entries[i])); + if LAST.contains(slot) { last.push(node(app, &entries[i])) } else { items.push(node(app, &entries[i])) } } } // everything else that names this menu @@ -580,6 +586,10 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec)> { let children = sub(&name, &mut used); items.push(MenuNode::Submenu { label: name, children }); } + if !last.is_empty() { + items.push(MenuNode::Separator); + items.extend(last); + } bar.push((title.to_string(), tidy(items))); } bar @@ -723,6 +733,28 @@ mod tests { }) } + /// File opens with the import entry points, worded as importing (not as adding a sidebar + /// folder): Import Photos… (⇧⌘I), Import from Folder…, Import from Device ▸. + #[test] + fn file_menu_starts_with_the_import_entry_points() { + let bar = menu_bar(&app()); + let file = &bar.iter().find(|(t, _)| t == "File").expect("File menu").1; + let labels: Vec = file + .iter() + .take(3) + .map(|n| match n { + MenuNode::Item { label, shortcut, .. } => format!("{label}{}", shortcut.as_deref().map(|s| format!(" [{s}]")).unwrap_or_default()), + MenuNode::Submenu { label, .. } => format!("{label} ▸"), + MenuNode::Separator => "---".into(), + }) + .collect(); + assert_eq!(labels[0], "Import Photos… [Cmd+Shift+I]"); + assert_eq!(labels[1..], ["Import from Folder…".to_string(), "Import from Device ▸".to_string()]); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let text = serde_json::to_string(&all).unwrap(); + assert!(!text.contains("Add Folder") && !text.contains("\"Add Photos"), "no add-folder wording left in the menus"); + } + #[test] fn bar_follows_the_registry_and_state() { let mut app = app(); @@ -773,6 +805,28 @@ mod tests { assert_eq!(*checked, Some(app.ui.filmstrip)); } + #[test] + fn quit_ends_the_file_menu_after_unlisted_entries() { + let app = app(); + // the File menu has registry entries the layout doesn't list (appended as fallbacks) + let listed = LAYOUT.iter().find(|(t, _)| *t == "File").unwrap().1; + let unlisted: Vec = crate::menus::menu_entries(&app) + .into_iter() + .filter(|e| e.menu.first().map(String::as_str) == Some("File") && !listed.contains(&e.id.as_str()) && !HIDDEN.contains(&e.id.as_str())) + .map(|e| e.id) + .collect(); + assert!(!unlisted.is_empty(), "no fallback File entries to order"); + let bar = menu_bar(&app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + // Quit is the last item, alone in its separator group + assert!(matches!(file.last(), Some(MenuNode::Item { id, .. }) if id == "app.quit"), "{file:?}"); + assert_eq!(file.get(file.len() - 2), Some(&MenuNode::Separator)); + // every fallback entry is still reachable, before Quit + for id in &unlisted { + assert!(find(&file[..file.len() - 1], id).is_some(), "{id} missing before Quit"); + } + } + #[test] fn export_with_preset_submenu() { let mut app = app(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fde1e087..721f177e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -53,6 +53,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.back", "Back to Grid", Some("Escape"), ""), ("tool.done", "Done", Some("Enter"), ""), ("view.filterBar", "Filter Bar", Some("Shift+F"), "View"), + ("local.addRoot", "Add Folder to Local", None, ""), ("local.hide", "Remove from Local", None, ""), ("local.restoreHidden", "Show Hidden Local Locations", None, ""), ("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"), @@ -109,9 +110,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("merge.hdrLast", "HDR with Last Settings", Some("Ctrl+Shift+H"), "Photo>Photo Merge"), ("merge.panoramaLast", "Panorama with Last Settings", Some("Ctrl+Shift+M"), "Photo>Photo Merge"), ("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"), - ("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"), - ("file.addFolder", "Add Folder…", None, "File"), - ("file.addFromDevice", "Add from Device", None, ""), + ("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"), + ("file.addFolder", "Import from Folder…", None, "File"), + ("file.addFromDevice", "Import from Device", None, ""), ("file.findMissing", "Find Missing Photos…", None, "File"), ("photo.locate", "Locate Missing File…", None, ""), ("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""), @@ -555,9 +556,24 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // a folder kept in Local's sidebar (saved with the UI state); nothing on disk changes + let Some(path) = p.get("path").and_then(Value::as_str) else { return Some(Err("local.addRoot needs a path".into())) }; + let abs = std::path::absolute(path).map(|a| a.to_string_lossy().trim_end_matches(['/', '\\']).to_string()).unwrap_or(path.into()); + let abs = if abs.is_empty() { path.to_string() } else { abs }; + if !std::path::Path::new(&abs).is_dir() { + return Some(Err(format!("{path}: not a folder"))); + } + use crate::panels::left::same_folder; + if !app.ui.local_roots.iter().any(|r| same_folder(r, &abs)) { + app.ui.local_roots.push(abs.clone()); + } + app.ui.hidden_locations.retain(|h| !same_folder(h, &abs)); + Ok(json!({"roots": app.ui.local_roots})) + } "local.hide" => match p.get("path").and_then(Value::as_str) { Some(path) => { - if !app.ui.hidden_locations.iter().any(|h| h == path) { + if !app.ui.hidden_locations.iter().any(|h| crate::panels::left::same_folder(h, path)) { app.ui.hidden_locations.push(path.to_string()); } Ok(json!({"hidden": app.ui.hidden_locations})) @@ -567,7 +583,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { // one path, or (no path) every hidden location match p.get("path").and_then(Value::as_str) { - Some(path) => app.ui.hidden_locations.retain(|h| h != path), + Some(path) => app.ui.hidden_locations.retain(|h| !crate::panels::left::same_folder(h, path)), None => app.ui.hidden_locations.clear(), } Ok(json!({"hidden": app.ui.hidden_locations})) @@ -915,7 +931,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option x.to_string(), - None => match lightcraft_engine::devices::devices().into_iter().next() { + None => match lightcraft_engine::devices::devices_now().into_iter().next() { Some(d) => d.path, None => return Some(Err("no camera or memory card found".into())), }, diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index f68292b2..5e153723 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -124,12 +124,16 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b let (tx, rx) = channel(); let (j, p, c) = (job.clone(), progress.clone(), cancel.clone()); let work = move || { - let r = j.run(&|f, stage| { - if let Ok(mut g) = p.lock() { - *g = (f, stage.to_string()); - } - !c.load(Ordering::Relaxed) - }); + let run = || { + j.run(&|f, stage| { + if let Ok(mut g) = p.lock() { + *g = (f, stage.to_string()); + } + !c.load(Ordering::Relaxed) + }) + }; + // a panicking merge reports an error instead of leaving "Merging…" up forever + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(run)).unwrap_or_else(|_| Err("the merge failed unexpectedly".into())); let _ = tx.send(r); }; #[cfg(not(target_arch = "wasm32"))] @@ -220,7 +224,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(t) = &app.merge.preview_task { match t.rx.try_recv() { Ok(Ok(out)) => { - let t = app.merge.preview_task.take().expect("task"); + let Some(t) = app.merge.preview_task.take() else { return }; if let Some(img) = out.preview { let color = Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes())); let tex = ctx.load_texture("merge-preview", color.clone(), egui::TextureOptions::LINEAR); @@ -229,7 +233,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { } } Ok(Err(e)) => { - let t = app.merge.preview_task.take().expect("task"); + let Some(t) = app.merge.preview_task.take() else { return }; if e != "cancelled" { app.merge.error = Some(e); app.merge.failed_options = Some(t.options); @@ -242,7 +246,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(t) = &app.merge.final_task { match t.rx.try_recv() { Ok(r) => { - let t = app.merge.final_task.take().expect("task"); + let Some(t) = app.merge.final_task.take() else { return }; let what = if t.options.command == "merge.panorama" { "Panorama" } else if t.options.command == "merge.hdrPanorama" { diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 28cee606..a904c87c 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -17,179 +17,199 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); - ui.horizontal_centered(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - for (id, icon, mode, tip) in [ - ("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"), - ("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (G toggles)"), - ("detail", Icon::Single, ViewMode::Detail, "Detail (D)"), - ("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"), - ("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"), - ] { - if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() { - let _ = app.run(&format!("view.{id}"), json!({})); - } - } - ui.add_space(10.0); - let (sep, _) = ui.allocate_exact_size(vec2(1.0, 22.0), Sense::hover()); - ui.painter().rect_filled(sep, 0.0, t.button_border); - ui.add_space(10.0); - let sort = icon_button(ui, "sort", Icon::Sort, vec2(30.0, 30.0), false, true, "Sort"); - egui::Popup::menu(&sort).show(|ui| sort_menu(app, ui)); - }); - - // centre: rating/flag pill + copy/paste settings - let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned()); - let pill = Rect::from_center_size(pos2(full.center().x - 60.0, full.center().y), vec2(196.0, 30.0)); - ui.painter().rect_filled(pill, 15.0, t.canvas); - let mut pill_ui = - ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); - pill_ui.spacing_mut().item_spacing.x = 3.0; - let rating = active.as_ref().map(|p| p.rating).unwrap_or(0); - if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) { - let mut params = json!({"rating": r}); - crate::panels::compare::target_active(app, &mut params); - let _ = app.run("photo.rate", params); - } - pill_ui.add_space(6.0); - let flag = active.as_ref().map(|p| p.flag).unwrap_or_default(); - for (id, icon, f) in - [("unflag", Icon::Circle, Flag::None), ("pick", Icon::FlagPick, Flag::Pick), ("reject", Icon::FlagReject, Flag::Reject)] - { - let (r, resp) = pill_ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - register(pill_ui.ctx(), format!("flag:{id}"), r); - let on = flag == f && f != Flag::None; - let c = if on { - if f == Flag::Reject { t.reject } else { t.pick } - } else if resp.hovered() { - t.text - } else { - t.icon - }; - paint(pill_ui.painter(), r.shrink(3.0), icon, c); - if resp.clicked() { - let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }}); - crate::panels::compare::target_active(app, &mut params); - let _ = app.run("photo.flag", params); - } - } - // copy / paste settings - let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(136.0, 30.0)); - let has_clip = app.session.clipboard.is_some(); - let label = if has_clip { "Paste Edit Settings" } else { "Copy Edit Settings" }; - let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click()); - register(ui.ctx(), "button:copySettings", copy_r); - ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas }); - ui.painter().text( - copy_r.center(), - Align2::CENTER_CENTER, - label, - t.font(13.0), - if active.is_some() { t.text_label } else { t.text_disabled }, - ); - if cresp.clicked() && active.is_some() { - let _ = if has_clip { app.run("develop.paste", json!({})) } else { app.run("develop.copy", json!({})) }; - let msg = if has_clip { "Settings pasted" } else { "Edit settings copied" }; - app.toast(ui.ctx(), msg); - } - let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0)); - let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click()); - register(ui.ctx(), "icon:copyGear", gear_r); - ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas }); - paint(ui.painter(), gear_r.shrink(7.0), Icon::Gear, t.icon); - if gresp.clicked() { - let _ = app.run("dialog.copySettings", json!({})); - } - - // right side - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(full.right() - 360.0, full.top()), full.right_bottom())) - .layout(egui::Layout::right_to_left(egui::Align::Center)), - ); - child.spacing_mut().item_spacing.x = 6.0; - if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) - && icon_button( - &mut child, - "autoAdvance", - Icon::ChevronRight, - vec2(30.0, 30.0), - app.ui.auto_advance, - true, - "Auto Advance after rating or flagging", - ) - .clicked() - { - let _ = app.run("view.autoAdvance", json!({})); - } - if app.ui.view == ViewMode::Survey { - // no zoom or thumbnail size in Survey - } else if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { - if icon_button( - &mut child, - "beforeAfter", - Icon::BeforeAfter, - vec2(30.0, 30.0), - app.ui.before_after != BeforeAfter::Off, - true, - "Before/After (Y)", - ) - .clicked() - { - let _ = app.run("view.beforeAfter", json!({})); - } - if icon_button(&mut child, "filmstrip", Icon::Filmstrip, vec2(30.0, 30.0), app.ui.filmstrip, true, "Filmstrip (/)").clicked() { - let _ = app.run("view.filmstrip", json!({})); - } - child.add_space(10.0); - let zoom_label = match app.ui.zoom { - Zoom::Fit => "Fit".to_string(), - Zoom::Fill => "Fill".to_string(), - Zoom::Percent(p) => format!("{p}%"), - }; - let zr = crate::widgets::dropdown(&mut child, "zoom", &zoom_label, t.font(13.0), t.text_label); - egui::Popup::menu(&zr).show(|ui| { - for (label, z) in [ - ("Fit", Zoom::Fit), - ("Fill", Zoom::Fill), - ("50%", Zoom::Percent(50)), - ("100%", Zoom::Percent(100)), - ("200%", Zoom::Percent(200)), - ("400%", Zoom::Percent(400)), + let left_end = ui + .horizontal_centered(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (id, icon, mode, tip) in [ + ("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"), + ("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (G toggles)"), + ("detail", Icon::Single, ViewMode::Detail, "Detail (D)"), + ("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"), + ("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"), ] { - if ui.selectable_label(app.ui.zoom == z, label).clicked() { - app.ui.zoom = z; - app.ui.pan = (0.5, 0.5); + if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() { + let _ = app.run(&format!("view.{id}"), json!({})); } } - }); - child.add_space(6.0); - let cz = - crate::widgets::dropdown(&mut child, "clickZoom", &format!("Click {}:1", app.ui.click_zoom / 100), t.font(13.0), t.text_label); - egui::Popup::menu(&cz).show(|ui| { - for pct in crate::state::CLICK_ZOOMS { - if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() { - let _ = app.run("view.clickZoom", json!({"ratio": pct / 100})); - } - } - }); - } else { - // thumbnail size slider - let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag()); - register(child.ctx(), "slider:thumbSize", r); - let p = child.painter(); - let y = r.center().y; - p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], egui::Stroke::new(2.0, t.track)); - let f = (app.ui.thumb_size - 90.0) / (480.0 - 90.0); - let x = r.left() + f * r.width(); - p.rect_filled(Rect::from_center_size(pos2(x, y), vec2(3.0, 14.0)), 1.0, t.thumb_hover); - if let Some(pp) = resp.interact_pointer_pos() - && (resp.dragged() || resp.clicked()) - { - app.ui.thumb_size = 90.0 + ((pp.x - r.left()) / r.width()).clamp(0.0, 1.0) * (480.0 - 90.0); + ui.add_space(10.0); + let (sep, _) = ui.allocate_exact_size(vec2(1.0, 22.0), Sense::hover()); + ui.painter().rect_filled(sep, 0.0, t.button_border); + ui.add_space(10.0); + let sort = icon_button(ui, "sort", Icon::Sort, vec2(30.0, 30.0), false, true, "Sort"); + egui::Popup::menu(&sort).show(|ui| sort_menu(app, ui)); + }) + .response + .rect + .right(); + let right_start = right_side(app, ui, full); + centre(app, ui, full, left_end + 12.0, right_start - 12.0); + }); +} + +/// Width of the centre group: the rating/flag pill, then Copy/Paste Edit Settings and its gear. +const PILL_W: f32 = 196.0; +const CENTRE_W: f32 = PILL_W + 10.0 + 136.0 + 4.0 + 30.0; + +/// The rating/flag pill and copy/paste settings, between `from` and `to` (the side groups): the +/// copy buttons go first when there is no room (they are in the Edit menu too), then the pill. +fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: f32) { + let t = Tokens::get(ui.ctx()); + let room = to - from; + if room < PILL_W { + return; + } + let with_copy = room >= CENTRE_W; + let w = if with_copy { CENTRE_W } else { PILL_W }; + // where it sits with room to spare (slightly left of centre), kept between the side groups + let left = (full.center().x - 60.0 - PILL_W / 2.0).clamp(from, to - w); + let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned()); + let pill = Rect::from_min_size(pos2(left, full.center().y - 15.0), vec2(PILL_W, 30.0)); + ui.painter().rect_filled(pill, 15.0, t.canvas); + let mut pill_ui = + ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + pill_ui.spacing_mut().item_spacing.x = 3.0; + let rating = active.as_ref().map(|p| p.rating).unwrap_or(0); + if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) { + let mut params = json!({"rating": r}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.rate", params); + } + pill_ui.add_space(6.0); + let flag = active.as_ref().map(|p| p.flag).unwrap_or_default(); + for (id, icon, f) in [("unflag", Icon::Circle, Flag::None), ("pick", Icon::FlagPick, Flag::Pick), ("reject", Icon::FlagReject, Flag::Reject)] { + let (r, resp) = pill_ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + register(pill_ui.ctx(), format!("flag:{id}"), r); + let on = flag == f && f != Flag::None; + let c = if on { + if f == Flag::Reject { t.reject } else { t.pick } + } else if resp.hovered() { + t.text + } else { + t.icon + }; + paint(pill_ui.painter(), r.shrink(3.0), icon, c); + if resp.clicked() { + let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.flag", params); + } + } + // copy / paste settings + if !with_copy { + return; + } + let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(136.0, 30.0)); + let has_clip = app.session.clipboard.is_some(); + let label = if has_clip { "Paste Edit Settings" } else { "Copy Edit Settings" }; + let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click()); + register(ui.ctx(), "button:copySettings", copy_r); + ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas }); + ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled }); + if cresp.clicked() && active.is_some() { + let _ = if has_clip { app.run("develop.paste", json!({})) } else { app.run("develop.copy", json!({})) }; + let msg = if has_clip { "Settings pasted" } else { "Edit settings copied" }; + app.toast(ui.ctx(), msg); + } + let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0)); + let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click()); + register(ui.ctx(), "icon:copyGear", gear_r); + ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas }); + paint(ui.painter(), gear_r.shrink(7.0), Icon::Gear, t.icon); + if gresp.clicked() { + let _ = app.run("dialog.copySettings", json!({})); + } +} + +/// The right-hand group (zoom, view toggles or thumbnail size); returns its left edge. +fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 { + let t = Tokens::get(ui.ctx()); + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(full.right() - 360.0, full.top()), full.right_bottom())) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + child.spacing_mut().item_spacing.x = 6.0; + if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) + && icon_button( + &mut child, + "autoAdvance", + Icon::ChevronRight, + vec2(30.0, 30.0), + app.ui.auto_advance, + true, + "Auto Advance after rating or flagging", + ) + .clicked() + { + let _ = app.run("view.autoAdvance", json!({})); + } + if app.ui.view == ViewMode::Survey { + // no zoom or thumbnail size in Survey + } else if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { + if icon_button( + &mut child, + "beforeAfter", + Icon::BeforeAfter, + vec2(30.0, 30.0), + app.ui.before_after != BeforeAfter::Off, + true, + "Before/After (Y)", + ) + .clicked() + { + let _ = app.run("view.beforeAfter", json!({})); + } + if icon_button(&mut child, "filmstrip", Icon::Filmstrip, vec2(30.0, 30.0), app.ui.filmstrip, true, "Filmstrip (/)").clicked() { + let _ = app.run("view.filmstrip", json!({})); + } + child.add_space(10.0); + let zoom_label = match app.ui.zoom { + Zoom::Fit => "Fit".to_string(), + Zoom::Fill => "Fill".to_string(), + Zoom::Percent(p) => format!("{p}%"), + }; + let zr = crate::widgets::dropdown(&mut child, "zoom", &zoom_label, t.font(13.0), t.text_label); + egui::Popup::menu(&zr).show(|ui| { + for (label, z) in [ + ("Fit", Zoom::Fit), + ("Fill", Zoom::Fill), + ("50%", Zoom::Percent(50)), + ("100%", Zoom::Percent(100)), + ("200%", Zoom::Percent(200)), + ("400%", Zoom::Percent(400)), + ] { + if ui.selectable_label(app.ui.zoom == z, label).clicked() { + app.ui.zoom = z; + app.ui.pan = (0.5, 0.5); } } }); + child.add_space(6.0); + let cz = crate::widgets::dropdown(&mut child, "clickZoom", &format!("Click {}:1", app.ui.click_zoom / 100), t.font(13.0), t.text_label); + egui::Popup::menu(&cz).show(|ui| { + for pct in crate::state::CLICK_ZOOMS { + if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() { + let _ = app.run("view.clickZoom", json!({"ratio": pct / 100})); + } + } + }); + } else { + // thumbnail size slider + let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag()); + register(child.ctx(), "slider:thumbSize", r); + let p = child.painter(); + let y = r.center().y; + p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], egui::Stroke::new(2.0, t.track)); + let f = (app.ui.thumb_size - 90.0) / (480.0 - 90.0); + let x = r.left() + f * r.width(); + p.rect_filled(Rect::from_center_size(pos2(x, y), vec2(3.0, 14.0)), 1.0, t.thumb_hover); + if let Some(pp) = resp.interact_pointer_pos() + && (resp.dragged() || resp.clicked()) + { + app.ui.thumb_size = 90.0 + ((pp.x - r.left()) / r.width()).clamp(0.0, 1.0) * (480.0 - 90.0); + } + } + if child.min_rect().width() > 0.0 { child.min_rect().left() } else { full.right() } } fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 24153a8b..3a71a55d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -27,7 +27,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::NewAlbum { .. } => "Create Album", Dialog::RenameAlbum { .. } => "Rename Album", Dialog::Rename { .. } => "Rename Photos", - Dialog::Import { .. } => "Add Photos", + Dialog::Import { .. } => "Import Photos", Dialog::LabelNames { .. } => "Edit Color Label Names", Dialog::CaptureTime { .. } => "Edit Capture Time", Dialog::RenameKeyword { .. } => "Rename Keyword", @@ -289,10 +289,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Rename { template, start } => { let n = app.session.targets(&json!({})).len(); ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim)); - field(ui, "Template", |ui| { - let r = ui.add(egui::TextEdit::singleline(template).hint_text("{name}").desired_width(f32::INFINITY)); + let template_id = egui::Id::new("rename-template"); + let tags_open = field(ui, "Template", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = (ui.available_width() - 50.0).max(80.0); + let r = ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w)); crate::widgets::register(ui.ctx(), "field:renameTemplate", r.rect); + crate::import::tag_toggle(ui, "renameTemplate") }); + if tags_open { + crate::import::tag_help(ui, "renameTemplate", template, template_id); + } + crate::import::unknown_tags_warning(ui, template); field(ui, "Presets", |ui| { ui.spacing_mut().item_spacing.x = 4.0; for (i, (label, tpl)) in @@ -305,7 +313,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999))); ui.label( - egui::RichText::new("Tokens: {name} {num} {seq} {seq:3} {date} {date:%Y-%m-%d} {folder} {camera} {lens} {iso} {rating} {title} {creator}. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…") + egui::RichText::new("Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…") .color(t.text_dim), ); let preview = app.session.execute("photo.renamePreview", &json!({"template": template, "start": start})).unwrap_or_default(); @@ -576,9 +584,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { use lightcraft_engine::export::DngCompression as Z; choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None")], &mut opts.dng_compression); } - field(ui, "File name", |ui| { - ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(f32::INFINITY)) + let naming_id = egui::Id::new("export-naming"); + let tags_open = field(ui, "File name", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = (ui.available_width() - 50.0).max(80.0); + ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w)); + crate::import::tag_toggle(ui, "exportNaming") }); + if tags_open { + crate::import::tag_help(ui, "exportNaming", &mut opts.naming, naming_id); + } + crate::import::unknown_tags_warning(ui, &opts.naming); if opts.naming.contains("{seq") { let mut v = opts.start_number as f64; if num(ui, &START_NUMBER, &mut v) { @@ -704,7 +720,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let ok = match &dlg { Dialog::Import { opts } => { let n = opts.selected_paths().len(); - add_label = format!("Add {n} Photo{}", if n == 1 { "" } else { "s" }); + add_label = format!("Import {n} Photo{}", if n == 1 { "" } else { "s" }); add_label.as_str() } Dialog::Merge { .. } => "Merge", @@ -729,8 +745,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { close = true; } if confirm { - let _ = confirm_dialog(app, &dlg); - close = true; + match confirm_dialog(app, &dlg) { + // the import review stays open on an error (e.g. an unusable folder template) + Err(e) if matches!(dlg, Dialog::Import { .. }) => app.toast(ctx, e), + _ => close = true, + } } app.ui.dialog = if close { None } else { Some(dlg) }; } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 5af5fa46..43fd84b8 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -66,7 +66,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else if app.session.source == lightcraft_engine::LibrarySource::Folder { super::empty_message(ui, ui.max_rect(), "No photos in this folder", "Turn on Include subfolders, or pick another folder under Local"); } else { - super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here"); + super::empty_message(ui, ui.max_rect(), "No photos", "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here"); } return; } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 72516322..941e5070 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -56,99 +56,99 @@ fn row( pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::left("left_panel") - .exact_size(t.left_w) - .resizable(false) - .frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider))) - .show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 0.0; - let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); - ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "My Photos", t.semibold(15.0), t.text); - let counts = app.caches.counts(&app.session.catalog); - let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted); - egui::ScrollArea::vertical().id_salt("left-scroll").auto_shrink([false, false]).show(ui, |ui| { - let src = app.session.source; - for (id, icon, label, count, s) in [ - ("all", Icon::Photos, "All Photos", Some(total), LibrarySource::All), - ("recentlyAdded", Icon::Clock, "Recently Added", None, LibrarySource::RecentlyAdded), - ("picks", Icon::FlagPick, "Picks", Some(picks), LibrarySource::Picks), - ] { - if row(app, ui, id, icon, label, count, src == s, 0.0).clicked() { - let _ = app.run("library.source", json!({"kind": id})); - } + let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); + let width = app.ui.left_width; + let resized = super::resizable_side(ui, true, "left_panel", frame, width, crate::state::LEFT_WIDTH, 0.0, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); + ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "My Photos", t.semibold(15.0), t.text); + let counts = app.caches.counts(&app.session.catalog); + let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted); + egui::ScrollArea::vertical().id_salt("left-scroll").auto_shrink([false, false]).show(ui, |ui| { + let src = app.session.source; + for (id, icon, label, count, s) in [ + ("all", Icon::Photos, "All Photos", Some(total), LibrarySource::All), + ("recentlyAdded", Icon::Clock, "Recently Added", None, LibrarySource::RecentlyAdded), + ("picks", Icon::FlagPick, "Picks", Some(picks), LibrarySource::Picks), + ] { + if row(app, ui, id, icon, label, count, src == s, 0.0).clicked() { + let _ = app.run("library.source", json!({"kind": id})); } - // photos whose files can't be found (checked every few seconds, not every frame) - let missing = missing_count(app, ui); - if (missing > 0 || src == LibrarySource::Missing) - && row(app, ui, "missing", Icon::Folder, "Missing Photos", Some(missing), src == LibrarySource::Missing, 0.0).clicked() - { - let _ = app.run("library.source", json!({"kind": "missing"})); + } + // photos whose files can't be found (checked every few seconds, not every frame) + let missing = missing_count(app, ui); + if (missing > 0 || src == LibrarySource::Missing) + && row(app, ui, "missing", Icon::Folder, "Missing Photos", Some(missing), src == LibrarySource::Missing, 0.0).clicked() + { + let _ = app.run("library.source", json!({"kind": "missing"})); + } + ui.add_space(10.0); + // Albums header + let (ar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); + ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, "Albums", t.semibold(13.5), t.text_label); + let mut hdr = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom())) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album"); + egui::Popup::menu(&plus).show(|ui| { + if ui.button("Create Album…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false }); } - ui.add_space(10.0); - // Albums header - let (ar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, "Albums", t.semibold(13.5), t.text_label); - let mut hdr = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom())) - .layout(egui::Layout::right_to_left(egui::Align::Center)), - ); - let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album"); - egui::Popup::menu(&plus).show(|ui| { - if ui.button("Create Album…").clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false }); - } - if ui.button("Create Smart Album…").clicked() { - app.ui.dialog = Some(crate::state::Dialog::SmartRules { - id: None, - name: String::new(), - rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, - }); - } - if ui.button("Create Smart Album from Filter…").clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new() }); - } - if ui.button("Create Folder…").clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true }); - } - }); - let albums: Vec = app.session.catalog.albums().cloned().collect(); - albums_tree(app, ui, &albums, None, 0.0); - ui.add_space(10.0); - local_section(app, ui); - // By date - let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, "By Date", t.semibold(13.5), t.text_label); - for g in app.caches.date_groups(&app.session.catalog).iter() { - // year → month → day; a click filters by that prefix, the triangle opens a level - if date_row(app, ui, &g.year, &g.year, g.count, 0.0) { - for (m, n) in &g.months { - let label = lightcraft_catalog::dates::group_label(m).split(' ').next().unwrap_or(m).to_string(); - if date_row(app, ui, m, &label, *n, 16.0) { - for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { - let label = lightcraft_catalog::dates::group_label(d); - // "Sunday, 20 September 2026" → "Sunday, 20" - let label = label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(); - date_row(app, ui, d, &label, *n, 32.0); - } + if ui.button("Create Smart Album…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::SmartRules { + id: None, + name: String::new(), + rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + }); + } + if ui.button("Create Smart Album from Filter…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new() }); + } + if ui.button("Create Folder…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true }); + } + }); + let albums: Vec = app.session.catalog.albums().cloned().collect(); + albums_tree(app, ui, &albums, None, 0.0); + ui.add_space(10.0); + local_section(app, ui); + // By date + let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); + ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, "By Date", t.semibold(13.5), t.text_label); + for g in app.caches.date_groups(&app.session.catalog).iter() { + // year → month → day; a click filters by that prefix, the triangle opens a level + if date_row(app, ui, &g.year, &g.year, g.count, 0.0) { + for (m, n) in &g.months { + let label = lightcraft_catalog::dates::group_label(m).split(' ').next().unwrap_or(m).to_string(); + if date_row(app, ui, m, &label, *n, 16.0) { + for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { + let label = lightcraft_catalog::dates::group_label(d); + // "Sunday, 20 September 2026" → "Sunday, 20" + let label = label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(); + date_row(app, ui, d, &label, *n, 32.0); } } } } - keywords_section(app, ui); - ui.add_space(10.0); - if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0) - .clicked() - { - let _ = app.run("library.source", json!({"kind": "recentlyDeleted"})); - } - }); + } + keywords_section(app, ui); + ui.add_space(10.0); + if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() { + let _ = app.run("library.source", json!({"kind": "recentlyDeleted"})); + } }); + }); + if let Some(w) = resized { + app.ui.left_width = w; + } } -/// How many library photos have no file. Checking stats every file, which on a network share -/// takes seconds, so it runs on a worker thread: the count shown is the last finished one, -/// refreshed at most every 5 s (or when the catalog changed). +/// How many library photos have no file (Local browse records are not checked; see +/// `cmd::missing::checked_path`). Checking stats every file, which on a network share takes +/// seconds, so it runs on a worker thread: the count shown is the last finished one, refreshed +/// at most every 5 s, and at once (after the running check) when the catalog changed. fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; #[derive(Clone, Default)] @@ -164,16 +164,8 @@ fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { let mut job: Job = ui.data(|d| d.get_temp(id)).unwrap_or(Job { rev: u64::MAX, at: f64::MIN, ..Default::default() }); let fresh = now - job.at < 5.0 && job.rev == rev; if !fresh && !job.running.load(Relaxed) { - let paths: Vec = app - .session - .catalog - .photos() - .filter(|p| !p.deleted) - .filter_map(|p| match &p.source { - lightcraft_catalog::Source::File { path } => Some(path.clone()), - _ => None, - }) - .collect(); + // the same scope as the Missing Photos view: library photos only, never Local browse records + let paths = lightcraft_engine::cmd::missing::candidates(&app.session.catalog); job.running.store(true, Relaxed); job.at = now; job.rev = rev; @@ -203,41 +195,35 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (lr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); ui.painter().text(pos2(lr.left() + 18.0, lr.center().y), Align2::LEFT_CENTER, "Local", t.semibold(13.5), t.text_label); let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default(); - let mut places: Vec<(String, String)> = Vec::new(); + let mut builtin: Vec<(String, String)> = Vec::new(); if !home.is_empty() { for (name, sub) in [("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")] { - let p = if sub.is_empty() { home.clone() } else { format!("{home}/{sub}") }; + // joined with the platform's separator, like the paths browsing gives back + let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }; if std::path::Path::new(&p).is_dir() { - places.push((name.to_string(), p)); + builtin.push((name.to_string(), p)); } } } let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); - // picked folders stay listed; the folder being browsed is listed even when it wasn't picked - let roots = app.ui.local_roots.iter().cloned().chain(browsing.iter().map(|b| b.path.clone())); - for path in roots { - if !places.iter().any(|(_, p)| *p == path) { - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); - places.push((name, path)); - } - } let current = browsing.as_ref().map(|b| b.path.clone()); - // hidden locations stay reachable: breadcrumbs, Browse Folder…, and the row below - places.retain(|(_, p)| !app.ui.hidden_locations.contains(p)); - for (name, path) in places { - folder_tree(app, ui, &name, &path, 0.0, current.as_deref()); + let local = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations); + if current.is_some() { + app.ui.local_browse_root = local.browse_root.clone(); + } + for (i, (name, path)) in local.places.iter().enumerate() { + let transient = local.browse_root.as_deref() == Some(path.as_str()); + let reveal = local.owner == Some(i); + folder_tree(app, ui, name, path, 0.0, current.as_deref(), reveal, transient); } if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, "Browse Folder…", None, false, 0.0).clicked() { let picked = app.services.pick_folder.as_mut().and_then(|f| f()); if let Some(path) = picked { - // choosing a folder again puts it back in the list - let _ = app.run("local.restoreHidden", json!({"path": path})); match app.run("library.browse", json!({"path": path})) { + // the picked folder stays in Local (and comes back if it was hidden) Ok(r) => { let dir = r["path"].as_str().unwrap_or(&path).to_string(); - if !app.ui.local_roots.contains(&dir) { - app.ui.local_roots.push(dir); - } + let _ = app.run("local.addRoot", json!({"path": dir})); } Err(e) => app.toast(ui.ctx(), e), } @@ -256,6 +242,69 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.add_space(10.0); } +/// Whether two paths name the same folder, however they are spelled (separators, trailing +/// slash, `.`/`..`, drive-letter case; see `lightcraft_catalog::query::folder_key`). +pub(crate) fn same_folder(a: &str, b: &str) -> bool { + a == b || lightcraft_catalog::query::folder_key(a) == lightcraft_catalog::query::folder_key(b) +} + +/// Local's top-level folders and how the folder being browsed sits among them. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct LocalPlaces { + /// (label, path) of each top-level folder, in order. + pub places: Vec<(String, String)>, + /// The top-level folder the browsed folder lies in (the innermost one): its tree opens on + /// the way down to it. + pub owner: Option, + /// A folder listed only for this session because the browsed folder is in no saved + /// location (browsed from a breadcrumb, the CLI…); it stays while browsing below it. + pub browse_root: Option, +} + +fn folder_label(path: &str) -> String { + std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) +} + +/// Local's top-level folders, in order: the built-in places, the folders kept with Browse +/// Folder… / Keep in Local (`saved`), then — only when the browsed folder lies in none of those — +/// a session root for it: the previous one (`browse_root`) while browsing stays below it, else +/// the browsed folder itself. A folder inside a listed one is shown inside that one's tree (the +/// root stays; its siblings stay reachable), never as a root of its own. Each folder is listed +/// once however its path is spelled (the first spelling wins), and hidden ones are left out — +/// they stay reachable through breadcrumbs and Browse Folder…. +pub(crate) fn local_places( + builtin: Vec<(String, String)>, + saved: &[String], + browsing: Option<&str>, + browse_root: Option<&str>, + hidden: &[String], +) -> LocalPlaces { + use lightcraft_catalog::query::{folder_key, folder_within}; + let is_hidden = |p: &str| hidden.iter().any(|h| same_folder(h, p)); + let mut places = builtin; + for path in saved { + if !places.iter().any(|(_, p)| same_folder(p, path)) { + places.push((folder_label(path), path.clone())); + } + } + places.retain(|(_, p)| !is_hidden(p)); + let mut out = LocalPlaces::default(); + if let Some(c) = browsing + && !places.iter().any(|(_, p)| folder_within(c, p)) + { + let root = browse_root.filter(|r| folder_within(c, r)).unwrap_or(c); + if !is_hidden(root) { + places.push((folder_label(root), root.to_string())); + out.browse_root = Some(root.to_string()); + } + } + if let Some(c) = browsing { + out.owner = places.iter().enumerate().filter(|(_, (_, p))| folder_within(c, p)).max_by_key(|(_, (_, p))| folder_key(p).len()).map(|(i, _)| i); + } + out.places = places; + out +} + /// The subfolders of `path` (not hidden ones), sorted; listed at most every 2 s per folder. fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> { let id = egui::Id::new(("subfolders", path.to_string())); @@ -282,13 +331,37 @@ fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> { } /// A folder on disk with a disclosure triangle: click browses it, the triangle lists its -/// subfolders (expanded on the way to the folder being browsed). -fn folder_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str, path: &str, indent: f32, current: Option<&str>) { +/// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the +/// way down to it open whenever the browsed folder changes, so it shows highlighted in place. +/// `transient`: a top-level row listed for this session only (it offers Keep in Local). +#[allow(clippy::too_many_arguments)] +fn folder_tree( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + name: &str, + path: &str, + indent: f32, + current: Option<&str>, + reveal: bool, + transient: bool, +) { let t = Tokens::get(ui.ctx()); let open_id = egui::Id::new(("folder-open", path.to_string())); - let on_the_way = current.is_some_and(|c| c != path && std::path::Path::new(c).starts_with(path)); - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(on_the_way); - let sel = current == Some(path); + let sel = current.is_some_and(|c| same_folder(c, path)); + let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)); + let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false); + if reveal && on_the_way { + // opened once per browsed folder: collapsing it again afterwards sticks + let revealed_id = egui::Id::new(("folder-revealed", path.to_string())); + let target = current.map(str::to_string); + if ui.data(|d| d.get_temp::>(revealed_id)) != Some(target.clone()) { + open = true; + ui.data_mut(|d| { + d.insert_temp(open_id, true); + d.insert_temp(revealed_id, target); + }); + } + } let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent + 12.0).on_hover_text(path); let c = pos2(resp.rect.left() + 10.0 + indent, resp.rect.center().y); let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); @@ -310,6 +383,12 @@ fn folder_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str, path: &st app.toast(ui.ctx(), e); } resp.context_menu(|ui| { + if (transient || indent > 0.0) + && ui.button(if transient { "Keep in Local" } else { "Add to Local" }).on_hover_text("List this folder in Local from now on").clicked() + { + let _ = app.run("local.addRoot", json!({"path": path})); + ui.close(); + } if indent == 0.0 && ui.button("Remove from Local").on_hover_text("Hides this shortcut only; the folder and its photos stay as they are").clicked() { @@ -344,7 +423,7 @@ fn folder_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str, path: &st }); if open && indent < 12.0 * 8.0 { for (n, p) in subfolders(ui, path) { - folder_tree(app, ui, &n, &p, indent + 12.0, current); + folder_tree(app, ui, &n, &p, indent + 12.0, current, reveal, false); } } } @@ -398,7 +477,9 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent } else { let sel = app.session.source == LibrarySource::Album(a.id); let icon = if a.is_smart() { Icon::SmartAlbum } else { Icon::Album }; - let n = app.session.catalog.album_count(a.id); + // cached: a smart album's count scans the catalog + let now = (app.session.clock)(); + let n = app.caches.album_counts(&app.session.catalog, &now).get(&a.id).copied().unwrap_or(0); // the album B adds to is marked "+" let target = app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection()); @@ -597,3 +678,76 @@ fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode } } } + +#[cfg(test)] +mod tests { + use super::local_places; + + fn names(v: &[(String, String)]) -> Vec<&str> { + v.iter().map(|(n, _)| n.as_str()).collect() + } + + /// A built-in location browsed (or picked) under another spelling of its path is the same + /// row, not a second root — for every built-in, not just named ones. + #[test] + fn equivalent_paths_are_one_local_location() { + for (home, sep) in [("D:\\Example", '\\'), ("D:/Example", '/'), ("/home/example", '/')] { + let builtin: Vec<(String, String)> = + ["Pictures", "Desktop", "Downloads"].iter().map(|s| (s.to_string(), format!("{home}{sep}{s}"))).collect(); + for (_, p) in builtin.clone() { + // the browsed spelling: other separators, a trailing one, a different drive-letter case + let flipped = p.replace(['/', '\\'], if sep == '/' { "\\" } else { "/" }); + for browsing in [flipped.clone(), format!("{p}{sep}"), p.replacen("D:", "d:", 1)] { + let l = local_places(builtin.clone(), std::slice::from_ref(&browsing), Some(&browsing), None, &[]); + assert_eq!(names(&l.places), ["Pictures", "Desktop", "Downloads"], "{p} browsed as {browsing}"); + assert_eq!(l.places[l.owner.unwrap()].1, p, "the built-in row is the one highlighted"); + } + // hiding under one spelling hides the other + let l = local_places(builtin.clone(), &[], None, None, std::slice::from_ref(&flipped)); + assert_eq!(l.places.len(), 2, "hidden {flipped}"); + } + } + // a different folder is still added + let l = local_places(vec![("Pictures".into(), "/home/example/Pictures".into())], &[], Some("/home/example/Pictures2"), None, &[]); + assert_eq!(names(&l.places), ["Pictures", "Pictures2"]); + } + + /// A folder inside a kept root is shown inside that root's tree: the root stays (its other + /// subfolders stay reachable) and no second root appears; other kept roots stay too. + #[test] + fn browsing_below_a_kept_root_keeps_the_root() { + let photos = "/data/Photos".to_string(); + let other = "/data/Scans".to_string(); + let saved = [photos.clone(), other.clone()]; + let builtin = || vec![("Home".to_string(), "/home/example".to_string())]; + for browsing in ["/data/Photos/2026/20260101", "/data/Photos/2026", "/data/Photos", "D:\\x"] { + let l = local_places(builtin(), &saved, Some(browsing), None, &[]); + let roots: Vec<&str> = l.places.iter().map(|(_, p)| p.as_str()).collect(); + if browsing.starts_with("/data") { + assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans"], "{browsing}"); + assert_eq!(l.owner, Some(1), "the Photos tree opens down to {browsing}"); + assert_eq!(l.browse_root, None); + } else { + assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans", "D:\\x"], "a folder outside them gets a row"); + } + } + // the innermost containing root is the one that opens + let l = local_places(builtin(), &["/home/example/Pictures".into()], Some("/home/example/Pictures/Trip"), None, &[]); + assert_eq!(l.owner, Some(1)); + } + + /// A browsed folder outside every kept root gets a session row, which stays while browsing + /// below it and gives way when browsing moves elsewhere. + #[test] + fn session_root_stays_while_browsing_below_it() { + let l = local_places(Vec::new(), &[], Some("/t/base"), None, &[]); + assert_eq!(l.browse_root.as_deref(), Some("/t/base")); + let l = local_places(Vec::new(), &[], Some("/t/base/Trip/Day 1"), l.browse_root.as_deref(), &[]); + assert_eq!((l.places.len(), l.browse_root.as_deref(), l.owner), (1, Some("/t/base"), Some(0)), "{l:?}"); + let l = local_places(Vec::new(), &[], Some("/elsewhere"), l.browse_root.as_deref(), &[]); + assert_eq!(l.browse_root.as_deref(), Some("/elsewhere")); + // hidden: no row at all + let l = local_places(Vec::new(), &[], Some("/t/base"), None, &["/t/base/".into()]); + assert!(l.places.is_empty() && l.browse_root.is_none()); + } +} diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 0f501f9f..ab494432 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -58,6 +58,16 @@ fn kind_label(s: &MaskShape) -> (&'static str, Icon) { } } +const TILE: f32 = 52.0; +const TILE_GAP: f32 = 6.0; + +/// Columns and tile width of the Create New Mask grid in `width`: four tiles of up to 52 pt (at +/// least 48, so the labels fit), else three (narrowed if even those don't fit). +fn tile_layout(width: f32) -> (usize, f32) { + let tile = |n: f32| ((width - (n - 1.0) * TILE_GAP) / n).floor().min(TILE); + if tile(4.0) >= 48.0 { (4, tile(4.0)) } else { (3, tile(3.0).max(24.0)) } +} + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let t = Tokens::get(ui.ctx()); let d = app.session.develop_of(id).unwrap_or_default(); @@ -75,9 +85,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("luminanceRange", "Luminance", Icon::Sliders), ("colorRange", "Color", Icon::Picker), ]; - egui::Grid::new("mask-tiles").spacing(vec2(6.0, 6.0)).show(ui, |ui| { + // four 52 pt tiles a row when they fit, else as many as fit (at least three, shrunk) + let (cols, tile) = tile_layout(ui.available_width()); + egui::Grid::new("mask-tiles").spacing(vec2(TILE_GAP, TILE_GAP)).show(ui, |ui| { for (i, (kind, label, icon)) in tiles.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(52.0, 52.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(tile, 52.0), Sense::click()); register(ui.ctx(), format!("maskNew:{kind}"), r); ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.inset }); paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 7.0), vec2(20.0, 20.0)), *icon, t.text_label); @@ -101,7 +113,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } } - if i % 4 == 3 { + if i % cols == cols - 1 { ui.end_row(); } } @@ -228,7 +240,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } return; } - let resp = ui.add(egui::Label::new(format!("{op}{label}{}", if c.invert { " (inverted)" } else { "" })).sense(Sense::click())); + // a long name is cut short (with …) before the options button, not past the panel + let text = format!("{op}{label}{}", if c.invert { " (inverted)" } else { "" }); + let room = (ui.available_width() - 30.0).max(0.0); + let resp = ui.scope(|ui| { + ui.set_max_width(room); + ui.add(egui::Label::new(text).truncate().sense(Sense::click())) + }); + let resp = resp.inner; register(ui.ctx(), format!("component:{i}"), resp.rect); if resp.double_clicked() { app.ui.renaming_component = Some((m.id, i, label.clone())); @@ -243,7 +262,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { range_controls(app, ui, i, &c.shape); } ui.add_space(6.0); - ui.horizontal(|ui| { + // wraps: the five actions are wider than a narrow panel + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.y = 6.0; let add = text_button(ui, "maskAddComp", "Add", false); egui::Popup::menu(&add).show(|ui| component_menu(app, ui, "add")); let sub = text_button(ui, "maskSubComp", "Subtract", false); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 06f1bf90..726dc599 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -25,6 +25,38 @@ use egui::{Align2, Rect, pos2, vec2}; use crate::LightcraftApp; use crate::theme::Tokens; +/// Show a side panel the user resizes by dragging its inner edge. `width` (in [`crate::UiState`], +/// so it survives navigation and restarts) is the source of truth: egui's own remembered size is +/// dropped every frame, and a drag of the edge returns the new width to store. The panel never +/// takes more than `available − reserve − MIN_PHOTO_WIDTH` (its minimum permitting), so the photo +/// stays usable; a narrower window shrinks it without forgetting the chosen width. +pub fn resizable_side( + ui: &mut egui::Ui, + left: bool, + id: &str, + frame: egui::Frame, + width: f32, + limits: crate::state::PanelWidth, + reserve: f32, + add: impl FnOnce(&mut egui::Ui), +) -> Option { + let pid = egui::Id::new(id); + ui.ctx().data_mut(|d| d.remove::(pid)); + let avail = ui.available_rect_before_wrap(); + let max = (avail.width() - reserve - crate::state::MIN_PHOTO_WIDTH).clamp(limits.min, limits.max); + let panel = if left { egui::Panel::left(pid) } else { egui::Panel::right(pid) }; + let r = panel.frame(frame).resizable(true).size_range(limits.min..=max).default_size(limits.clamp(width).min(max)).show(ui, add); + crate::widgets::register(ui.ctx(), format!("panel:{id}"), r.response.rect); + // egui names the edge's drag widget `.with("__resize")`; the width is measured from + // the panel's fixed edge to the pointer (contents wider than the panel don't count) + if !ui.ctx().is_being_dragged(pid.with("__resize")) { + return None; + } + let x = ui.ctx().pointer_interact_pos()?.x; + let w = if left { x - avail.left() } else { avail.right() - x }; + Some(w.round().clamp(limits.min, max)) +} + /// The HUD toast at the bottom centre of the canvas. pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let now = ctx.input(|i| i.time); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 1f954b40..b2cef17f 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -13,33 +13,36 @@ use crate::widgets::{divider, register, slider, text_button}; pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::right("right_panel") - .exact_size(t.panel_w) - .resizable(false) - .frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider))) - .show(ui, |ui| { - let Some(id) = app.session.active() else { - let r = ui.max_rect(); - super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); - return; - }; - egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 0.0; - match app.ui.right { - RightPanel::Edit => super::edit::show(app, ui, id), - RightPanel::Profiles => super::profiles::show(app, ui, id), - RightPanel::Crop => crop(app, ui, id), - RightPanel::Remove => remove(app, ui, id), - RightPanel::Masking => super::masking::show(app, ui, id), - RightPanel::RedEye => red_eye(app, ui, id), - RightPanel::Info => info(app, ui, id), - RightPanel::Keywords => keywords(app, ui, id), - RightPanel::Versions => versions(app, ui, id), - RightPanel::Activity => activity(app, ui, id), - RightPanel::None => {} - } - }); + let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); + // the presets column and the left sidebar are laid out after this panel: leave them their room + let reserve = if app.ui.presets { t.panel_w } else { 0.0 } + if app.ui.left_panel { crate::state::LEFT_WIDTH.min } else { 0.0 }; + let width = app.ui.right_width; + let resized = super::resizable_side(ui, false, "right_panel", frame, width, crate::state::RIGHT_WIDTH, reserve, |ui| { + let Some(id) = app.session.active() else { + let r = ui.max_rect(); + super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); + return; + }; + egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + match app.ui.right { + RightPanel::Edit => super::edit::show(app, ui, id), + RightPanel::Profiles => super::profiles::show(app, ui, id), + RightPanel::Crop => crop(app, ui, id), + RightPanel::Remove => remove(app, ui, id), + RightPanel::Masking => super::masking::show(app, ui, id), + RightPanel::RedEye => red_eye(app, ui, id), + RightPanel::Info => info(app, ui, id), + RightPanel::Keywords => keywords(app, ui, id), + RightPanel::Versions => versions(app, ui, id), + RightPanel::Activity => activity(app, ui, id), + RightPanel::None => {} + } }); + }); + if let Some(w) = resized { + app.ui.right_width = w; + } } pub fn header(ui: &mut egui::Ui, title: &str) { diff --git a/crates/ui-egui/src/softpaint.rs b/crates/ui-egui/src/softpaint.rs index fb7632d7..66f8b491 100644 --- a/crates/ui-egui/src/softpaint.rs +++ b/crates/ui-egui/src/softpaint.rs @@ -216,7 +216,7 @@ fn paint_band(prepared: &[Prepared<'_>], band: &mut [Color32], y0: usize, w: usi continue; } let clip = [p.clip[0], cy0, p.clip[2], cy1]; - for tri in p.indices.chunks_exact(3) { + for tri in p.indices.as_chunks::<3>().0 { let (Some(a), Some(b), Some(c)) = (p.verts.get(tri[0] as usize), p.verts.get(tri[1] as usize), p.verts.get(tri[2] as usize)) else { continue; }; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index a9749f89..ad759a06 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -175,6 +175,28 @@ pub const PREVIEW_EDGES: [u32; 4] = [1600, 2560, 3840, 5120]; /// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1. pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800]; +/// Width limits of a side panel the user resizes (points). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PanelWidth { + pub min: f32, + pub default: f32, + pub max: f32, +} + +impl PanelWidth { + /// `w` within the limits (the default when it isn't a number). + pub fn clamp(&self, w: f32) -> f32 { + if w.is_finite() { w.clamp(self.min, self.max) } else { self.default } + } +} + +/// The left sidebar (sources, albums, folders). +pub const LEFT_WIDTH: PanelWidth = PanelWidth { min: 200.0, default: 268.0, max: 480.0 }; +/// The right panel (Edit, Masking, Info, …). +pub const RIGHT_WIDTH: PanelWidth = PanelWidth { min: 250.0, default: 270.0, max: 520.0 }; +/// The photo area the side panels always leave free (as far as their minimum widths allow). +pub const MIN_PHOTO_WIDTH: f32 = 360.0; + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { @@ -184,6 +206,10 @@ pub struct UiState { pub view: ViewMode, pub left_panel: bool, pub right: RightPanel, + /// Widths of the left sidebar and of the right panel in points (dragging their inner edge + /// resizes them; within [`LEFT_WIDTH`] / [`RIGHT_WIDTH`]). + pub left_width: f32, + pub right_width: f32, /// Presets column open (opens to the left of the Edit panel). pub presets: bool, /// Presets column: show a live thumbnail of the photo with each preset. @@ -289,8 +315,13 @@ pub struct UiState { pub spots_threshold: f32, /// The library filter bar above the grid. pub filter_bar: bool, - /// Folders picked with Local → Browse Folder…, kept in the sidebar after browsing elsewhere. + /// Folders kept in Local (Browse Folder…, Keep in Local / Add to Local; `local.addRoot`): + /// listed after the built-in locations, across restarts, whatever is browsed. pub local_roots: Vec, + /// The top-level Local row listed for this session only, for a browsed folder outside every + /// kept location; it stays while browsing below it (see `panels::left::local_places`). + #[serde(skip)] + pub local_browse_root: Option, /// Culling: after a rating, flag or colour-label key, move to the next photo. pub auto_advance: bool, /// Full-screen preview (F): the photo alone on black, no chrome. @@ -372,7 +403,7 @@ pub enum Dialog { minutes: i32, zone: f32, }, - /// The import review (File → Add Photos…). + /// The import review (File → Import Photos…). Import { opts: Box, }, @@ -477,6 +508,8 @@ impl Default for UiState { luminance_map_restore: None, view: ViewMode::Detail, left_panel: false, + left_width: LEFT_WIDTH.default, + right_width: RIGHT_WIDTH.default, right: RightPanel::Edit, presets: false, preset_thumbs: false, @@ -544,6 +577,7 @@ impl Default for UiState { window_fullscreen: None, filter_bar: false, local_roots: Vec::new(), + local_browse_root: None, compare: None, reference: None, toast: None, @@ -580,6 +614,8 @@ impl UiState { /// Clamp values restored from disk. pub fn sanitized(mut self) -> Self { self.thumb_size = self.thumb_size.clamp(90.0, 480.0); + self.left_width = LEFT_WIDTH.clamp(self.left_width); + self.right_width = RIGHT_WIDTH.clamp(self.right_width); self.brush_size = self.brush_size.clamp(0.002, 0.5); self.dialog = None; self.fullscreen = false; diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs new file mode 100644 index 00000000..7de05e0b --- /dev/null +++ b/crates/ui-egui/src/tests_panels.rs @@ -0,0 +1,150 @@ +//! Headless tests of the side panels: dragging their inner edge resizes them within limits, and +//! the chosen widths survive switching panels and a save/load of the UI state (issue #20). + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::{LEFT_WIDTH, MIN_PHOTO_WIDTH, RIGHT_WIDTH}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn demo(size: [f32; 2], ui: serde_json::Value) -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, size, 1.0); + let r = h.request("ui.set", ui, T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn widget(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +/// Drag horizontally from `x` by `dx` (within the 1400 pt window) at mid height. +fn drag(h: &mut Headless, x: f32, dx: f32) { + let to = (x + dx).clamp(1.0, 1399.0); + let r = h.request("ui.drag", json!({"x": x, "y": 500.0, "toX": to, "toY": 500.0, "steps": 12}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); +} + +#[test] +fn side_panels_resize_by_their_inner_edge_and_remember_it() { + let mut h = demo([1400.0, 900.0], json!({"view": "detail", "right": "edit", "leftPanel": true})); + let right = widget(&h, "panel:right_panel"); + let left = widget(&h, "panel:left_panel"); + assert_eq!((left.width(), right.width()), (LEFT_WIDTH.default, RIGHT_WIDTH.default)); + // the right panel's left edge: 120 pt wider + drag(&mut h, right.left() + 2.0, -120.0); + assert!((h.app.ui.right_width - (RIGHT_WIDTH.default + 120.0)).abs() <= 2.0, "{}", h.app.ui.right_width); + assert!((widget(&h, "panel:right_panel").width() - h.app.ui.right_width).abs() <= 1.0); + // the left sidebar's right edge: 70 pt wider + drag(&mut h, left.right() - 2.0, 70.0); + assert!((h.app.ui.left_width - (LEFT_WIDTH.default + 70.0)).abs() <= 2.0, "{}", h.app.ui.left_width); + // kept across panel switches and view changes + let (lw, rw) = (h.app.ui.left_width, h.app.ui.right_width); + for set in [json!({"right": "info"}), json!({"view": "photoGrid"}), json!({"right": "masking", "view": "detail"})] { + h.request("ui.set", set, T); + h.step(); + assert_eq!((widget(&h, "panel:left_panel").width(), widget(&h, "panel:right_panel").width()), (lw, rw)); + } + // and across a save/load of the UI state (ui.json) + let saved = serde_json::to_string(&h.app.ui).unwrap(); + let back = serde_json::from_str::(&saved).unwrap().sanitized(); + assert_eq!((back.left_width, back.right_width), (lw, rw)); + // limits: the photo area stays usable however far the edges are dragged… + let right = widget(&h, "panel:right_panel"); + drag(&mut h, right.left() + 2.0, -1400.0); + let left = widget(&h, "panel:left_panel"); + drag(&mut h, left.right() - 2.0, 1400.0); + h.step(); + let (left, right) = (widget(&h, "panel:left_panel"), widget(&h, "panel:right_panel")); + assert!(right.width() <= RIGHT_WIDTH.max && left.width() <= LEFT_WIDTH.max, "{left:?} {right:?}"); + assert!(right.left() - left.right() >= MIN_PHOTO_WIDTH - 1.0, "photo area {} wide", right.left() - left.right()); + // …and neither panel gets narrower than its minimum + drag(&mut h, right.left() + 2.0, 1400.0); + let left = widget(&h, "panel:left_panel"); + drag(&mut h, left.right() - 2.0, -1400.0); + assert_eq!((h.app.ui.left_width, h.app.ui.right_width), (LEFT_WIDTH.min, RIGHT_WIDTH.min)); + // out-of-range saved widths are clamped on load + let mut u = crate::UiState { left_width: 5.0, right_width: f32::NAN, ..Default::default() }.sanitized(); + assert_eq!((u.left_width, u.right_width), (LEFT_WIDTH.min, RIGHT_WIDTH.default)); + u.right_width = 9000.0; + assert_eq!(u.sanitized().right_width, RIGHT_WIDTH.max); +} + +/// Every widget drawn over the right panel lies within it (issue #47: selecting a mask added a +/// row wider than the panel, which pushed the panel's contents left, clipping them). +fn assert_inside_right_panel(h: &Headless, what: &str) { + let panel = widget(h, "panel:right_panel"); + let mut n = 0; + for (id, r) in &h.app.widgets { + // widgets of the photo area (pins, filmstrip cells clipped at its edge) and of the tool + // strip are outside on purpose + if ["panel:", "film:", "maskPin"].iter().any(|p| id.starts_with(p)) + || r.bottom() <= panel.top() + || r.right() <= panel.left() + 1.5 + || r.left() >= panel.right() - 1.5 + { + continue; + } + n += 1; + assert!(r.left() >= panel.left() - 0.5 && r.right() <= panel.right() + 0.5, "{what}: {id} {r:?} sticks out of the panel {panel:?}"); + } + assert!(n > 10, "{what}: only {n} widgets in the panel"); +} + +#[test] +fn masking_contents_fit_the_right_panel_at_any_width() { + let mut h = demo([1400.0, 900.0], json!({"view": "detail", "right": "masking"})); + for (kind, op) in [("subject", None), ("linear", None), ("luminanceRange", Some("subtract")), ("radial", Some("intersect"))] { + let (command, params) = match op { + None => ("mask.add", json!({"kind": kind})), + Some(op) => ("mask.addComponent", json!({"kind": kind, "op": op})), + }; + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{r}"); + } + let r = h.request( + "engine.execute", + json!({"command": "mask.component", "params": {"component": 0, "action": "rename", "name": "A rather long component name for this mask"}}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + for width in [RIGHT_WIDTH.min, RIGHT_WIDTH.default, 330.0, RIGHT_WIDTH.max] { + for (selected, tool) in [(false, ""), (true, ""), (true, "brush")] { + let mask = if selected { json!(2) } else { serde_json::Value::Null }; + h.request("engine.execute", json!({"command": "mask.select", "params": {"id": mask}}), T); + h.request("ui.set", json!({"rightWidth": width, "tool": tool}), T); + h.step(); + h.step(); + let what = format!("width {width}, mask selected {selected}, tool {tool:?}"); + assert_eq!(widget(&h, "panel:right_panel").width(), width, "{what}"); + if selected { + assert!(h.app.widgets.iter().any(|(id, _)| id == "button:maskInvert"), "{what}: no mask actions"); + } + assert_inside_right_panel(&h, &what); + } + } +} + +#[test] +fn a_narrow_window_shrinks_the_panels_without_forgetting_their_width() { + let mut h = demo([1400.0, 900.0], json!({"view": "detail", "right": "edit", "leftPanel": true, "rightWidth": 480.0, "leftWidth": 400.0})); + assert_eq!(widget(&h, "panel:right_panel").width(), 480.0); + h.request("ui.resize", json!({"width": 1000.0, "height": 800.0}), T); + h.settle(SETTLE); + let (left, right) = (widget(&h, "panel:left_panel"), widget(&h, "panel:right_panel")); + assert!(right.left() - left.right() >= MIN_PHOTO_WIDTH - 1.0, "{left:?} {right:?}"); + // the chosen widths come back when the window does + assert_eq!((h.app.ui.left_width, h.app.ui.right_width), (400.0, 480.0)); + h.request("ui.resize", json!({"width": 1400.0, "height": 900.0}), T); + h.settle(SETTLE); + assert_eq!(widget(&h, "panel:right_panel").width(), 480.0); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c3f0b322..65e9c3ef 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -47,7 +47,6 @@ pub struct Tokens { pub bottom_bar_h: f32, pub panel_w: f32, pub strip_w: f32, - pub left_w: f32, pub slider_row_h: f32, pub section_h: f32, pub film_h: f32, @@ -88,7 +87,6 @@ impl Default for Tokens { bottom_bar_h: 48.0, panel_w: 270.0, strip_w: 48.0, - left_w: 268.0, slider_row_h: 45.0, section_h: 57.0, film_h: 142.0, diff --git a/docs/mcp.md b/docs/mcp.md index 2220edfe..32c155fa 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -76,7 +76,7 @@ During development you can also point the client at `cargo run --release -p ligh |---|---| | `list_commands {filter?}` | Every command: id, label, menu, shortcut, parameter doc, enabled now | | `run_command {command, params?}` | Run any command by id | -| `import {paths, album?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active | +| `import {paths, album?, mode?, destination?, organize?, rename?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active. `mode: "copy"` copies into `destination` (default: the library's Originals/), filed by `organize`: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or a folder template such as `{date:%Y}/{date:%Y%m%d}` (→ `2026/20260114`; capture date, else the import date; always inside the destination), and named by the `rename` template (`run_command photo.renameTokens` lists the tags) | | `query_photos {filter?, sort?, offset?, limit?}` | Photos in the current view (or matching a catalog `Filter`) | | `select_photos {ids, active?, mode?}` | Set the selection / active photo | | `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value | diff --git a/docs/parity.md b/docs/parity.md index 06be8dba..4b6abd69 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 14 | 1 | 2 | 1 | 8/8 (100%) | 5/5 (100%) | +| X. Cross-cutting behaviours (BEHAV) | 15 | 1 | 2 | 1 | 8/8 (100%) | 6/6 (100%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 29 | 9 | 41 | 9 | — | 20/21 (95%) | -| **Total** | 383 | 25 | 92 | 37 | 194/198 (98%) | 136/144 (94%) | +| **Total** | 384 | 25 | 92 | 37 | 194/198 (98%) | 137/145 (94%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.1%** of 500 in-scope rows — P0 99.0% of 198 · P1 96.2% of 144 · P2 38.6% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.1%** of 501 in-scope rows — P0 99.0% of 198 · P1 96.2% of 145 · P2 38.6% of 158. ## Top gaps @@ -99,16 +99,16 @@ 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: candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked), destination, 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); batched import with a progress window; one undo step. Drag-and-drop still imports directly | +| 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, 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); batched import with a progress window; one undo step. Drag-and-drop still imports directly | | 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 → Add from Device → the import review, copying into the library by default; no PTP/MTP (cameras that don't mount as a disk) | +| 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); no PTP/MTP (cameras that don't mount as a disk) | | LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s | | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | -| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back | +| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | @@ -123,7 +123,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | | LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) | -| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs` | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`) | +| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`); sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | @@ -137,7 +137,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | -| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming); preview; renames files on disk with their XMP sidecars, never overwriting (-1, -2… suffixes), rolls back on failure; undo/redo move the files; virtual copies follow | +| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars, never overwriting (-1, -2… suffixes), rolls back on failure; undo/redo move the files; virtual copies follow | | LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time” | | LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ✅ | `cmd:app.showInFinder` | ⌘R (see MENU-FILE-SHOWFINDER) | | LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | | @@ -194,7 +194,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`) | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; no embedded map, no geocoding | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -309,7 +309,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-MASK-PANEL | Masks panel | P0 | ✅ | `cmd:panel.masking`, `cmd:mask.add`, `cmd:mask.select`, `cmd:mask.rename`, `cmd:mask.duplicate` (`invert`), `cmd:mask.move`, `cmd:mask.visible`, `cmd:mask.delete`, `crates/ui-egui/src/panels/masking.rs` | list with per-mask show/hide eye, double-click rename, right-click menu (duplicate, duplicate and invert, invert, hide, move up/down, rename, delete); no drag-to-reorder | +| LR-MASK-PANEL | Masks panel | P0 | ✅ | `cmd:panel.masking`, `cmd:mask.add`, `cmd:mask.select`, `cmd:mask.rename`, `cmd:mask.duplicate` (`invert`), `cmd:mask.move`, `cmd:mask.visible`, `cmd:mask.delete`, `crates/ui-egui/src/panels/masking.rs` | list with per-mask show/hide eye, double-click rename, right-click menu (duplicate, duplicate and invert, invert, hide, move up/down, rename, delete); fits any panel width (create tiles go to three columns, mask actions wrap, long component names are cut short); no drag-to-reorder | | LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model | | LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`) | heuristic | | LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject heuristic | @@ -482,6 +482,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | +| LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed | | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader | @@ -504,9 +505,9 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-APP-SYNC | Sync status / pause | OOS | 🚫 | | | | MENU-APP-SIGNOUT | Sign out | OOS | 🚫 | | | | MENU-APP-HIDE | Hide / hide others / show all | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's own items in the app menu (⌘H, ⌥⌘H) | -| MENU-APP-QUIT | Quit | P0 | ✅ | `cmd:app.quit`, `apps/lightcraft/src/native_menu.rs` | macOS: app menu (native); elsewhere: File → Quit LightCraft | -| MENU-FILE-ADDPHOTOS | Add Photos… | P0 | ✅ | `cmd:file.addPhotos` | | -| MENU-FILE-ADDFOLDER | Add Folder… | P0 | ✅ | `cmd:file.addFolder`, `cmd:library.importPreview` | folder picker (desktop) → the import review, subfolders included; `path` param for agents | +| MENU-APP-QUIT | Quit | P0 | ✅ | `cmd:app.quit`, `apps/lightcraft/src/native_menu.rs` | macOS: app menu (native); elsewhere: File → Quit LightCraft, always the last item in its own group (after commands the File layout does not list) | +| MENU-FILE-ADDPHOTOS | Import Photos… (was Add Photos…) | P0 | ✅ | `cmd:file.addPhotos`, `crates/ui-egui/src/menus.rs` | first item of File, ⇧⌘I; file picker → the import review | +| MENU-FILE-ADDFOLDER | Import from Folder… (was Add Folder…) | P0 | ✅ | `cmd:file.addFolder`, `cmd:library.importPreview` | folder picker (desktop) → the import review, subfolders included; `path` param for agents. Worded as importing: it doesn't save a Local location (that is Local → Browse Folder…) | | MENU-FILE-MIGRATE | Migrate photos | OOS | 🚫 | | | | MENU-FILE-NEWALBUM | New Album… | P0 | ✅ | `cmd:dialog.newAlbum` | | | MENU-FILE-NEWFOLDER | New Folder… | P0 | ✅ | `cmd:dialog.newFolder` | ⇧⌘N | @@ -638,7 +639,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-CREATEVERSION | Create version — ⇧M | P1 | ✅ | `cmd:version.create` | secondary binding (primary ⌘⇧S) | | KEY-CLOSEWINDOW | Close window — ⌘W | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | native File ▸ Close Window | | KEY-DELETE | Delete photo — ⌫ | P0 | ✅ | `cmd:photo.delete` | | -| KEY-ADDPHOTOS | Add photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | | +| KEY-ADDPHOTOS | Import photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | | | KEY-VERSIONS | Versions panel — ⇧V | P1 | ✅ | `cmd:panel.versions` | | | KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | ✅ | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | ⌘1–⌘5 (Light, Color, Effects, Detail, Optics) as observed; our Edit panel has no Lens Blur / Geometry section for ⌘6 / ⌘7; Zoom 100% moved to ⌥⌘0 | | KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | | @@ -686,7 +687,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder; rename template with live example, numbered across the import); develop preset, metadata preset, keywords, album on import | +| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder / custom folder template such as `{date:%Y}/{date:%Y%m%d}` → 2026/20260114, kept inside the destination; example destination shown; rename template with live example, Tags picker (insert at cursor) and unknown-tag warning, numbered across the import); develop preset, metadata preset, keywords, album on import | | LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO | | LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | | LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info); no people view | @@ -708,7 +709,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-PEOPLE | People view | P2 | ⬜ | | | | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | -| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate` | File → Find Missing Photos… (same name and size anywhere in a folder, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. No missing-photos collection | +| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`) | File → Find Missing Photos… (same name and size anywhere in a folder, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | | LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable | | LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (progress toasts, stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW | diff --git a/docs/web.md b/docs/web.md index 8af632c3..6efe1345 100644 --- a/docs/web.md +++ b/docs/web.md @@ -90,7 +90,7 @@ same-origin or send CORP/CORS headers (the bundle has no third-party resources). (`navigator.storage.persist()`); without it the browser may evict the data under storage pressure. URL options: `?store=idb` forces IndexedDB, `?store=memory` keeps nothing, `?reset` deletes the stored library first. -- **Importing photos with no filesystem.** *File ▸ Add Photos…* (⌘⇧I) opens the +- **Importing photos with no filesystem.** *File ▸ Import Photos…* (⌘⇧I) opens the browser's file picker. You can also drop files anywhere on the page. The bytes are written to storage, then imported and decoded by the same engine code as the desktop app (`lightcraft_engine::files::{probe_bytes, load_bytes}`): JPEG/PNG/TIFF/WebP and the supported diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 6ed40a4b..7332b9ce 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -31,7 +31,10 @@ What we write (standard namespaces, so other tools can read the metadata): `lc:` is `http://ns.lightcraft.app/lc/1.0/`. Reading merges into the catalog with the **sidecar winning** for every field it states; fields it doesn't state are -kept. `xmp:Rating="-1"` (the XMP convention for rejected) sets the reject flag. Develop settings come from +kept. The one exception is the **capture time**: a time embedded in the file (EXIF/IPTC) always wins; when the file +has none, the sidecar's `exif:DateTimeOriginal`, else `photoshop:DateCreated`, else `xmp:CreateDate` (ISO 8601) is +used — on import (it then also files a copied photo in its date folder) and by `photo.readMetadataFromFile` (undoable). +`xmp:Rating="-1"` (the XMP convention for rejected) sets the reject flag. Develop settings come from `lc:settings` when present (exact); otherwise from the `crs:` fields below (approximate). ## Reading `crs:` develop fields