LR-VER-AUTO, LR-VIEW-SLIDESHOW, LRC-LIB-CONVERT and more: auto versions, slideshow, DNG

- 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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 22:49:39 -04:00
parent 1c4a00c35d
commit 0fafacfccd
22 changed files with 467 additions and 29 deletions

View File

@ -122,6 +122,8 @@ fn accelerator(sc: &str) -> Option<Accelerator> {
"'" => 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,

View File

@ -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<String>,
},
/// 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() {

View File

@ -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 `<stem>.dng` (then `<stem>-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<String, String> {
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<Value> {
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<CommandSpec> {
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
)]
}

View File

@ -636,7 +636,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
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();

View File

@ -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<Value> {

View File

@ -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());

View File

@ -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<String>,
/// 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);

View File

@ -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<Value> {
match (old, new) {

View File

@ -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<u64> = 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);
}

View File

@ -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<String> = 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);
}

View File

@ -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<Value, String>
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);

View File

@ -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);

View File

@ -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<bool> {
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),

View File

@ -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<Re
app.ui.view = ViewMode::SquareGrid;
Ok(Value::Null)
}
"view.photoCounts" => {
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<Re
app.ui.dialog = None;
} else if app.ui.fullscreen {
app.ui.fullscreen = false;
app.ui.slideshow = None;
} else if !app.ui.tool.is_empty() {
app.ui.tool.clear();
} else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) {
@ -223,8 +231,32 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
Ok(Value::Null)
}
"view.slideshow" => {
// {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<Re
app.ui.dialog = Some(Dialog::About);
Ok(Value::Null)
}
"app.systemInfo" => {
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::<Vec<_>>());
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)

View File

@ -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),

View File

@ -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<serde_jso
Dialog::RenameKeyword { from, to } => 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 {

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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") {

View File

@ -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<String>,
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(),

View File

@ -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);
}

View File

@ -21,20 +21,20 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
<!-- parity:summary -->
| 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.
<!-- /parity:summary -->
## 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 |