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:
parent
1c4a00c35d
commit
0fafacfccd
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)]
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue