From 71dc30b951acfa2fe9f8af30884388e022cc955f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 08:53:58 -0400 Subject: [PATCH] LR-IMP-LOCAL: browse folders on disk without adding them (Local), failed renders aren't retried MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - library.browse {path, subfolders}: a folder's photos are read in place as "local" photos (catalog flag, SetLocal op) that only folder views list — not All Photos, albums, date groups, keyword counts or stats. photo.addToLibrary promotes them; importing the folder for real promotes instead of reporting duplicates. The browsed folder persists with the view. - UI: Local section (Pictures, Desktop, Downloads, Home, the browsed folder, Browse Folder…); folder views get a breadcrumb header (each part opens that folder), Include subfolders and "Add N to My Photos". - Renderer: a job that fails (unreadable / missing file) is remembered by slot + key and not requested again every frame — the grid re-queued it forever (a render loop per broken file). The loupe says "This photo can't be opened" with the reason; grid cells show "!". ui.inspect lists pending render slots. - Headless app.export (MCP, lightcraft-cli run) exports the selection like the desktop app. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 4 +- crates/catalog/src/lib.rs | 9 ++++ crates/catalog/src/model.rs | 9 ++++ crates/catalog/src/query.rs | 27 +++++++++- crates/catalog/src/tests.rs | 11 ++++ crates/engine/src/cmd/browse.rs | 69 ++++++++++++++++++++++++ crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/query.rs | 2 +- crates/engine/src/import.rs | 13 ++++- crates/engine/src/lib.rs | 15 ++++-- crates/engine/src/library.rs | 10 +++- crates/engine/src/tests_import.rs | 42 +++++++++++++++ crates/engine/src/view.rs | 13 +++++ crates/mcp/src/headless.rs | 31 +++++++++++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/headless.rs | 39 ++++++++++++++ crates/ui-egui/src/panels/detail.rs | 10 +++- crates/ui-egui/src/panels/filterbar.rs | 2 +- crates/ui-egui/src/panels/grid.rs | 74 ++++++++++++++++++++++++-- crates/ui-egui/src/panels/left.rs | 50 ++++++++++++++++- crates/ui-egui/src/render.rs | 31 +++++++++-- docs/parity.md | 16 +++--- 23 files changed, 451 insertions(+), 32 deletions(-) create mode 100644 crates/engine/src/cmd/browse.rs diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index d62d45c5..4fc1b9c6 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -75,7 +75,7 @@ impl Catalog { } let mut roots = Vec::new(); let mut seen = std::collections::HashSet::new(); - for p in self.photos().filter(|p| !p.deleted) { + for p in self.photos().filter(|p| p.in_library()) { for k in &p.meta.keywords { let parts: Vec<&str> = k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect(); insert(&mut roots, &parts, "", p.id.0, &mut seen); @@ -162,7 +162,7 @@ impl Catalog { } else { let mut co: std::collections::HashMap = Default::default(); if !current.is_empty() { - for p in self.photos().filter(|p| !p.deleted) { + for p in self.photos().filter(|p| p.in_library()) { if p.meta.keywords.iter().any(|k| has(k)) { for k in p.meta.keywords.iter().filter(|k| !has(k)) { *co.entry(k.clone()).or_default() += 1; diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index a3256848..d07bdbc9 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -84,6 +84,11 @@ pub enum Op { id: PhotoId, deleted: bool, }, + /// Browsed-only (`true`) or part of the library. + SetLocal { + id: PhotoId, + local: bool, + }, SetVersions { id: PhotoId, versions: Vec, @@ -339,6 +344,10 @@ impl Catalog { let p = self.photo_mut(id)?; Op::SetDeleted { id, deleted: std::mem::replace(&mut p.deleted, deleted) } } + Op::SetLocal { id, local } => { + let p = self.photo_mut(id)?; + Op::SetLocal { id, local: std::mem::replace(&mut p.local, local) } + } Op::SetVersions { id, versions } => { let p = self.photo_mut(id)?; Op::SetVersions { id, versions: std::mem::replace(&mut p.versions, versions) } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index e67f2cc5..184a9380 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -168,6 +168,10 @@ pub struct Photo { /// In "Recently Deleted". #[serde(default)] pub deleted: bool, + /// Seen while browsing a folder on disk (Local), not added to the library: only folder views + /// show it until it is added. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub local: bool, /// Video duration in seconds. #[serde(default)] pub duration: Option, @@ -217,6 +221,7 @@ impl Photo { versions: Vec::new(), history: Vec::new(), deleted: false, + local: false, duration: None, as_shot_wb: None, content_hash: None, @@ -246,6 +251,10 @@ impl Photo { } d } + /// In the library: not deleted and not only browsed (Local). + pub fn in_library(&self) -> bool { + !self.deleted && !self.local + } /// Edited by the user: settings differ from what import gave the photo. pub fn is_edited(&self) -> bool { *self.develop != self.import_defaults() && !self.develop.is_unedited() diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 6e7eedae..89613b90 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -42,6 +42,9 @@ pub struct Filter { pub date_to: Option, /// Import date prefix (Recently Added). pub imported: Option, + /// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below. + pub folder: Option, + pub subfolders: bool, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -156,6 +159,17 @@ impl Filter { if p.deleted != self.deleted { return false; } + match &self.folder { + // browsed photos only show in folder views + None if p.local => return false, + None => {} + Some(dir) => { + let crate::Source::File { path } = &p.source else { return false }; + if !in_folder(path, dir, self.subfolders) { + return false; + } + } + } if self.rating > 0 { let ok = match self.rating_op { RatingOp::AtLeast => p.rating >= self.rating, @@ -239,7 +253,7 @@ impl Catalog { pub fn date_groups(&self) -> Vec { use std::collections::BTreeMap; let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new(); - for p in self.photos().filter(|p| !p.deleted) { + for p in self.photos().filter(|p| p.in_library()) { let d = p.date(); if d.len() >= 10 { let (months, days) = years.entry(&d[..4]).or_default(); @@ -263,7 +277,7 @@ impl Catalog { /// All keywords with usage counts, sorted by name. pub fn keywords(&self) -> Vec<(String, usize)> { let mut m: std::collections::BTreeMap = Default::default(); - for p in self.photos().filter(|p| !p.deleted) { + for p in self.photos().filter(|p| p.in_library()) { for k in &p.meta.keywords { *m.entry(k.clone()).or_default() += 1; } @@ -281,3 +295,12 @@ pub struct DateGroup { /// `YYYY-MM-DD` and counts, newest first. pub days: Vec<(String, usize)>, } + +/// Whether file `path` is directly in `dir` (or anywhere below it with `deep`). Both `/` and `\\` +/// separate. +pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool { + let dir = dir.trim_end_matches(['/', '\\']); + let Some(rest) = path.strip_prefix(dir) else { return false }; + let Some(rest) = rest.strip_prefix(['/', '\\']) else { return false }; + !rest.is_empty() && (deep || !rest.contains(['/', '\\'])) +} diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 234f637e..caecf16f 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -205,3 +205,14 @@ fn capture_times_display_like_the_info_panel() { assert_eq!(display_time("2022-07-04"), "July 4, 2022"); assert_eq!(display_time("someday"), "someday"); } + +#[test] +fn folder_paths() { + use crate::query::in_folder; + assert!(in_folder("/a/b/c.jpg", "/a/b", false)); + assert!(in_folder("/a/b/c.jpg", "/a/b/", false)); + assert!(!in_folder("/a/b/x/c.jpg", "/a/b", false)); + assert!(in_folder("/a/b/x/c.jpg", "/a/b", true)); + assert!(!in_folder("/a/bc/d.jpg", "/a/b", true), "a sibling with the same prefix"); + assert!(in_folder("C:\\Pics\\a.jpg", "C:\\Pics", false)); +} diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs new file mode 100644 index 00000000..baf50eee --- /dev/null +++ b/crates/engine/src/cmd/browse.rs @@ -0,0 +1,69 @@ +//! Local: browse a folder on disk without adding it to the library (Lightroom's Local tab). +//! The folder's photos are probed in place as `local` photos — only folder views list them — +//! and edits go to the files' XMP sidecars like any photo added in place. "Add to My Photos" +//! makes them ordinary library photos. + +use std::path::Path; + +use lightcraft_catalog::{Op, PhotoId}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, bool_or, cmd, str_param}; +use crate::import::{ImportMode, ImportOptions, import_with, is_supported}; +use crate::{Browse, LibrarySource, Result, Session}; + +fn browse(s: &mut Session, p: &Value) -> Result { + const C: &str = "library.browse"; + let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; + let dir = std::path::absolute(Path::new(path)).map_err(|e| bad(C, e.to_string()))?; + if !dir.is_dir() { + return Err(bad(C, format!("{path}: not a folder"))); + } + let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + let subfolders = bool_or(p, "subfolders", s.browse.as_ref().is_some_and(|b| b.subfolders)); + let files: Vec = if subfolders { + crate::import::expand(std::slice::from_ref(&dir_s), None) + } else { + let mut v: Vec = std::fs::read_dir(&dir) + .map_err(|e| bad(C, format!("{path}: {e}")))? + .flatten() + .map(|e| e.path()) + .filter(|f| f.is_file() && is_supported(f) && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))) + .map(|f| f.to_string_lossy().to_string()) + .collect(); + v.sort(); + v + }; + let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?; + s.browse = Some(Browse { path: dir_s.clone(), subfolders }); + s.source = LibrarySource::Folder; + let shown = s.visible().len(); + Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()})) +} + +/// The browsed photos among `ids` (default: the selection) become library photos. +fn add_to_library(s: &mut Session, p: &Value) -> Result { + let ids: Vec = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect(); + if ids.is_empty() { + return Ok(json!({"added": 0})); + } + let n = ids.len(); + let ops = ids.into_iter().map(|id| Op::SetLocal { id, local: false }).collect(); + s.commit("Add to My Photos", Op::Batch { ops })?; + Ok(json!({"added": n})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "library.browse", + "Browse Folder", + [], + None, + "{path, subfolders?: bool} — show a folder's photos without adding them to the library (they're read in place; edits go to XMP sidecars) → {path, photos, new}", + always, + browse + ), + cmd!("photo.addToLibrary", "Add to My Photos", ["Photo"], None, "{ids?} — browsed (Local) photos join the library", always, add_to_library), + ] +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 33cd4738..f6372f44 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -568,7 +568,7 @@ pub fn specs() -> Vec { { return Err(bad("library.import", "album must be a regular album")); } - let opts = crate::import::ImportOptions { mode, preset, keywords: strs(p, "keywords") }; + let opts = crate::import::ImportOptions { mode, preset, keywords: strs(p, "keywords"), ..Default::default() }; let undo0 = s.undo.len(); let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default(); let imported: Vec = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 06e3e6f4..74c24077 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -5,6 +5,7 @@ //! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…). mod before; +mod browse; mod color; mod develop; mod edit; @@ -121,6 +122,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(prefs::specs()); v.extend(export::specs()); v.extend(before::specs()); + v.extend(browse::specs()); v }) } diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 74eec8d5..07caaae9 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -66,7 +66,7 @@ pub fn specs() -> Vec { Ok(json!({"total": ids.len(), "photos": items})) }), cmd!(query "catalog.stats", "Catalog Statistics", [], None, "{}", always, |s, _| { - let all: Vec<_> = s.catalog.photos().filter(|p| !p.deleted).collect(); + let all: Vec<_> = s.catalog.photos().filter(|p| p.in_library()).collect(); Ok(json!({ "photos": all.len(), "edited": all.iter().filter(|p| p.is_edited()).count(), diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index ea030be8..5236d97a 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -49,6 +49,9 @@ pub struct ImportOptions { pub preset: Option, /// Added to every imported photo. pub keywords: Vec, + /// Browsing a folder: photos come in as `local` (not in the library), and a file with the + /// same content as one already known is still listed. + pub local: bool, } /// A file found by [`scan`], for the import review. @@ -372,8 +375,14 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c } } let mut todo = Vec::new(); + // a file that was only browsed (Local) joins the library when it is imported for real + let mut promote = Vec::new(); for f in files { match by_path.get(f.as_str()) { + Some(id) if !opts.local && s.catalog.photo(*id).is_some_and(|p| p.local) => { + promote.push(Op::SetLocal { id: *id, local: false }); + report.imported.push(id.0); + } Some(id) => report.duplicates.push(Duplicate { path: f, existing: Some(id.0), reason: "path" }), None => todo.push(f), } @@ -382,7 +391,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c let probed = probe_all(s, &todo); crate::memory::release(); let now = (s.clock)(); - let mut ops = Vec::new(); + let mut ops = promote; for (path, info) in todo.into_iter().zip(probed) { let info = match info { Ok(i) => i, @@ -393,6 +402,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c } }; if let Some(h) = &info.content_hash + && !opts.local && let Some(id) = by_hash.get(h) { report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" }); @@ -452,6 +462,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c p.history.push(lightcraft_catalog::HistoryStep { label, settings: d }); } report.imported.push(id.0); + p.local = opts.local; ops.push(Op::AddPhoto { photo: Box::new(p) }); } if !ops.is_empty() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1625e62e..0bec679d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -33,7 +33,7 @@ use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort}; use lightcraft_develop::DevelopSettings; pub use media::{RenderJob, SourceLevel}; use serde_json::Value; -pub use view::{LibrarySource, Selection}; +pub use view::{Browse, LibrarySource, Selection}; pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_gpu as gpu, lightcraft_pipeline as pipeline}; #[derive(Debug, thiserror::Error)] @@ -81,6 +81,8 @@ pub struct Session { pub interaction: Option, /// Copied develop settings (partial JSON) for Paste. pub clipboard: Option, + /// The folder on disk the [`LibrarySource::Folder`] view browses. + pub browse: Option, /// Copied metadata (`photo.copyMetadata`): photo.setMeta params. pub meta_clipboard: Option, /// The photo that was active before the current one (Paste Settings from Previous). @@ -144,6 +146,7 @@ impl Session { interaction: None, clipboard: None, meta_clipboard: None, + browse: None, previous_active: None, copy_groups: lightcraft_develop::SettingsGroup::default_copy(), presets: presets::builtin(), @@ -356,9 +359,15 @@ impl Session { /// Photos shown in the grid/filmstrip for the current source, filter and sort. pub fn visible(&mut self) -> &[PhotoId] { - let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.filter, self.sort)); + let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse)); if self.visible_key.as_ref() != Some(&key) { - let f = self.source.to_filter(&self.filter, &self.catalog); + let mut f = self.source.to_filter(&self.filter, &self.catalog); + if self.source == LibrarySource::Folder { + // no folder chosen: nothing (an empty path matches nothing) + let b = self.browse.clone().unwrap_or_default(); + f.folder = Some(b.path); + f.subfolders = b.subfolders; + } self.visible = self.catalog.query(&f, &self.sort); if matches!(self.source, LibrarySource::Album(_)) && self.sort.key == lightcraft_catalog::SortKey::CaptureDate diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index e5d73da0..31c196a3 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -81,6 +81,7 @@ struct PresetsFile { #[serde(default)] struct ViewFile { source: LibrarySource, + browse: Option, filter: lightcraft_catalog::Filter, sort: lightcraft_catalog::Sort, selection: Selection, @@ -178,6 +179,7 @@ impl Session { // view state if let Some(v) = read_json::(files.as_mut(), "view.json") { self.source = v.source; + self.browse = v.browse; self.filter = v.filter; self.sort = v.sort; self.selection = v.selection; @@ -251,7 +253,13 @@ impl Session { } fn view_json(&self) -> Vec { - let view = ViewFile { source: self.source, filter: self.filter.clone(), sort: self.sort, selection: self.selection.clone() }; + let view = ViewFile { + source: self.source, + browse: self.browse.clone(), + filter: self.filter.clone(), + sort: self.sort, + selection: self.selection.clone(), + }; serde_json::to_vec_pretty(&view).unwrap_or_default() } diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 6e1289e3..31c7d2ea 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -101,3 +101,45 @@ fn civil_dates() { assert_eq!(crate::import::civil(951_782_400), "2000-02-29T00:00:00"); assert_eq!(crate::import::civil(1_790_000_000), "2026-09-21T14:13:20"); } + +#[test] +fn browsing_a_folder_lists_its_photos_without_adding_them() { + use crate::LibrarySource; + let dir = std::env::temp_dir().join(format!("lc-browse-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + let png = |p: &std::path::Path, seed: u8| { + let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]); + let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }) + .unwrap(); + std::fs::write(p, b).unwrap(); + }; + png(&dir.join("a.png"), 1); + png(&dir.join("b.png"), 2); + png(&dir.join("sub/c.png"), 3); + std::fs::write(dir.join("notes.txt"), "x").unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.browse", &serde_json::json!({"path": dir.to_string_lossy()})).unwrap(); + assert_eq!(r["photos"], 2, "{r}"); + assert_eq!(s.source, LibrarySource::Folder); + assert_eq!(s.visible_cloned().len(), 2); + // not in the library + s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap(); + assert!(s.visible_cloned().is_empty(), "browsed photos stay out of All Photos"); + assert_eq!(s.execute("catalog.stats", &serde_json::json!({})).unwrap()["photos"], 0, "nor in the counts"); + assert!(s.catalog.date_groups().is_empty()); + // subfolders; browsing again reuses the photos + let r = s.execute("library.browse", &serde_json::json!({"path": dir.to_string_lossy(), "subfolders": true})).unwrap(); + assert_eq!((r["photos"].as_u64(), r["new"].as_u64()), (Some(3), Some(1))); + // add one to the library + let first = s.visible_cloned()[0]; + s.execute("photo.addToLibrary", &serde_json::json!({"ids": [first.0]})).unwrap(); + // importing the folder for real brings in the rest (no duplicates of the browsed ones) + let r = s.execute("library.import", &serde_json::json!({"paths": [dir.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"].as_array().map(Vec::len), Some(2), "{r}"); + s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap(); + assert_eq!(s.visible_cloned().len(), 3); + assert_eq!(s.catalog.photos().count(), 3); + assert!(s.execute("library.browse", &serde_json::json!({"path": dir.join("a.png").to_string_lossy()})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 20375439..01c1b385 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -15,6 +15,16 @@ pub enum LibrarySource { RecentlyDeleted, /// Photos with picks. Picks, + /// A folder on disk ([`crate::Session::browse`]): its files, added to the library or not. + Folder, +} + +/// The folder a [`LibrarySource::Folder`] view shows. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Browse { + pub path: String, + pub subfolders: bool, } impl LibrarySource { @@ -29,6 +39,8 @@ impl LibrarySource { LibrarySource::Album(a) => f.album = Some(*a), LibrarySource::RecentlyDeleted => f.deleted = true, LibrarySource::Picks => f.flag = Some(lightcraft_catalog::Flag::Pick), + // the folder itself is filled in by the session (it holds the path) + LibrarySource::Folder => {} } f } @@ -40,6 +52,7 @@ impl LibrarySource { LibrarySource::Album(a) => cat.album(*a).map(|a| a.name.clone()).unwrap_or_else(|| "Album".into()), LibrarySource::RecentlyDeleted => "Recently Deleted".into(), LibrarySource::Picks => "Picks".into(), + LibrarySource::Folder => "Folder".into(), } } } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 58553529..d45edc4c 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -70,6 +70,11 @@ impl Headless { } let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) { Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(), + // like the desktop app: the selection (in grid order), else the given / active photo + None if p.get("id").is_none() && self.session.selection.ids.len() > 1 => { + let sel: std::collections::HashSet<_> = self.session.selection.ids.iter().copied().collect(); + self.session.visible_cloned().into_iter().filter(|id| sel.contains(id)).collect() + } None => vec![self.photo_or_active(p)?], }; let dir = p.get("dir").and_then(Value::as_str).unwrap_or(""); @@ -180,3 +185,29 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> let bytes = encode_image(&ext, img, quality)?; std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display())) } + +#[cfg(test)] +mod tests { + use super::*; + + /// `app.export` without `ids` exports the selection (as the desktop app does), else the active photo. + #[test] + fn export_follows_the_selection() { + let dir = std::env::temp_dir().join(format!("lc-mcp-export-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut h = Headless::demo(); + let ids: Vec = h.session.visible_cloned().iter().take(3).map(|p| p.0).collect(); + h.call("engine.execute", json!({"command": "library.select", "params": {"ids": ids}})).unwrap(); + let r = h + .call("engine.execute", json!({"command": "app.export", "params": {"dir": dir.to_string_lossy(), "longEdge": 64, "format": "png"}})) + .unwrap(); + assert_eq!(r["files"].as_array().map(Vec::len), Some(3), "{r}"); + // one photo selected: that one + h.call("engine.execute", json!({"command": "library.select", "params": {"ids": [ids[0]]}})).unwrap(); + let r = h + .call("engine.execute", json!({"command": "app.export", "params": {"dir": dir.to_string_lossy(), "longEdge": 64, "format": "png"}})) + .unwrap(); + assert_eq!(r["files"].as_array().map(Vec::len), Some(1)); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e8075de2..820eadd3 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -77,7 +77,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "selection": app.session.selection.ids.iter().map(|p| p.0).collect::>(), "activeMask": app.session.active_mask, "widgetCount": app.widgets.len(), - "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()}, + "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()}, "loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})), "hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()), "status": app.ui.status, diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index ddd50bcf..5895d3e5 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -667,6 +667,45 @@ mod tests { assert_eq!((m.alt_text.as_str(), m.city.as_str()), ("A lake at dawn", "Zermatt")); } + /// Local: a folder's photos show without joining the library; the breadcrumb, Include + /// subfolders and Add to My Photos work from the grid header. + #[test] + fn browse_a_local_folder() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-browse-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("inner")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + for (i, p) in [dir.join("a.png"), dir.join("inner/b.png")].iter().enumerate() { + let mut img = img.clone(); + img.data[0][0] = i as u8; // different bytes per file + std::fs::write(p, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + } + let library_before = h.app.session.catalog.photos().filter(|p| !p.local).count(); + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t); + assert_eq!(r["result"]["photos"], 1, "{r}"); + assert!(h.settle(SETTLE), "a thumbnail that can't load must not keep the renderer busy"); + // this test session has no file hooks: the thumbnail fails once, is remembered, and isn't retried + let id = h.app.session.visible_cloned()[0]; + assert!(h.app.renderer.failure(crate::render::Slot::Thumb(id)).is_some()); + let done = h.app.renderer.completed; + for _ in 0..30 { + h.step(); + } + assert_eq!(h.app.renderer.completed, done, "no retry loop"); + let w = h.request("ui.widgets", json!({"filter": "crumb:"}), t); + assert!(w["result"].as_array().is_some_and(|a| !a.is_empty()), "breadcrumb: {w}"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:includeSubfolders"}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.app.session.visible_cloned().len(), 2); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:addToLibrary"}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.app.session.catalog.photos().filter(|p| !p.local).count(), library_before + 2); + let _ = std::fs::remove_dir_all(&dir); + } + /// ⌘Q (File → Quit LightCraft) closes the window. #[test] fn cmd_q_quits() { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 396ebf13..2143902f 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -271,7 +271,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else { shown = draw(Slot::Main, img_rect); if shown == "none" { - p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim); + match app.renderer.failure(Slot::Main) { + Some(e) => { + p.text(canvas.center() - vec2(0.0, 10.0), Align2::CENTER_CENTER, "This photo can't be opened", t.semibold(14.0), t.text); + p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim); + } + None => { + p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim); + } + } } } app.loupe_shown = Some((id, shown)); diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index ee1ba155..51954fec 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -223,7 +223,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // metadata pickers let ui: &mut egui::Ui = if two_rows { &mut row2 } else { &mut row1 }; - let photos: Vec<_> = app.session.catalog.photos().filter(|p| !p.deleted).cloned().collect(); + let photos: Vec<_> = app.session.catalog.photos().filter(|p| p.in_library()).cloned().collect(); let cameras = distinct(photos.iter().map(|p| p.meta.camera.clone()).collect()); let lenses = distinct(photos.iter().map(|p| p.meta.lens.clone()).collect()); let keywords: Vec = app.session.catalog.keywords().into_iter().map(|(k, _)| k).collect(); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 2c93d17d..863db3dc 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -30,18 +30,25 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let ids = app.session.visible_cloned(); // header: source title + count let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); - let title = app.session.source.label(&app.session.catalog); ui.painter().rect_filled(hr, 0.0, t.canvas); - ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text); let sel_n = app.session.selection.ids.len(); let cnt = if sel_n > 1 { format!("{sel_n} of {} photos", ids.len()) } else { format!("{} photos", ids.len()) }; - ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim); + match app.session.browse.clone().filter(|_| app.session.source == lightcraft_engine::LibrarySource::Folder) { + Some(b) => folder_header(app, ui, hr, &b, &ids, &cnt), + None => { + let title = app.session.source.label(&app.session.catalog); + ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text); + ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim); + } + } if app.ui.filter_bar { super::filterbar::show(app, ui); } app.canvas_rect = Some(ui.max_rect()); if ids.is_empty() { - if app.session.filter != Default::default() { + 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 if app.session.filter != Default::default() { super::empty_message(ui, ui.max_rect(), "No matching photos", "Change the filter, or clear it (View → Clear Filters)"); } else { super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here"); @@ -300,7 +307,12 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.rect_stroke(fit, 0.0, Stroke::new(2.0, Color32::from_gray(170)), StrokeKind::Outside); } } else { - p.rect_filled(img_rect.shrink(if square { 20.0 } else { 0.0 }), 0.0, Color32::from_gray(38)); + let ph = img_rect.shrink(if square { 20.0 } else { 0.0 }); + p.rect_filled(ph, 0.0, Color32::from_gray(38)); + if app.renderer.failure(Slot::Thumb(id)).is_some() { + // unreadable / missing file + p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text_dim); + } } // labels and badges if square && app.ui.show_filenames { @@ -387,6 +399,58 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square resp.context_menu(|ui| context_menu(app, ui, id)); } +/// The header of a Local folder view: the path as a breadcrumb (each part opens that folder), +/// Include subfolders, Add to My Photos. +fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, hr: Rect, b: &lightcraft_engine::Browse, ids: &[PhotoId], cnt: &str) { + let t = Tokens::get(ui.ctx()); + let mut child = + ui.new_child(egui::UiBuilder::new().max_rect(hr.shrink2(vec2(16.0, 6.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + child.spacing_mut().item_spacing.x = 4.0; + let parts: Vec<&str> = b.path.split(['/', '\\']).filter(|p| !p.is_empty()).collect(); + // the last few parts (the root side is elided) + let skip = parts.len().saturating_sub(4); + if skip > 0 { + child.label(egui::RichText::new("… ›").color(t.text_dim)); + } + for (i, part) in parts.iter().enumerate().skip(skip) { + let last = i + 1 == parts.len(); + let text = egui::RichText::new(*part).size(13.0).color(if last { t.text } else { t.text_label }); + let r = child.add(egui::Label::new(if last { text.strong() } else { text }).sense(Sense::click())); + register(child.ctx(), format!("crumb:{i}"), r.rect); + if !last { + if r.hovered() { + child.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + if r.clicked() { + let prefix = if b.path.starts_with('/') { format!("/{}", parts[..=i].join("/")) } else { parts[..=i].join("/") }; + let _ = app.run("library.browse", json!({"path": prefix})); + } + child.label(egui::RichText::new("›").color(t.text_dim)); + } + } + child.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(cnt).size(12.5).color(t.text_dim)); + ui.add_space(10.0); + let local: Vec = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect(); + if !local.is_empty() { + let label = format!("Add {} to My Photos", local.len()); + if crate::widgets::text_button(ui, "addToLibrary", &label, false).clicked() { + let n = local.len(); + match app.run("photo.addToLibrary", json!({"ids": local})) { + Ok(_) => app.toast(ui.ctx(), format!("Added {n} photo{} to My Photos", if n == 1 { "" } else { "s" })), + Err(e) => app.toast(ui.ctx(), e), + } + } + } + let mut sub = b.subfolders; + let c = ui.checkbox(&mut sub, "Include subfolders"); + register(ui.ctx(), "check:includeSubfolders", c.rect); + if c.changed() { + let _ = app.run("library.browse", json!({"path": b.path, "subfolders": sub})); + } + }); +} + /// While photos are dragged: a badge at the pointer; the drag ends when the button is up /// (drop targets act on the release frame, before this runs). pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index ae0bc19a..d1c28573 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -59,10 +59,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::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 stats: Vec<_> = app.session.catalog.photos().filter(|p| !p.deleted).map(|p| p.flag).collect(); + let stats: Vec<_> = app.session.catalog.photos().filter(|p| p.in_library()).map(|p| p.flag).collect(); let total = stats.len(); let picks = stats.iter().filter(|f| **f == lightcraft_catalog::Flag::Pick).count(); - let deleted = app.session.catalog.photos().filter(|p| p.deleted).count(); + let deleted = app.session.catalog.photos().filter(|p| p.deleted && !p.local).count(); 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 [ @@ -98,6 +98,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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); @@ -128,6 +129,51 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop, +/// Downloads, the home folder, the folder being browsed, and Browse Folder…. +fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { + if cfg!(target_arch = "wasm32") { + return; + } + let t = Tokens::get(ui.ctx()); + 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(); + 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}") }; + if std::path::Path::new(&p).is_dir() { + places.push((name.to_string(), p)); + } + } + } + let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); + if let Some(b) = &browsing + && !places.iter().any(|(_, p)| *p == b.path) + { + let name = std::path::Path::new(&b.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| b.path.clone()); + places.push((name, b.path.clone())); + } + for (name, path) in places { + let sel = browsing.as_ref().is_some_and(|b| b.path == path); + if row(app, ui, &format!("local:{path}"), Icon::Folder, &name, None, sel, 0.0).on_hover_text(&path).clicked() + && let Err(e) = app.run("library.browse", json!({"path": path})) + { + app.toast(ui.ctx(), e); + } + } + 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 + && let Err(e) = app.run("library.browse", json!({"path": path})) + { + app.toast(ui.ctx(), e); + } + } + ui.add_space(10.0); +} + /// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open. fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index dc84839e..5bc73dab 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -101,6 +101,9 @@ pub struct Renderer { stages: HashMap>, /// Slot → key of the last quick job requested for it (each is tried once). quick_tried: HashMap, + /// Jobs that failed (unreadable or missing file…), by slot and job key, with the error: not + /// requested again until the key changes (an edit, another size) — the grid asks every frame. + failed: HashMap, /// Prefetch slot → key of the last job submitted (each runs once). prefetched: HashMap, /// Variant key → frame it was last asked for (LRU of [`Slot::Variant`] textures). @@ -131,6 +134,7 @@ impl Default for Renderer { keep_pixels: false, stages: HashMap::new(), quick_tried: HashMap::new(), + failed: HashMap::new(), prefetched: HashMap::new(), variant_used: HashMap::new(), frame: 0, @@ -157,7 +161,7 @@ impl Renderer { /// Request a render for `slot` (no-op if already current or pending at the same priority). pub fn request(&mut self, slot: Slot, job: RenderJob, priority: u32) { - if self.textures.get(&slot).is_some_and(|t| t.key == job.key) { + if self.textures.get(&slot).is_some_and(|t| t.key == job.key) || self.failed.get(&slot).is_some_and(|f| f.0 == job.key) { return; } if let Some(&(key, prio)) = self.pending.get(&slot) @@ -216,6 +220,7 @@ impl Renderer { self.textures.clear(); self.stages.clear(); self.quick_tried.clear(); + self.failed.clear(); self.prefetched.clear(); self.pending.clear(); self.queue.clear(); @@ -251,6 +256,16 @@ impl Renderer { self.pending.len() } + /// Why the last render for `slot` failed (until a render with another key succeeds). + pub fn failure(&self, slot: Slot) -> Option<&str> { + self.failed.get(&slot).map(|f| f.1.as_str()) + } + + /// The slots with a render pending (diagnostics: `ui.inspect`). + pub fn pending_slots(&self) -> Vec { + self.pending.keys().map(|s| format!("{s:?}")).collect() + } + /// Thumbnail textures currently loaded. /// A variant thumbnail ([`Slot::Variant`]): its texture once rendered; until then the job is /// queued at low priority (below on-screen grid thumbnails). Call it every frame the variant @@ -343,8 +358,18 @@ impl Renderer { if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) { self.pending.remove(&slot); } - let Ok(rendered) = r.rendered else { - continue; + let rendered = match r.rendered { + Ok(x) => { + self.failed.remove(&slot); + x + } + Err(e) => { + if !matches!(slot, Slot::Prefetch(_)) { + log::warn!("render {slot:?}: {e}"); + self.failed.insert(slot, (r.key, e)); + } + continue; + } }; if matches!(slot, Slot::Prefetch(_)) { continue; diff --git a/docs/parity.md b/docs/parity.md index 869739c2..d2d3cbd2 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -21,7 +21,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| -| A. Import (IMP) | 6 | 3 | 4 | 1 | 2/4 (50%) | 3/4 (75%) | +| A. Import (IMP) | 7 | 2 | 4 | 1 | 3/4 (75%) | 3/4 (75%) | | B. Library management (LIB) | 18 | 1 | 6 | 2 | 9/9 (100%) | 8/9 (89%) | | C. Views & navigation (VIEW) | 14 | 0 | 3 | 0 | 9/9 (100%) | 4/4 (100%) | | D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) | @@ -45,12 +45,12 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 12 | 1 | 4 | 1 | 8/8 (100%) | 4/5 (80%) | -| Y. Menus | 68 | 9 | 8 | 8 | 46/47 (98%) | 17/23 (74%) | +| Y. Menus | 69 | 8 | 8 | 8 | 46/47 (98%) | 18/23 (78%) | | Z. Keyboard shortcuts (desktop) | 63 | 12 | 5 | 1 | 49/52 (94%) | 12/23 (52%) | -| Lightroom Classic extras | 7 | 19 | 53 | 9 | — | 6/21 (29%) | -| **Total** | 306 | 64 | 130 | 37 | 192/198 (97%) | 95/144 (66%) | +| Lightroom Classic extras | 7 | 20 | 52 | 9 | — | 6/21 (29%) | +| **Total** | 308 | 63 | 129 | 37 | 193/198 (97%) | 96/144 (67%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **67.6%** of 500 in-scope rows — P0 98.5% of 198 · P1 79.9% of 144 · P2 17.7% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **67.9%** of 500 in-scope rows — P0 98.7% of 198 · P1 80.6% of 144 · P2 17.7% of 158. ## Top gaps @@ -107,7 +107,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` | presets (.lcpreset, XMP `crs:`) only; no profile import; Adobe profile formats are deliberately unsupported | -| LR-IMP-LOCAL | Work on files in place | P0 | 🟡 | `cmd:library.import` (mode add), `cmd:library.importPreview`, `crates/engine/src/sidecar.rs`, `cmd:library.toggleAutoWriteXmp` | files referenced in place with XMP sidecars; the import review shows a folder's photos before adding (“Add in place” or “Copy into library”); no browse-a-folder-without-adding view in the main grid | +| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `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 | | 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, CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | ⬜ | | | @@ -547,7 +547,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-ZOOM | Zoom in / out / toggle / fit / 1:1 | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.zoomFit`, `cmd:view.zoom100` | | | MENU-VIEW-CLIPPING | Show Clipping | P0 | ✅ | `cmd:view.clipping` | | | MENU-VIEW-MASKOVERLAY | Mask overlay / cycle colour | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor` | colour cycles through the panel's swatches (no params) or takes `color` / `opacity` | -| MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | 🟡 | `cmd:library.import` | folder import is always recursive | +| MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | ✅ | `cmd:library.browse` (`subfolders`) | toggle in the folder header; imports of folders are recursive | | MENU-VIEW-SORT | Sort submenu | P0 | ✅ | `cmd:library.sort` | no colour-label key | | MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ✅ | `cmd:stack.expandAll`, `cmd:stack.collapseAll` | | | MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | 🟡 | `crates/ui-egui/src/panels/left.rs` | counts always shown; no toggle | @@ -693,7 +693,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ⬜ | | | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | 🟡 | `cmd:library.source` | all, recently added, picks, recently deleted; no missing/problem sets | -| LRC-LIB-FOLDERS | Disk folder tree | P1 | ⬜ | | | +| LRC-LIB-FOLDERS | Disk folder tree | P1 | 🟡 | `cmd:library.browse`, `crates/ui-egui/src/panels/left.rs` (`local_section`) | standard places + the browsed folder + Browse Folder…, breadcrumb navigation upwards; no expandable folder tree, no folder rename/move | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | 🟡 | `cmd:album.create` | albums + folders; no smart / quick / target collections | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | 🟡 | `cmd:album.createSmart`, `crates/catalog/src/query.rs` | the filter fields + date range; no rule editor, any/none groups or operators beyond ≥/=/≤ | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |