From 7c662bc5d6e54ddbe080b01b0f4f227bd7296bc4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 10:38:44 -0400 Subject: [PATCH 01/22] Issue #27: Menus: keep Quit last in the in-window File menu Registered File commands the explicit layout doesn't list were appended after Quit LightCraft. Items in LAST (app.quit) are now held back and placed after the fallback entries, in their own separator group. The macOS native menu filters Quit into the app menu and is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menubar.rs | 36 +++++++++++++++++++++++++++++++++-- docs/parity.md | 2 +- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 051341da..f0a46f95 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -47,7 +47,8 @@ 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", @@ -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] = &[ @@ -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 @@ -773,6 +783,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/docs/parity.md b/docs/parity.md index a33caa46..17411235 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -504,7 +504,7 @@ 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-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 | 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-FILE-MIGRATE | Migrate photos | OOS | 🚫 | | | From 20d156c8784d9c221f76d48f573ae33e31bd1122 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 10:39:11 -0400 Subject: [PATCH 02/22] Issue #34: Missing Photos checks library photos only Missing Photos (sidebar count, the view, library.missing, Find Missing) now shares one scope rule, cmd::missing::checked_path: non-deleted library photos backed by a file. Local browse records, which can outnumber the library by orders of magnitude, are never stat'ed. The Missing view checks only the photos its query kept instead of every catalog record. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/missing.rs | 40 ++++++++++++++++++++++++------- crates/engine/src/lib.rs | 5 ++-- crates/engine/src/tests_import.rs | 28 ++++++++++++++++++++++ crates/ui-egui/src/panels/left.rs | 19 +++++---------- docs/parity.md | 2 +- 5 files changed, 70 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/missing.rs b/crates/engine/src/cmd/missing.rs index 8ec96355..c7d1a2ec 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 { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ce0404f2..192d90be 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -507,8 +507,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/tests_import.rs b/crates/engine/src/tests_import.rs index 2a1480ba..b927dd1b 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -585,3 +585,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/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 72516322..9e68ac7a 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -146,9 +146,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } -/// 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 +165,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; diff --git a/docs/parity.md b/docs/parity.md index a33caa46..51edb835 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -708,7 +708,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 | From 37ab41478d1837db835aa55d611c6259cae5195e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 10:40:54 -0400 Subject: [PATCH 03/22] Issue #19: XMP sidecar capture time fills in when the file has none A sidecar's exif:DateTimeOriginal / photoshop:DateCreated / xmp:CreateDate now reaches Photo.captured on import and on Read Metadata from File when the file has no embedded capture time (embedded wins). On import the sidecar is read before copying, so the date also files and names copies. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/import.rs | 39 ++++++++++++++----------- crates/engine/src/sidecar.rs | 17 +++++++++-- crates/engine/src/tests_xmp.rs | 52 ++++++++++++++++++++++++++++++++++ docs/parity.md | 2 +- docs/xmp-interop.md | 5 +++- 5 files changed, 95 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 5b9211d3..bc57fe6d 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. //! @@ -553,6 +554,23 @@ 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)) => @@ -583,7 +601,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 +623,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/sidecar.rs b/crates/engine/src/sidecar.rs index 714478d3..5deedd3f 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. @@ -80,6 +82,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, } @@ -108,6 +113,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; } @@ -306,6 +316,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_xmp.rs b/crates/engine/src/tests_xmp.rs index 2f00a315..c0331ea7 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -445,3 +445,55 @@ 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); +} diff --git a/docs/parity.md b/docs/parity.md index a33caa46..cb4462dd 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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, 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) diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index eea43e2a..f9a68ea9 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -29,7 +29,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 From 1a6a9de7eedb72d95a87dca1e16fff71e56a7661 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 10:50:29 -0400 Subject: [PATCH 04/22] Issue #33: Devices: serve the cached card list and rescan in the background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menus are rebuilt every frame, and File → Add from Device called devices(), which scanned every mount (D:..Z: on Windows) while holding the cache mutex once its 2 s cache expired. devices() now returns the last scan at once and starts at most one background rescan when it is stale; the scan runs without the lock. A changed result calls an on_change hook (the UI requests a repaint), so hot-plugged cards still appear. library.devices and Add from Device without a path scan synchronously (devices_now). Volume roots are probed for DCIM directly instead of listing them (Windows never lists a root). No threads or clock on wasm. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/devices.rs | 221 ++++++++++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menus.rs | 2 +- docs/parity.md | 2 +- 5 files changed, 207 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index b4c3b292..d8fad84b 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, creator, location and keywords of the active photo", has_active, |s, _| { let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?; diff --git a/crates/engine/src/devices.rs b/crates/engine/src/devices.rs index 0ad26922..bc82a727 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 //! 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/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e42ed5ea..684fed5b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -404,6 +404,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; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fde1e087..7fd93b80 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -915,7 +915,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/docs/parity.md b/docs/parity.md index a33caa46..45f32a51 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -102,7 +102,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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-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 → Add 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 | From 889583433cd55449bd42ed8581324030b2dcbd7b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 10:51:10 -0400 Subject: [PATCH 05/22] Issue #36: cache smart-album counts in the sidebar The album rows read their counts from Caches::album_counts, recomputed only when the catalog revision changes (photos, metadata and album rules all bump it) and, while a smart album uses an "in the last..." rule, when the session clock enters a new minute. Catalog::album_count counts smart-album matches in place instead of building the id list for its length. RuleSet/Filter::depends_on_now tell clock-relative rules apart. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 3 +- crates/catalog/src/query.rs | 5 ++ crates/catalog/src/rules.rs | 9 +++ crates/ui-egui/src/lib.rs | 94 +++++++++++++++++++++++++++++++ crates/ui-egui/src/panels/left.rs | 4 +- docs/parity.md | 2 +- 6 files changed, 114 insertions(+), 3 deletions(-) diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 79318235..1828399b 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -289,7 +289,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..084d6d6f 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; diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index b5925781..f4137cb6 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -341,6 +341,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/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e42ed5ea..a9341b9b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -658,6 +658,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 +782,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/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 72516322..03524d46 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -398,7 +398,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()); diff --git a/docs/parity.md b/docs/parity.md index a33caa46..84b23a04 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -123,7 +123,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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 | From ce3e2215fef9e55b388d5fadbcec46a5729bffef Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 11:04:21 -0400 Subject: [PATCH 06/22] Issue #22: Local: recognise equivalent folder paths Local compared folder paths as raw strings, so a built-in location built as "{home}/{sub}" and the same folder returned by library.browse with native separators could be listed twice. Folder identity now goes through catalog::query::folder_key (both separators, trailing/doubled separators, ./.., verbatim and UNC prefixes, drive-letter case; whole path case-folded on Windows): the sidebar's root list, the highlighted and expanded-on-the-way folder, saved Browse Folder roots and local.hide / local.restoreHidden all compare by it. Built-in paths are joined with the platform separator. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/query.rs | 46 ++++++++++++++++++ crates/catalog/src/tests.rs | 33 +++++++++++++ crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/panels/left.rs | 79 ++++++++++++++++++++++++------- docs/parity.md | 2 +- 5 files changed, 145 insertions(+), 19 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index bac31f48..417d6bf0 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -346,6 +346,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/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/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fde1e087..6f29a310 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -557,7 +557,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option 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 +567,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})) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 72516322..bd4201c8 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -203,27 +203,19 @@ 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)); + let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), &app.ui.hidden_locations); for (name, path) in places { folder_tree(app, ui, &name, &path, 0.0, current.as_deref()); } @@ -235,7 +227,7 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { match app.run("library.browse", json!({"path": path})) { Ok(r) => { let dir = r["path"].as_str().unwrap_or(&path).to_string(); - if !app.ui.local_roots.contains(&dir) { + if !app.ui.local_roots.iter().any(|p| same_folder(p, &dir)) { app.ui.local_roots.push(dir); } } @@ -256,6 +248,28 @@ 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, in order: the built-in places, the folders picked with Browse +/// Folder…, then the folder being browsed when it isn't one of those. 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>, hidden: &[String]) -> Vec<(String, String)> { + let mut places = builtin; + for path in saved.iter().map(String::as_str).chain(browsing) { + if !places.iter().any(|(_, p)| same_folder(p, path)) { + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); + places.push((name, path.to_string())); + } + } + places.retain(|(_, p)| !hidden.iter().any(|h| same_folder(h, p))); + places +} + /// 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())); @@ -286,9 +300,9 @@ fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> { fn folder_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str, path: &str, indent: f32, current: Option<&str>) { 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 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(on_the_way); - let sel = current == Some(path); 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)); @@ -597,3 +611,36 @@ 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 places = local_places(builtin.clone(), std::slice::from_ref(&browsing), Some(&browsing), &[]); + assert_eq!(names(&places), ["Pictures", "Desktop", "Downloads"], "{p} browsed as {browsing}"); + } + // hiding under one spelling hides the other + let places = local_places(builtin.clone(), &[], None, std::slice::from_ref(&flipped)); + assert_eq!(places.len(), 2, "hidden {flipped}"); + } + } + // a different folder is still added + let places = local_places(vec![("Pictures".into(), "/home/example/Pictures".into())], &[], Some("/home/example/Pictures2"), &[]); + assert_eq!(names(&places), ["Pictures", "Pictures2"]); + } +} diff --git a/docs/parity.md b/docs/parity.md index a33caa46..2d1b7392 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -108,7 +108,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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.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; 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 | From 8be82cacf460183f0c01eed8f4a622d432204a47 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 11:04:27 -0400 Subject: [PATCH 07/22] Issue #31: Import: tag help beside the Rename field rename::TOKENS / DATE_DIRECTIVES / TEMPLATE_NOTES are now the one list of file-name template tags (a test checks expand_tokens knows each, and that the rename/import command descriptions mention them all). A Tags toggle beside the import Rename field (also Rename Photos and export File name) lists every tag with its meaning and a live example, the {date:...} directives and the rules (extension appended, blank keeps names, missing date/metadata, unknown tags stay literal); clicking a tag inserts it at the text cursor. Unknown tags are flagged under the field. New query command photo.renameTokens returns the same help as JSON. The import preview now uses the probe's metadata and the import time for undated photos, as the import itself does. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/cmd/manage.rs | 7 +- crates/engine/src/rename.rs | 142 +++++++++++++++++++++++++++ crates/ui-egui/src/headless.rs | 46 +++++++++ crates/ui-egui/src/import.rs | 100 ++++++++++++++++++- crates/ui-egui/src/panels/dialogs.rs | 26 ++++- docs/parity.md | 4 +- 7 files changed, 313 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index b4c3b292..b0d65f31 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -735,7 +735,7 @@ pub fn specs() -> Vec { "Add 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|month|flat, 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"); 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/rename.rs b/crates/engine/src/rename.rs index 2b540c85..0c077d27 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -82,6 +82,113 @@ 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) +} + +/// 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 +465,41 @@ impl Session { mod tests { use super::*; + /// 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/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index b911896b..f89e324f 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1211,6 +1211,52 @@ mod tests { 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..58f6b302 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -449,26 +449,39 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { 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 tags_open { + tag_help(ui, "importRename", &mut d.rename, rename_id); + } 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 }, + 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 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)); } + unknown_tags_warning(ui, &d.rename); } let albums: Vec<(u64, String)> = { let mut v: Vec<(u64, String)> = @@ -602,6 +615,85 @@ 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()); + } + }); + }); +} + /// 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/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 24153a8b..1cb732f4 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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) { diff --git a/docs/parity.md b/docs/parity.md index a33caa46..c718a5aa 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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` | | @@ -686,7 +686,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; 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 | From a16a81ad086e74ce58d911159db683e782d8a7ac Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 11:15:12 -0400 Subject: [PATCH 08/22] Issue #21: Local: kept folders stay roots while browsing below them Browsing a folder inside a listed Local location (built-in or kept) no longer adds it as a root of its own: it is highlighted inside that location's tree, which opens down to it once per browsed folder, so its ancestors and siblings stay reachable. A folder outside every listed location gets a session-only row that stays while browsing below it and offers Keep in Local; subfolder rows offer Add to Local. New UI command local.addRoot keeps a folder in Local (saved with ui.json, so it survives restarts; it un-hides it too); Browse Folder... uses it. Removal stays sidebar-only (local.hide). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/headless.rs | 48 +++++++++ crates/ui-egui/src/menus.rs | 16 +++ crates/ui-egui/src/panels/left.rs | 165 +++++++++++++++++++++++++----- crates/ui-egui/src/state.rs | 8 +- docs/parity.md | 2 +- 5 files changed, 211 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index b911896b..69c0a1f8 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -827,6 +827,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] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6f29a310..0bff45ae 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"), @@ -555,6 +556,21 @@ 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| crate::panels::left::same_folder(h, path)) { diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index bd4201c8..ff88a334 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -215,21 +215,23 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { } let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); let current = browsing.as_ref().map(|b| b.path.clone()); - let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), &app.ui.hidden_locations); - 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.iter().any(|p| same_folder(p, &dir)) { - app.ui.local_roots.push(dir); - } + let _ = app.run("local.addRoot", json!({"path": dir})); } Err(e) => app.toast(ui.ctx(), e), } @@ -254,20 +256,61 @@ 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, in order: the built-in places, the folders picked with Browse -/// Folder…, then the folder being browsed when it isn't one of those. Each folder is listed +/// 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>, hidden: &[String]) -> Vec<(String, String)> { +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.iter().map(String::as_str).chain(browsing) { + for path in saved { if !places.iter().any(|(_, p)| same_folder(p, path)) { - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); - places.push((name, path.to_string())); + places.push((folder_label(path), path.clone())); } } - places.retain(|(_, p)| !hidden.iter().any(|h| same_folder(h, p))); - places + 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. @@ -296,13 +339,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 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(on_the_way); + 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)); @@ -324,6 +391,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() { @@ -358,7 +431,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); } } } @@ -631,16 +704,56 @@ mod tests { // 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 places = local_places(builtin.clone(), std::slice::from_ref(&browsing), Some(&browsing), &[]); - assert_eq!(names(&places), ["Pictures", "Desktop", "Downloads"], "{p} browsed as {browsing}"); + 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 places = local_places(builtin.clone(), &[], None, std::slice::from_ref(&flipped)); - assert_eq!(places.len(), 2, "hidden {flipped}"); + 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 places = local_places(vec![("Pictures".into(), "/home/example/Pictures".into())], &[], Some("/home/example/Pictures2"), &[]); - assert_eq!(names(&places), ["Pictures", "Pictures2"]); + 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/state.rs b/crates/ui-egui/src/state.rs index a9749f89..87154661 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -289,8 +289,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. @@ -544,6 +549,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, diff --git a/docs/parity.md b/docs/parity.md index 2d1b7392..1c2e46dc 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -108,7 +108,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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; 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-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 | From d6476e749736ee80bd3137728417ae4629cb65b6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 11:18:04 -0400 Subject: [PATCH 09/22] Issue #20: Panels: resize the left sidebar and the right panel by dragging their edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both side panels had an exact width. They are now resizable egui panels whose width lives in UiState (leftWidth / rightWidth, serde, sanitized on load) so it survives panel switches, view changes and restarts; egui's own remembered size is dropped each frame and only a drag of the edge writes back (measured from the fixed edge to the pointer, so content wider than the panel can't creep it). Limits: left 200–480 pt, right 250–520 pt, and the photo area keeps at least 360 pt; a narrower window shrinks the panels without forgetting the chosen width. The bottom bar, which sits between the panels, now drops Copy/Paste Edit Settings and then the rating pill when its centre has no room instead of overlapping the zoom controls. The unused left_w theme token is gone. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/bottombar.rs | 354 +++++++++++++------------ crates/ui-egui/src/panels/left.rs | 161 ++++++----- crates/ui-egui/src/panels/mod.rs | 32 +++ crates/ui-egui/src/panels/right.rs | 55 ++-- crates/ui-egui/src/state.rs | 30 +++ crates/ui-egui/src/tests_panels.rs | 95 +++++++ crates/ui-egui/src/theme.rs | 2 - docs/parity.md | 7 +- 9 files changed, 459 insertions(+), 279 deletions(-) create mode 100644 crates/ui-egui/src/tests_panels.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e42ed5ea..128f3c03 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,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}; 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/left.rs b/crates/ui-egui/src/panels/left.rs index 72516322..60ef6004 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -56,94 +56,93 @@ 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 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 ab31da25..b8aedb96 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/state.rs b/crates/ui-egui/src/state.rs index a9749f89..116c6c6b 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. @@ -477,6 +503,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, @@ -580,6 +608,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..2f6873ed --- /dev/null +++ b/crates/ui-egui/src/tests_panels.rs @@ -0,0 +1,95 @@ +//! 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); +} + +#[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/parity.md b/docs/parity.md index a33caa46..55cf2f73 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 @@ -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 | From 24cf3abb623177d7ebb4b7c00ed2300d492c30e8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 11:36:38 -0400 Subject: [PATCH 10/22] Issue #32: Import: custom date-based folder template for Copy library.import organize now also takes a folder template, e.g. {date:%Y}/{date:%Y%m%d} -> 2026/20260114 (Organize::Template). The template's own / (or \) make the folder levels; each level is expanded with the rename tokens and sanitized, so metadata can never add a level or climb out of the destination; absolute paths, drive letters, ~ and . / .. levels are refused. Dated by capture time, else the import time; a level whose metadata is missing becomes 'unknown'. The import dialog's Folders menu gains 'Custom template...' with a template field, tag help, live validation and an example full destination for every mode; the dialog stays open (with a toast) when the import is refused. The MCP import helper passes mode/destination/organize/rename through (docs/mcp.md). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 16 +++- crates/engine/src/import.rs | 50 ++++++---- crates/engine/src/rename.rs | 60 ++++++++++++ crates/engine/src/tests_import.rs | 31 ++++++ crates/mcp/src/tests.rs | 20 ++++ crates/mcp/src/tools.rs | 15 ++- crates/ui-egui/src/control.rs | 9 +- crates/ui-egui/src/headless.rs | 53 +++++++++++ crates/ui-egui/src/import.rs | 137 +++++++++++++++++++++------ crates/ui-egui/src/panels/dialogs.rs | 7 +- docs/mcp.md | 2 +- docs/parity.md | 2 +- 12 files changed, 344 insertions(+), 58 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index b0d65f31..29826e63 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -735,7 +735,7 @@ pub fn specs() -> Vec { "Add 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, 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?}", + "{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"); @@ -760,11 +760,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/import.rs b/crates/engine/src/import.rs index 5b9211d3..40970b97 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -66,8 +66,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 +77,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 +342,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) @@ -557,17 +571,17 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c (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)); diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 0c077d27..4b956c84 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -157,6 +157,41 @@ 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(); @@ -465,6 +500,31 @@ 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] diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 2a1480ba..53f9feaf 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] 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/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 f89e324f..cba31161 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1211,6 +1211,59 @@ 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] diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 58f6b302..72b00c84 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1,6 +1,7 @@ //! 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 +//! 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. @@ -35,8 +36,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 +64,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() } @@ -277,8 +296,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()); @@ -434,17 +453,53 @@ 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); @@ -460,28 +515,15 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { if tags_open { tag_help(ui, "importRename", &mut d.rename, rename_id); } - 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::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 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)); - } 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(), + ); + } } let albums: Vec<(u64, String)> = { let mut v: Vec<(u64, String)> = @@ -694,6 +736,45 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: }); } +/// 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)) +} + /// 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/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 1cb732f4..78651bd8 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -745,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/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 c718a5aa..9422b194 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -686,7 +686,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, Tags picker (insert at cursor) and unknown-tag warning, 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 | From e95e9259ea3544d166802a93b1cd48ae30287088 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 11:39:13 -0400 Subject: [PATCH 11/22] Never crash: steering rule, clippy.toml for the no-panic lints AGENTS.md/CLAUDE.md gain a "Never crash" section ahead of the non-negotiables (no unwrap/expect/panic/unreachable/todo/unimplemented or unsafe in non-test code, Result + ?, checked indexing and arithmetic on input-derived values, bounded recursion, lock poisoning, the last-resort guard, a regression test per crash fix), linking craftrules/standards/never-crash.md. README's Contributing list points to it. clippy.toml allows unwrap/expect/panic/indexing in tests only, so crates can carry #![deny(clippy::unwrap_used, ...)] without touching their tests. unsafe_code stays "deny" for now: apps/lightcraft and apps/lightcraft-cli call malloc_zone_pressure_relief (libSystem) through a two-line FFI block to return freed memory on macOS, and no safe wrapper crate exists. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 24 ++++++++++++++++++++++++ CLAUDE.md | 24 ++++++++++++++++++++++++ README.md | 3 +++ clippy.toml | 4 ++++ 4 files changed, 55 insertions(+) create mode 100644 clippy.toml diff --git a/AGENTS.md b/AGENTS.md index 8252ed1b..93aa1b19 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; crates that are clean carry + `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, + clippy::unreachable)]`, and the lints move to `[workspace.lints.clippy]` once every crate is clean. 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..93aa1b19 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; crates that are clean carry + `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, + clippy::unreachable)]`, and the lints move to `[workspace.lints.clippy]` once every crate is clean. 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/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 From 0ad313d445fdeea88f72aa3a5cc9dadeef2f87a4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 11:41:35 -0400 Subject: [PATCH 12/22] Issue #47: Masking: keep the panel's contents within its width when a mask is selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a mask added the Add/Subtract/Intersect/Invert/Delete row in one non-wrapping horizontal layout (~273 pt in a ~220 pt column), and the Create New Mask grid needed 226 pt: the panel's contents grew wider than the panel (a 250 pt panel came out 275 pt wide) and were drawn shifted left, clipped by the photo area. The action row now wraps, the tile grid sizes its tiles and columns to the available width (four tiles of 48–52 pt, else three), and a component's name is truncated before its options button. A headless test checks that every widget stays inside the right panel at 250, 270, 330 and 520 pt, with and without a selected mask and with the brush settings open. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/masking.rs | 31 +++++++++++++--- crates/ui-egui/src/tests_panels.rs | 55 ++++++++++++++++++++++++++++ docs/parity.md | 2 +- 3 files changed, 82 insertions(+), 6 deletions(-) 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/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 2f6873ed..7de05e0b 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -79,6 +79,61 @@ fn side_panels_resize_by_their_inner_edge_and_remember_it() { 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})); diff --git a/docs/parity.md b/docs/parity.md index 55cf2f73..3699bea8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 | From 4f4426e498e9206227675858c0a394d63697c27a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 11:44:15 -0400 Subject: [PATCH 13/22] =?UTF-8?q?Issue=20#30:=20Import=20Photos=E2=80=A6?= =?UTF-8?q?=20as=20the=20clear=20import=20entry=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File now opens with Import Photos… (Cmd+Shift+I), Import from Folder… and Import from Device (command ids file.addPhotos / file.addFolder / file.addFromDevice unchanged; library.import's label is Import Photos). The review dialog is titled Import Photos, names its source (a scanned folder is not added to Local; Local -> Browse Folder... is the persistent location), labels the transfer mode and explains Add in place vs Copy, and its button reads Import N Photos. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/devices.rs | 2 +- crates/ui-egui/src/headless.rs | 11 +++++- crates/ui-egui/src/import.rs | 55 +++++++++++++++++++++++++--- crates/ui-egui/src/menubar.rs | 26 ++++++++++++- crates/ui-egui/src/menus.rs | 6 +-- crates/ui-egui/src/panels/dialogs.rs | 4 +- crates/ui-egui/src/panels/grid.rs | 2 +- crates/ui-egui/src/state.rs | 2 +- docs/parity.md | 10 ++--- docs/web.md | 2 +- 11 files changed, 97 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index b4c3b292..9077b00a 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -732,7 +732,7 @@ 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?}", diff --git a/crates/engine/src/devices.rs b/crates/engine/src/devices.rs index 0ad26922..c1953a74 100644 --- a/crates/engine/src/devices.rs +++ b/crates/engine/src/devices.rs @@ -1,5 +1,5 @@ //! 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. use serde::Serialize; diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index b911896b..6a0486ea 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -894,7 +894,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]); @@ -916,6 +916,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); @@ -1109,7 +1116,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] diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 719b235a..52cf6e87 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1,5 +1,5 @@ -//! 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 +//! 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 dated `Originals/` folders), 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 +21,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). @@ -92,6 +94,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 +103,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})) } @@ -212,6 +217,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) }); } @@ -341,7 +347,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 +413,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 +435,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; @@ -602,6 +628,23 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial } } +/// 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/menubar.rs b/crates/ui-egui/src/menubar.rs index 051341da..8ba3a7da 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -54,7 +54,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ &[ "file.addPhotos", "file.addFolder", - "@Add from Device", + "@Import from Device", "---", "app.openLibrary", "---", @@ -428,7 +428,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)] @@ -723,6 +723,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(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fde1e087..7b69a29d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -109,9 +109,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, ""), diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 24153a8b..162df2b9 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", @@ -704,7 +704,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", 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/state.rs b/crates/ui-egui/src/state.rs index a9749f89..e7b3c6cb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -372,7 +372,7 @@ pub enum Dialog { minutes: i32, zone: f32, }, - /// The import review (File → Add Photos…). + /// The import review (File → Import Photos…). Import { opts: Box, }, diff --git a/docs/parity.md b/docs/parity.md index a33caa46..79d72dba 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -99,10 +99,10 @@ 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; 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 | @@ -505,8 +505,8 @@ visible menu bar yet: the menu model is only exposed through the control channel | 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-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 +638,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` | | 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 From a8c3f01e1edf7ce5e76a171f7605c3767aa40d77 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 11:53:43 -0400 Subject: [PATCH 14/22] CI: clean under clippy 1.99 (as_chunks for constant chunk sizes, ? in export resize parsing) Rust 1.99 (stable since 2026-09-28) adds clippy::chunks_exact_to_as_chunks and widens clippy::question_mark, which failed `cargo xtask ci` on main. Same behaviour: the remainder that chunks_exact skipped is the remainder as_chunks returns separately. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/convert.rs | 4 +++- crates/codecs/src/encode.rs | 4 ++-- crates/codecs/src/icc.rs | 2 +- crates/codecs/src/jpeg.rs | 4 ++-- crates/codecs/src/png_codec.rs | 2 +- crates/engine/src/export.rs | 9 ++++----- crates/pipeline/src/output.rs | 4 ++-- crates/preview/src/disk.rs | 2 +- crates/raster/src/lib.rs | 2 +- crates/raw/src/demosaic/mod.rs | 2 +- crates/raw/src/opcodes.rs | 2 +- crates/raw/src/unpack.rs | 2 +- crates/raw/src/vendor/raf.rs | 2 +- crates/tiff/src/reader.rs | 26 ++++++++++++++------------ crates/ui-egui/src/softpaint.rs | 2 +- 15 files changed, 36 insertions(+), 33 deletions(-) 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/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/engine/src/export.rs b/crates/engine/src/export.rs index 388e0b8b..ed9490db 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/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/raster/src/lib.rs b/crates/raster/src/lib.rs index 50593f29..2ea21fa1 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -243,7 +243,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/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/raf.rs b/crates/raw/src/vendor/raf.rs index e8ee126e..ad9eaf85 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -98,7 +98,7 @@ fn cfa(h: &Header) -> Cfa { /// 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/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/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; }; From b42ca767772c796820b02322ba0f51d978725677 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 12:00:26 -0400 Subject: [PATCH 15/22] Never crash: raw and tiff decoders free of panicking calls - ljpeg: a scan naming an undefined Huffman table is an error, not an expect. - ARW2/RW2 block reads use get() + try_from instead of slice + expect. - Vendor fallback CFAs use Cfa::bayer_static (literal names, tested) instead of Cfa::bayer(..).expect("static"). - dngwrite matches (data, compression) instead of a let-else unreachable!. - lightcraft-raw and lightcraft-tiff now #![deny] unwrap/expect/panic/ unimplemented/todo/unreachable; clippy.toml (same as the steering PR) keeps them allowed in tests. - tests/vendor_robust.rs: synthetic NEF, ARW, PEF, SRW, ORF, RW2, CR2 and RAF files, truncated at every length and randomly mutated, must decode to an error, never panic (no new crash found). Co-Authored-By: Claude Opus 5.5 --- clippy.toml | 4 + crates/raw/src/dngwrite.rs | 9 +- crates/raw/src/lib.rs | 9 ++ crates/raw/src/ljpeg.rs | 6 +- crates/raw/src/vendor/arw.rs | 5 +- crates/raw/src/vendor/cr2.rs | 2 +- crates/raw/src/vendor/nef.rs | 2 +- crates/raw/src/vendor/orf.rs | 2 +- crates/raw/src/vendor/pef.rs | 2 +- crates/raw/src/vendor/raf.rs | 2 +- crates/raw/src/vendor/rw2.rs | 6 +- crates/raw/tests/vendor_robust.rs | 220 ++++++++++++++++++++++++++++++ crates/tiff/src/lib.rs | 1 + 13 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 clippy.toml create mode 100644 crates/raw/tests/vendor_robust.rs 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/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/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..05b09f49 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -91,7 +91,7 @@ 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"), } } 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/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; From eb378c491afd731150d98747c6f3757968da9c1e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 12:18:11 -0400 Subject: [PATCH 16/22] Never crash: codecs and catalog free of panicking calls - catalog store: the journal appender is a let-else with an io::Error, not expect("appender just opened"). - catalog dates: group_label slices with get(), so a non-ASCII key of a date's length no longer panics mid-character (regression test). - codecs jpeg_par: rows come from map_while instead of next().unwrap(). - lightcraft-codecs and lightcraft-catalog now #![deny] unwrap/expect/ panic/unimplemented/todo/unreachable (one test let-else uses panic! instead of unreachable!); clippy.toml as in the steering PR. Co-Authored-By: Claude Opus 5.5 --- clippy.toml | 4 ++++ crates/catalog/src/dates.rs | 13 +++++++++---- crates/catalog/src/lib.rs | 1 + crates/catalog/src/stacks.rs | 2 +- crates/catalog/src/store.rs | 2 +- crates/codecs/src/jpeg_par.rs | 2 +- crates/codecs/src/lib.rs | 1 + 7 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 clippy.toml 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..693e4496 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; 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/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; From a74b1b32ff7c77966ddd59ef1f7c1f22a65cc7cd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 12:20:44 -0400 Subject: [PATCH 17/22] Never crash: engine panic sites outside export.rs; mcp denies panics - merge output_path: a bounded search for a free name instead of `for k in 1..` + unreachable!(). - library open: Option::insert returns the new library, no expect. - relink: a one-op batch unwraps through <[Op; 1]>::try_from, no expect. - lightcraft-mcp (already clean) now #![deny] unwrap/expect/panic/ unimplemented/todo/unreachable; clippy.toml as in the steering PR. crates/engine/src/export.rs keeps one expect (open PR #51 edits that file), so lightcraft-engine doesn't get the deny attribute yet. Co-Authored-By: Claude Opus 5.5 --- clippy.toml | 4 ++++ crates/engine/src/cmd/missing.rs | 6 +++++- crates/engine/src/library.rs | 4 ++-- crates/engine/src/merge.rs | 12 ++++-------- crates/mcp/src/lib.rs | 1 + 5 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 clippy.toml 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/engine/src/cmd/missing.rs b/crates/engine/src/cmd/missing.rs index 8ec96355..22fceda5 100644 --- a/crates/engine/src/cmd/missing.rs +++ b/crates/engine/src/cmd/missing.rs @@ -50,7 +50,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/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/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; From 5218981c7b1967ebc9c00beb1285cfb033ddac37 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 12:24:36 -0400 Subject: [PATCH 18/22] Never crash: last-resort guard around command execution Session::execute (the entry point of the UI, CLI, control channel and MCP) runs each command through lightcraft_engine::guard::catch: a panic that escapes a command becomes that command's error (EngineError::Other, shown like any failed command) and the session, catalog and undo history stay usable. The desktop app installs guard::install_hook, which keeps the default stderr report and appends the panic location and message to /lightcraft-panics.log. The desktop app crate (already clean) now #![deny]s unwrap/expect/panic/ unimplemented/todo/unreachable; clippy.toml as in the steering PR. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft/src/main.rs | 2 ++ clippy.toml | 4 +++ crates/engine/src/guard.rs | 55 +++++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 4 ++- 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 clippy.toml create mode 100644 crates/engine/src/guard.rs 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/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/lib.rs b/crates/engine/src/lib.rs index ce0404f2..d7fd6be5 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; From 685cf6f2e8bdea789587b4cbbcade3b7247a1c49 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 12:33:45 -0400 Subject: [PATCH 19/22] Never crash: ui-egui and the web app free of panicking calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - import / merge polling: let-else on the task instead of expect("task"). - the merge worker catches a panic and reports "the merge failed unexpectedly" instead of dropping its channel, which left "Merging…" up forever. - web: window() returns Option; its ten callers handle a missing window. - lightcraft-ui-egui and lightcraft-web now #![deny] unwrap/expect/panic/ unimplemented/todo/unreachable; clippy.toml as in the steering PR. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-web/src/lib.rs | 1 + apps/lightcraft-web/src/web.rs | 29 +++++++++++++++++------------ clippy.toml | 4 ++++ crates/ui-egui/src/import.rs | 6 +++--- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/merge.rs | 22 +++++++++++++--------- 6 files changed, 39 insertions(+), 24 deletions(-) create mode 100644 clippy.toml 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/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/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 719b235a..6d74f103 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -189,7 +189,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; } @@ -307,7 +307,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 +328,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); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e42ed5ea..5553c66c 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; 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" { From fc13807f9173f57bf4dbe1f96deaff562bf02634 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 12:34:53 -0400 Subject: [PATCH 20/22] Never crash: color, geom, raster, develop, scenes, preview, pipeline - color: RgbSpace::to_xyz/from_xyz and bradford() no longer expect a matrix inverse. Degenerate (collinear) primaries, which readers reject before building a space from a file, leave the primaries unscaled / give the identity; the constant Bradford and OKLab matrices fall back to the identity, which can't happen (round trips are tested). - pipeline geometry: sample() matches (mode, warp) instead of expect("warp"); a warp plan without a warp (sample_plan never makes one) samples through the equivalent affine. - color, geom, raster, develop, scenes, preview and pipeline now #![deny] unwrap/expect/panic/unimplemented/todo/unreachable; clippy.toml as in the steering PR. Co-Authored-By: Claude Opus 5.5 --- clippy.toml | 4 ++++ crates/color/src/lib.rs | 11 ++++++++--- crates/color/src/perceptual.rs | 5 +++-- crates/develop/src/lib.rs | 1 + crates/geom/src/lib.rs | 1 + crates/pipeline/src/geometry.rs | 29 ++++++++++++++++------------- crates/pipeline/src/lib.rs | 1 + crates/preview/src/lib.rs | 1 + crates/raster/src/lib.rs | 1 + crates/scenes/src/lib.rs | 1 + 10 files changed, 37 insertions(+), 18 deletions(-) create mode 100644 clippy.toml 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/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/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/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/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..a4c7a9c2 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; 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; From bd2e756b24d7fa410fd52cd78b5fa86d7d40be20 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 12:34:53 -0400 Subject: [PATCH 21/22] Never crash: gpu and merge free of panicking calls gpu (its render already runs under catch_unwind and falls back to the CPU): - a failed readback (device lost, out of memory) logs, marks the GPU broken and returns zeros, so crate::render discards the result and renders on the CPU, instead of panicking; - an unknown kernel name does the same instead of panic!; - finish-parameter fields are looked up as Option (debug_assert on an unknown name) instead of panic!; - the warp sampler matches (mode, warp) like the CPU pipeline; - Buf::raw keeps one documented expect (Some from construction to Drop). merge: - pyramid building (push_pull, Blender::finish), camera graph growth, bundle adjustment, focal median and alignment use let-else / filter_map instead of expect; - synth::bracket_dngs / pano_views return Result (a missing demo scene or a DNG write error is an error); lightcraft-cli synth-merge and the engine merge tests handle it. lightcraft-gpu, lightcraft-merge and lightcraft-cli now #![deny] unwrap/expect/panic/unimplemented/todo/unreachable; clippy.toml as in the steering PR. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-cli/src/main.rs | 9 +++++++-- clippy.toml | 4 ++++ crates/engine/src/tests_merge.rs | 4 ++-- crates/gpu/src/ctx.rs | 24 +++++++++++++++++++++--- crates/gpu/src/lib.rs | 1 + crates/gpu/src/params.rs | 22 ++++++++++++---------- crates/gpu/src/render.rs | 24 +++++++++++++----------- crates/merge/src/align.rs | 3 ++- crates/merge/src/features.rs | 2 +- crates/merge/src/lib.rs | 1 + crates/merge/src/pano/blend.rs | 16 ++++++++-------- crates/merge/src/pano/camera.rs | 11 +++++++---- crates/merge/src/pano/mod.rs | 2 +- crates/merge/src/synth.rs | 27 ++++++++++++++++----------- 14 files changed, 96 insertions(+), 54 deletions(-) create mode 100644 clippy.toml 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/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/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/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/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()) } From ef57ae208b00328c2c9b0e7f2fb8084aaaf28d6d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 13:57:00 -0400 Subject: [PATCH 22/22] Never crash: the per-crate deny attribute is the end state, not workspace lints Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 8 ++++---- CLAUDE.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 93aa1b19..190c759b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,10 +36,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **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; crates that are clean carry - `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, - clippy::unreachable)]`, and the lints move to `[workspace.lints.clippy]` once every crate is clean. New crates start - with the attribute. +- 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…). diff --git a/CLAUDE.md b/CLAUDE.md index 93aa1b19..190c759b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,10 +36,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **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; crates that are clean carry - `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, - clippy::unreachable)]`, and the lints move to `[workspace.lints.clippy]` once every crate is clean. New crates start - with the attribute. +- 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…).