From 0fafacfccd2cc76619ecd78bac26ab7288069924 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:49:39 -0400 Subject: [PATCH] LR-VER-AUTO, LR-VIEW-SLIDESHOW, LRC-LIB-CONVERT and more: auto versions, slideshow, DNG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto versions: leaving an edited photo keeps its settings as an Auto version (when they differ from its latest version; 20 kept); saved with the library, not an undo step. - View ▸ Slideshow (⌥⌘↩, `interval`): the photos in view full screen, wrapping; Space pauses, ←/→ step, Esc ends. - Photo ▸ Convert to DNG (photo.convertToDng): lossless DNG next to the raw with the settings embedded; the photo and its virtual copies are relinked (Relink gains an optional format), originals kept, undoable. Import: Copy as DNG (`dng`) for copy imports. - A fine grid over the photo while a geometry slider is dragged. - View ▸ Show Photo Counts; Help ▸ System Info… (app.systemInfo, JSON for agents). - Native menus: modified Enter accelerators. - Parity rows refreshed (target / quick collection, auto sync, location rules, bring to front). Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft/src/native_menu.rs | 2 + crates/catalog/src/lib.rs | 8 ++- crates/engine/src/cmd/convert.rs | 104 +++++++++++++++++++++++++++ crates/engine/src/cmd/library.rs | 3 +- crates/engine/src/cmd/missing.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/import.rs | 15 ++++ crates/engine/src/lib.rs | 30 ++++++++ crates/engine/src/tests.rs | 28 ++++++++ crates/engine/src/tests_xmp.rs | 57 +++++++++++++++ crates/ui-egui/src/import.rs | 9 +++ crates/ui-egui/src/lib.rs | 25 +++++++ crates/ui-egui/src/menubar.rs | 2 + crates/ui-egui/src/menus.rs | 58 +++++++++++++++ crates/ui-egui/src/panels/detail.rs | 14 ++++ crates/ui-egui/src/panels/dialogs.rs | 16 ++++- crates/ui-egui/src/panels/edit.rs | 2 + crates/ui-egui/src/panels/left.rs | 2 +- crates/ui-egui/src/shortcuts.rs | 9 +++ crates/ui-egui/src/state.rs | 16 +++++ crates/ui-egui/src/tests_masking.rs | 46 ++++++++++++ docs/parity.md | 46 ++++++------ 22 files changed, 467 insertions(+), 29 deletions(-) create mode 100644 crates/engine/src/cmd/convert.rs diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 9dc3500f..f6b7dc06 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -122,6 +122,8 @@ fn accelerator(sc: &str) -> Option { "'" => Code::Quote, "," => Code::Comma, "Delete" => Code::Backspace, + // with modifiers only: a bare Enter belongs to text fields and tools + "Enter" if !mods.is_empty() => Code::Enter, "F1" => Code::F1, "F2" => Code::F2, "F3" => Code::F3, diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 8bbd0888..cf56d490 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -162,6 +162,9 @@ pub enum Op { id: PhotoId, file_name: String, source: Source, + /// The file's format when it changes too (Convert to DNG). + #[serde(default, skip_serializing_if = "Option::is_none")] + format: Option, }, /// The name shown for a colour label (`None` = its colour's name). SetLabelName { @@ -487,10 +490,11 @@ impl Catalog { let p = self.photo_mut(id)?; Op::SetCaptured { id, captured: std::mem::replace(&mut p.captured, captured) } } - Op::Relink { id, file_name, source } => { + Op::Relink { id, file_name, source, format } => { let p = self.photo_mut(id)?; let old_name = std::mem::replace(&mut p.file_name, file_name); - Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source) } + let old_format = format.map(|f| std::mem::replace(&mut p.format, f)); + Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format } } Op::SetFile { id, file_name, source } => { if file_name.trim().is_empty() { diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs new file mode 100644 index 00000000..f10f55c9 --- /dev/null +++ b/crates/engine/src/cmd/convert.rs @@ -0,0 +1,104 @@ +//! Convert to DNG: a raw photo's original is re-encoded as a lossless DNG (its develop settings +//! embedded as XMP) next to it, and the photo is relinked to the DNG. The original stays on +//! disk; undo relinks the photo to it. + +use std::path::Path; + +use lightcraft_catalog::{MediaKind, Op, Source}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_selection}; +use crate::{Result, Session}; + +/// A free `.dng` (then `-2.dng`…) next to `path`. +fn dng_path(path: &str) -> String { + let p = Path::new(path); + let dir = p.parent().unwrap_or(Path::new("")); + let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()); + let mut out = dir.join(format!("{stem}.dng")); + let mut n = 2; + while out.exists() { + out = dir.join(format!("{stem}-{n}.dng")); + n += 1; + } + out.to_string_lossy().to_string() +} + +/// Write the DNG for raw file `path` (develop settings in `packet`); returns the new path. +/// An empty `packet` writes no XMP. +pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::result::Result { + let bytes = match &s.media.file_bytes { + Some(r) => r(path)?, + None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?, + }; + let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?; + drop(bytes); + let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() }) + .map_err(|e| e.to_string())?; + let out = dng_path(path); + std::fs::write(&out, dng).map_err(|e| format!("{out}: {e}"))?; + Ok(out) +} + +fn convert(s: &mut Session, p: &Value) -> Result { + const C: &str = "photo.convertToDng"; + let ids = s.targets(p); + let mut ops = Vec::new(); + let mut converted = Vec::new(); + let mut skipped = Vec::new(); + for id in ids { + let Some(ph) = s.catalog.photo(id).cloned() else { continue }; + let Source::File { path } = &ph.source else { + skipped.push(json!([id.0, "not a file"])); + continue; + }; + if ph.kind != MediaKind::Raw || ph.format.eq_ignore_ascii_case("DNG") || ph.copy_of.is_some() { + skipped.push(json!([ + id.0, + if ph.kind != MediaKind::Raw { + "not a raw photo" + } else if ph.copy_of.is_some() { + "a virtual copy" + } else { + "already a DNG" + } + ])); + continue; + } + let packet = crate::sidecar::sidecar_packet(&ph, &s.catalog); + match write_dng_for(s, path, packet) { + Ok(out) => { + let file_name = Path::new(&out).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + // virtual copies of this photo follow it to the DNG + for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) { + ops.push(Op::Relink { + id: c.id, + file_name: file_name.clone(), + source: Source::File { path: out.clone() }, + format: Some("DNG".into()), + }); + } + ops.push(Op::Relink { id, file_name, source: Source::File { path: out.clone() }, format: Some("DNG".into()) }); + converted.push(json!({"id": id.0, "path": out, "original": path})); + } + Err(e) => skipped.push(json!([id.0, e])), + } + } + if !ops.is_empty() { + let n = converted.len(); + s.commit(&format!("Convert {n} Photo{} to DNG", if n == 1 { "" } else { "s" }), Op::Batch { ops }).map_err(|e| bad(C, e.to_string()))?; + } + Ok(json!({"converted": converted, "skipped": skipped})) +} + +pub fn specs() -> Vec { + vec![cmd!( + "photo.convertToDng", + "Convert to DNG", + ["Photo"], + None, + "{ids?} — write each raw photo as a lossless DNG next to it (settings embedded) and relink the photo to it; originals are kept → {converted: [{id, path, original}], skipped}", + has_selection, + convert + )] +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index cb7c5729..c301b850 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -636,7 +636,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, 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 ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}", always, |s, p| { let paths = strs(p, "paths"); @@ -681,6 +681,7 @@ pub fn specs() -> Vec { rename: str_param(p, "rename").map(str::to_string), rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize, metadata_preset, + convert_dng: super::bool_or(p, "dng", false), ..Default::default() }; let undo0 = s.undo.len(); diff --git a/crates/engine/src/cmd/missing.rs b/crates/engine/src/cmd/missing.rs index cb2b55cc..a005e2c2 100644 --- a/crates/engine/src/cmd/missing.rs +++ b/crates/engine/src/cmd/missing.rs @@ -27,7 +27,7 @@ pub fn missing(s: &Session) -> Vec<(PhotoId, String)> { fn relink_op(id: PhotoId, path: &str) -> Op { let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); - Op::Relink { id, file_name, source: Source::File { path: path.to_string() } } + Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None } } fn relink(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 802d81fa..6893d218 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -7,6 +7,7 @@ mod before; mod browse; mod color; +pub(crate) mod convert; mod develop; mod edit; mod export; @@ -120,6 +121,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(keywords::specs()); v.extend(manage::specs()); v.extend(previews::specs()); + v.extend(convert::specs()); v.extend(merge::specs()); v.extend(query::specs()); v.extend(xmp::specs()); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index ec23f841..40303c8c 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -62,6 +62,8 @@ pub struct ImportOptions { pub rename_start: usize, /// A metadata preset (by name) applied to every imported photo. pub metadata_preset: Option, + /// Copy: raws are copied as DNG (Copy as DNG). + pub convert_dng: bool, } /// How copies are filed in the destination. @@ -489,6 +491,19 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c if let Some(h) = &info.content_hash { by_hash.insert(h.clone(), id); } + // 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) => { + let _ = std::fs::remove_file(&stored); + stored = dng; + info.format = "DNG".into(); + } + Err(e) => log::warn!("import {path}: copy as DNG: {e}"), + } + } // a copy is catalogued under its new name let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4211c0b9..e392b080 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -219,6 +219,9 @@ impl Session { self.depth -= 1; if self.depth == 0 && was_active.is_some() && self.active() != was_active { self.previous_active = was_active; + if let Some(left) = was_active { + self.auto_version(left); + } } if r.is_ok() && spec.journal && self.depth == 0 { self.journal.push((id.to_string(), params.clone())); @@ -256,6 +259,30 @@ impl Session { Ok(()) } + /// Leaving photo `id` after editing it: keep its settings as an automatic version (when they + /// differ from its latest version; at most [`AUTO_VERSIONS`] auto versions, oldest dropped). + /// Saved with the library but not an undo step. + pub fn auto_version(&mut self, id: PhotoId) { + let Some(p) = self.catalog.photo(id) else { return }; + if !p.is_edited() || p.versions.last().is_some_and(|v| *v.settings == *p.develop) || self.interaction.is_some() { + return; + } + let mut versions = p.versions.clone(); + let created = (self.clock)(); + let name = lightcraft_catalog::dates::display_time(&created); + versions.push(lightcraft_catalog::Version { name, created, settings: p.develop.clone(), auto: true }); + let autos = versions.iter().filter(|v| v.auto).count(); + if autos > AUTO_VERSIONS + && let Some(i) = versions.iter().position(|v| v.auto) + { + versions.remove(i); + } + let op = Op::SetVersions { id, versions }; + if self.catalog.apply(op.clone()).is_ok() { + self.pending_log.push(op); + } + } + /// Fold the last `n` undo steps into one (commands that commit step by step because each op /// depends on the state the previous one left). pub fn merge_undo(&mut self, n: usize, label: &str) { @@ -485,6 +512,9 @@ impl Session { } } +/// Automatic versions kept per photo. +pub const AUTO_VERSIONS: usize = 20; + /// The parts of `new` that differ from `old` (objects recurse; anything else is taken whole). pub fn json_delta(old: &Value, new: &Value) -> Option { match (old, new) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 877fa2e2..e9cee067 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -558,3 +558,31 @@ fn color_range_sampling_selects_the_clicked_colour() { assert_eq!(s.execute("mask.sampleColor", &json!({"x": 0.5, "y": 0.5})).unwrap()["samples"].as_array().unwrap().len(), 1); assert!(s.execute("mask.sampleColor", &json!({"x": 1.5, "y": 0.5})).is_err()); } + +#[test] +fn leaving_an_edited_photo_keeps_an_auto_version() { + use lightcraft_catalog::PhotoId; + let mut s = demo(); + let ids: Vec = s.catalog.photos().take(2).map(|p| p.id.0).collect(); + s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap(); + let before = s.catalog.photo(PhotoId(ids[0])).unwrap().versions.len(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap(); + let undo = s.undo.len(); + s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap(); + let v = s.catalog.photo(PhotoId(ids[0])).unwrap().versions.clone(); + assert_eq!(v.len(), before + 1); + assert!(v.last().unwrap().auto && v.last().unwrap().settings.light.exposure == 0.4); + assert_eq!(s.undo.len(), undo, "not an undo step"); + // back and away again without changes: nothing new + s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap(); + s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap(); + assert_eq!(s.catalog.photo(PhotoId(ids[0])).unwrap().versions.len(), before + 1); + // at most AUTO_VERSIONS + for i in 0..(crate::AUTO_VERSIONS + 5) { + s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.01 * i as f64 + 0.5})).unwrap(); + s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap(); + } + let autos = s.catalog.photo(PhotoId(ids[0])).unwrap().versions.iter().filter(|v| v.auto).count(); + assert_eq!(autos, crate::AUTO_VERSIONS); +} diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index fffe7ee8..82cad6a9 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -271,3 +271,60 @@ fn all_metadata_lists_exif_and_xmp() { assert!(r["xmp"].as_array().unwrap().iter().any(|x| x["name"] == "dc:title" && x["value"] == "A title"), "the sidecar's fields: {r}"); let _ = std::fs::remove_dir_all(&dir); } + +/// Convert to DNG: a corpus raw (skipped without the corpus) becomes a DNG next to it with the +/// photo relinked and its edits kept; undo goes back to the original; non-raws are skipped. +#[test] +fn convert_raw_to_dng() { + let corpus = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(PathBuf::from) + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/nef-nikon-d5100-uncompressed.nef"); + let mut s = Session::new().with_fs(); + // a non-raw is skipped + let dir = temp_dir("todng"); + let png = dir.join("a.png"); + write_png(&png, 3); + s.execute("library.import", &json!({"paths": [png.to_string_lossy()]})).unwrap(); + let r = s.execute("photo.convertToDng", &json!({})).unwrap(); + assert_eq!((r["converted"].as_array().unwrap().len(), r["skipped"].as_array().unwrap().len()), (0, 1)); + if !corpus.exists() { + eprintln!("skip: {} absent", corpus.display()); + return; + } + let nef = dir.join("shot.nef"); + std::fs::copy(&corpus, &nef).unwrap(); + s.execute("library.import", &json!({"paths": [nef.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.6})).unwrap(); + let r = s.execute("photo.convertToDng", &json!({})).unwrap(); + assert_eq!(r["converted"].as_array().unwrap().len(), 1, "{r}"); + let p = s.catalog.photo(id).unwrap().clone(); + assert_eq!((p.file_name.as_str(), p.format.as_str()), ("shot.dng", "DNG")); + assert!(dir.join("shot.dng").exists() && nef.exists(), "the original is kept"); + assert_eq!(p.develop.light.exposure, 0.6, "edits kept"); + let bytes = std::fs::read(dir.join("shot.dng")).unwrap(); + assert_eq!(lightcraft_raw::probe(&bytes), Some(lightcraft_raw::RawFormat::Dng)); + assert!(s.render_now(id, 64, 64).is_ok(), "the DNG renders"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().file_name, "shot.nef"); + // Copy as DNG at import: only the DNG lands in the destination + let card = dir.join("card"); + std::fs::create_dir_all(&card).unwrap(); + std::fs::copy(&corpus, card.join("DSC_1.NEF")).unwrap(); + let dest = dir.join("dest"); + let mut s = Session::new().with_fs(); + let r = s + .execute( + "library.import", + &json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat", "dng": true}), + ) + .unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 1, "{r}"); + let names: Vec = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + assert_eq!(names, ["DSC_1.dng"]); + assert!(card.join("DSC_1.NEF").exists(), "the card is untouched"); + let id2 = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + assert_eq!(s.catalog.photo(id2).unwrap().format, "DNG"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 208aae99..d7c5dfd3 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -40,6 +40,8 @@ pub struct ImportDialog { pub rename: String, /// Metadata preset name ("" = none). pub metadata_preset: String, + /// Copy: raws are copied as DNG. + pub dng: bool, } impl ImportDialog { @@ -125,6 +127,9 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result params["rename"] = json!(d.rename.trim()); params["renameStart"] = json!(1); } + if d.dng { + params["dng"] = json!(true); + } } let total = queue.len(); app.import = Some(ImportTask { queue, total, params, undo0, ..Default::default() }); @@ -276,6 +281,10 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { } }); }); + field(ui, "Raw files", |ui| { + let r = ui.checkbox(&mut d.dng, "Copy as DNG"); + register(ui.ctx(), "check:importDng", r.rect); + }); field(ui, "Rename", |ui| { let r = ui.add(egui::TextEdit::singleline(&mut d.rename).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(f32::INFINITY)); register(ui.ctx(), "field:importRename", r.rect); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f71e4f86..202ea339 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -193,6 +193,30 @@ impl LightcraftApp { } /// Show a transient toast at the bottom of the canvas (like the reference app's HUD). + /// Advance a running slideshow (wrapping around at the end). + fn slideshow_tick(&mut self, ctx: &egui::Context) { + let Some((interval, due, paused)) = self.ui.slideshow else { return }; + if !self.ui.fullscreen { + self.ui.slideshow = None; + return; + } + if paused { + return; + } + let now = ctx.input(|i| i.time); + if now >= due { + let vis = self.session.visible_cloned(); + if let Some(cur) = self.session.active() + && !vis.is_empty() + { + let i = vis.iter().position(|x| *x == cur).map_or(0, |i| (i + 1) % vis.len()); + let _ = self.run("library.select", serde_json::json!({"ids": [vis[i].0]})); + } + self.ui.slideshow = Some((interval, now + interval, false)); + } + ctx.request_repaint_after(std::time::Duration::from_secs_f64((due - now).clamp(0.05, interval))); + } + /// Announce the start and end of a Build Previews run. fn preview_build_status(&mut self, ctx: &egui::Context) { use std::sync::atomic::Ordering; @@ -374,6 +398,7 @@ impl LightcraftApp { merge::poll(self, ctx); import::tick(self, ctx); self.preview_build_status(ctx); + self.slideshow_tick(ctx); self.session.persist_if_dirty(); self.collect_screenshots(ctx); self.issue_screenshots(ctx); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index faee428f..ffa693b3 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -108,6 +108,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.survey", "---", "view.leftPanel", + "view.photoCounts", "view.filmstrip", "view.histogram", "view.navigator", @@ -226,6 +227,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { let panel = |p: RightPanel| Some(u.right == p); match id { "develop.autoSync" => Some(app.session.auto_sync), + "view.photoCounts" => Some(u.show_counts), "view.photoGrid" => Some(u.view == ViewMode::PhotoGrid), "view.squareGrid" => Some(u.view == ViewMode::SquareGrid), "view.detail" => Some(u.view == ViewMode::Detail), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 17453e79..fe72dfbb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -81,6 +81,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), ("dialog.smartAlbum", "New Smart Album…", None, "File"), + ("view.photoCounts", "Show Photo Counts", None, "View"), + ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), ("dialog.allMetadata", "All Metadata…", None, "Photo"), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), @@ -107,6 +109,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), ("app.openLibrary", "Open Library…", None, "File"), ("app.about", "About LightCraft", None, "Help"), + ("app.systemInfo", "System Info…", None, "Help"), ("app.help", "LightCraft Help", Some("F1"), "Help"), ("app.discord", "Join the ArtCraft Discord…", None, "Help"), ("app.website", "LightCraft Website", None, "Help"), @@ -189,6 +192,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.show_counts = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.show_counts); + Ok(json!({"show": app.ui.show_counts})) + } "view.gridToggle" => { app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }; Ok(Value::Null) @@ -214,6 +221,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Option { + // {interval?: seconds (4)}: the photos in view, full screen, one after another + let interval = p.get("interval").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.5, 120.0); + if app.session.active().is_none() { + let first = app.session.visible_cloned().first().copied(); + match first { + Some(f) => { + let _ = app.run("library.select", json!({"ids": [f.0]})); + } + None => return Some(Err("no photos to show".into())), + } + } + let now = ctx.input(|i| i.time); + app.ui.slideshow = Some((interval, now + interval, false)); + app.ui.fullscreen = true; + app.ui.zoom = Zoom::Fit; + app.ui.tool.clear(); + let _ = app.session.end_interaction(); + app.toast(&ctx, "Slideshow · Space pauses · Esc ends"); + Ok(json!({"interval": interval})) + } "view.fullScreenPreview" => { app.ui.fullscreen = !app.ui.fullscreen; + if !app.ui.fullscreen { + app.ui.slideshow = None; + } if app.ui.fullscreen { app.ui.zoom = Zoom::Fit; app.ui.tool.clear(); @@ -637,6 +669,32 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + let info = app.session.execute("library.info", &json!({})).unwrap_or_default(); + let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(); + let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64); + let mut rows = vec![ + ("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()), + ("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)), + ("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())), + ("GPU".to_string(), gpu.unwrap_or_else(|| "none (CPU rendering)".into())), + ("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }), + ("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)), + ("Preview size".to_string(), format!("{} px", app.ui.settings.preview_edge)), + ("Photos".to_string(), info["photos"].to_string()), + ("Albums".to_string(), info["albums"].to_string()), + ]; + if let Some(dir) = info["dir"].as_str().or(info["path"].as_str()) { + rows.push(("Library".into(), dir.to_string())); + } + rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps))); + rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms))); + let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::>()); + if p.get("open").and_then(Value::as_bool).unwrap_or(true) { + app.ui.dialog = Some(Dialog::SystemInfo { rows }); + } + Ok(r) + } "app.shortcuts" => { app.ui.dialog = Some(Dialog::Shortcuts); Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 476cc43a..f0aa267f 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -306,6 +306,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let map = CanvasMap::new(&frame, img_rect); let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); info_overlay(app, &p, canvas, &photo); + // a fine grid while a transform (geometry) slider is dragged, to judge verticals + if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) { + let n = 12; + let stroke = Stroke::new(1.0, Color32::from_white_alpha(70)); + for i in 1..n { + let fx = img_rect.left() + img_rect.width() * i as f32 / n as f32; + p.line_segment([pos2(fx, img_rect.top()), pos2(fx, img_rect.bottom())], stroke); + } + let rows = ((n as f32) * img_rect.height() / img_rect.width()).round().max(2.0) as usize; + for i in 1..rows { + let fy = img_rect.top() + img_rect.height() * i as f32 / rows as f32; + p.line_segment([pos2(img_rect.left(), fy), pos2(img_rect.right(), fy)], stroke); + } + } match right { RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id), RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d), diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 7b3e412c..95c38839 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -31,6 +31,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::MergeKeywords { .. } => "Merge Keywords", Dialog::NewSmartAlbum { .. } => "Create Smart Album", Dialog::AllMetadata { .. } => "All Metadata", + Dialog::SystemInfo { .. } => "System Info", Dialog::SmartRules { id: None, .. } => "New Smart Album", Dialog::SmartRules { .. } => "Edit Smart Album", Dialog::AutoStack { .. } => "Auto-Stack by Capture Time", @@ -75,6 +76,19 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::RichText::new(format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"])).color(t.text_dim), ); } + Dialog::SystemInfo { rows } => { + egui::Grid::new("sysinfo").num_columns(2).spacing([16.0, 4.0]).striped(true).show(ui, |ui| { + for (k, v) in rows.iter() { + ui.label(egui::RichText::new(k).color(t.text_dim)); + ui.add(egui::Label::new(egui::RichText::new(v).color(t.text_label)).wrap()); + ui.end_row(); + } + }); + if ui.button("Copy to Clipboard").clicked() { + let text: String = rows.iter().map(|(k, v)| format!("{k}: {v}\n")).collect(); + ui.ctx().copy_text(text); + } + } Dialog::AllMetadata { title, rows, search } => { ui.label(egui::RichText::new(title.as_str()).color(t.text_label)); let r = ui.add(egui::TextEdit::singleline(search).hint_text("Filter fields").desired_width(f32::INFINITY)); @@ -725,7 +739,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("keyword.rename", json!({"from": from, "to": to})), Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), - Dialog::AllMetadata { .. } => Ok(serde_json::Value::Null), + Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } => Ok(serde_json::Value::Null), Dialog::SmartRules { id, name, rules } => { let name = if name.trim().is_empty() { "Smart Album".to_string() } else { name.trim().to_string() }; match id { diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index c1758cb7..b01d691a 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -23,12 +23,14 @@ pub fn apply_slider_out( ) { if out.drag_started && !out.reset { let _ = app.run("develop.beginInteraction", json!({"label": spec.label})); + app.ui.dragging_control = Some(spec.id.to_string()); } if let Some(v) = out.value { let _ = set(app, v); } if out.drag_stopped || out.reset { let _ = app.run("develop.endInteraction", json!({})); + app.ui.dragging_control = None; } } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 326c3243..04e6af29 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -42,7 +42,7 @@ fn row( t.font(13.5), if selected { t.text } else { t.text_label }, ); - if let Some(n) = count { + if let Some(n) = count.filter(|_| app.ui.show_counts) { ui.painter().text(pos2(r.right() - 18.0, r.center().y), Align2::RIGHT_CENTER, n.to_string(), t.font(12.5), t.text_dim); } let _ = app; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4ede1ad0..eb1ca5aa 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -140,6 +140,15 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } }; for (id, params) in aliased { + // Space pauses / resumes a slideshow + if id == "view.zoomToggle" + && let Some((interval, _, paused)) = app.ui.slideshow + { + let now = ctx.input(|i| i.time); + app.ui.slideshow = Some((interval, now + interval, !paused)); + app.toast(ctx, if paused { "Slideshow resumed" } else { "Slideshow paused" }); + continue; + } // flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey, // `advance` moves to the next candidate there if matches!(id, "photo.flag" | "photo.rate" | "photo.label") { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5f29f010..91f78b1d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -208,6 +208,11 @@ pub struct UiState { pub crop_overlay_orient: u8, pub crop_overlay: CropOverlay, pub show_filenames: bool, + /// Photo counts next to sources and albums in the left panel. + pub show_counts: bool, + /// The develop control whose slider is being dragged (geometry sliders show a grid). + #[serde(skip)] + pub dragging_control: Option, pub search: String, /// Focus the search field on the next frame (Edit → Find…). #[serde(skip)] @@ -255,6 +260,10 @@ pub struct UiState { pub auto_advance: bool, /// Full-screen preview (F): the photo alone on black, no chrome. pub fullscreen: bool, + /// A running slideshow (full screen): seconds per photo, when the next one is due (egui + /// time), paused. + #[serde(skip)] + pub slideshow: Option<(f64, f64, bool)>, /// Info overlay on the loupe. pub info_overlay: InfoOverlay, /// Navigator mini map in the loupe while zoomed in. @@ -353,6 +362,10 @@ pub enum Dialog { NewSmartAlbum { name: String, }, + /// Help ▸ System Info: (label, value) rows. + SystemInfo { + rows: Vec<(String, String)>, + }, /// Every metadata field of a photo's file (`photo.allMetadata`), filtered by `search`. AllMetadata { title: String, @@ -434,6 +447,8 @@ impl Default for UiState { crop_overlay: CropOverlay::Thirds, crop_overlay_orient: 0, show_filenames: true, + show_counts: true, + dragging_control: None, search: String::new(), focus_search: false, renaming_mask: None, @@ -459,6 +474,7 @@ impl Default for UiState { spots_threshold: 50.0, auto_advance: false, fullscreen: false, + slideshow: None, info_overlay: InfoOverlay::Off, navigator: true, settings: AppSettings::default(), diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index e7a15a5e..7af058f7 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -372,3 +372,49 @@ fn local_folder_tree_expands_and_browses() { let _ = std::fs::remove_dir_all(&base); h.settle(SETTLE); } + +#[test] +fn slideshow_advances_pauses_and_ends() { + let mut h = detail("panel.edit"); + let first = h.app.session.active(); + exec(&mut h, "view.slideshow", json!({"interval": 0.5})); + assert!(h.app.ui.fullscreen && h.app.ui.slideshow.is_some()); + // simulated time runs with the frames + let mut moved = false; + for _ in 0..200 { + h.step(); + if h.app.session.active() != first { + moved = true; + break; + } + } + assert!(moved, "the next photo comes up"); + // Space pauses: nothing moves + let r = h.request("ui.key", json!({"key": "space"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.slideshow.is_some_and(|s| s.2), "paused"); + let held = h.app.session.active(); + for _ in 0..120 { + h.step(); + } + assert_eq!(h.app.session.active(), held); + // Esc ends it + let r = h.request("ui.key", json!({"key": "escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(!h.app.ui.fullscreen && h.app.ui.slideshow.is_none()); + h.settle(SETTLE); +} + +#[test] +fn geometry_slider_drag_marks_the_grid() { + let mut h = detail("panel.crop"); + let spec = lightcraft_develop::controls::find("geometry.vertical").unwrap(); + let start = crate::widgets::SliderOut { value: None, drag_started: true, drag_stopped: false, reset: false }; + crate::panels::edit::apply_slider_out(&mut h.app, spec, start, |_, _| Ok(serde_json::Value::Null)); + assert_eq!(h.app.ui.dragging_control.as_deref(), Some("geometry.vertical")); + h.step(); + let stop = crate::widgets::SliderOut { value: None, drag_started: false, drag_stopped: true, reset: false }; + crate::panels::edit::apply_slider_out(&mut h.app, spec, stop, |_, _| Ok(serde_json::Value::Null)); + assert!(h.app.ui.dragging_control.is_none()); + h.settle(SETTLE); +} diff --git a/docs/parity.md b/docs/parity.md index cd8cfd7d..041ef3d3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -21,20 +21,20 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| -| A. Import (IMP) | 7 | 2 | 4 | 1 | 3/4 (75%) | 3/4 (75%) | -| B. Library management (LIB) | 19 | 0 | 6 | 2 | 9/9 (100%) | 9/9 (100%) | -| C. Views & navigation (VIEW) | 14 | 0 | 3 | 0 | 9/9 (100%) | 4/4 (100%) | -| D. Search & filter (FILT) | 9 | 1 | 3 | 0 | 4/4 (100%) | 4/4 (100%) | +| A. Import (IMP) | 8 | 2 | 3 | 1 | 3/4 (75%) | 3/4 (75%) | +| B. Library management (LIB) | 21 | 0 | 4 | 2 | 9/9 (100%) | 9/9 (100%) | +| C. Views & navigation (VIEW) | 15 | 0 | 2 | 0 | 9/9 (100%) | 4/4 (100%) | +| D. Search & filter (FILT) | 10 | 0 | 3 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | -| F. Edit panel — global adjustments (EDIT) | 41 | 1 | 6 | 1 | 28/28 (100%) | 13/14 (93%) | +| F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 6 | 0 | 4 | 0 | 3/3 (100%) | 2/3 (67%) | | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 6 | 1 | 3 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | | K. Masking (MASK) | 13 | 3 | 7 | 0 | 8/8 (100%) | 5/5 (100%) | | L. Presets (PRE) | 5 | 1 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | -| M. Versions & history (VER) | 4 | 0 | 1 | 0 | 1/1 (100%) | 3/3 (100%) | -| N. Copy / paste / sync (SYNC) | 4 | 1 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | +| M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | +| N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | | O. Merge (MERGE) | 4 | 0 | 0 | 0 | — | — | | P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | | Q. HDR (HDR) | 0 | 0 | 5 | 0 | — | — | @@ -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) | 13 | 0 | 4 | 1 | 8/8 (100%) | 5/5 (100%) | -| Y. Menus | 73 | 4 | 8 | 8 | 46/47 (98%) | 22/23 (96%) | +| Y. Menus | 76 | 2 | 7 | 8 | 46/47 (98%) | 22/23 (96%) | | Z. Keyboard shortcuts (desktop) | 73 | 3 | 4 | 1 | 49/52 (94%) | 22/23 (96%) | -| Lightroom Classic extras | 19 | 11 | 49 | 9 | — | 18/21 (86%) | -| **Total** | 345 | 31 | 124 | 37 | 193/198 (97%) | 132/144 (92%) | +| Lightroom Classic extras | 20 | 11 | 48 | 9 | — | 18/21 (86%) | +| **Total** | 357 | 27 | 116 | 37 | 193/198 (97%) | 132/144 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **72.1%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.4% of 144 · P2 18.4% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **74.1%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.4% of 144 · P2 24.7% of 158. ## Top gaps @@ -111,7 +111,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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), HEIC/AVIF decode | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | ⬜ | | | -| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ⬜ | `crates/raw/src/dngwrite.rs` | DNG writer exists, not wired to import | +| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched) | ## B. Library management (LIB) @@ -125,7 +125,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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-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 | ⬜ | | | +| LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | | LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `crates/ui-egui/src/shortcuts.rs` | | | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | @@ -142,7 +142,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | | | LR-LIB-CULL | Assisted culling | P2 | ⬜ | | | | LR-LIB-ACTIVITY | Comments & likes | OOS | 🚫 | | | -| LR-LIB-QUICKCOLL | Quick collection [Classic] | P2 | ⬜ | | | +| LR-LIB-QUICKCOLL | Quick collection [Classic] | P2 | ✅ | `cmd:album.toggleTarget`, `cmd:album.clearQuick` | B in the grids | | LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ✅ | `cmd:photo.virtualCopy`, `crates/engine/src/cmd/organize.rs` | “Copy N” badge, stacked with the original, same albums, no XMP writes; no “Set Copy as Master” | ## C. Views & navigation (VIEW) @@ -161,7 +161,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | -| LR-VIEW-SLIDESHOW | Slideshow | P2 | ⬜ | | | +| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ⬜ | | | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | | LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs` | no drag-to-adjust on the histogram | @@ -179,7 +179,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-FILT-TYPE | Type / edited filter | P1 | ✅ | `cmd:library.filter` (`kind`, `merged`, `edited`), `crates/ui-egui/src/panels/filterbar.rs`, `crates/catalog/src/query.rs` (`merged_kind`) | photos / raw / videos / HDR / panoramas / HDR panoramas (merge results by name), edited / unedited; no depth kind | | LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | `cmd:library.filter` (`keyword`), `crates/ui-egui/src/panels/filterbar.rs` | keyword picker | | LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | `cmd:library.filter` (`camera`, `lens`), `crates/ui-egui/src/panels/filterbar.rs` | camera and lens pickers | -| LR-FILT-LOCATION | Location filter | P2 | 🟡 | `cmd:library.filter` (`text`) | free-text match on the location field only | +| LR-FILT-LOCATION | Location filter | P2 | ✅ | `cmd:library.filter` (`text`, `ruleSet` field `location`) | free text, or a rule on location / city / state / country (smart albums, `library.filter`) | | LR-FILT-PEOPLE | People filter | P2 | ⬜ | | | | LR-FILT-CULL | Culling-score filters | P2 | ⬜ | | | | LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `crates/ui-egui/src/panels/bottombar.rs` | no colour-label or custom (manual) order | @@ -243,7 +243,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | | | LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | | LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`) | | -| LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ⬜ | | | +| LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged | | LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | | LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP | | LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled` | | @@ -351,7 +351,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. |---|---|---|---|---|---| | LR-VER-CREATE | Create version | P1 | ✅ | `cmd:version.create` | | | LR-VER-PANEL | Versions panel | P1 | ✅ | `cmd:panel.versions`, `cmd:version.create`, `cmd:version.restore`, `cmd:version.delete`, `cmd:version.rename`, `cmd:version.update`, `crates/ui-egui/src/panels/right.rs` (`versions`) | Named / Auto tabs, a thumbnail of each version's look, name and date, current marker; resting on a version previews it in the loupe; click restores, double-click renames; row menu (update, rename, set as before, delete). No automatic versions are created yet (LR-VER-AUTO) | -| LR-VER-AUTO | Automatic versions | P2 | ⬜ | | `auto` flag reserved in the model | +| LR-VER-AUTO | Automatic versions | P2 | ✅ | `crates/engine/src/lib.rs` (`auto_version`) | leaving an edited photo keeps its settings as an Auto version (when they differ from the latest version; 20 kept; not an undo step); Versions panel ▸ Auto | | LR-VER-HISTORY | Edit history | P1 | ✅ | `cmd:panel.activity`, `cmd:history.list`, `cmd:history.restore` | | | LR-VER-UNDO | Undo / redo | P0 | ✅ | `cmd:edit.undo`, `cmd:edit.redo` | | @@ -363,7 +363,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-SYNC-CHOOSE | Choose settings to copy | P0 | ✅ | `cmd:dialog.copySettings` | groups are coarser than per-slider | | LR-SYNC-PASTE | Paste to selection | P0 | ✅ | `cmd:develop.paste` | no separate "paste selected" (choose at copy time instead) | | LR-SYNC-SYNCBTN | Sync active → selected | P1 | ✅ | `cmd:develop.sync` | | -| LR-SYNC-PREVIOUS | Paste from previous / auto sync [Classic] | P2 | 🟡 | `cmd:develop.pastePrevious` | ⌥⌘V pastes the previously active photo's settings (copy groups); no auto sync | +| LR-SYNC-PREVIOUS | Paste from previous / auto sync [Classic] | P2 | ✅ | `cmd:develop.pastePrevious`, `cmd:develop.autoSync` | ⌥⌘V pastes the previously active photo's settings (copy groups); Auto Sync (⌥⇧⌘A) | ## O. Merge (MERGE) @@ -550,7 +550,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | 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 | +| MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | ✅ | `cmd:view.photoCounts` | View ▸ Show Photo Counts toggles the left panel's counts | | MENU-VIEW-HDR | HDR display options | P2 | ⬜ | | | | MENU-PHOTO-ADDTOALBUM | Add to album | P0 | ✅ | `cmd:album.addPhotos` | | | MENU-PHOTO-REMOVEFROMALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | @@ -574,13 +574,13 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-PHOTO-MOVETOCLOUD | Move/copy to cloud | OOS | 🚫 | | | | MENU-WINDOW-MINIMIZE | Minimize / zoom | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's Minimize (⌘M) and Zoom items at the top of Window | | MENU-WINDOW-PANELS | Panel switches | P0 | ✅ | `cmd:panel.edit`, `cmd:panel.crop`, `cmd:panel.remove`, `cmd:panel.masking`, `cmd:panel.presets`, `cmd:panel.versions` | | -| MENU-WINDOW-BRINGFRONT | Bring all to front | P2 | 🟡 | | platform window defaults (unverified) | +| MENU-WINDOW-BRINGFRONT | Bring all to front | P2 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's item at the end of Window | | MENU-HELP-HELP | Help | P2 | ✅ | `cmd:app.help` | opens the documentation | | MENU-HELP-TUTORIALS | Tutorials | OOS | 🚫 | | | | MENU-HELP-WHATSNEW | What's new | P2 | ⬜ | | | | MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | | | MENU-HELP-FEEDBACK | Send feedback | P2 | ⬜ | | | -| MENU-HELP-SYSINFO | System info | P2 | ⬜ | `cmd:library.info` | library info only | +| MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; JSON for agents (`open: false`) | | MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete | | MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu | | MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), move to a folder or the top level, rename, delete; smart albums: update rules | @@ -708,7 +708,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ⬜ | | | | 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-CONVERT | Convert to DNG | P2 | ⬜ | `crates/raw/src/dngwrite.rs` | writer exists, not exposed | +| 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 | ⬜ | | | | LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions |