From f6fd65f6c1637ff82067852a5058018ad074c324 Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Sat, 17 Oct 2026 08:22:53 -0500 Subject: [PATCH 01/91] Never crash: Refuse an ERF or MEF whose raw strip runs past the file (#2119) --- crates/raw/src/vendor/erf.rs | 37 +++++++++++++++++++++++++++++++++++- crates/raw/src/vendor/mef.rs | 37 +++++++++++++++++++++++++++++++++++- docs/whats-new.md | 7 +++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/raw/src/vendor/erf.rs b/crates/raw/src/vendor/erf.rs index b9861ef38..7f88dae2a 100644 --- a/crates/raw/src/vendor/erf.rs +++ b/crates/raw/src/vendor/erf.rs @@ -95,8 +95,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { _ => Rect::new(0, 0, w, h), }; // the white balance is measured from the samples, so the header probe reads them too + // the strip length is the tag value, so a short file must fail before the sample buffer is sized from it let chunks = info.chunks(bytes.len() as u64); - let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Erf strip outside file".into()))?; + let src = chunks + .first() + .and_then(|c| chunk_bytes(bytes, c).filter(|s| s.len() as u64 == c.len)) + .ok_or_else(|| RawError::Corrupt("Erf strip outside file".into()))?; let mut d = vec![0u16; n]; unpack(src, &mut d); let white = super::white_from_data(&d, 12); @@ -196,4 +200,35 @@ mod tests { } } } + + /// Rewrite the raw IFD's height, rows per strip and strip length in place (the strip data stays as written). + fn declare(f: &mut [u8], rows: u32, strip_len: u32) { + let tiff = Tiff::parse(f).unwrap(); + let raw = &tiff.ifds[0].sub_ifds[0]; + let spots: Vec<_> = [(t::IMAGE_LENGTH, rows), (t::ROWS_PER_STRIP, rows), (t::STRIP_BYTE_COUNTS, strip_len)] + .into_iter() + .map(|(tag, v)| { + let e = raw.entries.iter().find(|e| e.tag == tag).unwrap(); + (e.offset as usize, matches!(e.value, Value::Short(_)), v) + }) + .collect(); + for (at, short, v) in spots { + if short { + f[at..at + 2].copy_from_slice(&u16::try_from(v).unwrap().to_be_bytes()); + } else { + f[at..at + 4].copy_from_slice(&v.to_be_bytes()); + } + } + } + + #[test] + fn a_strip_longer_than_the_file_is_an_error() { + // #2119: the declared strip length matched the size, so a few bytes of data were decoded as a full frame + let px: Vec = (0..40).map(|i| (i * 13 % 4096) as u16).collect(); + let mut f = sample(20, 2, &px); + declare(&mut f, 2000, 32 * 2000); + assert_eq!(crate::probe(&f), Some(RawFormat::Erf)); + assert!(matches!(crate::probe_info(&f), Err(RawError::Corrupt(_)))); + assert!(matches!(crate::decode(&f), Err(RawError::Corrupt(_)))); + } } diff --git a/crates/raw/src/vendor/mef.rs b/crates/raw/src/vendor/mef.rs index 207bc8871..30993ad72 100644 --- a/crates/raw/src/vendor/mef.rs +++ b/crates/raw/src/vendor/mef.rs @@ -87,8 +87,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { None => Rect::new(0, 0, w, h), }; // the white balance is measured from the samples, so the header probe reads them too + // the strip length is the tag value, so a short file must fail before the sample buffer is sized from it let chunks = info.chunks(bytes.len() as u64); - let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Mef strip outside file".into()))?; + let src = chunks + .first() + .and_then(|c| chunk_bytes(bytes, c).filter(|s| s.len() as u64 == c.len)) + .ok_or_else(|| RawError::Corrupt("Mef strip outside file".into()))?; let mut d = vec![0u16; n]; crate::unpack::unpack_msb(src, 12, &mut d); let white = super::white_from_data(&d, 12); @@ -184,4 +188,35 @@ mod tests { } } } + + /// Rewrite the raw IFD's height, rows per strip and strip length in place (the strip data stays as written). + fn declare(f: &mut [u8], rows: u32, strip_len: u32) { + let tiff = Tiff::parse(f).unwrap(); + let raw = &tiff.ifds[0].sub_ifds[0]; + let spots: Vec<_> = [(t::IMAGE_LENGTH, rows), (t::ROWS_PER_STRIP, rows), (t::STRIP_BYTE_COUNTS, strip_len)] + .into_iter() + .map(|(tag, v)| { + let e = raw.entries.iter().find(|e| e.tag == tag).unwrap(); + (e.offset as usize, matches!(e.value, Value::Short(_)), v) + }) + .collect(); + for (at, short, v) in spots { + if short { + f[at..at + 2].copy_from_slice(&u16::try_from(v).unwrap().to_be_bytes()); + } else { + f[at..at + 4].copy_from_slice(&v.to_be_bytes()); + } + } + } + + #[test] + fn a_strip_longer_than_the_file_is_an_error() { + // #2119: the declared strip length matched the size, so a few bytes of data were decoded as a full frame + let px: Vec = (0..40).map(|i| (i * 13 % 4096) as u16).collect(); + let mut f = sample(20, 2, &px); + declare(&mut f, 2000, 30 * 2000); + assert_eq!(crate::probe(&f), Some(RawFormat::Mef)); + assert!(matches!(crate::probe_info(&f), Err(RawError::Corrupt(_)))); + assert!(matches!(crate::decode(&f), Err(RawError::Corrupt(_)))); + } } diff --git a/docs/whats-new.md b/docs/whats-new.md index 97e1367a8..6d4a3fcba 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-17 + +### Short Epson and Mamiya raws are refused +- An Epson ERF or Mamiya MEF whose raw strip runs past the end of the file is now an error on import (issue #2119). + Before, the header probe trusted the declared strip length and sized its sample buffer from it, so a few KB of file + could make it allocate gigabytes. + ## 2026-10-14 ### Lightroom radial masks on the right side From 2b518b55fe65340a2bc62f3a271c8b7963022556 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:58:54 +0200 Subject: [PATCH 02/91] Reject non-finite values when parsing .cube LUTs Found while testing crates/pipeline/src/lut.rs:25 (Lut3d::parse_cube, the `three` helper) Co-Authored-By: Claude Opus 5.5 --- crates/pipeline/src/lut.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/pipeline/src/lut.rs b/crates/pipeline/src/lut.rs index b66c3444e..fccb34e8f 100644 --- a/crates/pipeline/src/lut.rs +++ b/crates/pipeline/src/lut.rs @@ -24,7 +24,7 @@ impl Lut3d { let mut data = Vec::new(); let three = |rest: &str| -> Option<[f32; 3]> { let v: Vec = rest.split_whitespace().filter_map(|x| x.parse().ok()).collect(); - (v.len() == 3).then(|| [v[0], v[1], v[2]]) + (v.len() == 3 && v.iter().all(|x| x.is_finite())).then(|| [v[0], v[1], v[2]]) }; for line in text.lines() { let l = line.trim(); @@ -140,4 +140,19 @@ mod tests { assert!(Lut3d::parse_cube("LUT_1D_SIZE 4\n0 0 0").is_err()); assert!(Lut3d::parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err(), "too few entries"); } + + #[test] + fn non_finite_entries_rejected() { + let bad_line = |body: &str| { + let e = Lut3d::parse_cube(body).expect_err(body); + assert!(e.starts_with("bad line"), "{e}"); + }; + bad_line(&format!("LUT_3D_SIZE 2\n{}", "-inf inf nan\n".repeat(8))); + bad_line(&format!("LUT_3D_SIZE 2\n{}", "1e39 0 0\n".repeat(8))); + bad_line(&format!("LUT_3D_SIZE 2\n-inf 0 0\n{}", "0 0 0\n".repeat(7))); + let ok = cube(|c| c, 2); + assert!(Lut3d::parse_cube(&ok).is_ok()); + assert!(Lut3d::parse_cube(&format!("DOMAIN_MIN nan 0 0\n{ok}")).is_err()); + assert!(Lut3d::parse_cube(&format!("DOMAIN_MAX inf 1 1\n{ok}")).is_err()); + } } From 4ef14cc4b3a97db1b56e24f0a101ce63fdbe7e8e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:38:36 +0200 Subject: [PATCH 03/91] Strip trailing NULs from non-UTF-8 IPTC text Found while testing crates/meta/src/iptc.rs:8 (decode) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/meta/src/iptc.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/meta/src/iptc.rs b/crates/meta/src/iptc.rs index 24955748f..391774287 100644 --- a/crates/meta/src/iptc.rs +++ b/crates/meta/src/iptc.rs @@ -8,8 +8,8 @@ use crate::{DateTime, Metadata}; fn decode(b: &[u8], utf8: bool) -> String { match std::str::from_utf8(b) { Ok(s) => s.trim_end_matches('\0').trim().to_string(), - Err(_) if utf8 => String::from_utf8_lossy(b).trim().to_string(), - Err(_) => b.iter().map(|&c| c as char).collect::().trim().to_string(), + Err(_) if utf8 => String::from_utf8_lossy(b).trim_end_matches('\0').trim().to_string(), + Err(_) => b.iter().map(|&c| c as char).collect::().trim_end_matches('\0').trim().to_string(), } } @@ -106,6 +106,20 @@ pub(crate) mod tests { assert_eq!(m.capture_time.unwrap().to_iso(), "2021-03-04T10:11:12+01:00"); } + #[test] + fn non_utf8_text_drops_trailing_nul() { + let mut v = Vec::new(); + ds(&mut v, 2, 5, &[0x43, 0xe9, 0x00]); + ds(&mut v, 2, 25, &[0x6b, 0xe9, 0x00]); + let m = parse_iptc(&v); + assert_eq!(m.title.as_deref(), Some("Cé")); + assert_eq!(m.keywords, vec!["ké".to_string()]); + let mut u = Vec::new(); + ds(&mut u, 1, 90, b"\x1b%G"); + ds(&mut u, 2, 5, &[0x43, 0xff, 0x00]); + assert_eq!(parse_iptc(&u).title.as_deref(), Some("C\u{fffd}")); + } + #[test] fn latin1_and_extended_and_garbage() { let mut v = Vec::new(); From 89e08db8b84a5dd11904bf7de39f6ee06144db49 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:50:11 +0000 Subject: [PATCH 04/91] Clamp negative signals to zero in hlg_decode Found while testing crates/color/src/transfer.rs:52 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/transfer.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/color/src/transfer.rs b/crates/color/src/transfer.rs index 8a7e4fdbf..72ff76fc7 100644 --- a/crates/color/src/transfer.rs +++ b/crates/color/src/transfer.rs @@ -49,6 +49,7 @@ pub fn hlg_encode(e: f64) -> f64 { } pub fn hlg_decode(s: f64) -> f64 { + let s = s.max(0.0); if s <= 0.5 { s * s / 3.0 } else { (((s - HLG_C) / HLG_A).exp() + HLG_B) / 12.0 } } @@ -112,6 +113,11 @@ mod tests { } } + #[test] + fn hlg_decode_clamps_negative() { + assert_eq!(hlg_decode(-0.5), 0.0); + } + #[test] fn pq_hlg_roundtrip() { for i in 0..=50 { From dd119ca0074c9ae5324f38c2b8e08c8e84b0c4f4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:01:52 +0000 Subject: [PATCH 05/91] Reject signed strings in Hash128::parse Found while testing crates/preview/src/hash.rs:21 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/preview/src/hash.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/preview/src/hash.rs b/crates/preview/src/hash.rs index e3ab51fe8..63650fd16 100644 --- a/crates/preview/src/hash.rs +++ b/crates/preview/src/hash.rs @@ -18,7 +18,8 @@ impl fmt::Display for Hash128 { impl Hash128 { pub fn parse(s: &str) -> Option { - (s.len() == 32).then(|| u128::from_str_radix(s, 16).ok().map(Hash128)).flatten() + // `from_str_radix` also accepts a leading '+', so check the digits explicitly. + (s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u128::from_str_radix(s, 16).ok().map(Hash128)).flatten() } } @@ -59,3 +60,16 @@ impl Hasher128 { pub fn hash_bytes(bytes: &[u8]) -> Hash128 { Hasher128::new().update(bytes).finish() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_rejects_signed_and_non_hex() { + assert_eq!(Hash128::parse(&format!("+{}", "1".repeat(31))), None); + assert_eq!(Hash128::parse(&format!("-{}", "1".repeat(31))), None); + let h = "0123456789abcdefABCDEF0123456789"; + assert_eq!(Hash128::parse(h).map(|x| x.to_string()), Some(h.to_lowercase())); + } +} From 8fe7198dbdac3d90ee0f6bd15f6150e5b4649611 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 21:20:42 +0000 Subject: [PATCH 06/91] LR-FILT-SEARCH-META: >= and <= in fielded search tokens `rating:>=3`, `iso:<=400`, `f:>=2.8` read the `=` as part of the number, the parse failed, and the token silently matched no photo. Parse the two-character operators first and compare inclusively; `>`, `<`, `=` and bare numbers are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqKgSkHi5MfTQFrZvDKBjm --- crates/catalog/src/query.rs | 24 +++++++++++++----------- crates/catalog/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++++ docs/parity-checklist.md | 4 ++-- docs/whats-new.md | 6 ++++++ 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index ff94268a8..7dea42e3f 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -20,7 +20,7 @@ pub enum RatingOp { #[serde(default, rename_all = "camelCase")] pub struct Filter { /// Free-text search: every token must match filename, title, caption, keywords, camera, lens, - /// location or format. Tokens like `rating:3`, `flag:pick`, `iso:>800`, `camera:x2` are fielded. + /// location or format. Tokens like `rating:3`, `rating:>=3`, `flag:pick`, `iso:>800`, `camera:x2` are fielded. pub text: String, pub rating: u8, pub rating_op: RatingOp, @@ -179,23 +179,25 @@ fn token_matches(p: &Photo, tok: &str) -> bool { // a token that starts with a quote is a phrase, even with a colon in it if let Some((field, quoted)) = t.split_once(':').filter(|_| !t.starts_with('"')) { let val = unquote(quoted); - let num = |s: &str| -> Option<(char, f64)> { - let (op, rest) = match s.chars().next()? { - c @ ('>' | '<' | '=') => (c, &s[1..]), - _ => ('=', s), - }; - rest.parse().ok().map(|v| (op, v)) + // `>=` / `<=` before `>` / `<`: otherwise `rating:>=3` reads `=3` as the number and matches nothing + let num = |s: &str| -> Option<(&str, f64)> { + let op = [">=", "<=", ">", "<", "="].into_iter().find(|op| s.starts_with(op)).unwrap_or(""); + s.get(op.len()..)?.parse().ok().map(|v| (op, v)) }; let cmp = |x: f64, s: &str| match num(s) { - Some(('>', v)) => x > v, - Some(('<', v)) => x < v, + Some((">", v)) => x > v, + Some(("<", v)) => x < v, + Some((">=", v)) => x > v || (x - v).abs() < 1e-9, + Some(("<=", v)) => x < v || (x - v).abs() < 1e-9, Some((_, v)) => (x - v).abs() < 1e-9, None => false, }; // Fields stored as `f32` compare at f32 precision: 2.8f32 widened to f64 is not 2.8. let cmp32 = |x: f32, s: &str| match num(s) { - Some(('>', v)) => x > v as f32, - Some(('<', v)) => x < v as f32, + Some((">", v)) => x > v as f32, + Some(("<", v)) => x < v as f32, + Some((">=", v)) => x >= v as f32, + Some(("<=", v)) => x <= v as f32, Some((_, v)) => x == v as f32, None => false, }; diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 579175086..c27d1514b 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -163,6 +163,42 @@ fn search_tokens_match_f32_aperture_and_focal() { assert!(q("f:>2.8").is_empty(), "f:>2.8"); assert!(q("focal:<5.6").is_empty(), "focal:<5.6"); assert_eq!(q("f:>2.7"), vec![a], "f:>2.7"); + assert_eq!(q("f:>=2.8"), vec![a], "f:>=2.8 counts the photo's own value"); + assert_eq!(q("focal:<=5.6"), vec![a], "focal:<=5.6"); + assert!(q("f:>=2.9").is_empty(), "f:>=2.9"); + assert!(q("focal:<=5.5").is_empty(), "focal:<=5.5"); +} + +/// `>=` and `<=` in a fielded token are the inclusive comparisons. They used to read the `=` as +/// part of the number, so `rating:>=3` or `iso:<=400` silently matched no photo at all. +#[test] +fn search_tokens_compare_inclusively() { + let mut c = Catalog::new(); + let (two, three, four) = ( + photo(&mut c, "two.jpg", "2026-05-01T10:00:00"), + photo(&mut c, "three.jpg", "2026-05-02T10:00:00"), + photo(&mut c, "four.jpg", "2026-05-03T10:00:00"), + ); + for (id, rating, iso) in [(two, 2, 200), (three, 3, 400), (four, 4, 800)] { + c.apply(Op::SetRating { id, rating }).unwrap(); + let mut m = c.photo(id).unwrap().meta.clone(); + m.iso = Some(iso); + c.apply(Op::SetMeta { id, meta: Box::new(m) }).unwrap(); + } + let q = |t: &str| { + let mut ids = c.query(&Filter { text: t.into(), ..Default::default() }, &Sort::default()); + ids.sort(); + ids + }; + assert_eq!(q("rating:>=3"), vec![three, four]); + assert_eq!(q("stars:<=3"), vec![two, three]); + assert_eq!(q("iso:>=400"), vec![three, four]); + assert_eq!(q("iso:<=400"), vec![two, three]); + assert_eq!(q("rating:>3"), vec![four], "strict comparisons are unchanged"); + assert_eq!(q("rating:=3"), vec![three]); + assert_eq!(q("rating:3"), vec![three]); + assert!(q("rating:>=").is_empty(), "an operator without a number matches nothing"); + assert!(q("rating:=>3").is_empty(), "not an operator"); } /// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 080680b9f..90cc314f7 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -146,7 +146,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-FILT-SEARCH-META | Text search | P0 | ✅ | `cmd:library.filter` (`text`), `crates/ui-egui/src/panels/topbar.rs`, `crates/catalog/src/query.rs` | fielded tokens (`rating:3`, `iso:>800`, `camera:…`); plain words also match the people named on faces; double quotes keep several words in one token (`person:"Jane Doe"`, `"jane doe"`, issue #1449); no suggestions dropdown | +| LR-FILT-SEARCH-META | Text search | P0 | ✅ | `cmd:library.filter` (`text`), `crates/ui-egui/src/panels/topbar.rs`, `crates/catalog/src/query.rs` | fielded tokens (`rating:3`, `iso:>800`, `rating:>=3` / `iso:<=400`, `camera:…`); plain words also match the people named on faces; double quotes keep several words in one token (`person:"Jane Doe"`, `"jane doe"`, issue #1449); no suggestions dropdown | | LR-FILT-SEARCH-AI | Natural-language search | P2 | ⬜ | | | | LR-FILT-RATING | Rating filter | P0 | ✅ | `cmd:library.filter` (`rating`, `ratingOp`), `crates/ui-egui/src/panels/filterbar.rs` | ≥ / = / ≤ stars in the filter bar (`cmd:view.filterBar`) | | LR-FILT-FLAG | Flag filter | P0 | ✅ | `cmd:library.filter` (`flag`), `crates/ui-egui/src/panels/filterbar.rs` | picked / rejected / unflagged (one at a time) | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..cacd78b3d 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,11 @@ # What's new in LightCraft +## 2026-10-25 + +### `>=` and `<=` work in the search box +- Fielded search tokens now take inclusive comparisons: `rating:>=3`, `iso:<=400`, `f:>=2.8`, `focal:<=50`. Before, + the `=` was read as part of the number, so such a search quietly showed no photos at all. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock From e4da1d1db74a24030b09902af3efefcf0dd08cfa Mon Sep 17 00:00:00 2001 From: softwaregames1 <167585946+softwaregames1@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:25:07 +0000 Subject: [PATCH 07/91] Update crates/catalog/src/query.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- crates/catalog/src/query.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 7dea42e3f..94cfa1764 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -187,8 +187,8 @@ fn token_matches(p: &Photo, tok: &str) -> bool { let cmp = |x: f64, s: &str| match num(s) { Some((">", v)) => x > v, Some(("<", v)) => x < v, - Some((">=", v)) => x > v || (x - v).abs() < 1e-9, - Some(("<=", v)) => x < v || (x - v).abs() < 1e-9, + Some((">=", v)) => x >= v, + Some(("<=", v)) => x <= v, Some((_, v)) => (x - v).abs() < 1e-9, None => false, }; From f790e057787b3e8c05a373ad4c7195f2ea7cb967 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:32:10 +0000 Subject: [PATCH 08/91] Keep hue below 360 in rgb_to_hsv and rgb_to_hsl Found while testing crates/color/src/perceptual.rs:129 (hue_of) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/perceptual.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/color/src/perceptual.rs b/crates/color/src/perceptual.rs index 78824321c..fb5b89b75 100644 --- a/crates/color/src/perceptual.rs +++ b/crates/color/src/perceptual.rs @@ -132,7 +132,9 @@ fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 { } else { (c[0] - c[1]) / d + 4.0 }; - h * 60.0 + let deg = h * 60.0; + // rem_euclid can round up to exactly 6.0 in f32; keep the range [0, 360). + if deg >= 360.0 { 0.0 } else { deg } } #[cfg(test)] @@ -173,4 +175,11 @@ mod tests { } assert_eq!(rgb_to_hsv([0.0, 1.0, 0.0])[0], 120.0); } + + #[test] + fn hue_below_360_for_tiny_blue() { + let c = [1.0, 0.0, 1e-9]; + assert!(rgb_to_hsv(c)[0] < 360.0, "hsv hue {}", rgb_to_hsv(c)[0]); + assert!(rgb_to_hsl(c)[0] < 360.0, "hsl hue {}", rgb_to_hsl(c)[0]); + } } From 26fc9c1fa4dd136ced965270d2f546b0883cd49f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:55:02 +0000 Subject: [PATCH 09/91] Report a photo's custom white balance left out of an imported look Fixes #2938 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/preset_import.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index f1d787ae0..d1dbc946c 100644 --- a/crates/engine/src/preset_import.rs +++ b/crates/engine/src/preset_import.rs @@ -502,12 +502,16 @@ pub fn group_from_dir(dir: &str) -> Option { } pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option, from_photo: bool) -> Option { - let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), crate::crs::Target::Any, crate::crs_masks::DEFAULT_ASPECT); + let (mut settings, mut unmapped) = crate::crs::to_partial_report(props, Some(values), crate::crs::Target::Any, crate::crs_masks::DEFAULT_ASPECT); if from_photo && let Some(o) = settings.as_object_mut() { // a photo's own framing and absolute white balance don't belong in a look o.remove("crop"); o.remove("geometry"); - if o.get("wb").and_then(|w| w.get("mode")).and_then(Value::as_str).is_none_or(|m| m == "custom" || m == "asShot") { + let mode = o.get("wb").and_then(|w| w.get("mode")).and_then(Value::as_str); + if mode == Some("custom") { + unmapped.push("Temperature, Tint (custom white balance of a photo: not kept in a look)".into()); + } + if mode.is_none_or(|m| m == "custom" || m == "asShot") { o.remove("wb"); } } @@ -750,6 +754,23 @@ text]], z = ZSTR "loc" }"#, assert!(read_presets("plain.jpg", b"\xff\xd8 no xmp", None).is_err()); } + #[test] + fn photo_custom_white_balance_left_out_is_reported() { + // issue #2938: a photo's custom Kelvin that the look leaves out is reported + let photo = |wb: &str| { + let mut file = b"\xff\xd8\xff\xe1 http://ns.adobe.com/xap/1.0/\0".to_vec(); + file.extend_from_slice(XMP.replace(r#"crs:WhiteBalance="As Shot" crs:Temperature="5600""#, wb).as_bytes()); + read_presets("/x/repro.jpg", &file, None).unwrap().remove(0) + }; + let custom = photo(r#"crs:WhiteBalance="Custom" crs:Temperature="8000" crs:Tint="+16""#); + assert!(custom.unmapped.iter().any(|u| u.starts_with("Temperature, Tint (")), "{:?}", custom.unmapped); + // a named white balance stays, unreported; As Shot is no loss + let daylight = photo(r#"crs:WhiteBalance="Daylight""#); + assert_eq!(daylight.preset.settings["wb"]["mode"], json!("daylight")); + assert!(daylight.unmapped.is_empty(), "{:?}", daylight.unmapped); + assert!(photo(r#"crs:WhiteBalance="As Shot""#).unmapped.is_empty()); + } + #[test] fn zip_bombs_are_refused() { // an entry unpacking past the limit (3 MB of zeros pack into a few kB): refused, named From 7ebbb17a8252291229386a4e1d31214467a10c10 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 15:31:09 -0700 Subject: [PATCH 10/91] Originals: on Windows a hard link to an original counts as that original safe_file::same_file compared length and canonical paths on every platform other than Unix. On Windows canonicalize returns a hard link's own path, so a hard link to a library original under another name was not the same file, and pin_write_target accepted it as a screenshot destination. For this reason the engine test write_targets_pin_absent_paths_and_recheck_the_current_library failed on Windows. On Windows same_file now first compares the volume serial number and file index through the same-file crate (already in Cargo.lock through walkdir), then falls back to the resolved-path comparison. The catalog crate takes same-file as a Windows-only dependency. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/catalog/Cargo.toml | 3 +++ crates/catalog/src/safe_file.rs | 26 +++++++++++++++++++++++++- 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index b69533958..455758fb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3220,6 +3220,7 @@ dependencies = [ "lightcraft-raw", "log", "proptest", + "same-file", "serde", "serde_json", "thiserror 2.0.21", diff --git a/Cargo.toml b/Cargo.toml index 5c0418d30..fc2d90d2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ log = "0.4" # All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" +same-file = "1" # lightcraft-catalog reads a file's volume serial number and file index with it on Windows proptest = "1" bytemuck = { version = "1", features = ["derive"] } crc32fast = "1" diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 27273d5c4..e9df63166 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -20,6 +20,9 @@ log = { workspace = true } chrono = { workspace = true } web-time = { workspace = true } +[target.'cfg(windows)'.dependencies] +same-file = { workspace = true } + [dev-dependencies] lightcraft-geom = { workspace = true } proptest = { workspace = true } diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index fa067de0f..83139756c 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -23,6 +23,7 @@ //! replacing anything** (a hard link, which fails on a taken name; where the volume has no hard //! links, an exclusive create that claims the name, then a rename over that placeholder). //! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix, +//! volume serial number + file index on Windows, //! resolved paths elsewhere), for "is this target an original?" checks. use std::cell::Cell; @@ -267,7 +268,8 @@ fn same_content(path: &Path, data: &[u8]) -> io::Result { } } -/// Do `a` and `b` name the same existing file (through links, `..`, case-insensitive names)? +/// Do `a` and `b` name the same existing file (through hard links, symlinks, `..`, +/// case-insensitive names)? pub fn same_file(a: &Path, b: &Path) -> bool { let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return false }; #[cfg(unix)] @@ -277,6 +279,14 @@ pub fn same_file(a: &Path, b: &Path) -> bool { } #[cfg(not(unix))] { + // A hard link has its own path but shares the volume serial number and file index with + // its original. A volume that does not report them is compared by resolved path below. + #[cfg(windows)] + { + if ::same_file::is_same_file(a, b).unwrap_or(false) { + return true; + } + } if ma.len() != mb.len() { return false; } @@ -397,6 +407,20 @@ mod tests { let _ = fs::remove_dir_all(&d); } + #[test] + fn same_file_sees_a_hard_link_under_another_name() { + let d = temp("same-link"); + let p = d.join("IMG_1.jpg"); + fs::write(&p, b"original").unwrap(); + let linked = d.join("linked.png"); + fs::hard_link(&p, &linked).unwrap(); + fs::write(d.join("copy.png"), b"original").unwrap(); + assert!(same_file(&p, &linked), "a hard link is the same file"); + assert!(same_file(&linked, &p)); + assert!(!same_file(&p, &d.join("copy.png")), "a copy with the same content and length is another file"); + let _ = fs::remove_dir_all(&d); + } + #[test] fn same_file_sees_through_other_spellings() { let d = temp("same"); From a97f19605c9fbfa9e52379faf5ff339742b71e41 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 22:32:37 -0700 Subject: [PATCH 11/91] Originals: compare lengths before the Windows file identity On ReFS two files can share the 64-bit file index that same_file compares, so same_file now rejects files of different lengths before it checks the volume serial number and file index. The hard-link test skips with a recorded reason when the temporary folder's volume has no hard links (FAT/exFAT, some shares), the same errors publish_new treats as a volume without hard links. --- Cargo.lock | 1 + crates/catalog/Cargo.toml | 1 + crates/catalog/src/safe_file.rs | 18 ++++++++++++++---- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 455758fb0..3e9acf98c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3218,6 +3218,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-meta", "lightcraft-raw", + "lightcraft-testkit", "log", "proptest", "same-file", diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index e9df63166..7a9c80439 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -25,4 +25,5 @@ same-file = { workspace = true } [dev-dependencies] lightcraft-geom = { workspace = true } +lightcraft-testkit = { workspace = true } proptest = { workspace = true } diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index 83139756c..f7737bf78 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -279,17 +279,18 @@ pub fn same_file(a: &Path, b: &Path) -> bool { } #[cfg(not(unix))] { + if ma.len() != mb.len() { + return false; + } // A hard link has its own path but shares the volume serial number and file index with // its original. A volume that does not report them is compared by resolved path below. + // On ReFS two files can share the 64-bit file index, so the lengths are compared first. #[cfg(windows)] { if ::same_file::is_same_file(a, b).unwrap_or(false) { return true; } } - if ma.len() != mb.len() { - return false; - } match (fs::canonicalize(a), fs::canonicalize(b)) { (Ok(x), Ok(y)) => x == y || x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(), _ => false, @@ -413,7 +414,16 @@ mod tests { let p = d.join("IMG_1.jpg"); fs::write(&p, b"original").unwrap(); let linked = d.join("linked.png"); - fs::hard_link(&p, &linked).unwrap(); + match fs::hard_link(&p, &linked) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => panic!("{}: {e}", linked.display()), + // no hard links on this volume (FAT/exFAT, some shares), as in `publish_new` + Err(e) => { + let _ = fs::remove_dir_all(&d); + lightcraft_testkit::skip!(System, "no hard links in {}: {e}", d.display()); + return; + } + } fs::write(d.join("copy.png"), b"original").unwrap(); assert!(same_file(&p, &linked), "a hard link is the same file"); assert!(same_file(&linked, &p)); From ff913833de19cbb017c79c596cc31d3cbe75f2b4 Mon Sep 17 00:00:00 2001 From: Florent Siegenthaler <64897087+Atalantis@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:24:28 +0100 Subject: [PATCH 12/91] Declare the UI languages to AppKit so the open and save panels are translated --- packaging/macos/Info.plist.in | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index fd87f601e..f93cfda0e 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -6,6 +6,29 @@ CFBundleDevelopmentRegion en + + CFBundleLocalizations + + en + cs + de + es + fi + fr + id + it + ja + ka + pl + pt-BR + ru + sv + uk + vi + zh-Hans + zh-Hant + CFBundleDisplayName LightCraft CFBundleName From 1bb11ff321a1385647cecce8058f43da4dd94dfc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 03:03:39 -0700 Subject: [PATCH 13/91] Local: on Windows the folder trees leave out hidden folders such as AppData The Local trees left out folders whose names start with a dot, but on Windows a folder is hidden by its attribute. Home's tree listed AppData, and browsing a folder inside the temp folder opened Home's tree down through AppData\Local\Temp, drawing one row for every folder there on each frame. On a machine with 148,400 folders in Temp each frame took about 4 s, and tests_panels::local_photos_cannot_be_added_to_albums_until_they_join_my_photos timed out on its ui.drag. list_subfolders now also skips folders with FILE_ATTRIBUTE_HIDDEN set. Test: panels::left::tests::subfolders_leave_out_hidden_folders. --- crates/ui-egui/src/panels/left.rs | 37 ++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 5a81edfab..3304b4b8f 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1040,7 +1040,7 @@ fn list_subfolders(path: &str) -> Vec<(String, String)> { let mut v: Vec<(String, String)> = std::fs::read_dir(path) .map(|rd| { rd.flatten() - .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()) && !hidden_attribute(e)) .filter_map(|e| { let name = e.file_name().to_string_lossy().to_string(); (!name.starts_with('.')).then(|| (name, e.path().to_string_lossy().to_string())) @@ -1052,6 +1052,20 @@ fn list_subfolders(path: &str) -> Vec<(String, String)> { v } +/// On Windows a folder is hidden by an attribute (AppData in the home folder is), not by a +/// leading dot. +#[cfg(windows)] +fn hidden_attribute(e: &std::fs::DirEntry) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2; + e.metadata().is_ok_and(|m| m.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0) +} + +#[cfg(not(windows))] +fn hidden_attribute(_: &std::fs::DirEntry) -> bool { + false +} + /// How many [`fs_cached`] answers for `ctx` are being worked out right now. Rows appear (and /// the sidebar below them moves) when they land, so the headless driver counts them as pending /// work and waits for them before acting on widget positions. @@ -2370,6 +2384,27 @@ mod tests { assert_eq!((names(&l.places), l.owner), (vec!["Pictures"], Some(0))); } + /// A folder tree leaves out hidden subfolders: a leading dot everywhere, and on Windows the + /// hidden attribute, so Home's tree does not list AppData and open down into its temp folder. + #[test] + fn subfolders_leave_out_hidden_folders() { + let dir = std::env::temp_dir().join(format!("lc-ui-hidden-subfolders-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + for name in ["Shown", ".dot", "Hidden"] { + std::fs::create_dir_all(dir.join(name)).unwrap(); + } + let expected = if cfg!(windows) { + let status = std::process::Command::new("attrib").arg("+h").arg(dir.join("Hidden")).status().unwrap(); + assert!(status.success(), "attrib +h: {status}"); + vec!["Shown"] + } else { + vec!["Hidden", "Shown"] + }; + let listed = super::list_subfolders(&dir.to_string_lossy()); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(listed.iter().map(|(n, _)| n.as_str()).collect::>(), expected); + } + #[test] fn a_local_folder_lines_up_with_the_rows_above_and_its_triangle_stays_left_of_the_icon() { use crate::Services; From 545ed92d6a1b28ed29117b43b7b8f7f1e0094cd1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 07:16:41 -0700 Subject: [PATCH 14/91] Originals: refuse a screenshot target whose file identity cannot be read On Windows the identity check opens both files to read the volume serial number and file index. When that open failed (another process holds the file without sharing it, or the file cannot be read), the hard link was treated as an ordinary file. The screenshot target check now refuses the target in that case. A missing original is still not a match, and other callers of same_file keep the current comparison. --- crates/catalog/src/safe_file.rs | 30 ++++++++++++---- crates/engine/src/originals.rs | 63 ++++++++++++++++++++++++++------- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index f7737bf78..b0974961f 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -271,30 +271,46 @@ fn same_content(path: &Path, data: &[u8]) -> io::Result { /// Do `a` and `b` name the same existing file (through hard links, symlinks, `..`, /// case-insensitive names)? pub fn same_file(a: &Path, b: &Path) -> bool { - let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return false }; + compare_files(a, b, false).unwrap_or(false) +} + +/// [`same_file`] for a check that must not let an unknown identity pass: `Err` when both paths +/// exist with the same length but the file identity of either cannot be read (on Windows, when +/// either file cannot be opened). A path that does not exist is still `Ok(false)`. +pub fn same_file_strict(a: &Path, b: &Path) -> io::Result { + compare_files(a, b, true) +} + +fn compare_files(a: &Path, b: &Path, strict: bool) -> io::Result { + #[cfg(not(windows))] + let _ = strict; + let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return Ok(false) }; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; - ma.dev() == mb.dev() && ma.ino() == mb.ino() + Ok(ma.dev() == mb.dev() && ma.ino() == mb.ino()) } #[cfg(not(unix))] { if ma.len() != mb.len() { - return false; + return Ok(false); } // A hard link has its own path but shares the volume serial number and file index with // its original. A volume that does not report them is compared by resolved path below. // On ReFS two files can share the 64-bit file index, so the lengths are compared first. #[cfg(windows)] { - if ::same_file::is_same_file(a, b).unwrap_or(false) { - return true; + match ::same_file::is_same_file(a, b) { + Ok(true) => return Ok(true), + Ok(false) => {} + Err(e) if strict => return Err(e), + Err(_) => {} } } - match (fs::canonicalize(a), fs::canonicalize(b)) { + Ok(match (fs::canonicalize(a), fs::canonicalize(b)) { (Ok(x), Ok(y)) => x == y || x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(), _ => false, - } + }) } } diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs index 890e98f81..e20a9378b 100644 --- a/crates/engine/src/originals.rs +++ b/crates/engine/src/originals.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; #[cfg(not(any(unix, windows)))] use std::time::SystemTime; -use lightcraft_catalog::safe_file::same_file; +use lightcraft_catalog::safe_file::{same_file, same_file_strict}; use lightcraft_catalog::{Catalog, Source}; /// The directory entry a screenshot is about to write, pinned at request admission. @@ -147,14 +147,30 @@ impl OriginalGuard { if let Some(n) = std::fs::canonicalize(target).ok().and_then(|c| c.file_name().map(|n| n.to_string_lossy().to_lowercase())) { names.push(n); } + // The identity check refuses a target whose file identity cannot be read, so an unreadable + // hard link to an original is not taken for an ordinary file. + let refuse_if_same = |p: &Path, what: &str| -> Result<(), String> { + let same = if hard_links { + same_file_strict(p, target).map_err(|e| { + format!( + "{}: LightCraft cannot tell whether this is {what} in the library ({e}) and never writes over an original (choose another folder or file name)", + target.display() + ) + })? + } else { + same_file(p, target) + }; + if same { + return Err(format!( + "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", + target.display() + )); + } + Ok(()) + }; for n in &names { for (p, what) in self.by_name.get(n).into_iter().flatten() { - if same_file(p, target) { - return Err(format!( - "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", - target.display() - )); - } + refuse_if_same(p, what)?; } } // An imported hard link or symlink can have a different name from its original. A write @@ -166,12 +182,7 @@ impl OriginalGuard { if !hard_links && std::fs::read_link(p).is_err() { continue; } - if same_file(p, target) { - return Err(format!( - "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", - target.display() - )); - } + refuse_if_same(p, what)?; } Ok(()) } @@ -339,4 +350,30 @@ mod tests { } let _ = std::fs::remove_dir_all(&d); } + + #[cfg(windows)] + #[test] + fn a_hard_link_whose_identity_cannot_be_read_is_refused() { + use std::os::windows::fs::OpenOptionsExt; + let d = std::env::temp_dir().join(format!("lc-unreadable-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let mut s = crate::Session::new(); + let original = d.join("IMG_1.jpg"); + std::fs::write(&original, b"original").unwrap(); + let photo = Photo::new(PhotoId(1), Source::File { path: original.to_string_lossy().to_string() }, "IMG_1.jpg", "JPEG", 10, 10, "2026-01-01"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + let linked = d.join("shot.png"); + std::fs::hard_link(&original, &linked).unwrap(); + + // A handle that shares nothing makes every other open of the file fail, so the file + // identity of either name cannot be read while it is held. + let held = std::fs::OpenOptions::new().read(true).share_mode(0).open(&original).unwrap(); + let refused = s.pin_write_target(&linked.to_string_lossy()).unwrap_err(); + assert!(refused.contains("original"), "{refused}"); + drop(held); + + assert!(s.pin_write_target(&linked.to_string_lossy()).unwrap_err().contains("original")); + let _ = std::fs::remove_dir_all(&d); + } } From 86f2ba81eb5fe5fec35606dca597ce116ad77927 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 11:01:51 -0700 Subject: [PATCH 15/91] Originals: refuse a screenshot target when an original cannot be queried same_file_strict returned false when the metadata of either path could not be read, so a hard link to an original in a folder the process cannot enter was accepted as a screenshot target. It now returns the error unless the path does not exist. --- crates/catalog/src/safe_file.rs | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index b0974961f..9b8bf5bcd 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -274,17 +274,22 @@ pub fn same_file(a: &Path, b: &Path) -> bool { compare_files(a, b, false).unwrap_or(false) } -/// [`same_file`] for a check that must not let an unknown identity pass: `Err` when both paths -/// exist with the same length but the file identity of either cannot be read (on Windows, when -/// either file cannot be opened). A path that does not exist is still `Ok(false)`. +/// [`same_file`] for a check that must not let an unknown identity pass: `Err` when the metadata +/// of either path cannot be read for a reason other than the path not existing (for example a +/// folder it cannot enter), or when both paths exist with the same length but the file identity +/// of either cannot be read (on Windows, when either file cannot be opened). A path that does not +/// exist is still `Ok(false)`. pub fn same_file_strict(a: &Path, b: &Path) -> io::Result { compare_files(a, b, true) } fn compare_files(a: &Path, b: &Path, strict: bool) -> io::Result { - #[cfg(not(windows))] - let _ = strict; - let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return Ok(false) }; + let missing = |e: &io::Error| matches!(e.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory); + let (ma, mb) = match (fs::metadata(a), fs::metadata(b)) { + (Ok(ma), Ok(mb)) => (ma, mb), + (Err(e), _) | (_, Err(e)) if strict && !missing(&e) => return Err(e), + _ => return Ok(false), + }; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; @@ -459,4 +464,20 @@ mod tests { assert!(!same_file(&p, &d.join("missing.jpg"))); let _ = fs::remove_dir_all(&d); } + + #[test] + fn strict_comparison_reports_a_path_it_cannot_query() { + let d = temp("strict-query"); + let p = d.join("x.jpg"); + fs::write(&p, b"x").unwrap(); + // A NUL byte makes the metadata lookup fail with an error other than "not found" on + // every platform, as a folder the process cannot enter does. + let unreadable = Path::new("IMG_1\0.jpg"); + assert!(same_file_strict(unreadable, &p).is_err()); + assert!(same_file_strict(&p, unreadable).is_err()); + assert!(!same_file(unreadable, &p), "the lenient comparison still answers false"); + assert!(!same_file_strict(&d.join("missing.jpg"), &p).unwrap(), "a missing original is another file"); + assert!(!same_file_strict(&p.join("under-a-file.jpg"), &p).unwrap()); + let _ = fs::remove_dir_all(&d); + } } From 4826fff7346f831d5bd7d17ef3218ac3dd7e1012 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 11:34:06 -0700 Subject: [PATCH 16/91] Originals: a Windows file index of 0 does not identify a file same_file::is_same_file treats two files with the same volume serial number and file index as one file, also when both report index 0. The Windows comparison now reads both identities through winapi-util and accepts a match only when both file indexes are nonzero; otherwise the resolved paths are compared, as for a volume without file indexes. --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/catalog/Cargo.toml | 2 +- crates/catalog/src/safe_file.rs | 33 +++++++++++++++++++++++++++++---- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e9acf98c..0fcf8a35c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3221,11 +3221,11 @@ dependencies = [ "lightcraft-testkit", "log", "proptest", - "same-file", "serde", "serde_json", "thiserror 2.0.21", "web-time", + "winapi-util", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fc2d90d2e..4e122eb24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ log = "0.4" # All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" -same-file = "1" # lightcraft-catalog reads a file's volume serial number and file index with it on Windows +winapi-util = "0.1" # lightcraft-catalog reads a file's volume serial number and file index with it on Windows proptest = "1" bytemuck = { version = "1", features = ["derive"] } crc32fast = "1" diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 7a9c80439..b12e18803 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -21,7 +21,7 @@ chrono = { workspace = true } web-time = { workspace = true } [target.'cfg(windows)'.dependencies] -same-file = { workspace = true } +winapi-util = { workspace = true } [dev-dependencies] lightcraft-geom = { workspace = true } diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index 9b8bf5bcd..bc05d4139 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -301,13 +301,13 @@ fn compare_files(a: &Path, b: &Path, strict: bool) -> io::Result { return Ok(false); } // A hard link has its own path but shares the volume serial number and file index with - // its original. A volume that does not report them is compared by resolved path below. + // its original. A volume that reports file index 0 is compared by resolved path below. // On ReFS two files can share the 64-bit file index, so the lengths are compared first. #[cfg(windows)] { - match ::same_file::is_same_file(a, b) { - Ok(true) => return Ok(true), - Ok(false) => {} + match file_ids(a, b) { + Ok((ia, ib)) if same_file_id(ia, ib) => return Ok(true), + Ok(_) => {} Err(e) if strict => return Err(e), Err(_) => {} } @@ -319,6 +319,22 @@ fn compare_files(a: &Path, b: &Path, strict: bool) -> io::Result { } } +/// The volume serial number and file index of `a` and `b`, read while both files are open. +#[cfg(windows)] +fn file_ids(a: &Path, b: &Path) -> io::Result<((u64, u64), (u64, u64))> { + use winapi_util::{Handle, file::information}; + let (ha, hb) = (Handle::from_path_any(a)?, Handle::from_path_any(b)?); + let (ia, ib) = (information(&ha)?, information(&hb)?); + Ok(((ia.volume_serial_number(), ia.file_index()), (ib.volume_serial_number(), ib.file_index()))) +} + +/// Whether two (volume serial number, file index) pairs name one file. A file index of 0 is not +/// an identity and matches nothing. +#[cfg(any(windows, test))] +fn same_file_id(a: (u64, u64), b: (u64, u64)) -> bool { + a.1 != 0 && b.1 != 0 && a == b +} + #[cfg(test)] mod tests { use super::*; @@ -480,4 +496,13 @@ mod tests { assert!(!same_file_strict(&p.join("under-a-file.jpg"), &p).unwrap()); let _ = fs::remove_dir_all(&d); } + + #[test] + fn a_zero_file_index_is_not_a_file_identity() { + assert!(same_file_id((7, 42), (7, 42))); + assert!(!same_file_id((7, 42), (7, 43))); + assert!(!same_file_id((7, 42), (8, 42))); + assert!(!same_file_id((7, 0), (7, 0)), "two files on a volume that reports index 0 are not the same file"); + assert!(!same_file_id((0, 0), (0, 0))); + } } From 58f26e02ffb9a28a3252d89be088d0acefeac07e Mon Sep 17 00:00:00 2001 From: Kirill M <176898419+JaffarSk24@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:57:07 +0100 Subject: [PATCH 17/91] i18n(ru): 216 strings the Russian catalog was missing Print and slideshow templates, mask effects and effect presets, external editing, display profiles and HDR, the face model and background task rows, and the smart album Yes/No values. Written from the English meaning by a native speaker. --- crates/ui-egui/locales/ru.json | 218 ++++++++++++++++++++++++++++++++- 1 file changed, 217 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 306804df0..969304fdc 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -6,6 +6,7 @@ "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Фильтр скрывает фото в этом представлении: измените его или сбросьте (Вид → Сбросить фильтры)", "A one-time download of about": "Однократная загрузка около", "A pen's pressure sets the size and flow of the brush": "Нажим пера задаёт размер и поток кисти", + "A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "Слайд показывается заданное время, включая его появление. Нулевое затухание означает резкую смену.", "About LightCraft": "О программе LightCraft", "Activity": "Активность", "Actual size": "Реальный размер", @@ -13,9 +14,13 @@ "Add Catchlight": "Добавить блик в глазах", "Add Folder to Local": "Добавить папку в «Локальное»", "Add Point Color Sample": "Добавить точечный образец цвета", + "Add Print Cell": "Добавить ячейку печати", "Add Red Eye Correction": "Добавить коррекцию красных глаз", "Add Remove Spot": "Добавить удаление пятна", "Add Selected Photos": "Добавить выбранные фото", + "Add a Face Recognition Model": "Добавить модель распознавания лиц", + "Add a cell": "Добавить ячейку", + "Add a model file…": "Добавить файл модели…", "Add a rule (⌥-click: a group)": "Добавить условие (⌥+щелчок: группу)", "Add in place": "Добавить на месте", "Add in place: the library references the files where they are; nothing is copied or moved.": "Добавить на месте: библиотека ссылается на файлы там, где они лежат; ничего не копируется и не перемещается.", @@ -26,7 +31,192 @@ "Add to Album": "Добавить в альбом", "Add to Favorites": "Добавить в избранное", "Add to My Photos": "Добавить в «Мои фото»", + "Already installed. Installing again is harmless.": "Уже установлено. Повторная установка ничего не испортит.", + "Also the masks of the same name on the other selected photos": "Также маски с тем же именем на других выбранных фото", + "Apple Photos Imports": "Импорт из Apple Photos", + "Apply Mask Effect": "Применить эффект маски", + "Apply Print Template": "Применить шаблон печати", + "Apply Slideshow Template": "Применить шаблон слайд-шоу", + "Apply to All Masks": "Применить ко всем маскам", + "Arrange Print Cell": "Упорядочить ячейку печати", + "Auto Layout": "Автомакет", + "Background Tasks": "Фоновые задачи", "Can't be moved here": "Сюда переместить нельзя", + "Cancel Background Task": "Отменить фоновую задачу", + "Choose Layout Image…": "Выбрать изображение макета…", + "Choose Monitor Profile": "Выбрать профиль монитора", + "Choose Profile…": "Выбрать профиль…", + "Choose a group": "Выберите группу", + "Choose at least one setting to include": "Выберите хотя бы один параметр", + "Clear Print Layout": "Очистить макет печати", + "Click a cell to move, resize or arrange it": "Щёлкните ячейку, чтобы переместить её, изменить размер или порядок", + "Click to name this face": "Щёлкните, чтобы назвать это лицо", + "Color Overlay": "Цветное наложение", + "Color Overlay on B&W": "Цветное наложение на Ч/Б", + "Color space: Rec. 2020 HLG (HDR AVIF)": "Цветовое пространство: Rec. 2020 HLG (HDR AVIF)", + "Color wheel": "Цветовой круг", + "Combine with the existing person": "Объединить с существующим человеком", + "Contact Sheet PDF…": "Контактный лист PDF…", + "Copy Settings…": "Копировать настройки…", + "Create Keyword": "Создать ключевое слово", + "Curve channel": "Канал кривой", + "Cycle Flag": "Переключить флаг", + "Cycle Tool Overlay": "Переключить наложение инструмента", + "Decrease Flag Status": "Понизить статус флага", + "Decrease Grid Thumbnail Size": "Уменьшить миниатюры сетки", + "Delete Cell": "Удалить ячейку", + "Delete Mask Effect": "Удалить эффект маски", + "Delete Print Cell": "Удалить ячейку печати", + "Delete Print Template": "Удалить шаблон печати", + "Delete Slideshow Template": "Удалить шаблон слайд-шоу", + "Delete…": "Удалить…", + "Display": "Дисплей", + "Display Profile": "Профиль дисплея", + "Display profile not used": "Профиль дисплея не используется", + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "Перетащите, чтобы задать тон и насыщенность. Удерживайте Shift, чтобы сохранить тон, Cmd/Ctrl, чтобы сохранить насыщенность, Alt/Option для точной настройки. Двойной щелчок сбрасывает.", + "Duplicate Mask Effect": "Дублировать эффект маски", + "Edit In": "Редактировать в", + "Edit Keyword": "Изменить ключевое слово", + "Edit in HDR: highlights above SDR white, for HDR exports and displays": "Правка в HDR: света выше белого SDR, для экспорта и дисплеев HDR", + "Edit in Other Editor": "Редактировать в другом редакторе", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "«Редактировать в другом редакторе» (⌥⌘E) открывает копию в этом приложении (на macOS это имя приложения, в других системах путь к программе).", + "End Slideshow": "Завершить слайд-шоу", + "Enter to confirm, Esc to cancel": "Enter подтверждает, Esc отменяет", + "Export Contact Sheet PDF": "Экспорт контактного листа PDF", + "Export Effect Presets": "Экспортировать пресеты эффектов", + "Export Effect Presets…": "Экспортировать пресеты эффектов…", + "Export Mask Effects": "Экспортировать эффекты масок", + "External Editing": "Внешнее редактирование", + "Face Models on the GPU": "Модели лиц на ГП", + "Face models (ONNX)": "Модели лиц (ONNX)", + "Fade": "Затухание", + "Fade duration": "Длительность затухания", + "Favorite": "Избранное", + "Favorite Mask Effect": "Избранный эффект маски", + "Filters": "Фильтры", + "Find Blemishes": "Найти дефекты", + "Found automatically": "Найдено автоматически", + "Group (optional)": "Группа (необязательно)", + "HDR Headroom": "Запас HDR", + "Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied.": "Выделены ползунки, которые меняет эта маска. Остальные ползунки при применении пресета остаются как есть.", + "ICC Profiles": "Профили ICC", + "Image on Black": "Изображение на чёрном", + "Image on White": "Изображение на белом", + "Import Mask Effects": "Импортировать эффекты масок", + "Import Slideshow Template": "Импорт шаблона слайд-шоу", + "Include Amount": "Включить количество", + "Include brush size, feather and flow": "Включить размер, растушёвку и поток кисти", + "Include selected photos": "Включить выбранные фото", + "Increase Flag Status": "Повысить статус флага", + "Increase Grid Thumbnail Size": "Увеличить миниатюры сетки", + "Keyword Info": "Сведения о ключевом слове", + "LUT-based": "На основе LUT", + "Layout Image Matte": "Подложка изображения макета", + "Layout Image Opacity": "Непрозрачность изображения макета", + "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.": "Слева: стандартный диапазон (SDR) до белого SDR. Справа: диапазон HDR, по одной полосе на каждую ступень выше белого SDR (оранжевая линия показывает запас HDR). Когда включена визуализация диапазона HDR, полосы окрашиваются в её цвета.", + "LightCraft Effect Presets": "Пресеты эффектов LightCraft", + "Manage Effect Presets…": "Управление пресетами эффектов…", + "Manage Presets…": "Управление пресетами…", + "Managed by Printer (Adobe RGB)": "Управляет принтер (Adobe RGB)", + "Managed by Printer (sRGB)": "Управляет принтер (sRGB)", + "Mask": "Маска", + "Mask Effect Presets": "Пресеты эффектов масок", + "Masks keep their sliders. Export presets first to keep a copy.": "Маски сохранят свои ползунки. Чтобы сохранить копию, сначала экспортируйте пресеты.", + "Monitor profile": "Профиль монитора", + "Move Keyword": "Переместить ключевое слово", + "Move Mask Effect to Group": "Переместить эффект маски в группу", + "Move Print Cell": "Переместить ячейку печати", + "Move Print Cell to Page": "Переместить ячейку печати на страницу", + "Move to Group…": "Переместить в группу…", + "Name the preset": "Назовите пресет", + "New Print Page": "Новая страница печати", + "New group — Return": "Новая группа: Return", + "New spot (Return adds it here)": "Новая точка (Return добавляет её сюда)", + "Next History Step": "Следующий шаг истории", + "Next Slide": "Следующий слайд", + "Next page": "Следующая страница", + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "Nikon High Efficiency NEF (HE / HE★) использует сжатие intoPIX TicoRAW. У LightCraft пока нет лицензии на этот кодек, поэтому доступен только встроенный JPEG-просмотр камеры, а не необработанные данные сенсора. Чтобы редактировать RAW-данные, снимайте в NEF со сжатием без потерь или преобразуйте имеющиеся файлы HE в DNG лицензированным конвертером.", + "No Matte": "Без подложки", + "No change": "Без изменений", + "No effect matches": "Подходящих эффектов нет", + "No effect presets yet": "Пресетов эффектов пока нет", + "None (the display is treated as sRGB)": "Нет (дисплей считается sRGB)", + "One photo fills each page inside the margins.": "Одно фото заполняет каждую страницу в пределах полей.", + "Open Original in Other Editor (RAW, no copy)": "Открыть оригинал в другом редакторе (RAW, без копии)", + "Open Settings": "Открыть настройки", + "Other editor": "Другой редактор", + "Overlay color": "Цвет наложения", + "Page": "Страница", + "Pause / Resume Slideshow": "Пауза / продолжение слайд-шоу", + "Photo Above": "Фото выше", + "Photo Below": "Фото ниже", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.": "Фото › Редактировать в › Редактировать в PhotoCraft (⇧⌘E или щелчок правой кнопкой по фото) создаёт копию с вашими правками, объединяет её в стопку с оригиналом и открывает в PhotoCraft. Сохраните там, и копия обновится здесь. Если копия в формате PSD, PhotoCraft сохраняет прямо в неё. Оставьте расположение пустым, если не пользуетесь переносной копией.", + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "Фото показываются через ICC-профиль монитора, поэтому цвета верны на дисплеях с широким охватом и на откалиброванных дисплеях, а режим «Детализация» использует весь цветовой охват дисплея. Гистограмму, кэши просмотров и экспорт это не затрагивает.", + "Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "Фото останутся во «Всех фото». При удалении папки удаляются и её альбомы. Это действие можно отменить.", + "Previous History Step": "Предыдущий шаг истории", + "Previous Slide": "Предыдущий слайд", + "Previous page": "Предыдущая страница", + "Primaries (x, y)": "Основные цвета (x, y)", + "Print Settings": "Параметры печати", + "Print Templates": "Шаблоны печати", + "Print to PDF": "Печать в PDF", + "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "Профили, назначенные в настройках цвета рабочего стола, обычно лежат в ~/.local/share/icc.", + "Progressive": "Прогрессивный", + "Put New Keywords Inside": "Добавлять новые ключевые слова внутрь", + "Read Focus Points in the Background": "Читать точки фокусировки в фоне", + "Recheck Preview-Only Raws": "Перепроверить RAW, доступные только как просмотр", + "Remove Folder Color Label": "Убрать цветовую метку папки", + "Remove region": "Удалить область", + "Rename Mask Effect": "Переименовать эффект маски", + "Rename Mask Effect Group": "Переименовать группу эффектов масок", + "Rename Person": "Переименовать человека", + "Rename Person…": "Переименовать человека…", + "Rename Slideshow Template": "Переименовать шаблон слайд-шоу", + "Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).": "Готовит следующие слайды, пока показывается текущий, чтобы переходы не ждали. Выключено: каждый слайд готовится, когда подходит его очередь (меньше памяти и работы).", + "Reset Print Settings": "Сбросить параметры печати", + "Reset Sliders": "Сбросить ползунки", + "Resize Print Cell": "Изменить размер ячейки печати", + "Resize region": "Изменить размер области", + "SDR Brightness": "Яркость SDR", + "SDR Clarity": "Чёткость SDR", + "SDR Contrast": "Контрастность SDR", + "SDR Highlights": "Света SDR", + "SDR Shadows": "Тени SDR", + "SDR Whites": "Белые SDR", + "Save Current Settings as New Effect Preset…": "Сохранить текущие настройки как новый пресет эффекта…", + "Save Current Settings as New Preset…": "Сохранить текущие настройки как новый пресет…", + "Save Mask Effect": "Сохранить эффект маски", + "Save Print Template": "Сохранить шаблон печати", + "Save Slideshow Template": "Сохранить шаблон слайд-шоу", + "Scan Folder for Changes": "Проверить папку на изменения", + "Search effects": "Поиск эффектов", + "Select Only Active Photo": "Выбрать только активное фото", + "Select a mask to apply or update presets.": "Выберите маску, чтобы применить или обновить пресеты.", + "Select all": "Выбрать все", + "Select none": "Снять выбор", + "Set Effect for New Masks": "Задать эффект для новых масок", + "Set Folder Color Label": "Назначить цветовую метку папке", + "Set Print Cell Photo": "Задать фото ячейки печати", + "Set Print Settings": "Задать параметры печати", + "Show / hide": "Показать / скрыть", + "Show in Compare": "Показать в сравнении", + "Show mask": "Показать маску", + "Show photos": "Показать фото", + "Show this wheel's effect": "Показать действие этого круга", + "Slide duration": "Длительность слайда", + "Slideshow Export Status": "Состояние экспорта слайд-шоу", + "Slideshow Layout Settings": "Настройки макета слайд-шоу", + "Slideshow Options…": "Параметры слайд-шоу…", + "Slideshow Overlay Settings": "Настройки наложений слайд-шоу", + "Slideshow Playback Settings": "Настройки воспроизведения слайд-шоу", + "Slideshow Slide Preview": "Предпросмотр слайда", + "Slideshow Title Settings": "Настройки титров слайд-шоу", + "Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library.": "«Слайд-шоу» в меню «Вид» показывает фото текущего вида, начиная с выбранного. Эти настройки сохраняются в библиотеке.", + "Targeted adjustment on: drag up or down on the photo · Esc to stop": "Целевая коррекция включена: тяните по фото вверх или вниз · Esc для выхода", + "Template name": "Название шаблона", + "The layout image can't be shown": "Не удаётся показать изображение макета", + "Their photos in the grid": "Их фото в сетке", + "This page setup is not valid": "Эти параметры страницы недопустимы", "This row covers more than one disk: drop on a folder inside it": "Эта строка охватывает несколько дисков: перетащите на папку внутри неё", "Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальных фото ещё нет в библиотеке: сначала «Добавить в «Мои фото»», затем перемещайте", "Add to Selected Photos": "Добавить к выбранным фото", @@ -784,8 +974,28 @@ "No new presets": "Новых пресетов нет", "No photo selected": "Фото не выбрано", "No photos": "Нет фото", + "Thumbnail size": "Размер миниатюр", + "Title bar": "Строка заголовка", + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "Чтобы передать этому редактору сам исходный файл (RAW без копии, например для DxO PhotoLab), щёлкните фото правой кнопкой → Редактировать в → Открыть оригинал в другом редакторе (RAW, без копии).", + "Tool Overlay": "Наложение инструмента", + "Ungrouped": "Без группы", + "Unknown date": "Дата неизвестна", + "Unnamed face": "Лицо без имени", + "Update Mask Effect": "Обновить эффект маски", + "Update Slideshow Template": "Обновить шаблон слайд-шоу", + "Update to Current Process": "Обновить до текущей версии обработки", + "Use Screen Profile": "Использовать профиль экрана", + "Use sRGB": "Использовать sRGB", + "Visualize HDR": "Показать диапазон HDR", + "When pins show on the photo (Shift+H cycles)": "Когда показывать булавки на фото (Shift+H переключает)", + "White on Black": "Белое на чёрном", + "Write Waiting XMP Sidecars": "Записать ожидающие XMP-файлы", "Your library is empty": "Библиотека пуста", "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Импортированные фото появятся здесь. Ничего не копируется и не перемещается, пока вы сами этого не выберете.", + "cells": "яч.", + "cells overlap": "ячейки перекрываются", + "current edits": "текущие правки", + "or drop a .onnx file on the window": "или перетащите файл .onnx в окно", "or drop photos and folders into this window": "или перетащите фото и папки в это окно", "Import photos to get started": "Импортируйте фото, чтобы начать", "or drop photos into this window": "или перетащите фото в это окно", @@ -1371,7 +1581,12 @@ "not available here": "здесь недоступно", "of the following rules:": "из следующих условий:", "saved in": "сохранено в", + "size": "размер", + "smart album value|No": "Нет", + "smart album value|Yes": "Да", + "source": "источник", "this photo": "это фото", + "this screen's profile": "профиль этого экрана", "− harder border · + softer border": "− жёстче край · + мягче край", "Back to this person": "Вернуться к этому человеку", "AI Denoise": "ИИ-шумоподавление", @@ -2349,5 +2564,6 @@ "Import dropped files without review": "Импортировать перетащенные файлы без проверки", "An import is running": "Импорт уже выполняется", "An import is running. Drop photos when it has finished": "Импорт уже выполняется. Перетащите фото, когда он закончится", - "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото" + "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото", + "★ Favorites": "★ Избранное" } From 9dd7736666289c2033b46a55b7afda0c7a7938cb Mon Sep 17 00:00:00 2001 From: Kirill M <176898419+JaffarSk24@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:15:11 +0100 Subject: [PATCH 18/91] i18n(ru): printer-managed labels and the grading wheel's eye tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Managed by Printer" reads as a mode label now (Управляется принтером), "Show this wheel's effect" says эффект, and its pair "Hide this wheel's effect", which no catalog had, is added. --- crates/ui-egui/locales/ru.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 969304fdc..f740aaed9 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -98,6 +98,7 @@ "Found automatically": "Найдено автоматически", "Group (optional)": "Группа (необязательно)", "HDR Headroom": "Запас HDR", + "Hide this wheel's effect": "Скрыть эффект этого круга", "Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied.": "Выделены ползунки, которые меняет эта маска. Остальные ползунки при применении пресета остаются как есть.", "ICC Profiles": "Профили ICC", "Image on Black": "Изображение на чёрном", @@ -117,8 +118,8 @@ "LightCraft Effect Presets": "Пресеты эффектов LightCraft", "Manage Effect Presets…": "Управление пресетами эффектов…", "Manage Presets…": "Управление пресетами…", - "Managed by Printer (Adobe RGB)": "Управляет принтер (Adobe RGB)", - "Managed by Printer (sRGB)": "Управляет принтер (sRGB)", + "Managed by Printer (Adobe RGB)": "Управляется принтером (Adobe RGB)", + "Managed by Printer (sRGB)": "Управляется принтером (sRGB)", "Mask": "Маска", "Mask Effect Presets": "Пресеты эффектов масок", "Masks keep their sliders. Export presets first to keep a copy.": "Маски сохранят свои ползунки. Чтобы сохранить копию, сначала экспортируйте пресеты.", @@ -202,7 +203,7 @@ "Show in Compare": "Показать в сравнении", "Show mask": "Показать маску", "Show photos": "Показать фото", - "Show this wheel's effect": "Показать действие этого круга", + "Show this wheel's effect": "Показать эффект этого круга", "Slide duration": "Длительность слайда", "Slideshow Export Status": "Состояние экспорта слайд-шоу", "Slideshow Layout Settings": "Настройки макета слайд-шоу", From ea9130676b8945f0a3e8273b6f680b0e7bd33686 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 26 Oct 2026 22:18:43 +0000 Subject: [PATCH 19/91] feat(installer): default to a per-user ArtCraft folder --- .github/workflows/packaging-lint.yml | 19 ++ packaging/windows/INSTALLATION.md | 35 ++++ packaging/windows/check-install-policy.ps1 | 195 +++++++++++++++++++++ packaging/windows/install-policy.json | 35 ++++ packaging/windows/install-policy.wxi | 54 ++++++ packaging/windows/lightcraft.wxs | 82 +++------ packaging/windows/package.ps1 | 2 + packaging/windows/test-install-folder.ps1 | 11 +- packaging/windows/test-install-policy.ps1 | 54 ++++++ 9 files changed, 424 insertions(+), 63 deletions(-) create mode 100644 packaging/windows/INSTALLATION.md create mode 100644 packaging/windows/check-install-policy.ps1 create mode 100644 packaging/windows/install-policy.json create mode 100644 packaging/windows/install-policy.wxi create mode 100644 packaging/windows/test-install-policy.ps1 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 71d3b3063..04af18005 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -37,3 +37,22 @@ jobs: run: | ./packaging/windows/test-install-folder.ps1 -Arch x64 ./packaging/windows/test-install-folder.ps1 -Arch x86 + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 000000000..982a5b420 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 000000000..dd2895643 --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,195 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + if (-not $explicit) { + if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + foreach ($choice in @('user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 000000000..197b2fd8f --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,35 @@ +{ + "id": "lightcraft", + "name": "LightCraft", + "upgradeCode": "229BB552-5EFD-479A-BA86-8CB6D926390C", + "sources": [ + "lightcraft.wxs" + ], + "icon": "assets/app-icon/lightcraft.ico", + "localization": [], + "extensions": [ + "UI", + "Util" + ], + "payloads": [ + "FONT-LICENCES.txt", + "NOTICE", + "lightcraft-cli.exe", + "lightcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\lightcraft.exe", + "", + "[#LightcraftExe]" + ], + [ + "-1", + "Software\\LightCraft", + "InstallFolder", + "[INSTALLFOLDER]" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 000000000..afe75ee14 --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/lightcraft.wxs b/packaging/windows/lightcraft.wxs index 9e23e5430..4c5a01a2f 100644 --- a/packaging/windows/lightcraft.wxs +++ b/packaging/windows/lightcraft.wxs @@ -1,30 +1,11 @@ - - + + - + @@ -34,7 +15,9 @@ - + + + - + - + - @@ -96,8 +72,7 @@ - + @@ -108,8 +83,7 @@ event, so silent and passive installs never run it. Return="ignore": a failed launch must not turn a successful install into an error. --> - + @@ -152,35 +126,26 @@ whose OK validates too. --> - - - + + + - - + + - - + + - + - + @@ -194,13 +159,12 @@ - + - - + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 110d8b60c..9cd0da6a2 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -147,6 +147,8 @@ Invoke-Native 'wix build' { -d "UiDialogBmp=$UiDialogBmp" -d "UiBannerBmp=$UiBannerBmp" ` -o $Msi } +# Check the compiled scope, path and registration contract before signing. +& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi diff --git a/packaging/windows/test-install-folder.ps1 b/packaging/windows/test-install-folder.ps1 index be7733cdc..fcb8fdd10 100644 --- a/packaging/windows/test-install-folder.ps1 +++ b/packaging/windows/test-install-folder.ps1 @@ -41,7 +41,10 @@ try { $ns.AddNamespace('w', 'http://wixtoolset.org/schemas/v4/wxs') # Copy the real search/action authoring, changing only registry roots/keys for isolation. # Do not reimplement folder resolution here: removing the fallback must fail the legacy case. - $nodes = $source.SelectNodes('/w:Wix/w:Package/w:Property[w:RegistrySearch] | /w:Wix/w:Package/w:SetProperty[@Id="INSTALLFOLDER"]', $ns) + [xml] $scope = Get-Content (Join-Path $PSScriptRoot 'install-policy.wxi') -Raw + $scopeNs = [Xml.XmlNamespaceManager]::new($scope.NameTable) + $scopeNs.AddNamespace('w', 'http://wixtoolset.org/schemas/v4/wxs') + $nodes = $scope.SelectNodes('/w:Include/w:Property | /w:Include/w:SetProperty[@Sequence="both"]', $scopeNs) $authoring = foreach ($node in $nodes) { $copy = $node.CloneNode($true) foreach ($search in $copy.SelectNodes('.//w:RegistrySearch', $ns)) { @@ -50,11 +53,11 @@ try { } $copy.OuterXml } - $directory = $source.SelectSingleNode('/w:Wix/w:Package/w:StandardDirectory[w:Directory[@Id="INSTALLFOLDER"]]', $ns).OuterXml + $directory = $source.SelectSingleNode('/w:Wix/w:Package/w:StandardDirectory[.//w:Directory[@Id="INSTALLFOLDER"]]', $ns).OuterXml $probe = @" + Version="1.0.0" ProductCode="$product" UpgradeCode="$upgrade" Scope="perUserOrMachine"> $($authoring -join "`n") $directory @@ -77,7 +80,7 @@ try { New-Item -ItemType Directory -Path $legacy, $modern, $explicit, $emptied | Out-Null $legacyExe = Join-Path $legacy 'lightcraft.exe' [IO.File]::WriteAllText($legacyExe, 'test placeholder; never executed') - $programFiles = if ($Arch -eq 'x86' -and ${env:ProgramFiles(x86)}) { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + $programFiles = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'Programs\ArtCraft' $cases = @( @{ Name = 'fresh'; Expected = (Join-Path $programFiles 'LightCraft') }, @{ Name = 'legacy-app-paths'; Legacy = $legacyExe; Expected = $legacy }, diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 000000000..21199ca7f --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch -wx @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} From bf869209803ccbf537064f0ca5022ba1d883d32c Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 10:49:52 +0000 Subject: [PATCH 20/91] fix(installer): address scope and ARM64 review feedback --- .github/workflows/windows-arm64.yml | 6 +- packaging/windows/check-install-policy.ps1 | 66 +++++++++++++++++++++- packaging/windows/install-policy.wxi | 6 +- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 332193998..ea74625b9 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,8 +27,10 @@ permissions: jobs: windows-arm64: - # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 with: app-name: lightcraft app-dir: LightCraft + install-relative-path: Programs/ArtCraft/LightCraft + install-scope: perUser diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index dd2895643..00b683d70 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -131,9 +131,12 @@ try { } $ui = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } - if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and + $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and + $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and + $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { - throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } @@ -144,7 +147,18 @@ try { } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) - foreach ($choice in @('user','machine','user')) { + # Model the full-UI upgrade entry with an existing machine-wide copy. Run + # the actual preference action, then the compiled Next events before discovery. + Set-SessionProperty $session 'ALLUSERS' '2' + Set-SessionProperty $session 'CRAFT_SCOPE' 'user' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0] + if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or + (Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') { + throw 'Full-UI machine upgrades must initially select the existing machine context.' + } + foreach ($choice in @('machine','user','machine','user')) { Set-SessionProperty $session 'CRAFT_SCOPE' $choice foreach ($event in $events) { if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } @@ -176,6 +190,52 @@ try { if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } } } + # Follow the compiled control conditions, not the source's intended appearance. + $controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5) + $conditions = @() + if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) { + $conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4) + } + function Test-ControlVisible($Control) { + $visible = ([int]$Control[3] -band 1) -ne 0 + foreach ($condition in $conditions) { + if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) { + if ($condition[2] -eq 'Show') { $visible = $true } + if ($condition[2] -eq 'Hide') { $visible = $false } + } + } + return $visible + } + foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) { + $plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') }) + if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) { + throw 'The elevation shield must follow the selected scope.' + } + } + $dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1] + $shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + $plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' } + } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) { + if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' } + } + } + if ($policy.id -in 'deckcraft','filmcraft','soundcraft') { + foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) { + $dialog = $route[0]; $control = $route[1] + $routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2) + Set-SessionProperty $session 'Installed' '' + $destination = '' + foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } } + if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' } + } + } $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi index afe75ee14..b0f1c5b1e 100644 --- a/packaging/windows/install-policy.wxi +++ b/packaging/windows/install-policy.wxi @@ -50,5 +50,9 @@ - + + + + + From 148cc09be12aaa38f9622823d1ff0f7d92502f4e Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 11:46:17 +0000 Subject: [PATCH 21/91] fix(installer): harden install-policy checks and packaging-lint workflow --- .github/workflows/packaging-lint.yml | 9 +++++++-- packaging/windows/check-install-policy.ps1 | 13 ++++++++++--- packaging/windows/test-install-policy.ps1 | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 04af18005..dff954e93 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -44,6 +44,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install the pinned packaging toolchain shell: pwsh run: | @@ -51,8 +53,11 @@ jobs: dotnet tool install --tool-path $tools wix --version 5.0.2 if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } $tools >> $env:GITHUB_PATH - & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 - if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } - name: Build and check non-executable installer fixtures shell: pwsh run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 00b683d70..451295a2e 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -122,9 +122,11 @@ try { Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } - if (-not $explicit) { - if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } - } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } } @@ -145,6 +147,11 @@ try { throw 'Silent updates must restore the folder before directory costing.' } } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) # Model the full-UI upgrade entry with an existing machine-wide copy. Run diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 index 21199ca7f..d455d19d1 100644 --- a/packaging/windows/test-install-policy.ps1 +++ b/packaging/windows/test-install-policy.ps1 @@ -38,7 +38,7 @@ foreach ($arch in $Architectures) { $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) - & wix build @sources -arch $arch -wx @extensions ` + & wix build @sources -arch $arch @extensions ` -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` From 3d40cf08e7db722cadfb0737c10209fedbbb7091 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:09:03 +0000 Subject: [PATCH 22/91] docs(installer): describe the per-user default install location and scope --- README.md | 5 +++-- README.zh-hans.md | 5 +++-- README.zh-hant.md | 5 +++-- docs/mcp.md | 6 +++--- docs/user/getting-started/install.md | 4 ++-- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 71f30d4c2..c438a91b1 100644 --- a/README.md +++ b/README.md @@ -563,8 +563,9 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco | arm64 (Snapdragon and other ARM PCs) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` | | x86 (32-bit) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` | -Installers and executables are code-signed. The installer asks where to install (`C:\Program Files\LightCraft` by -default; upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an +Installers and executables are code-signed. The installer asks who to install for and where (for your account only, in +`%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`, by default; "Everyone on this computer" uses +`C:\Program Files\ArtCraft\LightCraft`; upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an option to start it. For unattended installs: `msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`. ### macOS diff --git a/README.zh-hans.md b/README.zh-hans.md index 2c6ca8191..f7c0808cf 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -484,8 +484,9 @@ egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI | arm64(骁龙及其他 ARM PC) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` | | x86(32 位) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` | -安装程序和可执行文件都有代码签名。安装程序会询问安装位置(默认 -`C:\Program Files\LightCraft`;升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装, +安装程序和可执行文件都有代码签名。安装程序会询问为谁安装以及安装位置(默认仅为当前账户,安装到 +`%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`;选择“这台计算机上的所有人”则安装到 +`C:\Program Files\ArtCraft\LightCraft`;升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装, 并可选择启动它。无人值守安装:`msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。 ### macOS diff --git a/README.zh-hant.md b/README.zh-hant.md index ca624ce68..0b52419a8 100644 --- a/README.zh-hant.md +++ b/README.zh-hant.md @@ -514,8 +514,9 @@ egui 前端是一個可替換的 crate;它之下沒有任何東西知道 UI | arm64(Snapdragon 及其他 ARM PC) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` | | x86(32 位元) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` | -安裝程式與執行檔都有程式碼簽章。安裝程式會詢問安裝位置(預設為 -`C:\Program Files\LightCraft`;升級時會留在你選擇的資料夾),結束時會有一頁確認 LightCraft 已安裝, +安裝程式與執行檔都有程式碼簽章。安裝程式會詢問為誰安裝以及安裝位置(預設僅為目前帳戶,安裝到 +`%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`;選擇「這台電腦上的所有人」則安裝到 +`C:\Program Files\ArtCraft\LightCraft`;升級時會留在你選擇的資料夾),結束時會有一頁確認 LightCraft 已安裝, 並可選擇立即啟動。無訊息安裝:`msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。 ### macOS diff --git a/docs/mcp.md b/docs/mcp.md index 4800c37d8..7be25f7f0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -96,13 +96,13 @@ The release packages ship `lightcraft-cli` alongside the desktop app, so no buil | Install | CLI | |---|---| -| Windows (MSI) | `C:\Program Files\LightCraft\lightcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` | +| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft\lightcraft-cli.exe` by default (`C:\Program Files\ArtCraft\LightCraft\lightcraft-cli.exe` for an all-users install, or wherever you installed it otherwise), not on `PATH` | | Linux (deb, rpm) | `/usr/bin/lightcraft-cli` | | macOS | the separate `lightcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | ```sh -# Windows, default install folder -claude mcp add lightcraft -- "C:\Program Files\LightCraft\lightcraft-cli.exe" mcp +# Windows (PowerShell), default install folder +claude mcp add lightcraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\LightCraft\lightcraft-cli.exe" mcp # Linux, or macOS with the CLI unzipped onto PATH claude mcp add lightcraft -- lightcraft-cli mcp ``` diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md index 8327222cb..a63157d98 100644 --- a/docs/user/getting-started/install.md +++ b/docs/user/getting-started/install.md @@ -11,12 +11,12 @@ Download the file for your system from the [latest release](https://github.com/s 1. Open the [latest release](https://github.com/storytold/lightcraft/releases/latest). 2. Download `lightcraft--windows-x64.msi`. It fits most PCs (Intel and AMD, 64-bit). -3. Run the file. The installer asks where to install. The default is `C:\Program Files\LightCraft`, and upgrades stay in the folder you chose. +3. Run the file. The first page asks who to install LightCraft for. "Only for me" is the default: it needs no administrator permission and installs to `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`. "Everyone on this computer" asks for an administrator and installs to `C:\Program Files\ArtCraft\LightCraft`. Upgrades stay in the folder you chose. 4. At the end, choose the option to start LightCraft. For a Snapdragon or other Arm PC, download `lightcraft--windows-arm64.msi` instead. For a 32-bit Windows, download `lightcraft--windows-x86.msi`. -Upgrading from an installer older than 0.6.0 also keeps LightCraft in the folder it was installed in. If that folder was deleted, the upgrade installs to `C:\Program Files\LightCraft`. +Upgrading from an installer older than 0.6.0 also keeps LightCraft in the folder it was installed in. If that folder was deleted, the upgrade installs to the default folder for the scope you choose. To run it without installing, download `lightcraft--windows-x64-portable.zip`. Portable builds for `arm64` and `x86` are on the release page too. From 51ecbfece86c0c6ce481d20d5aab9afab7641b2f Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:25:19 +0000 Subject: [PATCH 23/91] docs(installer): note that the 32-bit all-users folder is Program Files (x86); tidy wording --- README.md | 2 +- README.zh-hans.md | 2 +- README.zh-hant.md | 2 +- docs/mcp.md | 2 +- docs/user/getting-started/install.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c438a91b1..cac5794de 100644 --- a/README.md +++ b/README.md @@ -565,7 +565,7 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco Installers and executables are code-signed. The installer asks who to install for and where (for your account only, in `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`, by default; "Everyone on this computer" uses -`C:\Program Files\ArtCraft\LightCraft`; upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an +`C:\Program Files\ArtCraft\LightCraft` (`Program Files (x86)` for the 32-bit build); upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an option to start it. For unattended installs: `msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`. ### macOS diff --git a/README.zh-hans.md b/README.zh-hans.md index f7c0808cf..4acc44bea 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -486,7 +486,7 @@ egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI 安装程序和可执行文件都有代码签名。安装程序会询问为谁安装以及安装位置(默认仅为当前账户,安装到 `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`;选择“这台计算机上的所有人”则安装到 -`C:\Program Files\ArtCraft\LightCraft`;升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装, +`C:\Program Files\ArtCraft\LightCraft`(32 位版本为 `Program Files (x86)`);升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装, 并可选择启动它。无人值守安装:`msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。 ### macOS diff --git a/README.zh-hant.md b/README.zh-hant.md index 0b52419a8..ef264806a 100644 --- a/README.zh-hant.md +++ b/README.zh-hant.md @@ -516,7 +516,7 @@ egui 前端是一個可替換的 crate;它之下沒有任何東西知道 UI 安裝程式與執行檔都有程式碼簽章。安裝程式會詢問為誰安裝以及安裝位置(預設僅為目前帳戶,安裝到 `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`;選擇「這台電腦上的所有人」則安裝到 -`C:\Program Files\ArtCraft\LightCraft`;升級時會留在你選擇的資料夾),結束時會有一頁確認 LightCraft 已安裝, +`C:\Program Files\ArtCraft\LightCraft`(32 位元版本為 `Program Files (x86)`);升級時會留在你選擇的資料夾),結束時會有一頁確認 LightCraft 已安裝, 並可選擇立即啟動。無訊息安裝:`msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。 ### macOS diff --git a/docs/mcp.md b/docs/mcp.md index 7be25f7f0..2c155e72c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -96,7 +96,7 @@ The release packages ship `lightcraft-cli` alongside the desktop app, so no buil | Install | CLI | |---|---| -| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft\lightcraft-cli.exe` by default (`C:\Program Files\ArtCraft\LightCraft\lightcraft-cli.exe` for an all-users install, or wherever you installed it otherwise), not on `PATH` | +| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft\lightcraft-cli.exe` by default (`C:\Program Files\ArtCraft\LightCraft\lightcraft-cli.exe` for an all-users install, under `Program Files (x86)` instead for the 32-bit build, or wherever you installed it otherwise), not on `PATH` | | Linux (deb, rpm) | `/usr/bin/lightcraft-cli` | | macOS | the separate `lightcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md index a63157d98..255a6a5ed 100644 --- a/docs/user/getting-started/install.md +++ b/docs/user/getting-started/install.md @@ -11,7 +11,7 @@ Download the file for your system from the [latest release](https://github.com/s 1. Open the [latest release](https://github.com/storytold/lightcraft/releases/latest). 2. Download `lightcraft--windows-x64.msi`. It fits most PCs (Intel and AMD, 64-bit). -3. Run the file. The first page asks who to install LightCraft for. "Only for me" is the default: it needs no administrator permission and installs to `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`. "Everyone on this computer" asks for an administrator and installs to `C:\Program Files\ArtCraft\LightCraft`. Upgrades stay in the folder you chose. +3. Run the file. The first page asks who to install LightCraft for. **Only for me** is the default: it needs no administrator permission and installs to `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`. **Everyone on this computer** asks for an administrator and installs to `C:\Program Files\ArtCraft\LightCraft` (`Program Files (x86)` for the 32-bit build). Upgrades stay in the folder you chose. 4. At the end, choose the option to start LightCraft. For a Snapdragon or other Arm PC, download `lightcraft--windows-arm64.msi` instead. For a 32-bit Windows, download `lightcraft--windows-x86.msi`. From ea5db140fb07b7b5ab08019e2aa7311f35d38283 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:26:56 +0000 Subject: [PATCH 24/91] docs(installer): say that silent installs are per-user and when the scope page appears --- README.md | 2 ++ README.zh-hans.md | 1 + README.zh-hant.md | 1 + 3 files changed, 4 insertions(+) diff --git a/README.md b/README.md index cac5794de..6c556e46f 100644 --- a/README.md +++ b/README.md @@ -567,6 +567,8 @@ Installers and executables are code-signed. The installer asks who to install fo `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`, by default; "Everyone on this computer" uses `C:\Program Files\ArtCraft\LightCraft` (`Program Files (x86)` for the 32-bit build); upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an option to start it. For unattended installs: `msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`. +A silent install is per-user by default. `INSTALLFOLDER` chooses the folder, not who it is installed for; +add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install. ### macOS diff --git a/README.zh-hans.md b/README.zh-hans.md index 4acc44bea..fa782ba10 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -488,6 +488,7 @@ egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`;选择“这台计算机上的所有人”则安装到 `C:\Program Files\ArtCraft\LightCraft`(32 位版本为 `Program Files (x86)`);升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装, 并可选择启动它。无人值守安装:`msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。 +静默安装默认仅为当前用户安装。`INSTALLFOLDER` 只决定安装文件夹,不决定为谁安装;若要为所有用户安装,请在提升权限的终端或部署工具中加上 `ALLUSERS=1`。 ### macOS diff --git a/README.zh-hant.md b/README.zh-hant.md index ef264806a..07ac908d2 100644 --- a/README.zh-hant.md +++ b/README.zh-hant.md @@ -518,6 +518,7 @@ egui 前端是一個可替換的 crate;它之下沒有任何東西知道 UI `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`;選擇「這台電腦上的所有人」則安裝到 `C:\Program Files\ArtCraft\LightCraft`(32 位元版本為 `Program Files (x86)`);升級時會留在你選擇的資料夾),結束時會有一頁確認 LightCraft 已安裝, 並可選擇立即啟動。無訊息安裝:`msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。 +無訊息安裝預設僅為目前使用者安裝。`INSTALLFOLDER` 只決定安裝資料夾,不決定為誰安裝;若要為所有使用者安裝,請在提升權限的終端機或部署工具中加上 `ALLUSERS=1`。 ### macOS From 30d0a3705178c0a04089410c0d4c13772f5f2d4d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:43:41 +0100 Subject: [PATCH 25/91] Add a single-photo export benchmark examples/export_bench imports the files given and runs export_photo (decode, full-size render, encode) N times, forgetting the decoded original between runs like a batch export does, and prints the median and minimum wall time. OPT takes export options as app.export JSON, PHASES=1 times render and encode separately, OUT=dir writes the files for byte comparisons between builds. Co-Authored-By: Claude Opus 5.5 --- crates/engine/examples/export_bench.rs | 81 ++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 crates/engine/examples/export_bench.rs diff --git a/crates/engine/examples/export_bench.rs b/crates/engine/examples/export_bench.rs new file mode 100644 index 000000000..7fdb1c6c8 --- /dev/null +++ b/crates/engine/examples/export_bench.rs @@ -0,0 +1,81 @@ +//! Single-photo export benchmark (median and minimum of N runs): +//! `cargo run --release -p lightcraft-engine --example export_bench -- corpus/raw/arw-sony-a7m3-compressed.arw` +//! +//! Imports the files given and runs the export path the app, CLI and MCP share +//! ([`lightcraft_engine::export::export_photo`]: decode the original, render it full size, encode) +//! once per file per run, forgetting the decoded original between runs so each export reads and +//! decodes it like a batch export does. `N=9` runs (default), `OPT='{"format":"png"}'` for other +//! export options (JSON as `app.export` takes them), `LIGHTCRAFT_GPU=0` for the CPU pipeline, +//! `PHASES=1` to time render and encode separately, `OUT=dir` to write each file's export there +//! (for byte comparisons between builds). +use std::time::Instant; + +use lightcraft_engine::Session; +use lightcraft_engine::catalog::PhotoId; +use lightcraft_engine::export::{ExportOptions, encode_rendered, export_metadata, export_photo}; + +fn stats(mut v: Vec) -> (f64, f64) { + v.sort_by(f64::total_cmp); + (v[v.len() / 2], v[0]) +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + assert!(!args.is_empty(), "usage: export_bench FILE..."); + let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(9).max(1); + let opt: serde_json::Value = + std::env::var("OPT").ok().map(|s| serde_json::from_str(&s).expect("OPT is JSON")).unwrap_or_else(|| serde_json::json!({})); + let o = ExportOptions::from_params(&opt).expect("export options"); + let mut s = Session::new().with_fs(); + let paths: Vec = args.iter().map(|p| std::fs::canonicalize(p).expect("input file").to_string_lossy().to_string()).collect(); + let r = s.execute("library.import", &serde_json::json!({"paths": paths})).expect("import"); + let ids: Vec = r["imported"].as_array().expect("ids").iter().filter_map(|v| v.as_u64()).map(PhotoId).collect(); + assert_eq!(ids.len(), args.len(), "every file imports"); + println!("{} file(s), {:?}, {n} runs, gpu {}", ids.len(), o.format, lightcraft_engine::gpu::available()); + let mut times = Vec::new(); + for i in 0..=n { + let t = Instant::now(); + for (k, &id) in ids.iter().enumerate() { + let e = export_photo(&mut s, id, &o, k + 1).expect("export"); + if i == 0 + && let Some(dir) = std::env::var_os("OUT") + { + std::fs::write(std::path::Path::new(&dir).join(&e.file_name), &e.bytes).expect("write"); + println!(" {} {}×{} {} bytes", e.file_name, e.width, e.height, e.bytes.len()); + } + s.media.forget(id); + } + // run 0 warms up (GPU device, allocator, page cache) + if i > 0 { + times.push(t.elapsed().as_secs_f64() * 1e3); + } + } + let (med, min) = stats(times.clone()); + println!("export: median {med:.1} ms, min {min:.1} ms (runs: {})", times.iter().map(|t| format!("{t:.0}")).collect::>().join(" ")); + if std::env::var_os("PHASES").is_none() { + return; + } + let (mut render, mut encode) = (Vec::new(), Vec::new()); + for _ in 0..n { + let (mut tr, mut te) = (0.0, 0.0); + for &id in &ids { + let p = s.catalog.photo(id).expect("photo").clone(); + let (w, h) = lightcraft_engine::export::output_size(&p, &o); + let meta = export_metadata(&p, &s.catalog, &o); + let job = s.export_job(id, w, h, o.effective_space(), o.effective_depth()).expect("job"); + let t = Instant::now(); + let r = job.run().rendered.expect("render"); + tr += t.elapsed().as_secs_f64() * 1e3; + let t = Instant::now(); + std::hint::black_box(encode_rendered(&r, &o, meta.as_ref()).expect("encode")); + te += t.elapsed().as_secs_f64() * 1e3; + s.media.forget(id); + } + render.push(tr); + encode.push(te); + } + let (rm, rn) = stats(render); + let (em, en) = stats(encode); + println!(" decode+render: median {rm:.1} ms, min {rn:.1} ms"); + println!(" encode: median {em:.1} ms, min {en:.1} ms"); +} From 3ce8dbc55b0575a13ae15d4417501b5064f11464 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:43:41 +0100 Subject: [PATCH 26/91] Drop copies and libm calls from 8-bit export encoding - finish_8bit/finish_deep hand back the render itself when there is no output sharpening and no watermark. Before, every export cloned the image (~100 MB at 24 MP) and output_sharpen built a ~290 MB float copy of it even for SharpenFor::None, all on one thread. - Output sharpening's box blurs run row-parallel (par_rows); each pixel is computed as before. - The parallel JPEG encoder converts each band's rows to YCbCr inside the band's task instead of first filling whole-image planes (~290 MB for 4:4:4 at 24 MP, written once and read back). - fdct_quant rounds with plain arithmetic (round_i16) instead of f32::round, a libm call per coefficient without SSE4.1; it equals `x.round() as i16` for all 2^32 inputs. Output is byte-identical (pinned by encoded_bytes_are_pinned and sharpened_pixels_are_pinned, whose hashes come from main). Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/jpeg_par.rs | 121 +++++++++++++++++---------- crates/engine/src/export/encoding.rs | 40 ++++++--- crates/engine/src/export/tests.rs | 36 ++++++++ 3 files changed, 142 insertions(+), 55 deletions(-) diff --git a/crates/codecs/src/jpeg_par.rs b/crates/codecs/src/jpeg_par.rs index c8d9e5023..763b73afa 100644 --- a/crates/codecs/src/jpeg_par.rs +++ b/crates/codecs/src/jpeg_par.rs @@ -255,9 +255,20 @@ fn fdct_quant(block: &[f32; 64], recip: &[f32; 64], out: &mut [i16; 64]) { aan_1d(&mut d, c, 8); } for i in 0..64 { - out[i] = (d[i] * recip[i]).round() as i16; + out[i] = round_i16(d[i] * recip[i]); } } +/// `x.round() as i16` (half away from zero, saturating, NaN to 0) for every `x` (checked for all of them), in plain +/// arithmetic: without SSE4.1 `f32::round` is a libm call, one per coefficient, that keeps the loop from vectorising. +#[inline(always)] +fn round_i16(x: f32) -> i16 { + // (below 2^24 the truncation is exact and so is the remainder) + let c = x.clamp(-65536.0, 65536.0); + let t = c as i32; + let f = c - t as f32; + let r = t + i32::from(f >= 0.5) - i32::from(f <= -0.5); + r.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16 +} #[inline] fn magnitude(v: i32) -> (u32, u32) { @@ -395,31 +406,30 @@ fn encode_with( (true, _) | (false, ChromaSubsampling::S444) => (1usize, 1usize), (false, _) => (2, 2), }; - // colour conversion (JFIF YCbCr, full range, level-shifted) in one pass; chroma averaged 2×2 for 4:2:0 - let n = width * height; #[cfg(feature = "parallel")] use rayon::prelude::*; - // 4:4:4: Y, Cb, Cr at full resolution in one pass; 4:2:0: Y here, chroma below straight from 2×2 RGB means + // colour conversion (JFIF YCbCr, full range, level-shifted) of the pixel rows `rows`, one band's worth at a + // time (in the band's task: the planes of the whole image would be ~290 MB at 24 MP, written and read back + // once): 4:4:4 Y, Cb, Cr at full resolution in one pass; 4:2:0 Y, and chroma (rows `crows` of the + // half-size planes) straight from 2×2 RGB means (conversion is linear: average first, then convert once) let ncomp = if gray || hs == 2 { 1 } else { 3 }; - let mut full: Vec> = (0..ncomp).map(|_| vec![0f32; n]).collect(); - { - let rows: Vec<(usize, Vec<&mut [f32]>)> = { - let mut its: Vec<_> = full.iter_mut().map(|p| p.chunks_mut(width)).collect(); - (0..height).map_while(|y| Some((y, its.iter_mut().map(|it| it.next()).collect::>()?))).collect() - }; - let conv = |(y, mut out): (usize, Vec<&mut [f32]>)| { + let (cw, ch) = (width.div_ceil(2), height.div_ceil(2)); + let convert = |rows: std::ops::Range, crows: std::ops::Range| -> Vec { + let mut full: Vec> = (0..ncomp).map(|_| vec![0f32; rows.len() * width]).collect(); + for (k, y) in rows.clone().enumerate() { let src = &data[y * width * channels..(y + 1) * width * channels]; + let mut out: Vec<&mut [f32]> = full.iter_mut().map(|p| &mut p[k * width..(k + 1) * width]).collect(); if gray { for (o, p) in out[0].iter_mut().zip(src.chunks_exact(channels)) { *o = p[0] as f32 - 128.0; } - return; + continue; } if out.len() == 1 { for (o, p) in out[0].iter_mut().zip(src.chunks_exact(channels)) { *o = 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32 - 128.0; } - return; + continue; } for (x, p) in src.chunks_exact(channels).enumerate() { let (r, g, b) = (p[0] as f32, p[1] as f32, p[2] as f32); @@ -427,37 +437,26 @@ fn encode_with( out[1][x] = -0.168_736 * r - 0.331_264 * g + 0.5 * b; out[2][x] = 0.5 * r - 0.418_688 * g - 0.081_312 * b; } - }; - #[cfg(feature = "parallel")] - rows.into_par_iter().for_each(conv); - #[cfg(not(feature = "parallel"))] - rows.into_iter().for_each(conv); - } - let mut it = full.into_iter(); - let mut planes = vec![Plane { w: width, h: height, data: it.next().unwrap_or_default() }]; - planes.extend(it.map(|c| Plane { w: width, h: height, data: c })); - if !gray && hs == 2 { - // conversion is linear: average RGB over 2×2 first, then convert once - let (cw, ch) = (width.div_ceil(2), height.div_ceil(2)); - let (mut cb, mut cr) = (vec![0f32; cw * ch], vec![0f32; cw * ch]); - let down = |(cy, (ob, or)): (usize, (&mut [f32], &mut [f32]))| { - let (y0, y1) = (2 * cy, (2 * cy + 1).min(height - 1)); - let (r0, r1) = (&data[y0 * width * channels..], &data[y1 * width * channels..]); - for cx in 0..cw { - let (x0, x1) = (2 * cx * channels, (2 * cx + 1).min(width - 1) * channels); - let s = |k: usize| (r0[x0 + k] as f32 + r0[x1 + k] as f32 + r1[x0 + k] as f32 + r1[x1 + k] as f32) * 0.25; - let (r, g, b) = (s(0), s(1), s(2)); - ob[cx] = -0.168_736 * r - 0.331_264 * g + 0.5 * b; - or[cx] = 0.5 * r - 0.418_688 * g - 0.081_312 * b; + } + let mut planes: Vec = full.into_iter().map(|data| Plane { w: width, h: rows.len(), data }).collect(); + if !gray && hs == 2 { + let (mut cb, mut cr) = (vec![0f32; cw * crows.len()], vec![0f32; cw * crows.len()]); + for (cy, (ob, or)) in crows.clone().zip(cb.chunks_mut(cw).zip(cr.chunks_mut(cw))) { + let (y0, y1) = (2 * cy, (2 * cy + 1).min(height - 1)); + let (r0, r1) = (&data[y0 * width * channels..], &data[y1 * width * channels..]); + for cx in 0..cw { + let (x0, x1) = (2 * cx * channels, (2 * cx + 1).min(width - 1) * channels); + let s = |k: usize| (r0[x0 + k] as f32 + r0[x1 + k] as f32 + r1[x0 + k] as f32 + r1[x1 + k] as f32) * 0.25; + let (r, g, b) = (s(0), s(1), s(2)); + ob[cx] = -0.168_736 * r - 0.331_264 * g + 0.5 * b; + or[cx] = 0.5 * r - 0.418_688 * g - 0.081_312 * b; + } } - }; - #[cfg(feature = "parallel")] - cb.par_chunks_mut(cw).zip(cr.par_chunks_mut(cw)).enumerate().for_each(down); - #[cfg(not(feature = "parallel"))] - cb.chunks_mut(cw).zip(cr.chunks_mut(cw)).enumerate().for_each(down); - planes.push(Plane { w: cw, h: ch, data: cb }); - planes.push(Plane { w: cw, h: ch, data: cr }); - } + planes.push(Plane { w: cw, h: crows.len(), data: cb }); + planes.push(Plane { w: cw, h: crows.len(), data: cr }); + } + planes + }; let ql = scaled_quant(&Q_LUMA, quality); let qc = scaled_quant(&Q_CHROMA, quality); @@ -473,7 +472,12 @@ fn encode_with( let (mut blk, mut q) = ([0f32; 64], [0i16; 64]); let (mut tokens, mut freq) = (Vec::new(), [[0u64; 256]; 4]); let mut pred = [0i32; 3]; - for mcu_y in band * rows_per_band..((band + 1) * rows_per_band).min(my) { + let (m0, m1) = (band * rows_per_band, ((band + 1) * rows_per_band).min(my)); + // the band's planes, its MCU row `m0` at their top (blocks past the image's bottom repeat its last row, + // which is the band's last row then) + let planes = convert(m0 * mcu_h..(m1 * mcu_h).min(height), m0 * 8..(m1 * 8).min(ch)); + for mcu_y in m0..m1 { + let mcu_y = mcu_y - m0; for mcu_x in 0..mx { for vy in 0..vs { for hx in 0..hs { @@ -497,7 +501,6 @@ fn encode_with( let tokenized: Vec<(Vec, [Freq; 4])> = bands.par_iter().map(tokenize_band).collect(); #[cfg(not(feature = "parallel"))] let tokenized: Vec<(Vec, [Freq; 4])> = bands.iter().map(tokenize_band).collect(); - drop(planes); // the tables: DC luma, AC luma, DC chroma, AC chroma let tables: [([u8; 16], Vec); 4] = if optimize { let mut total = [[0u64; 256]; 4]; @@ -746,6 +749,34 @@ mod tests { assert_eq!(jpeg_decoder::Decoder::new(&std[..]).decode().unwrap(), jpeg_decoder::Decoder::new(&opt[..]).decode().unwrap()); } + /// The bytes written for 4:4:4, 4:2:0, grayscale and RGBA input at a size cut into many bands, with partial MCUs + /// at the right and bottom edges, pinned: each band converts its own rows to YCbCr, which must not change a bit. + #[test] + fn encoded_bytes_are_pinned() { + let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3)); + let (w, h) = (1203, 1037); + let rgb = test_rgb(w, h); + let gray: Vec = rgb.as_chunks::<3>().0.iter().map(|p| p[1]).collect(); + let rgba: Vec = rgb.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(); + let got = [ + fnv(&encode(&rgb, w, h, 3, 90, ChromaSubsampling::S444, &[])), + fnv(&encode(&rgb, w, h, 3, 75, ChromaSubsampling::S420, &[])), + fnv(&encode(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[])), + fnv(&encode(&rgba, w, h, 4, 85, ChromaSubsampling::S444, &[])), + ]; + assert_eq!(got, PINNED, "{got:#x?}"); + } + /// [`round_i16`] against `f32::round` at the halves, the ends of `i16`, NaN and infinities, and a spread of every + /// magnitude (all 2^32 inputs were compared once, offline). + #[test] + fn round_i16_is_round_as_i16() { + let special = [0.5f32, -0.5, 1.5, -1.5, 2.5, -2.5, 0.499_999_97, -0.499_999_97, 32767.5, -32768.5, 32766.5, 1e9, -1e9]; + let all = special.into_iter().chain([f32::NAN, f32::INFINITY, f32::NEG_INFINITY]).chain((0..=u32::MAX).step_by(9973).map(f32::from_bits)); + for x in all { + assert_eq!(round_i16(x), x.round() as i16, "{x:e}"); + } + } + const PINNED: [u64; 4] = [0x52ba_772a_589c_1430, 0x013f_8364_821f_1dde, 0xb2a3_b5e5_aef3_7402, 0x08df_d6dc_aa2f_1c78]; #[test] fn grayscale_and_quality_monotone() { let (w, h) = (300, 200); diff --git a/crates/engine/src/export/encoding.rs b/crates/engine/src/export/encoding.rs index c71903ac9..3cb3be3cb 100644 --- a/crates/engine/src/export/encoding.rs +++ b/crates/engine/src/export/encoding.rs @@ -1,9 +1,13 @@ use super::options::{ExportFormat, MetadataPolicy, SharpenAmount, SharpenFor}; use super::watermark::{Watermark, draw_watermark, draw_watermark_deep}; use super::*; +use std::borrow::Cow; /// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian). pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount) { + if !sharpens(img.width, img.height, target) { + return; + } let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); if let Some(out) = unsharp(img.width, img.height, &src, target, amount) { for (p, v) in img.data.iter_mut().zip(out) { @@ -17,6 +21,9 @@ pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount /// [`output_sharpen`] on a high-bit-depth image (float images are sharpened in linear light). pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: SharpenAmount) { let (w, h) = (img.width, img.height); + if !sharpens(w, h, target) { + return; + } match &mut img.samples { DeepSamples::U16(v) => { let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect(); @@ -37,6 +44,11 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar } } +/// Whether output sharpening for `target` changes a `w × h` image ([`unsharp`] returns `None` otherwise). +fn sharpens(w: usize, h: usize, target: SharpenFor) -> bool { + target != SharpenFor::None && w >= 3 && h >= 3 +} + /// The sharpened values of `src` (`w × h`), or `None` when nothing is to be done. fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: SharpenAmount) -> Option> { let (radius, base) = match target { @@ -57,8 +69,9 @@ fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: Sha let r = radius as isize; let box_pass = |inp: &[[f32; 3]], horizontal: bool| -> Vec<[f32; 3]> { let mut out = vec![[0.0; 3]; inp.len()]; - for y in 0..h { - for x in 0..w { + // rows in parallel: each output pixel is computed on its own, so the bits don't depend on the split + lightcraft_raster::par_rows(&mut out, w, |y, row| { + for (x, o) in row.iter_mut().enumerate() { let mut acc = [0.0f32; 3]; for d in -r..=r { let (sx, sy) = if horizontal { @@ -70,9 +83,9 @@ fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: Sha acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]]; } let n = (2 * r + 1) as f32; - out[y * w + x] = acc.map(|v| v / n); + *o = acc.map(|v| v / n); } - } + }); out }; let blur = box_pass(&box_pass(src, true), false); @@ -142,8 +155,12 @@ fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_cod best.ok_or_else(|| format!("cannot fit within {kb} KB")) } -/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`. -fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 { +/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`: the render itself when +/// there is neither (a copy is ~100 MB at 24 MP). +fn finish_8bit<'a>(img: &'a Rgba8, o: &ExportOptions) -> Cow<'a, Rgba8> { + if o.watermark.is_none() && !sharpens(img.width, img.height, o.sharpen) { + return Cow::Borrowed(img); + } let mut img = img.clone(); output_sharpen(&mut img, o.sharpen, o.sharpen_amount); let space = o.effective_space(); @@ -151,18 +168,21 @@ fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 { let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() }; draw_watermark(&mut img, &wm); } - img + Cow::Owned(img) } -/// Output sharpening and the watermark on a high-bit-depth render. -fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage { +/// Output sharpening and the watermark on a high-bit-depth render (the render itself when there is neither). +fn finish_deep<'a>(img: &'a DeepImage, o: &ExportOptions) -> Cow<'a, DeepImage> { + if o.watermark.is_none() && !sharpens(img.width, img.height, o.sharpen) { + return Cow::Borrowed(img); + } let mut img = img.clone(); output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); if let Some(wm) = &o.watermark { let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() }; draw_watermark_deep(&mut img, &wm); } - img + Cow::Owned(img) } /// Encode an ISO 21496-1 gain map JPEG from the SDR rendition `sdr` (8-bit, in the output space) diff --git a/crates/engine/src/export/tests.rs b/crates/engine/src/export/tests.rs index 5ad311c7d..80eb13430 100644 --- a/crates/engine/src/export/tests.rs +++ b/crates/engine/src/export/tests.rs @@ -209,6 +209,42 @@ fn sharpen_increases_edge_contrast() { assert!(img.data[3][0] < 80 && img.data[4][0] > 170); } +/// Output sharpening's pixels for every target and amount, pinned (its box blurs run row-parallel, which must not +/// change a bit), on 8- and 16-bit images; with no target the image is left as it is. +#[test] +fn sharpened_pixels_are_pinned() { + let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3)); + let mut got = Vec::new(); + for target in [SharpenFor::Screen, SharpenFor::Matte, SharpenFor::Glossy] { + for amount in [SharpenAmount::Low, SharpenAmount::High] { + let mut img = test_image(); + output_sharpen(&mut img, target, amount); + got.push(fnv(img.data.as_flattened())); + } + let src = test_image(); + let samples = src.data.iter().flat_map(|p| [p[0], p[1], p[2]].map(|v| u16::from(v) * 257)).collect(); + let mut deep = DeepImage { width: src.width, height: src.height, samples: DeepSamples::U16(samples), space: OutputSpace::Srgb }; + output_sharpen_deep(&mut deep, target, SharpenAmount::Standard); + let DeepSamples::U16(v) = &deep.samples else { unreachable!() }; + got.push(fnv(&v.iter().flat_map(|s| s.to_le_bytes()).collect::>())); + } + assert_eq!(got, SHARPENED, "{got:#x?}"); + let mut img = test_image(); + output_sharpen(&mut img, SharpenFor::None, SharpenAmount::High); + assert_eq!(img, test_image()); +} +const SHARPENED: [u64; 9] = [ + 0x1494_273e_0bdc_a61c, + 0x3fbb_40c4_74f4_b0c3, + 0xa27f_8ff5_82a0_99f9, + 0x6550_6571_a38d_4d9f, + 0x5e28_f4f3_fc8a_9685, + 0xdc2b_6f65_9138_3b1d, + 0x8ce6_f0b8_d8ed_8026, + 0x92d8_12e7_4b2e_81fe, + 0x139f_6d78_b1f2_a910, +]; + /// Exported files carry keywords as Lightroom Classic writes them: names flat in `dc:subject` /// (with the keywords containing them), full paths in `lr:hierarchicalSubject`, and nothing of /// a keyword left out of export. They used to carry the `a|b` paths in `dc:subject`. From 93487af3b367d34c391f9aa720bb33dcbbfc394e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:43:41 +0100 Subject: [PATCH 27/91] Cut serial work from raw development and GPU transfers - RawImage::develop demosaics only the default crop with AHD when there is no OpcodeList3 (ahd_window), instead of demosaicing the margin too and then copying the crop out of the full picture on one thread. - Gpu::upload writes through queue.write_buffer_with and copies into the staging buffer in parallel parts. create_buffer_init zeroed a staging buffer and then copied over it, two single-threaded passes over a full-size photo's ~290 MB. - finish_and_read copies the mapped readback out in parallel parts (one thread read the 91 MB of a 24 MP export at ~2 GB/s, ~45 ms). Output is byte-identical (ahd_window_is_the_crop_of_the_whole, uploads_read_back_exactly). Export of a 24 MP ARW to full-size JPEG (examples/export_bench, 75 interleaved runs each on a shared 16-thread Ryzen): main 802 ms median / 614 ms min -> this branch 647 / 525 (-19.3% / -14.5%). Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/ctx.rs | 62 ++++++++++++++++++++++++++++++---- crates/raw/src/demosaic.rs | 36 +++++++++++++++++++- crates/raw/src/demosaic/ahd.rs | 11 ++++-- crates/raw/src/lib.rs | 14 ++++++++ 4 files changed, 113 insertions(+), 10 deletions(-) diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 33e02fbe4..7bbbbc68d 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -499,11 +499,31 @@ impl Gpu { self.over_limit(bytes.len() as u64); return Buf { buf: Some(Tracked::new(self.placeholder())), len }; } - let buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: None, - contents: bytes, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, - }); + let usage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST; + if let Some(size) = wgpu::BufferSize::new(bytes.len() as u64).filter(|s| s.get() % wgpu::COPY_BUFFER_ALIGNMENT == 0) { + // through the queue: the bytes are copied once, into a staging buffer the next submission copies from, in + // parallel parts (`create_buffer_init` zeroes a staging buffer first and copies over that, two passes on + // one thread over a full-size photo's ~290 MB) + let buf = self.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: size.get(), usage, mapped_at_creation: false }); + if let Some(mut view) = self.queue.write_buffer_with(&buf, 0, size) { + const PART: usize = 1 << 20; + { + let (whole, mut tail) = view.slice(..).into_chunks::(); + let (src, src_tail) = bytes.as_chunks::(); + let mut parts: Vec<_> = whole.into_iter().zip(src).map(Some).collect(); + lightcraft_raster::par_rows(&mut parts, 1, |_, part| { + for (d, s) in part.iter_mut().filter_map(Option::take) { + wgpu::WriteOnly::<[u8]>::from(d).copy_from_slice(s); + } + }); + tail.copy_from_slice(src_tail); + } + // (dropping the view schedules the copy) + drop(view); + return Buf { buf: Some(Tracked::new(buf)), len }; + } + } + let buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: bytes, usage }); Buf { buf: Some(Tracked::new(buf)), len } } @@ -612,7 +632,17 @@ impl Gpu { Ok(d) => d, Err(e) => return failed(format!("{e:?}")), }; - let out: Vec = bytemuck::cast_slice(&data).to_vec(); + // copied out in parallel parts: one thread reads mapped memory slowly (~2 GB/s, so ~45 ms for a 24 MP + // export's pixels), and the new vector's pages are first touched there too + let src: &[u8] = &data; + // (`len` counts 32-bit words: as many `T` as they fill) + let mut out = vec![::zeroed(); src.len() / std::mem::size_of::().max(1)]; + const PART: usize = 1 << 20; + lightcraft_raster::par_rows(bytemuck::cast_slice_mut::(&mut out), PART, |k, part| { + if let Some(s) = src.get(k * PART..k * PART + part.len()) { + part.copy_from_slice(s); + } + }); drop(data); staging.unmap(); out @@ -811,6 +841,26 @@ mod tests { Some(stdout) } + /// An upload reaches the device whole: it is copied into staging memory in parallel parts, the last one + /// shorter than the others (and a small upload is that last part alone). + #[test] + fn uploads_read_back_exactly() { + let Some(g) = crate::device() else { + lightcraft_testkit::skip!(Gpu, "no GPU device"); + return; + }; + for len in [1usize, 1000, (3 << 20) / 4 + 777] { + let data: Vec = (0..len as u32).map(|i| i.wrapping_mul(2_654_435_761)).collect(); + let b = g.upload(&data); + let back: Vec = g.finish_and_read(g.encoder(), &b, len); + assert!(back == data, "{len} values"); + } + // read back as wider values (`len` counts 32-bit words), as `read_rgb` does + let data: Vec = (0..3 << 20).collect(); + let b = g.upload(&data); + let back: Vec<[u32; 3]> = g.finish_and_read(g.encoder(), &b, data.len()); + assert!(back.len() == 1 << 20 && back.as_flattened() == data.as_slice()); + } /// Issue #1858: once a Vulkan or DX12 device is lost, wgpu 30 panics in `Device::poll` instead of /// returning an error. A render's readback panicked; while it unwound, a buffer's way back to /// the pool polls again from a destructor, and that second panic aborted the app. diff --git a/crates/raw/src/demosaic.rs b/crates/raw/src/demosaic.rs index b8a3c599a..75f7674ac 100644 --- a/crates/raw/src/demosaic.rs +++ b/crates/raw/src/demosaic.rs @@ -15,7 +15,7 @@ mod bilinear; mod ppg; mod xtrans; -use crate::{Cfa, Normalized, Rgb32f}; +use crate::{Cfa, Normalized, Rect, Rgb32f}; use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -114,6 +114,20 @@ pub fn demosaic(n: &Normalized, method: Method) -> Rgb32f { } } +/// [`demosaic`] with [`Method::Ahd`] of the window `r` alone, for Bayer mosaics (`None` for any other data, or a +/// window not inside the mosaic): the same pixels as demosaicing it all and cropping that to `r`, without +/// demosaicing the rest or copying the window out of it. +pub(crate) fn ahd_window(n: &Normalized, r: Rect) -> Option { + let (w, h) = (n.width, n.height); + let cfa = n.cfa.as_ref()?; + let inside = r.x.checked_add(r.width).is_some_and(|e| e <= w) && r.y.checked_add(r.height).is_some_and(|e| e <= h); + if n.cpp != 1 || !cfa.is_bayer() || w < 4 || h < 4 || n.data.len() != w * h || r.width == 0 || r.height == 0 || !inside { + return None; + } + let m = Mosaic { w, h, data: &n.data, cfa }; + Some(ahd::ahd_window(&m, r.x, r.y, r.width, r.height)) +} + /// Build a mosaic from an RGB image by sampling each pixel's CFA colour (for tests and synthetic data). pub fn mosaic_from_rgb(img: &Rgb32f, cfa: &Cfa) -> Normalized { let data = (0..img.height).flat_map(|y| (0..img.width).map(move |x| (x, y))).map(|(x, y)| img.get(x, y)[cfa.color_at(x, y) as usize]).collect(); @@ -192,6 +206,26 @@ mod tests { assert!(out.data.iter().all(|p| (p[0] - 0.3).abs() < 1e-5 && (p[1] - 0.55).abs() < 1e-5 && (p[2] - 0.8).abs() < 1e-5)); } + /// A window demosaiced alone is the same, bit for bit, as that part of the whole picture: at the edges and + /// corners (where the mosaic is reflected), in the middle, across tile seams, at every CFA phase. + #[test] + fn ahd_window_is_the_crop_of_the_whole() { + let img = edgy_scene(300, 270); + for pat in ["RGGB", "GBRG"] { + let n = mosaic_from_rgb(&img, &Cfa::bayer(pat).unwrap()); + let whole = demosaic(&n, Method::Ahd); + for r in + [Rect::new(0, 0, 300, 270), Rect::new(0, 0, 7, 5), Rect::new(1, 3, 298, 266), Rect::new(121, 130, 140, 9), Rect::new(293, 263, 7, 7)] + { + let part = ahd_window(&n, r).unwrap(); + let want = whole.clone().into_crop(r.x, r.y, r.width, r.height); + assert_eq!((part.width, part.height), (r.width, r.height)); + assert!(part.data.iter().zip(&want.data).all(|(a, b)| a.map(f32::to_bits) == b.map(f32::to_bits)), "{pat} {r:?}"); + } + assert!(ahd_window(&n, Rect::new(1, 0, 300, 270)).is_none(), "a window past the mosaic is refused"); + } + } + #[test] fn known_samples_are_preserved() { let img = edgy_scene(40, 32); diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index d9fc27d76..6afcd9251 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -21,19 +21,24 @@ const TILE: usize = 128; const MARGIN: usize = 6; pub(crate) fn ahd(m: &Mosaic) -> Rgb32f { - let (w, h) = (m.w, m.h); + ahd_window(m, 0, 0, m.w, m.h) +} + +/// The `w × h` pixels at (`x`, `y`) of [`ahd`]'s picture (inside the mosaic), demosaiced alone: every pixel comes +/// from the mosaic around it, so they are the same as when the whole picture is demosaiced and then cropped. +pub(crate) fn ahd_window(m: &Mosaic, x: usize, y: usize, w: usize, h: usize) -> Rgb32f { let mut out = Rgb32f::new(w, h); if w == 0 || h == 0 { return out; } // one row of tiles per task, its tiles in parallel too, written straight into the picture out.data.par_chunks_mut((w * TILE).max(1)).enumerate().for_each(|(ty, band)| { - let (y0, th) = (ty * TILE, band.len() / w.max(1)); + let (y0, th) = (y + ty * TILE, band.len() / w.max(1)); let tiles: Vec<(usize, usize, Vec<[f32; 3]>)> = (0..w.div_ceil(TILE)) .into_par_iter() .map(|tx| { let (x0, tw) = (tx * TILE, TILE.min(w - tx * TILE)); - (x0, tw, tile(m, x0, y0, tw, th)) + (x0, tw, tile(m, x + x0, y0, tw, th)) }) .collect(); for (x0, tw, px) in tiles { diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 6626f10cb..17152e80f 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -866,6 +866,15 @@ impl RawImage { /// The result is camera RGB (not white balanced), white level = 1.0, not oriented. pub fn develop(&self, method: Method) -> Result { let n = self.normalized()?; + // with no `OpcodeList3` to apply to the whole picture first, AHD demosaics just the default crop (the same + // pixels, without demosaicing the margin and then copying the crop out of the full picture) + if self.opcodes.list3.is_empty() && method == Method::Ahd { + let c = self.develop_crop(n.width, n.height); + if let Some(rgb) = demosaic::ahd_window(&n, c) { + drop(n); + return Ok(self.stretched(rgb)); + } + } let rgb = demosaic(&n, method); drop(n); Ok(self.finish_demosaiced(rgb)) @@ -878,6 +887,11 @@ impl RawImage { let c = self.develop_crop(rgb.width, rgb.height); let rgb = if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { rgb } else { rgb.into_crop(c.x, c.y, c.width, c.height) }; + self.stretched(rgb) + } + + /// The cropped camera RGB with its rows stretched for non-square pixels. + fn stretched(&self, rgb: Rgb32f) -> Rgb32f { let h = stretched_height(rgb.height, self.pixel_aspect); if h == rgb.height { return rgb; From 79f662a5840886d5bc6b8080c2bb767874185efd Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 15:32:29 +0000 Subject: [PATCH 28/91] fix(installer): resolve the wizard scope and folder correctly Real msiexec runs (per-user and all-users, silent and wizard) found two defects that the metadata fixtures cannot see: - "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as per-machine and skipped the existing per-user copy. An interactive upgrade left two registered products and uninstall left files behind. The page now leaves ALLUSERS unset for a per-user choice. - Windows Installer resolves Program Files once, for the context it started in, so choosing "Everyone" in the wizard installed into the user profile with a machine-wide registration. The wizard now sets the new-install folder itself after the choice (Program Files, Program Files (x86) for 32-bit, or the per-user Programs folder). Silent installs are unchanged. check-install-policy.ps1 now fails if either regresses. --- packaging/windows/check-install-policy.ps1 | 24 +++++++++++++++++++++- packaging/windows/install-policy.wxi | 15 +++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 451295a2e..a4a14bc64 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -140,6 +140,28 @@ try { $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } + # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. + # The wizard therefore sets the new-install folder itself, and only in the wizard. + $defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' }) + if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' } + foreach ($row in $defaults) { + if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." } + } + if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { + throw 'Silent installs must not use the wizard-only default folder actions.' + } + foreach ($scope in @('1', '')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' '' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } + if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { @@ -177,7 +199,7 @@ try { $allUsers = Get-SessionProperty $session 'ALLUSERS' $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or - ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + ($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) { throw 'Scope selector does not reset the requested installation context.' } } diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi index b0f1c5b1e..d8531ab87 100644 --- a/packaging/windows/install-policy.wxi +++ b/packaging/windows/install-policy.wxi @@ -1,6 +1,9 @@ + @@ -25,6 +28,16 @@ + + + + + + + + + @@ -42,7 +55,7 @@ - + From b0485b0427e6eb89f395c7851e823078e0879f92 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 19:13:20 +0000 Subject: [PATCH 29/91] fix(installer): check the exact architecture-specific machine folder The pre-signing check matched any folder named Program Files, so it rejected a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit root for an x86 package and accepted a different app name. Build the expected folder from the package architecture's resolved root and the policy name. --- packaging/windows/check-install-policy.ps1 | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index a4a14bc64..cc9416e9c 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -32,6 +32,12 @@ function Set-SessionProperty($Session, [string] $Name, [string] $Value) { function Get-SessionProperty($Session, [string] $Name) { $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) } +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} $installer = $null; $database = $null; $session = $null try { $installer = New-Object -ComObject WindowsInstaller.Installer @@ -150,6 +156,12 @@ try { if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { throw 'Silent installs must not use the wizard-only default folder actions.' } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' foreach ($scope in @('1', '')) { Set-SessionProperty $session 'ALLUSERS' $scope Set-SessionProperty $session 'INSTALLFOLDER' '' @@ -159,8 +171,8 @@ try { if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } } $folder = Get-SessionProperty $session 'INSTALLFOLDER' - $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } - if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } From 97eb5fdb83cd7b1dbefd89fe65ff3c13d0f2d471 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 27 Oct 2026 20:51:53 +0000 Subject: [PATCH 30/91] ci: use storytold/craft-common-actions at the merged installer-policy commit --- .github/workflows/windows-arm64.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index ea74625b9..8477823bf 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -28,7 +28,7 @@ permissions: jobs: windows-arm64: # Immutable supporting workflow revision until the new install contract lands upstream. - uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: lightcraft app-dir: LightCraft From 59d482578a7d74d20be804155063ff6ada0f7cfe Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 21:13:28 +0000 Subject: [PATCH 31/91] test(installer): cover the compiled folder searches, resolver chains and wizard folder replay The policy check now reads the compiled AppSearch and RegLocator rows for the remembered-folder properties (registry root, key, value name and type for both scopes) and runs the DIR, FOLDER, PATH and legacy exe actions in sequence order against seeded values, so their precedence and the preset-folder guard cannot regress. It also replays the wizard folder actions in sequence order (a remembered folder beats the new-install default, a typed folder beats both) and requires the machine restore action between the scope page and costing. --- packaging/windows/check-install-policy.ps1 | 76 +++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index cc9416e9c..5463c0083 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -143,7 +143,8 @@ try { $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and - $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreMachineFolder'] -and $ui['CraftRestoreMachineFolder'] -lt $ui['CostInitialize'])) { throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. @@ -174,6 +175,28 @@ try { $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } } + # Replay the wizard's compiled folder actions in sequence order, as the full UI runs them. A remembered + # folder must win over the new-install default, and a folder the user typed must survive both. + $uiFolderActions = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | + Where-Object { $_[0] -match '^Craft(Restore|Default)' } | Sort-Object { [int]$_[1] }) + $userDefault = (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' + foreach ($case in @( + @('1', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'E:\Remembered machine app'), + @('', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'D:\Remembered user app'), + @('1', '', '', '', $machineFolder), + @('', '', '', '', $userDefault), + @('1', 'D:\Remembered user app', 'E:\Remembered machine app', 'F:\Chosen exact app', 'F:\Chosen exact app'))) { + Set-SessionProperty $session 'Installed' '' + Set-SessionProperty $session 'ALLUSERS' $case[0] + Set-SessionProperty $session 'CRAFT_USER_DIR' $case[1] + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $case[2] + Set-SessionProperty $session 'INSTALLFOLDER' $case[3] + foreach ($row in $uiFolderActions) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + if ($folder -ine $case[4]) { throw "Full-UI folder for ALLUSERS='$($case[0])' is '$folder', expected '$($case[4])'." } + } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { @@ -186,6 +209,57 @@ try { throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' } } + # AppSearch itself never runs in this safe session, so check the compiled searches and then run the + # property actions that turn the search results into the remembered folder, once per scope. + $appSearch = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Signature_` FROM `AppSearch`' 2)) { $appSearch[$row[0]] = $row[1] } + $locators = @{} + foreach ($row in (Read-Rows $database 'SELECT `Signature_`, `Root`, `Key`, `Name`, `Type` FROM `RegLocator`' 5)) { $locators[$row[0]] = $row } + $appPaths = '^Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\[^\\]+\.exe$' + # Suffix, how to match the key, key, value name, locator type (2 = raw value, 1 = file; the 64-bit flag is masked off). + $searchContract = @( + @('DIR', 'exact', "Software\$($policy.name)", 'InstallDir', '2'), + @('FOLDER', 'exact', "Software\$($policy.name)", 'InstallFolder', '2'), + @('PATH', 'regex', $appPaths, 'Path', '2'), + @('EXEFOLDER', 'regex', $appPaths, '', '1'), + @('EXE', 'regex', $appPaths, '', '1')) + $resolverCases = @( + @{ name = 'every record, DIR wins'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Dir\' }, + @{ name = 'FOLDER wins without DIR'; dir = ''; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Folder\' }, + @{ name = 'PATH wins without DIR, FOLDER'; dir = ''; folder = ''; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Path\' }, + @{ name = 'legacy exe folder is last'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Exe\' }, + @{ name = 'legacy folder needs the exe'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = ''; existing = ''; expected = '' }, + @{ name = 'a preset folder is kept'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = 'P:\Preset\'; expected = 'P:\Preset\' }) + foreach ($scopeSpec in @(@('U', '1', 'CRAFT_USER_DIR'), @('M', '2', 'CRAFT_MACHINE_DIR'))) { + $letter = $scopeSpec[0]; $target = $scopeSpec[2] + foreach ($item in $searchContract) { + $property = "CRAFT_${letter}_$($item[0])" + $locator = if ($appSearch.ContainsKey($property)) { $locators[$appSearch[$property]] } else { $null } + $keyOk = $null -ne $locator -and $(if ($item[1] -eq 'exact') { $locator[2] -eq $item[2] } else { $locator[2] -match $item[2] }) + if (-not $keyOk -or $locator[1] -ne $scopeSpec[1] -or $locator[3] -ne $item[3] -or ([int]$locator[4] -band 3) -ne [int]$item[4]) { + throw "Compiled search for $property is missing or points at the wrong registry location." + } + } + $chain = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallExecuteSequence`' 3 | + Where-Object { $_[0] -match "^Craft${letter}From" } | Sort-Object { [int]$_[1] }) + $order = @($chain | ForEach-Object { $_[0] }) -join ',' + if ($order -ne "Craft${letter}FromDIR,Craft${letter}FromFOLDER,Craft${letter}FromPATH,Craft${letter}FromLegacyExe") { + throw "Remembered-folder resolvers for scope $letter run in the wrong order: $order" + } + foreach ($case in $resolverCases) { + Set-SessionProperty $session $target $case.existing + Set-SessionProperty $session "CRAFT_${letter}_DIR" $case.dir + Set-SessionProperty $session "CRAFT_${letter}_FOLDER" $case.folder + Set-SessionProperty $session "CRAFT_${letter}_PATH" $case.path + Set-SessionProperty $session "CRAFT_${letter}_EXEFOLDER" $case.exeFolder + Set-SessionProperty $session "CRAFT_${letter}_EXE" $case.exe + foreach ($row in $chain) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $got = Get-SessionProperty $session $target + if ($got -ne $case.expected) { throw "Resolver chain for scope $letter, case '$($case.name)': got '$got', expected '$($case.expected)'." } + } + } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) # Model the full-UI upgrade entry with an existing machine-wide copy. Run From 5dea67e3481007dedc8e8382bf4ffa290a4214a1 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 21:13:30 +0000 Subject: [PATCH 32/91] docs(installer): say that an upgrade reuses the recorded folder even if it was deleted The restore actions use the recorded folder without checking that it still exists, so an upgrade creates it again rather than using the default folder. --- docs/user/getting-started/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md index 255a6a5ed..4ea8b09f5 100644 --- a/docs/user/getting-started/install.md +++ b/docs/user/getting-started/install.md @@ -16,7 +16,7 @@ Download the file for your system from the [latest release](https://github.com/s For a Snapdragon or other Arm PC, download `lightcraft--windows-arm64.msi` instead. For a 32-bit Windows, download `lightcraft--windows-x86.msi`. -Upgrading from an installer older than 0.6.0 also keeps LightCraft in the folder it was installed in. If that folder was deleted, the upgrade installs to the default folder for the scope you choose. +Upgrading from an installer older than 0.6.0 also keeps LightCraft in the folder it was installed in. An upgrade uses the folder that the earlier install recorded and does not check that it still exists, so if you deleted that folder, the upgrade creates it again. To run it without installing, download `lightcraft--windows-x64-portable.zip`. Portable builds for `arm64` and `x86` are on the release page too. From e472ffd6a4e2dbe928db080794e3789c8c54fe9c Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Tue, 27 Oct 2026 16:32:15 -0400 Subject: [PATCH 33/91] MENU-FILE-CLOSE: document that closing the window quits the app --- docs/parity-checklist.md | 7 ++++--- docs/user/faq/closing-the-window.md | 29 +++++++++++++++++++++++++++++ docs/user/faq/index.md | 1 + 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 docs/user/faq/closing-the-window.md diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..762e6ad7d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -517,7 +517,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-FILE-SHOWFINDER | Show in Finder | P0 | ✅ | `cmd:app.showInFinder` | ⌘R; Explorer on Windows, the folder on Linux; disabled for demo scenes and on the web | | MENU-PHOTO-SHOWFOLDERS | Show in Folders | P2 | ✅ | `cmd:app.showInFolders`, `crates/ui-egui/src/panels/left.rs` (`folder_row_of`), tests `show_in_folders_*` in `tests_panels.rs` | Photo ▸ Show in Folders and the photo right-click menu: selects the active photo's folder in the sidebar's Folders section, opens the rows above it and scrolls to it; opens the Library sidebar if it is behind another tab; disabled for demo scenes and photos outside a library folder | | MENU-FILE-OFFLINE | Store album locally | P2 | 🚫 | | not applicable: local-first | -| MENU-FILE-CLOSE | Close Window | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's Close Window item at the end of File (⌘W) | +| MENU-FILE-CLOSE | Close Window | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's Close Window item at the end of File (⌘W); closing the main window quits the app, after the check Quit makes (running tasks that can stop, changes not yet written); the second window just closes; kept on purpose, user page `docs/user/faq/closing-the-window.md` | | MENU-EDIT-UNDO | Undo | P0 | ✅ | `cmd:edit.undo` | label does not name the step | | MENU-EDIT-REDO | Redo | P0 | ✅ | `cmd:edit.redo` | | | MENU-EDIT-COPYPASTE | Copy / paste (edit settings) | P0 | ✅ | `cmd:develop.copy`, `cmd:develop.paste` | | @@ -639,7 +639,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-HIDE | Hide / hide others — ⌘H / ⌥⌘H | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | native app-menu items | | KEY-QUIT | Quit — ⌘Q | P0 | ✅ | `cmd:app.quit` | ⌘Q (Ctrl+Q off macOS) | | KEY-CREATEVERSION | Create version — ⇧M | P1 | ✅ | `cmd:version.create` | secondary binding (primary ⌘⇧S) | -| KEY-CLOSEWINDOW | Close window — ⌘W | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | native File ▸ Close Window | +| KEY-CLOSEWINDOW | Close window — ⌘W | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | native File ▸ Close Window; with the main window in front it quits the app like ⌘Q (see MENU-FILE-CLOSE); the second window just closes | | KEY-DELETE | Delete photo — ⌫ | P0 | ✅ | `cmd:photo.delete`, `cmd:album.removePhotos` | in a normal album ⌫ removes from the album only (`album.removePhotos`, issue #1805); in a smart album it does nothing; elsewhere it deletes (to Recently Deleted) | | KEY-ADDPHOTOS | Import photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | | | KEY-VERSIONS | Versions panel — ⇧V | P1 | ✅ | `cmd:panel.versions` | | @@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | +| 2026-10-27 | patch | MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say that Close Window and the red close button of the main window quit the app, after the check Quit makes; kept on purpose; a user page says so; status unchanged (✅) | diff --git a/docs/user/faq/closing-the-window.md b/docs/user/faq/closing-the-window.md new file mode 100644 index 000000000..f6dc94f16 --- /dev/null +++ b/docs/user/faq/closing-the-window.md @@ -0,0 +1,29 @@ +--- +title: Does closing the window quit LightCraft? +description: On a Mac, closing the LightCraft window with the red button or ⌘W quits the app, after it checks for running tasks and unsaved changes. +order: 30 +verified: 0.14.0 +--- + +On a Mac, yes. Closing LightCraft's main window quits the app, the same as **LightCraft** > **Quit LightCraft** (⌘Q). Many Mac apps keep running with no window open; LightCraft doesn't. + +These all do the same thing when the main window is the one in front: + +- the red close button in the top-left corner of the window +- **File** > **Close Window** (⌘W) +- **LightCraft** > **Quit LightCraft** (⌘Q) + +## Does it ask first? + +Only when something would be lost: + +- A task that can be stopped is still running, such as an import or an export. LightCraft asks **Quit while tasks are running?** Choose **Cancel** to keep working, or **Quit Anyway** to stop the task and quit. +- Some changes couldn't be written to disk, for example because a drive was unplugged. LightCraft tries to save them again, and if that still fails it asks **Quit with unsaved changes?** Choose **Cancel** to stay, **Try Saving Again** once the drive is back, or **Quit Anyway** to give up those changes. + +With nothing running and everything saved, the window closes and LightCraft quits at once. Your library (including your edits) and settings are saved as the app quits. + +The second window (**Window** > **Second Window**) is different: its red button, or ⌘W while it is in front, closes only that window, and LightCraft keeps running. + +## Can I keep LightCraft open without a window? + +No. To get the window out of the way, minimize it with the yellow button or **Window** > **Minimize** (⌘M), or hide the app with **LightCraft** > **Hide LightCraft** (⌘H). Both keep your place and any running task going. diff --git a/docs/user/faq/index.md b/docs/user/faq/index.md index 1ddafea53..863b0d14a 100644 --- a/docs/user/faq/index.md +++ b/docs/user/faq/index.md @@ -6,3 +6,4 @@ order: 40 - [Is LightCraft free, and does it need an account?](free-and-private.md) - [Which cameras and file types does LightCraft open?](supported-files.md) +- [Does closing the window quit LightCraft?](closing-the-window.md) From 4f6f4c4ee885d0d7f3fdd2ace2385c9d7d71a024 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Tue, 27 Oct 2026 19:53:43 -0400 Subject: [PATCH 34/91] MENU-FILE-CLOSE: the page says saving at quit can fail --- docs/user/faq/closing-the-window.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/faq/closing-the-window.md b/docs/user/faq/closing-the-window.md index f6dc94f16..d83f94bbb 100644 --- a/docs/user/faq/closing-the-window.md +++ b/docs/user/faq/closing-the-window.md @@ -20,7 +20,7 @@ Only when something would be lost: - A task that can be stopped is still running, such as an import or an export. LightCraft asks **Quit while tasks are running?** Choose **Cancel** to keep working, or **Quit Anyway** to stop the task and quit. - Some changes couldn't be written to disk, for example because a drive was unplugged. LightCraft tries to save them again, and if that still fails it asks **Quit with unsaved changes?** Choose **Cancel** to stay, **Try Saving Again** once the drive is back, or **Quit Anyway** to give up those changes. -With nothing running and everything saved, the window closes and LightCraft quits at once. Your library (including your edits) and settings are saved as the app quits. +With nothing running and everything saved, the window closes and LightCraft quits at once. As the app quits, LightCraft saves your settings and closes your library. If either save fails, the error goes to the log file and the app quits anyway. The second window (**Window** > **Second Window**) is different: its red button, or ⌘W while it is in front, closes only that window, and LightCraft keeps running. From e60b7d1787d3e02f640988104f3f7f12470e00a2 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Tue, 27 Oct 2026 22:40:25 -0400 Subject: [PATCH 35/91] MENU-EDIT-SELECTNONE: Edit lists Select Only Active Photo and Paste Settings from Previous with their kin, and the native Edit menu keeps single separators --- apps/lightcraft/src/native_menu.rs | 36 +++++++++++++++++++---- crates/ui-egui/src/menus/menubar.rs | 2 ++ crates/ui-egui/src/menus/menubar/tests.rs | 17 +++++++++++ docs/parity-checklist.md | 7 +++-- docs/whats-new.md | 5 ++++ 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index a5a14e208..d25b56566 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -295,12 +295,20 @@ fn label_text(s: &str) -> String { lightcraft_ui_egui::i18n::tr(s).replace('&', "&&") } -/// Drop a trailing separator (left behind when an item moved to the app menu). -fn tidy_separators(mut nodes: Vec) -> Vec { - while matches!(nodes.last(), Some(MenuNode::Separator)) { - nodes.pop(); +/// Drop the separators left behind when an item moved to the app menu: a leading one, a trailing +/// one and the second of two in a row. +fn tidy_separators(nodes: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(nodes.len()); + for node in nodes { + if node == MenuNode::Separator && out.last().is_none_or(|last| *last == MenuNode::Separator) { + continue; + } + out.push(node); } - nodes + while out.last() == Some(&MenuNode::Separator) { + out.pop(); + } + out } impl NativeMenu { @@ -570,6 +578,24 @@ impl NativeMenu { mod tests { use super::*; + /// An item that moves to the app menu (Settings…) can sit between two separators, or open or + /// close the menu's list: the rows left behind keep at most one separator between them. + #[test] + fn rows_left_after_a_move_keep_single_separators() { + let item = |id: &str| MenuNode::Item { + id: id.to_string(), + label: id.to_string(), + shortcut: None, + enabled: true, + checked: None, + params: Value::Null, + }; + let sep = || MenuNode::Separator; + let tidy = tidy_separators(vec![sep(), item("a"), sep(), sep(), item("b"), sep(), sep()]); + assert_eq!(tidy, vec![item("a"), sep(), item("b")]); + assert_eq!(tidy_separators(vec![sep(), sep()]), Vec::::new()); + } + /// The menu model's titles stay English, so only the language can tell the native menu to /// rebuild its titles (and the macOS app menu): every language switch — also between two CJK /// languages — must change the structure key. diff --git a/crates/ui-egui/src/menus/menubar.rs b/crates/ui-egui/src/menus/menubar.rs index 5c1bcb9fc..d96674306 100644 --- a/crates/ui-egui/src/menus/menubar.rs +++ b/crates/ui-egui/src/menus/menubar.rs @@ -167,11 +167,13 @@ const LAYOUT: &[(&str, &[&str])] = &[ "dialog.copySettings", "develop.paste", "dialog.pasteSettings", + "develop.pastePrevious", "develop.sync", "develop.autoSync", "---", "library.selectAll", "library.selectNone", + "library.selectOnlyActive", "@Select by", "---", "view.focusSearch", diff --git a/crates/ui-egui/src/menus/menubar/tests.rs b/crates/ui-egui/src/menus/menubar/tests.rs index 1e2fe388d..c1b4251d3 100644 --- a/crates/ui-egui/src/menus/menubar/tests.rs +++ b/crates/ui-egui/src/menus/menubar/tests.rs @@ -823,3 +823,20 @@ fn no_two_menu_commands_share_a_label() { } assert!(clashes.is_empty(), "{clashes:#?}"); } + +/// Edit lists Select Only Active Photo with the other Select rows and Paste Settings from Previous +/// with the other Paste rows, not after Settings… at the very end of the menu. +#[test] +fn edit_menu_places_the_select_and_paste_rows_with_their_kin() { + let a = app(); + let bar = menu_bar(&a); + let nodes = &bar.iter().find(|(title, _)| title == "Edit").unwrap().1; + let pos = |id: &str| { + nodes.iter().position(|n| matches!(n, MenuNode::Item { id: i, .. } if i == id)).unwrap_or_else(|| panic!("{id} is a top-level Edit row")) + }; + assert_eq!(pos("library.selectOnlyActive"), pos("library.selectNone") + 1, "Select Only Active Photo follows Deselect All"); + assert_eq!(pos("develop.pastePrevious"), pos("dialog.pasteSettings") + 1, "Paste Settings from Previous follows Paste Selected Settings…"); + assert!(pos("app.settings") > pos("library.selectOnlyActive"), "Settings… stays after them"); + let after = &nodes[pos("app.settings") + 1..]; + assert!(!after.iter().any(|n| matches!(n, MenuNode::Item { .. })), "no row follows Settings… but a submenu: {after:?}"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..4d3d4934f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -522,9 +522,9 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-EDIT-REDO | Redo | P0 | ✅ | `cmd:edit.redo` | | | MENU-EDIT-COPYPASTE | Copy / paste (edit settings) | P0 | ✅ | `cmd:develop.copy`, `cmd:develop.paste` | | | MENU-EDIT-CHOOSECOPY | Choose Edit Settings to Copy… | P0 | ✅ | `cmd:dialog.copySettings` | Return copies, Esc cancels | -| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; only copied groups can be pasted; Return pastes, Esc cancels | +| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; only copied groups can be pasted; Return pastes, Esc cancels; Paste Settings from Previous (⌥⌘V) follows it | | MENU-EDIT-SELECTALL | Select All | P0 | ✅ | `cmd:library.selectAll` | | -| MENU-EDIT-SELECTNONE | Select None | P0 | ✅ | `cmd:library.selectNone` | | +| MENU-EDIT-SELECTNONE | Select None | P0 | ✅ | `cmd:library.selectNone` | Select Only Active Photo (⇧⌘D) follows it | | MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ✅ | `cmd:library.selectBy` | Edit → Select by: picks, rejects, unflagged, ★…★★★★★ and higher, unrated, colour labels; `add` extends the selection | | MENU-EDIT-FIND | Find… | P0 | ✅ | `cmd:view.focusSearch`, `crates/ui-egui/src/panels/topbar.rs` | ⌘F focuses the search field | | MENU-VIEW-PHOTOGRID | Photo Grid | P0 | ✅ | `cmd:view.photoGrid` | | @@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | +| 2026-10-27 | patch | MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Select Only Active Photo and Paste Settings from Previous were not in the Edit menu's layout and ended up after Settings…, near the end of the menu; they now sit with the other Select and Paste rows. On macOS the Edit menu no longer shows two separators in a row where Settings… moved to the app menu; status unchanged (✅) | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..0c177ccf8 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,10 @@ # What's new in LightCraft +## 2026-10-27 + +### The Edit menu groups Select Only Active Photo and Paste Settings from Previous with their kin +- Select Only Active Photo now sits under Deselect All, and Paste Settings from Previous under Paste Selected Settings…, instead of both after Settings…, near the end of the Edit menu. On macOS, where Settings… lives in the application menu, the Edit menu no longer shows two separator lines in a row. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock From d85d975b3c042821638c32c84b6ab949eed78c6d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Tue, 27 Oct 2026 23:10:31 -0400 Subject: [PATCH 36/91] MENU-EDIT-SELECTNONE: reword the Edit layout test's comment --- crates/ui-egui/src/menus/menubar/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus/menubar/tests.rs b/crates/ui-egui/src/menus/menubar/tests.rs index c1b4251d3..72fc077d1 100644 --- a/crates/ui-egui/src/menus/menubar/tests.rs +++ b/crates/ui-egui/src/menus/menubar/tests.rs @@ -825,7 +825,7 @@ fn no_two_menu_commands_share_a_label() { } /// Edit lists Select Only Active Photo with the other Select rows and Paste Settings from Previous -/// with the other Paste rows, not after Settings… at the very end of the menu. +/// with the other Paste rows, not after Settings…, near the end of the menu. #[test] fn edit_menu_places_the_select_and_paste_rows_with_their_kin() { let a = app(); From 56b5c8b076d143f7f931524e0a9e36271dcac2e7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:18:52 -0400 Subject: [PATCH 37/91] Scan the whole repository again in SonarCloud (#3224) * Scan the whole repository again in SonarCloud * Classify the repository's test files as tests in SonarCloud --- sonar-project.properties | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/sonar-project.properties b/sonar-project.properties index 717d1dfac..462b15b79 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,8 +2,11 @@ sonar.projectKey=storytold_lightcraft sonar.organization=storytold sonar.coverage.exclusions=**/tools/**,**/docs/**,**/examples/** -# Keep packaging tests analyzed as tests, outside production coverage metrics. -sonar.tests=packaging/linux/flatpak -sonar.test.inclusions=packaging/linux/flatpak/test_*.py -sonar.exclusions=packaging/linux/flatpak/test_*.py +# Setting sonar.tests without sonar.sources stops the scanner defaulting to the whole repository, +# which left only the packaging test indexed and the main branch analysed as empty. Sources and +# tests both start at the root; sonar.test.inclusions picks out the test files, so they are +# analysed as tests (outside production metrics and coverage) rather than left out. +sonar.sources=. +sonar.tests=. +sonar.test.inclusions=**/tests/**,**/tests.rs,**/tests_*.rs,**/*_tests.rs,**/test_*.py sonar.python.coverage.reportPaths=python-coverage.xml From 96c3190300a4e31f7ccf612250ee9e859b89c1cf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 26 Oct 2026 20:09:47 -0400 Subject: [PATCH 38/91] chore(clippy): enforce workspace lint rules --- Cargo.toml | 4 +++- clippy.toml | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 42d4e5209..cd85d80f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,9 +122,11 @@ web-sys = "0.3" unsafe_code = "deny" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" needless_range_loop = "allow" type_complexity = "allow" excessive_precision = "allow" diff --git a/clippy.toml b/clippy.toml index 287e14edc..3522a15b6 100644 --- a/clippy.toml +++ b/clippy.toml @@ -5,3 +5,4 @@ allow-indexing-slicing-in-tests = true disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] +cognitive-complexity-threshold = 30 From c7ef259f36d0f06fdfc9fa7af69393b46825520d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 02:54:53 +0000 Subject: [PATCH 39/91] refactor(heif): isolate SPS VUI parsing --- crates/heif/src/vui.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/heif/src/vui.rs b/crates/heif/src/vui.rs index 226604303..6bc629fa5 100644 --- a/crates/heif/src/vui.rs +++ b/crates/heif/src/vui.rs @@ -201,6 +201,11 @@ fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option Option { let r = &mut Bits { data: rbsp, pos: 0 }; let (_, _, max_sub_layers_minus1) = sps_size(r)?; + parse_sps_parameters(r, max_sub_layers_minus1)?; + parse_vui(r) +} + +fn parse_sps_parameters(r: &mut Bits<'_>, max_sub_layers_minus1: u32) -> Option<()> { if r.flag()? { for _ in 0..4 { r.ue()?; // conf_win_*_offset @@ -253,6 +258,10 @@ fn parse_sps(rbsp: &[u8]) -> Option { } } r.skip(2)?; // sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag + Some(()) +} + +fn parse_vui(r: &mut Bits<'_>) -> Option { let mut signal = Signal::default(); if !r.flag()? { return Some(signal); // no VUI From 59083081aefac0d390626518512f066971420f53 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 02:58:48 +0000 Subject: [PATCH 40/91] fix(sysmem): inherit workspace Clippy denies --- crates/sysmem/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/sysmem/Cargo.toml b/crates/sysmem/Cargo.toml index 422d8c711..a7f0492c6 100644 --- a/crates/sysmem/Cargo.toml +++ b/crates/sysmem/Cargo.toml @@ -12,6 +12,9 @@ description = "LightCraft sysmem (L0): returns freed allocator memory to the OS. unsafe_code = "deny" [lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" From 32e4874569b739ef0163dbd3f7a6daa0ace490aa Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 03:02:08 +0000 Subject: [PATCH 41/91] docs: explain workspace lint inheritance --- CLAUDE.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 416ffc0a8..6034902e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,10 +52,12 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native. - **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix. -- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate - root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, - clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit - integration tests, examples and benches. New crates start with the attribute. +- Enforced by clippy: root `clippy.toml` keeps unwrap/expect/panic/indexing allowances scoped to tests. The workspace + denies `cognitive_complexity`, `too_many_arguments` and `wildcard_imports`; members inherit them with + `[lints] workspace = true`, and `sysmem` repeats those denies in its local lint table. Every production crate root + (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, + clippy::unimplemented, clippy::todo, clippy::unreachable)]`. New crates inherit workspace lints and start with the + attribute. ## Non-negotiables - **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…). From 38c3232214e5bea3722a2551c385306b44b504b6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 11:51:09 +0000 Subject: [PATCH 42/91] refactor(meta): isolate descriptive metadata merging --- crates/meta/src/lib.rs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 46c7a1043..07887bbdc 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -187,7 +187,16 @@ impl Metadata { orientation, gps, width, - height, + height + ); + self.fill_missing_descriptive(other); + } + + /// Fill descriptive metadata separately from the camera and image properties. + fn fill_missing_descriptive(&mut self, other: &Metadata) { + fill!( + self, + other, artist, copyright, copyright_marked, @@ -405,11 +414,22 @@ mod tests { #[test] fn merge_rules() { - let mut a = Metadata { make: Some("A".into()), rating: Some(1), ..Default::default() }; - let b = Metadata { make: Some("B".into()), model: Some("M".into()), rating: Some(4), keywords: vec!["k".into()], ..Default::default() }; + let mut a = Metadata { make: Some("A".into()), title: Some("Original".into()), rating: Some(1), ..Default::default() }; + let b = Metadata { + make: Some("B".into()), + model: Some("M".into()), + title: Some("Replacement".into()), + caption: Some("Caption".into()), + rating: Some(4), + keywords: vec!["k".into()], + ..Default::default() + }; a.fill_missing(&b); assert_eq!(a.make.as_deref(), Some("A")); assert_eq!(a.model.as_deref(), Some("M")); + assert_eq!(a.title.as_deref(), Some("Original")); + assert_eq!(a.caption.as_deref(), Some("Caption")); + assert_eq!(a.keywords, vec!["k".to_string()]); assert_eq!(a.rating, Some(1)); a.overlay_user_fields(&b); assert_eq!(a.rating, Some(4)); From c1766c016b6d68b61750c6ac69e42bc95939c5ba Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 12:32:17 +0000 Subject: [PATCH 43/91] refactor(scenes): model water lighting parameters --- crates/scenes/src/paint.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/scenes/src/paint.rs b/crates/scenes/src/paint.rs index 4f3c4560b..32ca0f053 100644 --- a/crates/scenes/src/paint.rs +++ b/crates/scenes/src/paint.rs @@ -179,7 +179,14 @@ fn pine(p: &P, x: f32, v: f32, ground: f32, height: f32, density: f32) -> Option best } -fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> C, tint: C, sun: (f32, f32), sun_col: C) -> C { +struct WaterLight { + tint: C, + sun: (f32, f32), + sun_col: C, +} + +fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> C, light: WaterLight) -> C { + let WaterLight { tint, sun, sun_col } = light; let d = v - wl; let rip = fbm(x * 9.0, d * 60.0 / (d + 0.05), p.seed ^ 0xaa, 4) * (1.0 - calm); let rv = (wl - d * 1.0 + rip * (0.01 + 0.06 * (1.0 - calm)) * (d * 10.0 + 0.2)).max(0.0); @@ -340,7 +347,7 @@ fn alpine(p: &P, x: f32, v: f32, blue: bool) -> C { }; if v > wl { let tint = if blue { hex(0x1a2440) } else { hex(0x20303a) }; - return water(p, x, v, wl, 0.75, &above, tint, sun, sun_col); + return water(p, x, v, wl, 0.75, &above, WaterLight { tint, sun, sun_col }); } above(x, v) } @@ -419,7 +426,7 @@ fn ocean(p: &P, x: f32, v: f32) -> C { if v < hz + head * 0.3 { return hex(0x120c14); } - return water(p, x, v, hz, 0.35, &above, hex(0x1a2040), sun, sun_col); + return water(p, x, v, hz, 0.35, &above, WaterLight { tint: hex(0x1a2040), sun, sun_col }); } let _ = head; above(x, v) @@ -465,7 +472,7 @@ fn aurora(p: &P, x: f32, v: f32) -> C { add(sky(p, &sk, x, v), aur(x, v)) }; if v > wl { - return water(p, x, v, wl, 0.9, &above, hex(0x020408), (-5.0, 5.0), [0.0; 3]); + return water(p, x, v, wl, 0.9, &above, WaterLight { tint: hex(0x020408), sun: (-5.0, 5.0), sun_col: [0.0; 3] }); } above(x, v) } From 2c7d98b5e24c71d4df2fd24f090dc0877c4d69c8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 12:46:49 +0000 Subject: [PATCH 44/91] refactor(raw): separate DNG metadata and opcode parsing --- crates/raw/src/dngwrite.rs | 92 +++++----- crates/raw/src/opcodes.rs | 243 +++++++++++++++----------- crates/raw/src/tiffraw.rs | 2 +- crates/raw/tests/it/crx_properties.rs | 76 +++++--- 4 files changed, 246 insertions(+), 167 deletions(-) diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index fae6015b6..bdafb5865 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -284,48 +284,8 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { if let Some(v) = c.baseline_sharpness { ifd.set(t::BASELINE_SHARPNESS, rat_vec(&[v])); } - // the source's own profile look travels with its data (conversions, smart previews, merges) - let p = &c.profile; - let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| { - ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32])); - if table.srgb_value { - ifd.set(enc, Value::Long(vec![1])); - } - }; - let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect()); - match &p.hue_sat_map { - [Some(a), b] => { - table_tags(&mut ifd, a, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); - ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(a)); - if let Some(b) = - b.as_ref().filter(|b| (b.hue_divisions, b.sat_divisions, b.val_divisions) == (a.hue_divisions, a.sat_divisions, a.val_divisions)) - { - ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(b)); - } - } - [None, Some(b)] => { - table_tags(&mut ifd, b, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); - ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(b)); - } - [None, None] => {} - } - if let Some(l) = &p.look_table { - table_tags(&mut ifd, l, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING); - ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(l)); - } - if let Some(curve) = &p.tone_curve { - ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect())); - } - if let Some(map) = &p.gain_table_map { - // the raw IFD is IFD 0 here, where both tag versions are valid - let (version2, bytes) = map.to_bytes(opts.order); - ifd.set(if version2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP }, Value::Undefined(bytes)); - } - for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] { - if !list.is_empty() { - ifd.set(tag, Value::Undefined(opcodes::write_list(list))); - } - } + write_profile_tags(&mut ifd, raw, opts.order); + write_opcode_tags(&mut ifd, raw); match (&raw.data, opts.compression) { (RawData::F32(v), DngCompression::Deflate { tile, half }) => { @@ -495,6 +455,54 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { Ok(TiffWriter::new(opts.order, false).write(&[ifd])?) } +fn write_profile_tags(ifd: &mut IfdBuilder, raw: &RawImage, order: ByteOrder) { + // The source's own profile look travels with its data (conversions, smart previews, merges). + let profile = &raw.color.profile; + let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| { + ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32])); + if table.srgb_value { + ifd.set(enc, Value::Long(vec![1])); + } + }; + let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect()); + match &profile.hue_sat_map { + [Some(first), second] => { + table_tags(ifd, first, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(first)); + if let Some(second) = second.as_ref().filter(|second| { + (second.hue_divisions, second.sat_divisions, second.val_divisions) == (first.hue_divisions, first.sat_divisions, first.val_divisions) + }) { + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(second)); + } + } + [None, Some(table)] => { + table_tags(ifd, table, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(table)); + } + [None, None] => {} + } + if let Some(table) = &profile.look_table { + table_tags(ifd, table, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING); + ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(table)); + } + if let Some(curve) = &profile.tone_curve { + ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect())); + } + if let Some(map) = &profile.gain_table_map { + // The raw IFD is IFD 0 here, where both tag versions are valid. + let (version2, bytes) = map.to_bytes(order); + ifd.set(if version2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP }, Value::Undefined(bytes)); + } +} + +fn write_opcode_tags(ifd: &mut IfdBuilder, raw: &RawImage) { + for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] { + if !list.is_empty() { + ifd.set(tag, Value::Undefined(opcodes::write_list(list))); + } + } +} + /// Exif `LightSource` code of a white point the rendered DNG writer knows (D65 = 21, D50 = 23). fn illuminant_of(white: lightcraft_color::Xy) -> u16 { if (white.x - lightcraft_color::D50.x).abs() < 1e-3 && (white.y - lightcraft_color::D50.y).abs() < 1e-3 { 23 } else { 21 } diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index df85e2703..f7750aaac 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -253,6 +253,9 @@ impl Rd<'_> { self.p += 8; Some(v) } + fn finite_f64(&mut self) -> Option { + self.f64().filter(|v| v.is_finite()) + } fn f32(&mut self) -> Option { let v = f32::from_be_bytes(self.d.get(self.p..self.p + 4)?.try_into().ok()?); self.p += 4; @@ -276,109 +279,126 @@ impl Rd<'_> { } fn parse_one(id: u32, flags: u32, params: &[u8]) -> Option { - let mut r = Rd { d: params, p: 0 }; - let f = |r: &mut Rd| r.f64().filter(|v| v.is_finite()); - Some(match id { + let mut reader = Rd { d: params, p: 0 }; + let parsed = match id { + 1 | 2 | 3 | 14 => parse_warp(id, flags, &mut reader)?, + 4 => Opcode::FixBadPixelsConstant { constant: reader.u32()?, bayer_phase: reader.u32()? }, + 5 => parse_bad_pixel_list(&mut reader)?, + 6 => Opcode::TrimBounds { top: reader.u32()?, left: reader.u32()?, bottom: reader.u32()?, right: reader.u32()? }, + 7..=13 => parse_area_opcode(id, &mut reader)?, + _ => Opcode::Unknown { id, flags, params: params.to_vec() }, + }; + Some(parsed) +} + +fn parse_warp(id: u32, flags: u32, reader: &mut Rd<'_>) -> Option { + match id { 1 => { - let n = r.u32()? as usize; - if n == 0 || n > 4 { - return None; - } - let mut planes = Vec::with_capacity(n); - for _ in 0..n { - planes.push([f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?]); - } - Opcode::WarpRectilinear { planes, center: [f(&mut r)?, f(&mut r)?] } + let planes = parse_planes(reader, 4, |r| { + Some([r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?]) + })?; + Some(Opcode::WarpRectilinear { planes, center: [reader.finite_f64()?, reader.finite_f64()?] }) } 2 => { - let n = r.u32()? as usize; - if n == 0 || n > 4 { - return None; - } - let mut planes = Vec::with_capacity(n); - for _ in 0..n { - planes.push([f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?]); - } - Opcode::WarpFisheye { planes, center: [f(&mut r)?, f(&mut r)?] } + let planes = parse_planes(reader, 4, |r| Some([r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?]))?; + Some(Opcode::WarpFisheye { planes, center: [reader.finite_f64()?, reader.finite_f64()?] }) } - 14 => { - let n = r.u32()? as usize; - // N sets of 19 doubles, then the centre (2 doubles) and reciprocalRadial (LONG); nothing else - if n == 0 || n > WR2_MAX_PLANES || params.len() != 4 + n * WR2_LEN * 8 + 16 + 4 { - return None; - } - let mut planes = Vec::with_capacity(n); - for _ in 0..n { - let mut k = [0.0; WR2_LEN]; - for v in k.iter_mut() { - *v = f(&mut r).filter(|v| v.abs() <= WR2_MAX_COEF)?; - } - // the spec requires 0 <= min_valid_radius < max_valid_radius <= 1 - if !(k[17] >= 0.0 && k[17] < k[18] && k[18] <= 1.0) { - return None; - } - planes.push(k); - } - let center = [f(&mut r).filter(|v| v.abs() <= 4.0)?, f(&mut r).filter(|v| v.abs() <= 4.0)?]; - Opcode::WarpRectilinear2 { planes, center, reciprocal: r.u32()? != 0, optional: flags & 1 != 0 } + 3 => Some(Opcode::FixVignetteRadial { + k: [reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?], + center: [reader.finite_f64()?, reader.finite_f64()?], + }), + 14 => parse_warp_rectilinear2(flags, reader), + _ => None, + } +} + +fn parse_planes(reader: &mut Rd<'_>, max: usize, mut parse: impl FnMut(&mut Rd<'_>) -> Option<[f64; N]>) -> Option> { + let count = reader.u32()? as usize; + if count == 0 || count > max { + return None; + } + (0..count).map(|_| parse(reader)).collect() +} + +fn parse_warp_rectilinear2(flags: u32, reader: &mut Rd<'_>) -> Option { + let count = reader.u32()? as usize; + // N sets of 19 doubles, then the centre (2 doubles) and reciprocalRadial (LONG); nothing else. + if count == 0 || count > WR2_MAX_PLANES || reader.remaining() != count * WR2_LEN * 8 + 16 + 4 { + return None; + } + let mut planes = Vec::with_capacity(count); + for _ in 0..count { + let mut coefficients = [0.0; WR2_LEN]; + for value in &mut coefficients { + *value = reader.finite_f64().filter(|value| value.abs() <= WR2_MAX_COEF)?; } - 3 => Opcode::FixVignetteRadial { k: [f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?], center: [f(&mut r)?, f(&mut r)?] }, - 4 => Opcode::FixBadPixelsConstant { constant: r.u32()?, bayer_phase: r.u32()? }, - 5 => { - let bayer_phase = r.u32()?; - let (np, nr) = (r.u32()? as usize, r.u32()? as usize); - if np.saturating_mul(8).saturating_add(nr.saturating_mul(16)) > r.remaining() { - return None; - } - let points = (0..np).map(|_| Some((r.u32()?, r.u32()?))).collect::>()?; - let rects = (0..nr).map(|_| Some([r.u32()?, r.u32()?, r.u32()?, r.u32()?])).collect::>()?; - Opcode::FixBadPixelsList { bayer_phase, points, rects } + if !(coefficients[17] >= 0.0 && coefficients[17] < coefficients[18] && coefficients[18] <= 1.0) { + return None; } - 6 => Opcode::TrimBounds { top: r.u32()?, left: r.u32()?, bottom: r.u32()?, right: r.u32()? }, + planes.push(coefficients); + } + let center = [reader.finite_f64().filter(|value| value.abs() <= 4.0)?, reader.finite_f64().filter(|value| value.abs() <= 4.0)?]; + Some(Opcode::WarpRectilinear2 { planes, center, reciprocal: reader.u32()? != 0, optional: flags & 1 != 0 }) +} + +fn parse_bad_pixel_list(reader: &mut Rd<'_>) -> Option { + let bayer_phase = reader.u32()?; + let (point_count, rect_count) = (reader.u32()? as usize, reader.u32()? as usize); + if point_count.saturating_mul(8).saturating_add(rect_count.saturating_mul(16)) > reader.remaining() { + return None; + } + let points = (0..point_count).map(|_| Some((reader.u32()?, reader.u32()?))).collect::>()?; + let rects = (0..rect_count).map(|_| Some([reader.u32()?, reader.u32()?, reader.u32()?, reader.u32()?])).collect::>()?; + Some(Opcode::FixBadPixelsList { bayer_phase, points, rects }) +} + +fn parse_area_opcode(id: u32, reader: &mut Rd<'_>) -> Option { + let area = reader.area()?; + match id { 7 => { - let area = r.area()?; - let n = r.u32()? as usize; - if n == 0 || n > 65536 || n * 2 > r.remaining() { + let count = reader.u32()? as usize; + if count == 0 || count > 65536 || count * 2 > reader.remaining() { return None; } - Opcode::MapTable { area, table: (0..n).map(|_| r.u16()).collect::>()? } + Some(Opcode::MapTable { area, table: (0..count).map(|_| reader.u16()).collect::>()? }) } 8 => { - let area = r.area()?; - let deg = r.u32()? as usize; - if deg > 8 { + let degree = reader.u32()? as usize; + if degree > 8 { return None; } - Opcode::MapPolynomial { area, coefficients: (0..=deg).map(|_| f(&mut r)).collect::>()? } + Some(Opcode::MapPolynomial { area, coefficients: (0..=degree).map(|_| reader.finite_f64()).collect::>()? }) } - 9 => { - let area = r.area()?; - let (pv, ph) = (r.u32()?, r.u32()?); - let spacing = [f(&mut r)?, f(&mut r)?]; - let origin = [f(&mut r)?, f(&mut r)?]; - let mp = r.u32()?; - let n = (pv as usize).checked_mul(ph as usize)?.checked_mul(mp as usize)?; - if n == 0 || n * 4 > r.remaining() { - return None; - } - let gains = (0..n).map(|_| r.f32()).collect::>()?; - Opcode::GainMap { area, points_v: pv, points_h: ph, spacing, origin, map_planes: mp, gains } - } - 10..=13 => { - let area = r.area()?; - let n = r.u32()? as usize; - if n * 4 > r.remaining() { - return None; - } - let v: Vec = (0..n).map(|_| r.f32()).collect::>()?; - match id { - 10 => Opcode::DeltaPerRow { area, deltas: v }, - 11 => Opcode::DeltaPerColumn { area, deltas: v }, - 12 => Opcode::ScalePerRow { area, scales: v }, - _ => Opcode::ScalePerColumn { area, scales: v }, - } - } - _ => Opcode::Unknown { id, flags, params: params.to_vec() }, + 9 => parse_gain_map(area, reader), + 10..=13 => parse_delta_scale(id, area, reader), + _ => None, + } +} + +fn parse_gain_map(area: Area, reader: &mut Rd<'_>) -> Option { + let (points_v, points_h) = (reader.u32()?, reader.u32()?); + let spacing = [reader.finite_f64()?, reader.finite_f64()?]; + let origin = [reader.finite_f64()?, reader.finite_f64()?]; + let map_planes = reader.u32()?; + let count = (points_v as usize).checked_mul(points_h as usize)?.checked_mul(map_planes as usize)?; + if count == 0 || count * 4 > reader.remaining() { + return None; + } + let gains = (0..count).map(|_| reader.f32()).collect::>()?; + Some(Opcode::GainMap { area, points_v, points_h, spacing, origin, map_planes, gains }) +} + +fn parse_delta_scale(id: u32, area: Area, reader: &mut Rd<'_>) -> Option { + let count = reader.u32()? as usize; + if count * 4 > reader.remaining() { + return None; + } + let values: Vec = (0..count).map(|_| reader.f32()).collect::>()?; + Some(match id { + 10 => Opcode::DeltaPerRow { area, deltas: values }, + 11 => Opcode::DeltaPerColumn { area, deltas: values }, + 12 => Opcode::ScalePerRow { area, scales: values }, + _ => Opcode::ScalePerColumn { area, scales: values }, }) } @@ -508,19 +528,29 @@ fn area_iter(a: &Area, w: usize, h: usize, cpp: usize) -> impl Iterator f32 { - let (pv, ph) = (points_v as usize, points_h as usize); - let mp = map_planes as usize; - let p = plane.min(mp - 1); - let fy = if spacing[0] > 0.0 { ((rv - origin[0]) / spacing[0]).clamp(0.0, (pv - 1) as f64) } else { 0.0 }; - let fx = if spacing[1] > 0.0 { ((rh - origin[1]) / spacing[1]).clamp(0.0, (ph - 1) as f64) } else { 0.0 }; - let (y0, x0) = (fy.floor() as usize, fx.floor() as usize); - let (y1, x1) = ((y0 + 1).min(pv - 1), (x0 + 1).min(ph - 1)); - let (ty, tx) = ((fy - y0 as f64) as f32, (fx - x0 as f64) as f32); - let g = |y: usize, x: usize| gains[(y * ph + x) * mp + p]; - let top = g(y0, x0) + (g(y0, x1) - g(y0, x0)) * tx; - let bot = g(y1, x0) + (g(y1, x1) - g(y1, x0)) * tx; - top + (bot - top) * ty +struct GainMapGrid<'a> { + points: [usize; 2], + spacing: [f64; 2], + origin: [f64; 2], + planes: usize, + gains: &'a [f32], +} + +impl GainMapGrid<'_> { + fn sample_at(&self, position: [f64; 2], plane: usize) -> f32 { + let [pv, ph] = self.points; + let mp = self.planes; + let p = plane.min(mp - 1); + let fy = if self.spacing[0] > 0.0 { ((position[0] - self.origin[0]) / self.spacing[0]).clamp(0.0, (pv - 1) as f64) } else { 0.0 }; + let fx = if self.spacing[1] > 0.0 { ((position[1] - self.origin[1]) / self.spacing[1]).clamp(0.0, (ph - 1) as f64) } else { 0.0 }; + let (y0, x0) = (fy.floor() as usize, fx.floor() as usize); + let (y1, x1) = ((y0 + 1).min(pv - 1), (x0 + 1).min(ph - 1)); + let (ty, tx) = ((fy - y0 as f64) as f32, (fx - x0 as f64) as f32); + let g = |y: usize, x: usize| self.gains[(y * ph + x) * mp + p]; + let top = g(y0, x0) + (g(y0, x1) - g(y0, x0)) * tx; + let bot = g(y1, x0) + (g(y1, x1) - g(y1, x0)) * tx; + top + (bot - top) * ty + } } /// Radius normaliser: distance from the centre to the farthest image corner. @@ -556,10 +586,17 @@ pub fn apply_list(list: &[Opcode], buf: &mut [f32], w: usize, h: usize, cpp: usi } } Opcode::GainMap { area, points_v, points_h, spacing, origin, map_planes, gains } => { + let grid = GainMapGrid { + points: [*points_v as usize, *points_h as usize], + spacing: *spacing, + origin: *origin, + planes: *map_planes as usize, + gains, + }; for (x, y, p) in area_iter(area, w, h, cpp) { let rv = y as f64 / h as f64; let rh = x as f64 / w as f64; - let g = gain_at(*points_v, *points_h, *spacing, *origin, *map_planes, gains, rv, rh, p - area.plane as usize); + let g = grid.sample_at([rv, rh], p - area.plane as usize); buf[(y * w + x) * cpp + p] *= g; } } diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 57878b881..b6a1971ce 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -1,6 +1,6 @@ //! Reading the pixel data of a TIFF IFD (strips or tiles; uncompressed, lossless/lossy JPEG, Deflate, JPEG XL), in parallel. -use crate::unpack::*; +use crate::unpack::{f16_to_f32, f24_to_f32, inflate, read_u16s, undo_diff_u16, undo_float_predictor, unpack_lsb, unpack_msb, unpack_words32_msb}; use crate::{MAX_SAMPLES, RawData, RawError, Result, ljpeg}; use lightcraft_tiff::image::{Chunk, ImageInfo, chunk_bytes}; use lightcraft_tiff::{ByteOrder, tags::compression as comp}; diff --git a/crates/raw/tests/it/crx_properties.rs b/crates/raw/tests/it/crx_properties.rs index 804970de4..ec61aa3b8 100644 --- a/crates/raw/tests/it/crx_properties.rs +++ b/crates/raw/tests/it/crx_properties.rs @@ -632,20 +632,23 @@ fn lossless_file((width, height): (usize, usize), values: &[i32]) -> Fixture { /// Constant-coefficient planes for one tile: LL3 flat at `ll3`, band `mid_band.0` flat at `mid_band.1`, /// everything else zero. Encoded at the given level count with the given quant/gain/base parameters. -fn constant_band_planes( - plane_w: usize, - plane_h: usize, +struct ConstantBandPlaneConfig<'a> { + plane_size: (usize, usize), levels: u8, ll3: i32, mid_band: Option<(usize, i32)>, quant: u8, gains: [u32; 10], bases: [u32; 10], - qp: Option<&[i32]>, + qp: Option<&'a [i32]>, qp_padding: usize, - before: bool, - after: bool, -) -> (usize, Vec) { + boundary_bands: (bool, bool), +} + +fn constant_band_planes(config: ConstantBandPlaneConfig<'_>) -> (usize, Vec) { + let ConstantBandPlaneConfig { plane_size: (plane_w, plane_h), levels, ll3, mid_band, quant, gains, bases, qp, qp_padding, boundary_bands } = + config; + let (before, after) = boundary_bands; let planes: [Vec>; 4] = if levels == 0 { let mut encoded: [Vec>; 4] = Default::default(); for bands in encoded.iter_mut() { @@ -681,7 +684,18 @@ fn constant_band_planes( /// A 16×16 three-level C-RAW file built from constant bands at the given uniform quant value. fn constant_bands_file(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Fixture { - let (header_size, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false); + let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig { + plane_size: (8, 8), + levels: 3, + ll3, + mid_band, + quant, + gains: [0; 10], + bases: [0; 10], + qp: None, + qp_padding: 0, + boundary_bands: (false, false), + }); let coding = cmp1(0x100, 16, 16, 16, 16, 3, header_size as u32); Fixture { file: wrap(&coding, 16, 16, &sample, None), expected: Vec::new() } } @@ -697,7 +711,18 @@ fn v200_mapped_file( bases: [u32; 10], ) -> Fixture { assert_eq!(map.len(), (width / 16) * (height / 4)); - let (header_size, sample) = constant_band_planes(width / 2, height / 2, 3, ll3, mid_band, 4, gains, bases, Some(map), 3, false, false); + let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig { + plane_size: (width / 2, height / 2), + levels: 3, + ll3, + mid_band, + quant: 4, + gains, + bases, + qp: Some(map), + qp_padding: 3, + boundary_bands: (false, false), + }); let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32); Fixture { file: wrap(&coding, width as u32, height as u32, &sample, None), expected: Vec::new() } } @@ -811,20 +836,18 @@ proptest! { } *b = if i < 4 { base + 1 } else { base }; } - let (header_size, sample) = constant_band_planes( - width / 2, - height / 2, - 3, + let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig { + plane_size: (width / 2, height / 2), + levels: 3, ll3, - Some((4, mid)), - 4, + mid_band: Some((4, mid)), + quant: 4, gains, bases, - Some(&qp_values[..map_w * map_h]), - 3, - false, - false, - ); + qp: Some(&qp_values[..map_w * map_h]), + qp_padding: 3, + boundary_bands: (false, false), + }); let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32); let file = wrap(&coding, width as u32, height as u32, &sample, None); let image = decode(&file).unwrap(); @@ -879,7 +902,18 @@ fn lossless_sample(value: i32) -> Vec { /// Raw sample bytes of a constant-band 16×16 C-RAW fixture. fn constant_bands_sample(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Vec { - let (_, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false); + let (_, sample) = constant_band_planes(ConstantBandPlaneConfig { + plane_size: (8, 8), + levels: 3, + ll3, + mid_band, + quant, + gains: [0; 10], + bases: [0; 10], + qp: None, + qp_padding: 0, + boundary_bands: (false, false), + }); sample } From 196a0c1a4513257d00c289fb2b15e7b984ca5e88 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 12:59:22 +0000 Subject: [PATCH 45/91] docs: require lint inheritance for new crates --- CLAUDE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6034902e9..1ff3defb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,8 +56,9 @@ of it. Full standard: `../craftrules/standards/never-crash.md` denies `cognitive_complexity`, `too_many_arguments` and `wildcard_imports`; members inherit them with `[lints] workspace = true`, and `sysmem` repeats those denies in its local lint table. Every production crate root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, - clippy::unimplemented, clippy::todo, clippy::unreachable)]`. New crates inherit workspace lints and start with the - attribute. + clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Every new workspace crate must include + `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`, and new production crate roots start with + the attribute. ## Non-negotiables - **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…). From 2f42ff894455f655630c7c2ce269ec801c4e78c5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:08 +0000 Subject: [PATCH 46/91] refactor(pipeline): group pixel finish parameters --- crates/engine/src/media/render.rs | 4 +- crates/gpu/src/render.rs | 12 +- crates/pipeline/src/finish.rs | 315 +++++++++++++++--------------- 3 files changed, 169 insertions(+), 162 deletions(-) diff --git a/crates/engine/src/media/render.rs b/crates/engine/src/media/render.rs index 1192a98e0..a87a0792c 100644 --- a/crates/engine/src/media/render.rs +++ b/crates/engine/src/media/render.rs @@ -1,6 +1,8 @@ use super::quick::QuickSource; use super::source::{DecodedSource, SourceLevel, SourceRef}; -use super::*; +use super::{ + Arc, DevelopSettings, Hash128, Histogram, PhotoId, PreviewCache, Quality, RenderRequest, Rendered, Rgb32f, Rgba8, Source, SourceInfo, StageCache, +}; /// Everything needed to render one photo, detached from the session. #[derive(Clone)] diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 64990db0e..90aed7974 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use lightcraft_develop::DevelopSettings; use lightcraft_geom::Orientation; -use lightcraft_pipeline::finish::{FinishParams, MASK_TERMS, mask_terms}; +use lightcraft_pipeline::finish::{FinishParams, FinishParamsInput, MASK_TERMS, mask_terms}; use lightcraft_pipeline::geometry::SampleMode; use lightcraft_pipeline::local::plane_at; use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local}; @@ -668,7 +668,15 @@ pub fn render( lap("masks", &mut t, &mut cx); // 5. per-pixel stage - let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); + let mut fp = FinishParams::new(FinishParamsInput { + settings: s, + frame: &plan.frame, + info, + image_size: (w, h), + px_per_long: plan.px_per_long, + air_pre: prep.air, + space: req.space, + }); // soft proofing as the CPU's `finish` sets it up (an output-device table never gets here: see `render`) fp.proof = req.proof.map(|pr| pr.params(req.space)); if let Some(d) = &req.display { diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index c1e6a9bc2..4fccd1134 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -186,19 +186,20 @@ pub struct FinishParams { pub peak: f32, } +pub struct FinishParamsInput<'a> { + pub settings: &'a DevelopSettings, + pub frame: &'a Frame, + pub info: &'a SourceInfo, + pub image_size: (usize, usize), + pub px_per_long: f64, + pub air_pre: f32, + pub space: OutputSpace, +} + impl FinishParams { /// Parameters for a `w × h` render into `space`; `ev`/`air_pre` as in [`crate::Prepared`]. - #[allow(clippy::too_many_arguments)] - pub fn new( - s: &DevelopSettings, - frame: &Frame, - info: &SourceInfo, - w: usize, - h: usize, - px_per_long: f64, - air_pre: f32, - space: OutputSpace, - ) -> FinishParams { + pub fn new(input: FinishParamsInput<'_>) -> FinishParams { + let FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long, air_pre, space } = input; let effects = s.section_enabled("effects"); let (clar, tex, dehaze) = if effects { ((s.effects.clarity / 100.0) as f32, (s.effects.texture / 100.0) as f32, (s.effects.dehaze / 100.0) as f32) @@ -288,7 +289,8 @@ pub(crate) fn finish( proof: Option, ) -> Rgba8 { let (w, h) = (p.img.width, p.img.height); - let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); + let mut fp = + FinishParams::new(FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long: p.px_per_long, air_pre: p.air, space }); fp.proof = proof.map(|pr| pr.params(space)); if let Some(d) = display { fp.for_display(d, proof); @@ -305,7 +307,6 @@ pub(crate) fn finish( } /// [`finish`] into 16-bit display-encoded or 32-bit float linear samples (output primaries). -#[allow(clippy::too_many_arguments)] pub(crate) fn finish_deep( p: &Prepared, s: &DevelopSettings, @@ -317,7 +318,8 @@ pub(crate) fn finish_deep( ) -> DeepImage { use lightcraft_color::transfer::srgb_to_linear; let (w, h) = (p.img.width, p.img.height); - let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); + let mut fp = + FinishParams::new(FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long: p.px_per_long, air_pre: p.air, space }); fp.proof = proof.map(|pr| pr.params(space)); let trc = fp.out_trc; let samples = match depth { @@ -354,29 +356,7 @@ pub(crate) fn finish_with( ) -> Vec { let (w, h) = (p.img.width, p.img.height); let p_lut = fp.lut.clone(); - let FinishParams { - tone, - ops, - curves, - vig, - to_out, - out_luma, - hl, - sh, - clar, - tex, - dehaze, - sharpen, - sharpen_mask, - air, - air_pre, - gain, - ev, - grain, - .. - } = fp; - let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) = - (*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev); + let FinishParams { curves, vig, to_out, out_luma, grain, .. } = fp; let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect(); let out_to_norm = fp.out_to_norm; let long = fp.ow.max(fp.oh); @@ -392,128 +372,17 @@ pub(crate) fn finish_with( for (x, px) in row.iter_mut().enumerate() { let i = y * w + x; let raw = p.img.data[i]; - let mut c = if gain == 1.0 { raw } else { raw.map(|v| v * gain) }; + let local = pixel_local_terms(p, &terms, i); + let l_sat = local.values[11]; + let l_hue = local.values[12]; let l_pre = p.log_l.data[i]; - let l0 = l_pre + ev; - - // --- local (mask) contributions: alpha-weighted sums of the masks' terms - let mut l = [0.0f32; MASK_SUMS]; - let mut tint_col: Option<([f32; 3], f32)> = None; - for (m, t) in p.masks.iter().zip(&terms) { - let a = m.alpha.data[i]; - if a <= 0.0 { - continue; - } - for k in 0..MASK_SUMS { - l[k] += a * t[k]; - } - if t[MASK_SUMS] > 0.0 { - tint_col = Some(([t[MASK_SUMS + 1], t[MASK_SUMS + 2], 0.0], a * t[MASK_SUMS + 3])); - } - } - let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp, l_noise, l_moire, l_defringe] = - l; - - // --- local Moiré (and the colour part of Noise): chromaticity towards its blur - let mt = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0); - if mt != 0.0 - && let Some(cb) = &p.chroma_blur - { - let (y, ch0, cb) = (luminance_2020(c), crate::masks::chromaticity(raw), cb.data[i]); - c = std::array::from_fn(|k| ((ch0[k] + (cb[k] - ch0[k]) * mt) * y).max(0.0)); - } - // --- local Defringe: desaturate purple / green fringes along edges - let df = l_defringe.clamp(0.0, 1.0); - if df > 0.0 - && let Some(b) = &p.texture_blur - { - let k = df * defringe_weight(c, p.log_l.data[i] - b.data[i]); - if k > 0.0 { - let y = luminance_2020(c); - c = c.map(|v| v + (y - v) * k); - } - } - - // --- dehaze (scene linear) - let dz = dehaze + l_dehaze; - if dz != 0.0 - && let Some(dark) = &p.dark - { - // The dark channel is a blur, not a patch minimum: near bright neighbours it credits - // dark pixels with veil they don't hold (and a windowed render borrows the whole - // frame's airlight), so the subtraction went past zero and crushed them to black - // (#2504). Never attribute more veil than the pixel's own minimum channel. - let c_min = c[0].min(c[1]).min(c[2]); - let d = (if air_pre > 0.0 { dark.data[i] / air_pre } else { 0.0 }).min(if air > 0.0 { c_min / air } else { 0.0 }).clamp(0.0, 1.0); - if dz > 0.0 { - let t = (1.0 - 0.95 * dz.min(1.0) * d).max(0.12); - c = c.map(|v| ((v - air * (1.0 - t)) / t).max(0.0)); - } else { - let k = (-dz).min(1.0) * 0.7 * (0.35 + 0.65 * d); - c = c.map(|v| v + (air * 0.9 - v) * k); - } - } - - // --- local exposure / temp / tint - if l_exp != 0.0 { - let g = l_exp.exp2(); - c = c.map(|v| v * g); - } - if l_temp != 0.0 || l_tint != 0.0 { - let y0 = luminance_2020(c); - c = [c[0] * (1.0 + 0.3 * l_temp), c[1] * (1.0 - 0.22 * l_tint), c[2] * (1.0 - 0.3 * l_temp).max(0.0)]; - let y1 = luminance_2020(c).max(1e-9); - c = c.map(|v| v * y0 / y1); - } + let l0 = l_pre + fp.ev; + let (mut c, l1) = adjust_scene_pixel(p, fp, i, raw, &local); // --- local tone in log luminance - let l1 = if dz != 0.0 || l_exp != 0.0 { log_lum(c) } else { l0 }; let shift = l1 - l0; - let base = p.base.data[i] + ev + shift; - let mut delta = 0.0f32; - let (hh, ss) = (hl + l_hl, sh + l_sh); - if hh != 0.0 || ss != 0.0 { - let ws = 1.0 - smooth(-4.8, 0.3, base); - let wh = smooth(-1.0, 2.8, base); - delta += ss * 1.7 * ws * ws.sqrt() + hh * 1.7 * wh; - } - if l_wh != 0.0 { - delta += l_wh * 0.8 * smooth(0.5, 3.0, l1); - } - if l_bl != 0.0 { - delta += l_bl * 0.8 * (1.0 - smooth(-6.0, -1.5, l1)); - } - if l_con != 0.0 { - delta += l_con * 0.14 * l1.clamp(-6.0, 4.0); - } - let cl = clar + l_clar; - if cl != 0.0 - && let Some(b) = &p.clarity_blur - { - let det = (l_pre - b.data[i]).clamp(-2.5, 2.5); - let mid = (-(base / 3.2).powi(2)).exp(); - delta += cl * 0.85 * det * (0.35 + 0.65 * mid); - } - let tx = tex + l_tex; - let sp = l_sharp * 0.6 + sharpen; - if (tx != 0.0 || sp != 0.0) - && let Some(b) = &p.texture_blur - { - let det = l_pre - b.data[i]; - let tame = 1.0 - 0.6 * smooth(0.4, 1.6, det.abs()); - delta += tx * 1.1 * det.clamp(-1.0, 1.0) * tame; - if sp != 0.0 { - let m = if sharpen_mask > 0.0 { smooth(sharpen_mask * 0.25, sharpen_mask * 0.25 + 0.15, det.abs()) } else { 1.0 }; - delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m; - } - } - // local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges - if l_noise != 0.0 - && let Some(b) = &p.texture_blur - { - let det = l_pre - b.data[i]; - delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * det * (1.0 - smooth(0.1, 0.5, det.abs())); - } + let base = p.base.data[i] + fp.ev + shift; + let delta = local_tone_delta(p, fp, i, base, l1, &local); if delta != 0.0 { let g = delta.exp2(); c = c.map(|v| v * g); @@ -526,9 +395,9 @@ pub(crate) fn finish_with( // --- tone map on luminance, highlight desaturation let yl = luminance_2020(c); - let o = tone.apply(yl); + let o = fp.tone.apply(yl); let mut d = if yl > 1e-9 { c.map(|v| v * o / yl) } else { [0.0; 3] }; - let k = tone.chroma_scale(o); + let k = fp.tone.chroma_scale(o); if k != 1.0 { d = d.map(|v| o + (v - o) * k); } @@ -539,8 +408,8 @@ pub(crate) fn finish_with( } // --- colour - d = ops.apply(d, l_sat, l_hue); - if let Some((dir, amt)) = tint_col { + d = fp.ops.apply(d, l_sat, l_hue); + if let Some((dir, amt)) = local.tint { let lab = lightcraft_color::perceptual::oklab_from_2020(d); d = lightcraft_color::perceptual::oklab_to_2020([lab[0], lab[1] + dir[0] * 0.08 * amt, lab[2] + dir[1] * 0.08 * amt]); } @@ -663,6 +532,134 @@ pub(crate) fn finish_with( out } +struct PixelLocalTerms { + values: [f32; MASK_SUMS], + tint: Option<([f32; 3], f32)>, +} + +fn pixel_local_terms(p: &Prepared, terms: &[[f32; MASK_TERMS]], index: usize) -> PixelLocalTerms { + let mut values = [0.0; MASK_SUMS]; + let mut tint = None; + for (mask, terms) in p.masks.iter().zip(terms) { + let alpha = mask.alpha.data[index]; + if alpha <= 0.0 { + continue; + } + for (sum, term) in values.iter_mut().zip(terms.iter().take(MASK_SUMS)) { + *sum += alpha * term; + } + if terms[MASK_SUMS] > 0.0 { + tint = Some(([terms[MASK_SUMS + 1], terms[MASK_SUMS + 2], 0.0], alpha * terms[MASK_SUMS + 3])); + } + } + PixelLocalTerms { values, tint } +} + +fn adjust_scene_pixel(p: &Prepared, fp: &FinishParams, index: usize, raw: [f32; 3], local: &PixelLocalTerms) -> ([f32; 3], f32) { + let [l_exp, l_temp, l_tint, _, _, _, _, _, _, _, l_dehaze, _, _, _, l_noise, l_moire, l_defringe] = local.values; + let mut color = if fp.gain == 1.0 { raw } else { raw.map(|value| value * fp.gain) }; + + let moire = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0); + if moire != 0.0 + && let Some(blur) = &p.chroma_blur + { + let (luminance, original, blurred) = (luminance_2020(color), crate::masks::chromaticity(raw), blur.data[index]); + color = std::array::from_fn(|channel| ((original[channel] + (blurred[channel] - original[channel]) * moire) * luminance).max(0.0)); + } + let defringe = l_defringe.clamp(0.0, 1.0); + if defringe > 0.0 + && let Some(blur) = &p.texture_blur + { + let amount = defringe * defringe_weight(color, p.log_l.data[index] - blur.data[index]); + if amount > 0.0 { + let luminance = luminance_2020(color); + color = color.map(|value| value + (luminance - value) * amount); + } + } + + let dehaze = fp.dehaze + l_dehaze; + if dehaze != 0.0 + && let Some(dark) = &p.dark + { + // Limit the estimated veil to the pixel's own minimum channel. This avoids crushing bright + // neighbours when the dark-channel blur spans the edge of a local window. + let min_channel = color[0].min(color[1]).min(color[2]); + let veil = (if fp.air_pre > 0.0 { dark.data[index] / fp.air_pre } else { 0.0 }) + .min(if fp.air > 0.0 { min_channel / fp.air } else { 0.0 }) + .clamp(0.0, 1.0); + if dehaze > 0.0 { + let transmission = (1.0 - 0.95 * dehaze.min(1.0) * veil).max(0.12); + color = color.map(|value| ((value - fp.air * (1.0 - transmission)) / transmission).max(0.0)); + } else { + let amount = (-dehaze).min(1.0) * 0.7 * (0.35 + 0.65 * veil); + color = color.map(|value| value + (fp.air * 0.9 - value) * amount); + } + } + + if l_exp != 0.0 { + let gain = l_exp.exp2(); + color = color.map(|value| value * gain); + } + if l_temp != 0.0 || l_tint != 0.0 { + let luminance_before = luminance_2020(color); + color = [color[0] * (1.0 + 0.3 * l_temp), color[1] * (1.0 - 0.22 * l_tint), color[2] * (1.0 - 0.3 * l_temp).max(0.0)]; + let luminance_after = luminance_2020(color).max(1e-9); + color = color.map(|value| value * luminance_before / luminance_after); + } + + let l0 = p.log_l.data[index] + fp.ev; + let log_luminance = if dehaze != 0.0 || l_exp != 0.0 { log_lum(color) } else { l0 }; + (color, log_luminance) +} + +fn local_tone_delta(p: &Prepared, fp: &FinishParams, index: usize, base: f32, l1: f32, local: &PixelLocalTerms) -> f32 { + let [_, _, _, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, _, _, _, l_sharp, l_noise, _, _] = local.values; + let mut delta = 0.0; + let (highlights, shadows) = (fp.hl + l_hl, fp.sh + l_sh); + if highlights != 0.0 || shadows != 0.0 { + let shadow_weight = 1.0 - smooth(-4.8, 0.3, base); + let highlight_weight = smooth(-1.0, 2.8, base); + delta += shadows * 1.7 * shadow_weight * shadow_weight.sqrt() + highlights * 1.7 * highlight_weight; + } + if l_wh != 0.0 { + delta += l_wh * 0.8 * smooth(0.5, 3.0, l1); + } + if l_bl != 0.0 { + delta += l_bl * 0.8 * (1.0 - smooth(-6.0, -1.5, l1)); + } + if l_con != 0.0 { + delta += l_con * 0.14 * l1.clamp(-6.0, 4.0); + } + let clarity = fp.clar + l_clar; + if clarity != 0.0 + && let Some(blur) = &p.clarity_blur + { + let detail = (p.log_l.data[index] - blur.data[index]).clamp(-2.5, 2.5); + let midtone = (-(base / 3.2).powi(2)).exp(); + delta += clarity * 0.85 * detail * (0.35 + 0.65 * midtone); + } + let texture = fp.tex + l_tex; + let sharpen = l_sharp * 0.6 + fp.sharpen; + if (texture != 0.0 || sharpen != 0.0) + && let Some(blur) = &p.texture_blur + { + let detail = p.log_l.data[index] - blur.data[index]; + let tame = 1.0 - 0.6 * smooth(0.4, 1.6, detail.abs()); + delta += texture * 1.1 * detail.clamp(-1.0, 1.0) * tame; + if sharpen != 0.0 { + let mask = if fp.sharpen_mask > 0.0 { smooth(fp.sharpen_mask * 0.25, fp.sharpen_mask * 0.25 + 0.15, detail.abs()) } else { 1.0 }; + delta += sharpen * 1.3 * detail.clamp(-0.8, 0.8) * mask; + } + } + if l_noise != 0.0 + && let Some(blur) = &p.texture_blur + { + let detail = p.log_l.data[index] - blur.data[index]; + delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * detail * (1.0 - smooth(0.1, 0.5, detail.abs())); + } + delta +} + /// A colour needing more desaturation than this (scale < `GAMUT_WARN`) to fit is out of gamut for /// the gamut warnings (tolerates rounding at the gamut boundary). const GAMUT_WARN: f32 = 0.995; From a3da2d1040b2c48b130b6eca96bb533742585ee6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:09 +0000 Subject: [PATCH 47/91] refactor(denoise): model Bayer tile regions --- crates/denoise-core/src/bayer.rs | 25 ++++++++++++++++++------- crates/denoise-core/src/run.rs | 9 +++++++-- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/crates/denoise-core/src/bayer.rs b/crates/denoise-core/src/bayer.rs index a4a00d947..365bec436 100644 --- a/crates/denoise-core/src/bayer.rs +++ b/crates/denoise-core/src/bayer.rs @@ -14,6 +14,14 @@ pub struct Layout { pub dy: usize, } +/// The mosaic dimensions and cell origin for one square denoiser input tile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TileRegion { + pub mosaic_size: (usize, usize), + pub cell_origin: (isize, isize), + pub tile_size: usize, +} + impl Layout { /// The layout of a 2 × 2 colour pattern, row-major, colours 0 = red, 1 = green, 2 = blue; `None` when /// it is not a Bayer cell (one red, one blue, the greens on the other diagonal). @@ -65,7 +73,10 @@ pub fn reflect(i: isize, n: usize) -> usize { /// Pack the `tile × tile` cells whose top-left cell is `(cx0, cy0)` into `out` as four planes `[R, G1, G2, B]` /// (`4 · tile²` values, NCHW). G1 is the green beside the red sample, G2 the green under it. Samples outside /// the picture are mirrored. Returns `false` (and writes nothing) when the sizes do not fit. -pub fn pack_tile(mosaic: &[f32], width: usize, height: usize, layout: Layout, cx0: isize, cy0: isize, tile: usize, out: &mut [f32]) -> bool { +pub fn pack_tile(mosaic: &[f32], layout: Layout, region: TileRegion, out: &mut [f32]) -> bool { + let (width, height) = region.mosaic_size; + let (cx0, cy0) = region.cell_origin; + let tile = region.tile_size; let Some(plane) = tile.checked_mul(tile) else { return false }; let Some(values) = plane.checked_mul(4) else { return false }; if width == 0 @@ -113,7 +124,7 @@ mod tests { let layout = Layout::from_cell([0, 1, 1, 2]).unwrap(); let mut out = [7.0; 4]; for (w, h, tile, x) in [(usize::MAX, 2, 1, 0), (1, 1, usize::MAX, 0), (1, 1, 0, 0), (1, 1, 1, isize::MAX), (1, 1, 1, isize::MIN)] { - assert!(!pack_tile(&[0.5], w, h, layout, x, 0, tile, &mut out)); + assert!(!pack_tile(&[0.5], layout, TileRegion { mosaic_size: (w, h), cell_origin: (x, 0), tile_size: tile }, &mut out)); assert_eq!(out, [7.0; 4]); } for n in [1, 2, isize::MAX as usize, usize::MAX] { @@ -160,7 +171,7 @@ mod tests { let (nx, ny) = layout.cells(w, h); let tile = nx.max(ny); let mut out = vec![0.0; 4 * tile * tile]; - assert!(pack_tile(&mosaic, w, h, layout, 0, 0, tile, &mut out)); + assert!(pack_tile(&mosaic, layout, TileRegion { mosaic_size: (w, h), cell_origin: (0, 0), tile_size: tile }, &mut out)); let plane = tile * tile; for (p, want) in [10.0, 20.0, 20.0, 30.0].into_iter().enumerate() { assert!(out[p * plane..(p + 1) * plane].iter().all(|&v| v == want), "layout {cell:?} plane {p}"); @@ -174,7 +185,7 @@ mod tests { let (w, h) = (4, 4); let mosaic: Vec = (0..16).map(|i| i as f32).collect(); let mut out = vec![0.0; 4 * 4]; - assert!(pack_tile(&mosaic, w, h, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out)); + assert!(pack_tile(&mosaic, Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (w, h), cell_origin: (0, 0), tile_size: 2 }, &mut out)); // cell (0,0): R = site (0,0)=0, G1 = site (1,0)=1, G2 = site (0,1)=4, B = site (1,1)=5 assert_eq!([out[0], out[4], out[8], out[12]], [0.0, 1.0, 4.0, 5.0]); // cell (1,0): sites (2,0)=2, (3,0)=3, (2,1)=6, (3,1)=7 @@ -184,8 +195,8 @@ mod tests { #[test] fn packing_rejects_wrong_sizes() { let mut out = vec![0.0; 16]; - assert!(!pack_tile(&[0.0; 15], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out)); - assert!(!pack_tile(&[0.0; 16], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 3, &mut out)); - assert!(!pack_tile(&[], 0, 0, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out)); + assert!(!pack_tile(&[0.0; 15], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (4, 4), cell_origin: (0, 0), tile_size: 2 }, &mut out)); + assert!(!pack_tile(&[0.0; 16], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (4, 4), cell_origin: (0, 0), tile_size: 3 }, &mut out)); + assert!(!pack_tile(&[], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (0, 0), cell_origin: (0, 0), tile_size: 2 }, &mut out)); } } diff --git a/crates/denoise-core/src/run.rs b/crates/denoise-core/src/run.rs index 4314ed8c3..180587d5d 100644 --- a/crates/denoise-core/src/run.rs +++ b/crates/denoise-core/src/run.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use lightcraft_raster::Rgb32f; use rayon::prelude::*; -use crate::bayer::{Layout, pack_tile}; +use crate::bayer::{Layout, TileRegion, pack_tile}; use crate::manifest::Gain; use crate::tiles::{starts, weight}; @@ -139,7 +139,12 @@ pub fn denoise_bayer( .par_iter() .map(|&(x0, y0, bx, ax, by, ay)| { let mut input = allocate(4 * p.tile * p.tile, 0f32)?; - if !pack_tile(mosaic, width, height, layout, x0 as isize, y0 as isize, p.tile, &mut input) { + if !pack_tile( + mosaic, + layout, + TileRegion { mosaic_size: (width, height), cell_origin: (x0 as isize, y0 as isize), tile_size: p.tile }, + &mut input, + ) { return Err(Error::Input("a tile could not be packed".into())); } let mut out = runner.run(&input)?; From 694b237f0a47337d722530893bee81f057f9ecb8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:12 +0000 Subject: [PATCH 48/91] refactor(faces): model crop and plane shift inputs --- crates/engine/src/faces_worker.rs | 14 ++++++-------- crates/faces/src/align.rs | 27 +++++++++++++++++++-------- crates/faces/src/net/kernels.rs | 20 ++++++++++++-------- crates/faces/src/net/program.rs | 2 +- crates/faces/src/net/shape.rs | 2 +- 5 files changed, 39 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/faces_worker.rs b/crates/engine/src/faces_worker.rs index 61dbc2544..c9512f6cb 100644 --- a/crates/engine/src/faces_worker.rs +++ b/crates/engine/src/faces_worker.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Condvar, Mutex, PoisonError}; use std::time::Duration; use lightcraft_catalog::PhotoId; -use lightcraft_faces::align::{Rgb, align_to_template, crop_box}; +use lightcraft_faces::align::{FaceCrop, Rgb, align_to_template, crop_box}; use lightcraft_faces::runtime::Embedder; use lightcraft_faces::yunet::{Detector, Face, Options}; use lightcraft_geom::Rect; @@ -221,13 +221,11 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { Some((_, f)) => align_to_template(&img, &scaled(f), iw, ih), None => crop_box( &img, - rect.x0 as f32 * w as f32, - rect.y0 as f32 * h as f32, - rect.x1 as f32 * w as f32, - rect.y1 as f32 * h as f32, - BOX_PAD, - iw, - ih, + FaceCrop { + bounds: (rect.x0 as f32 * w as f32, rect.y0 as f32 * h as f32, rect.x1 as f32 * w as f32, rect.y1 as f32 * h as f32), + padding: BOX_PAD, + output_size: (iw, ih), + }, ), }; // shown by the detector's box where it found this face: every face then has the same tightness diff --git a/crates/faces/src/align.rs b/crates/faces/src/align.rs index 4982325ae..387d028a6 100644 --- a/crates/faces/src/align.rs +++ b/crates/faces/src/align.rs @@ -19,6 +19,14 @@ pub struct Rgb<'a> { pub height: usize, } +/// Face bounds, padding factor, and output dimensions for box-based alignment. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FaceCrop { + pub bounds: (f32, f32, f32, f32), + pub padding: f32, + pub output_size: (usize, usize), +} + impl Rgb<'_> { fn valid(&self) -> bool { self.width > 0 && self.height > 0 && self.width.checked_mul(self.height).and_then(|n| n.checked_mul(3)) == Some(self.data.len()) @@ -145,7 +153,10 @@ pub fn align_to_template(img: &Rgb, landmarks: &[(f32, f32); 5], out_w: usize, o } /// A square around a face box (pixels of `img`), `pad` times its larger side, as `out_w` × `out_h`. -pub fn crop_box(img: &Rgb, x0: f32, y0: f32, x1: f32, y1: f32, pad: f32, out_w: usize, out_h: usize) -> Option> { +pub fn crop_box(img: &Rgb, crop: FaceCrop) -> Option> { + let (x0, y0, x1, y1) = crop.bounds; + let pad = crop.padding; + let (out_w, out_h) = crop.output_size; if ![x0, y0, x1, y1, pad].iter().all(|v| v.is_finite()) { return None; } @@ -247,16 +258,16 @@ mod tests { } } let img = Rgb { data: &data, width: w, height: h }; - let out = crop_box(&img, 90.0, 40.0, 110.0, 60.0, 2.0, 64, 64).unwrap(); + let out = crop_box(&img, FaceCrop { bounds: (90.0, 40.0, 110.0, 60.0), padding: 2.0, output_size: (64, 64) }).unwrap(); // the 20 px block fills the middle half of the 64 px output let at = |x: usize, y: usize| out[(y * 64 + x) * 3 + 1]; assert!(at(32, 32) > 250 && at(20, 20) > 250 && at(44, 44) > 250 && at(5, 5) < 5 && at(60, 32) < 5); - assert!(crop_box(&img, 5.0, 5.0, 5.0, 5.0, 2.0, 64, 64).is_none(), "an empty box"); - assert!(crop_box(&img, f32::NAN, 0.0, 10.0, 10.0, 2.0, 64, 64).is_none()); - assert!(crop_box(&img, 0.0, 0.0, 10.0, 10.0, 2.0, 0, 64).is_none()); - assert!(crop_box(&img, 0.0, 0.0, 10.0, 10.0, 2.0, 100_000, 64).is_none()); + assert!(crop_box(&img, FaceCrop { bounds: (5.0, 5.0, 5.0, 5.0), padding: 2.0, output_size: (64, 64) }).is_none(), "an empty box"); + assert!(crop_box(&img, FaceCrop { bounds: (f32::NAN, 0.0, 10.0, 10.0), padding: 2.0, output_size: (64, 64) }).is_none()); + assert!(crop_box(&img, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (0, 64) }).is_none()); + assert!(crop_box(&img, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (100_000, 64) }).is_none()); let bad = Rgb { data: &data[..10], width: w, height: h }; - assert!(crop_box(&bad, 0.0, 0.0, 10.0, 10.0, 2.0, 64, 64).is_none(), "wrong data length"); + assert!(crop_box(&bad, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (64, 64) }).is_none(), "wrong data length"); } #[test] @@ -264,7 +275,7 @@ mod tests { let data = vec![80u8; 50 * 50 * 3]; let img = Rgb { data: &data, width: 50, height: 50 }; // a box mostly outside the picture is padded by clamping, not an error - assert!(crop_box(&img, -30.0, -30.0, 20.0, 20.0, 1.5, 32, 32).is_some()); + assert!(crop_box(&img, FaceCrop { bounds: (-30.0, -30.0, 20.0, 20.0), padding: 1.5, output_size: (32, 32) }).is_some()); let far: [(f32, f32); 5] = [(1e6, 1e6), (1e6 + 5.0, 1e6), (1e6 + 2.0, 1e6 + 3.0), (1e6, 1e6 + 6.0), (1e6 + 5.0, 1e6 + 6.0)]; let _ = align_to_template(&img, &far, 112, 112); let tiny: [(f32, f32); 5] = [(1.0, 1.0), (1.01, 1.0), (1.0, 1.01), (1.01, 1.01), (1.005, 1.005)]; diff --git a/crates/faces/src/net/kernels.rs b/crates/faces/src/net/kernels.rs index 71ae95874..6ad7e83dd 100644 --- a/crates/faces/src/net/kernels.rs +++ b/crates/faces/src/net/kernels.rs @@ -1,5 +1,5 @@ //! Safe CPU kernels shared by detection and recognition. No inner thread pool. -use super::*; +use super::{ConvJob, Graph, MAX_TENSOR, NetError, Node, Result, Tensor, chw, conv_direct, pads, pair, program, shape, shape_err}; use ndarray::{ArrayView2, ArrayViewMut2, linalg::general_mat_mul}; const BLOCK: usize = 256; #[derive(Default)] @@ -30,7 +30,16 @@ fn multiply(w: &[f32], x: &[f32], y: &mut [f32], m: usize, k: usize, n: usize) - } /// For equal-width, unit-stride maps a shifted plane is one contiguous copy. Only horizontal /// border stripes need masking; clipping the flat source range also removes vertical padding. -fn shifted_plane(plane: &[f32], width: usize, ky: usize, kx: usize, pt: usize, pl: usize, start: usize, row: &mut [f32]) -> Result<()> { +#[derive(Clone, Copy)] +struct PlaneShift { + width: usize, + kernel: (usize, usize), + padding: (usize, usize), + start: usize, +} + +fn shifted_plane(plane: &[f32], row: &mut [f32], shift: PlaneShift) -> Result<()> { + let PlaneShift { width, kernel: (ky, kx), padding: (pt, pl), start } = shift; let end = start + row.len(); let delta = (ky as i128 - pt as i128) * width as i128 + kx as i128 - pl as i128; let lo = (-delta).clamp(start as i128, end as i128); @@ -145,13 +154,8 @@ pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Re if sh == 1 && sw == 1 && ow == w { shifted_plane( plane, - w, - ky, - kx, - pt, - pl, - start, row.get_mut(..len).ok_or_else(|| NetError::Shape("short lower panel".into()))?, + PlaneShift { width: w, kernel: (ky, kx), padding: (pt, pl), start }, )?; continue; } diff --git a/crates/faces/src/net/program.rs b/crates/faces/src/net/program.rs index e00e51da1..db7dcba5d 100644 --- a/crates/faces/src/net/program.rs +++ b/crates/faces/src/net/program.rs @@ -9,7 +9,7 @@ use std::borrow::Cow; -use super::*; +use super::{ConvJob, Data, HashMap, Net, NetError, Result, kernels, pads, pair, shape, shape_err, unsupported}; /// Value 0 is the network's input. pub const INPUT: usize = 0; diff --git a/crates/faces/src/net/shape.rs b/crates/faces/src/net/shape.rs index a1594ca6b..39e585c61 100644 --- a/crates/faces/src/net/shape.rs +++ b/crates/faces/src/net/shape.rs @@ -1,5 +1,5 @@ //! Bounded, allocation-free (apart from small shape lists) graph checking. -use super::*; +use super::{Data, Graph, HashMap, MAX_TENSOR, NetError, Node, Result, pads, pair, shape_err, unsupported}; use crate::graph::{Attr, Weight}; pub(super) fn elements(s: &[usize]) -> Result { From de6cb3cd4120b274dee48e6688b9a5460d8ca2de Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:14 +0000 Subject: [PATCH 49/91] refactor(codecs): separate TIFF sample encoders --- crates/codecs/src/encode.rs | 126 +++++++++++++++++++++++------------- 1 file changed, 82 insertions(+), 44 deletions(-) diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 725980e53..8aff31c5c 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -227,7 +227,7 @@ pub enum TiffCompression { /// Encode TIFF: 8/16-bit integer or 32-bit float, gray/RGB with optional (unassociated) alpha. /// ICC and XMP are embedded; EXIF is not (write it through the TIFF writer in `lightcraft-tiff`). pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &EncodeMeta) -> Result> { - use tiff::encoder::{Compression, DeflateLevel, TiffEncoder, colortype}; + use tiff::encoder::{Compression, DeflateLevel, TiffEncoder}; use tiff::tags::Predictor; img.validate()?; let e = |e: tiff::TiffError| Error::Encode(e.to_string()); @@ -243,54 +243,92 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod if !float && matches!(compression, TiffCompression::Lzw | TiffCompression::Deflate) { Predictor::Horizontal } else { Predictor::None }; let mut enc = TiffEncoder::new(&mut cur).map_err(e)?.with_compression(comp).with_predictor(predictor); let (w, h) = (img.width, img.height); - let n = w as usize * h as usize; - // Gray + alpha is stored as RGBA (the encoder has no GrayA colour type). - let ch = img.channels as usize; - - macro_rules! write { - ($ct:ty, $data:expr, $alpha:expr) => {{ - let mut im = enc.new_image::<$ct>(w, h).map_err(e)?; - if let Some(ppi) = meta.ppi.filter(|p| *p > 0) { - im.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: ppi as u32, d: 1 }); - } - if let Some(icc) = meta.icc { - im.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(e)?; - } - if let Some(xmp) = meta.xmp { - im.encoder().write_tag(tiff::tags::Tag::from_u16_exhaustive(700), xmp.as_bytes()).map_err(e)?; - } - if $alpha { - im.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).map_err(e)?; - } - im.write_data($data).map_err(e)?; - }}; - } - fn expand_ga(s: &[T], n: usize) -> Vec { - s.as_chunks::<2>().0.iter().take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect() - } + let pixels = w as usize * h as usize; match img.samples { - Samples::U8(s) => match ch { - 1 => write!(colortype::Gray8, &s[..n], false), - 2 => write!(colortype::RGBA8, &expand_ga(s, n), true), - 3 => write!(colortype::RGB8, &s[..n * 3], false), - _ => write!(colortype::RGBA8, &s[..n * 4], true), - }, - Samples::U16(s) => match ch { - 1 => write!(colortype::Gray16, &s[..n], false), - 2 => write!(colortype::RGBA16, &expand_ga(s, n), true), - 3 => write!(colortype::RGB16, &s[..n * 3], false), - _ => write!(colortype::RGBA16, &s[..n * 4], true), - }, - Samples::F32(s) => match ch { - 1 => write!(colortype::Gray32Float, &s[..n], false), - 2 => write!(colortype::RGBA32Float, &expand_ga(s, n), true), - 3 => write!(colortype::RGB32Float, &s[..n * 3], false), - _ => write!(colortype::RGBA32Float, &s[..n * 4], true), - }, + Samples::U8(samples) => write_tiff_u8(&mut enc, samples, pixels, img.channels as usize, img, meta)?, + Samples::U16(samples) => write_tiff_u16(&mut enc, samples, pixels, img.channels as usize, img, meta)?, + Samples::F32(samples) => write_tiff_f32(&mut enc, samples, pixels, img.channels as usize, img, meta)?, } Ok(cur.into_inner()) } +macro_rules! write_tiff_image { + ($encoder:expr, $image:expr, $meta:expr, $color:ty, $data:expr, $alpha:expr) => {{ + let mut image = $encoder.new_image::<$color>($image.width, $image.height).map_err(|e| Error::Encode(e.to_string()))?; + if let Some(ppi) = $meta.ppi.filter(|ppi| *ppi > 0) { + image.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: ppi as u32, d: 1 }); + } + if let Some(icc) = $meta.icc { + image.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(|e| Error::Encode(e.to_string()))?; + } + if let Some(xmp) = $meta.xmp { + image.encoder().write_tag(tiff::tags::Tag::from_u16_exhaustive(700), xmp.as_bytes()).map_err(|e| Error::Encode(e.to_string()))?; + } + if $alpha { + image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).map_err(|e| Error::Encode(e.to_string()))?; + } + image.write_data($data).map_err(|e| Error::Encode(e.to_string()))?; + }}; +} + +fn expanded_gray_alpha(samples: &[T], pixels: usize) -> Vec { + samples.as_chunks::<2>().0.iter().take(pixels).flat_map(|channel| [channel[0], channel[0], channel[0], channel[1]]).collect() +} + +fn write_tiff_u8( + encoder: &mut tiff::encoder::TiffEncoder, + samples: &[u8], + pixels: usize, + channels: usize, + image: &EncodeImage, + meta: &EncodeMeta, +) -> Result<()> { + use tiff::encoder::colortype; + match channels { + 1 => write_tiff_image!(encoder, image, meta, colortype::Gray8, &samples[..pixels], false), + 2 => write_tiff_image!(encoder, image, meta, colortype::RGBA8, &expanded_gray_alpha(samples, pixels), true), + 3 => write_tiff_image!(encoder, image, meta, colortype::RGB8, &samples[..pixels * 3], false), + _ => write_tiff_image!(encoder, image, meta, colortype::RGBA8, &samples[..pixels * 4], true), + } + Ok(()) +} + +fn write_tiff_u16( + encoder: &mut tiff::encoder::TiffEncoder, + samples: &[u16], + pixels: usize, + channels: usize, + image: &EncodeImage, + meta: &EncodeMeta, +) -> Result<()> { + use tiff::encoder::colortype; + match channels { + 1 => write_tiff_image!(encoder, image, meta, colortype::Gray16, &samples[..pixels], false), + 2 => write_tiff_image!(encoder, image, meta, colortype::RGBA16, &expanded_gray_alpha(samples, pixels), true), + 3 => write_tiff_image!(encoder, image, meta, colortype::RGB16, &samples[..pixels * 3], false), + _ => write_tiff_image!(encoder, image, meta, colortype::RGBA16, &samples[..pixels * 4], true), + } + Ok(()) +} + +fn write_tiff_f32( + encoder: &mut tiff::encoder::TiffEncoder, + samples: &[f32], + pixels: usize, + channels: usize, + image: &EncodeImage, + meta: &EncodeMeta, +) -> Result<()> { + use tiff::encoder::colortype; + match channels { + 1 => write_tiff_image!(encoder, image, meta, colortype::Gray32Float, &samples[..pixels], false), + 2 => write_tiff_image!(encoder, image, meta, colortype::RGBA32Float, &expanded_gray_alpha(samples, pixels), true), + 3 => write_tiff_image!(encoder, image, meta, colortype::RGB32Float, &samples[..pixels * 3], false), + _ => write_tiff_image!(encoder, image, meta, colortype::RGBA32Float, &samples[..pixels * 4], true), + } + Ok(()) +} + /// Encode a flat PSD (8/16-bit gray or RGB, no layers; alpha dropped): the hand-off an image /// editor such as PhotoCraft saves back to in place. ICC, EXIF, XMP and resolution are embedded. pub fn encode_psd(img: &EncodeImage, meta: &EncodeMeta) -> Result> { From 33252920e3f15a576d81aa6f49beb86630af6c69 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:16 +0000 Subject: [PATCH 50/91] refactor(denoise): model installation requests --- crates/engine/src/cmd/denoise.rs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/denoise.rs b/crates/engine/src/cmd/denoise.rs index 644d3b89e..1cb99ccf3 100644 --- a/crates/engine/src/cmd/denoise.rs +++ b/crates/engine/src/cmd/denoise.rs @@ -129,16 +129,18 @@ fn identify(path: &Path, c: &str, sha_known: Option<&str>) -> Result { /// Install the model in `src`. A model that passes its self-test becomes the one in use (`activate`, the default): installing /// a model is how someone says they want it. `verified` is for a download whose hash was already checked. -fn install_file( - dir: &Path, - loader: &crate::denoise::Loader, +struct InstallFile<'a> { + dir: &'a Path, run_on: RunOn, accepted_at: String, - c: &str, - src: &Path, + command: &'a str, + src: &'a Path, acknowledged: bool, verified: bool, -) -> Result<(Value, Arc)> { +} + +fn install_file(loader: &crate::denoise::Loader, request: InstallFile<'_>) -> Result<(Value, Arc)> { + let InstallFile { dir, run_on, accepted_at, command: c, src, acknowledged, verified } = request; let ident = identify(src, c, None)?; let m = ident.manifest.clone(); manifest::validate(&m).map_err(|e| bad(c, e.to_string()))?; @@ -262,7 +264,8 @@ fn start_install(s: &mut Session, src: PathBuf, id: String, activate: bool, veri .spawn(move || { let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let _held = crate::memory::work_gate().acquire(512 * 1024 * 1024); - install_file(&dir, &loader, run_on, at, C, &src, true, verified).map_err(|e| plain(&e)) + install_file(&loader, InstallFile { dir: &dir, run_on, accepted_at: at, command: C, src: &src, acknowledged: true, verified }) + .map_err(|e| plain(&e)) })) .unwrap_or_else(|_| Err("the model installation stopped unexpectedly".into())); if verified { @@ -290,8 +293,18 @@ fn install(s: &mut Session, p: &Value) -> Result { if s.denoise.install.is_some() { return Err(bad(C, "another model is being installed")); } - let (result, model) = - install_file(&models_dir(s, C)?, &s.denoise.loader, s.denoise.settings.run_on(), (s.clock)(), C, Path::new(path), acknowledged, false)?; + let (result, model) = install_file( + &s.denoise.loader, + InstallFile { + dir: &models_dir(s, C)?, + run_on: s.denoise.settings.run_on(), + accepted_at: (s.clock)(), + command: C, + src: Path::new(path), + acknowledged, + verified: false, + }, + )?; activate_install(s, &result, model, activate) } From e8f52cc07e1a7520b9365c26288e68a556d75465 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:18 +0000 Subject: [PATCH 51/91] refactor(print): define blit rectangles --- crates/engine/src/print/compose.rs | 19 +++++++++++++++---- crates/engine/src/print/compose/tests.rs | 4 ++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs index d8e1eeb87..16c683a84 100644 --- a/crates/engine/src/print/compose.rs +++ b/crates/engine/src/print/compose.rs @@ -255,6 +255,15 @@ pub struct Image16 { pub rgb: Vec, } +/// A rectangular image region used as a source or destination for `Image16::blit`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BlitRect { + pub x: usize, + pub y: usize, + pub w: usize, + pub h: usize, +} + impl Image16 { pub fn filled(w: usize, h: usize, c: [u16; 3]) -> Result { let n = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or("image too large")?; @@ -284,9 +293,11 @@ impl Image16 { } /// Copies the `w` x `h` block of `src` at (`sx`, `sy`) to (`dx`, `dy`), clipped to both images. - pub fn blit(&mut self, src: &Image16, sx: usize, sy: usize, dx: usize, dy: usize, w: usize, h: usize) { - let w = w.min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx)); - let h = h.min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy)); + pub fn blit(&mut self, src: &Image16, source: BlitRect, destination: BlitRect) { + let BlitRect { x: sx, y: sy, .. } = source; + let BlitRect { x: dx, y: dy, .. } = destination; + let w = source.w.min(destination.w).min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx)); + let h = source.h.min(destination.h).min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy)); for row in 0..h { let from = src.rgb.get(((sy + row) * src.w + sx) * 3..((sy + row) * src.w + sx + w) * 3); let to = self.rgb.get_mut(((dy + row) * self.w + dx) * 3..((dy + row) * self.w + dx + w) * 3); @@ -424,7 +435,7 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell if border > 0 { page.fill_rect(dx - border, dy - border, w + 2 * border, h + 2 * border, style.border); } - page.blit(&img, sx, sy, dx, dy, w, h); + page.blit(&img, BlitRect { x: sx, y: sy, w, h }, BlitRect { x: dx, y: dy, w, h }); let s = style.stroke_px.min(w.min(h) / 2); if s > 0 { page.fill_rect(dx, dy, w, s, style.stroke); diff --git a/crates/engine/src/print/compose/tests.rs b/crates/engine/src/print/compose/tests.rs index cdec92799..f1e979cc0 100644 --- a/crates/engine/src/print/compose/tests.rs +++ b/crates/engine/src/print/compose/tests.rs @@ -182,9 +182,9 @@ fn rotate_and_blit_are_clipped() { let r = img.rotated_cw().unwrap(); assert_eq!((r.w, r.h), (2, 3)); let mut p = page(); - p.blit(&img, 0, 0, 99, 99, 10, 10); + p.blit(&img, BlitRect { x: 0, y: 0, w: 10, h: 10 }, BlitRect { x: 99, y: 99, w: 10, h: 10 }); assert_eq!(at(&p, 99, 99), at(&img, 0, 0)); - p.blit(&img, 5, 5, 0, 0, 3, 3); + p.blit(&img, BlitRect { x: 5, y: 5, w: 3, h: 3 }, BlitRect { x: 0, y: 0, w: 3, h: 3 }); p.fill_rect(95, 95, 50, 50, RED); assert_eq!(at(&p, 99, 99), RED); p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED); From 9e1483388f93fc5590c59b198183c36b58b3bab9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:20 +0000 Subject: [PATCH 52/91] refactor(import): separate Lightroom collection staging --- crates/engine/src/lightroom_catalog.rs | 146 +++++++++++++------------ 1 file changed, 79 insertions(+), 67 deletions(-) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 207d1c148..3066187ce 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -858,6 +858,83 @@ pub(crate) struct ApplyPrepared<'a> { pub(crate) undo0: usize, } +fn stage_collections( + s: &mut crate::Session, + data: &CatalogImport, + index: &mut ImportIndex, + ids: &HashMap, + ops: &mut Vec, + warnings: &mut Vec, +) -> usize { + let mut albums = HashMap::new(); + for collection in &data.collections { + if number(collection, "systemOnly") != 0 { + continue; + } + let collection_key = format!("{}:{}", key(&data.source), number(collection, "id_local")); + let id = index + .collections + .get(&collection_key) + .copied() + .map(lightcraft_catalog::AlbumId) + .filter(|id| s.catalog.album(*id).is_some()) + .unwrap_or_else(|| s.catalog.alloc_album_id()); + index.collections.insert(collection_key, id.0); + albums.insert(number(collection, "id_local"), id); + } + let parents: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), number(r, "parent"))).collect(); + let mut grouped_members: HashMap> = HashMap::new(); + for member in &data.members { + grouped_members.entry(number(member, "collection")).or_default().push(member); + } + for members in grouped_members.values_mut() { + members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection"))); + } + let mut ordered: Vec<_> = data.collections.iter().collect(); + ordered.sort_by_key(|r| { + let mut depth = 0; + let mut at = number(r, "parent"); + while at != 0 && depth < 64 { + depth += 1; + at = parents.get(&at).copied().unwrap_or(0); + } + depth + }); + for collection in ordered { + let Some(id) = albums.get(&number(collection, "id_local")).copied() else { continue }; + let mut album = Album::new(id, text(collection, "name")); + album.parent = albums.get(&number(collection, "parent")).copied(); + album.folder = text(collection, "creationId").contains("group"); + if text(collection, "creationId").contains("smart") { + warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name)); + } + if !album.folder { + album.photos = grouped_members + .get(&number(collection, "id_local")) + .into_iter() + .flatten() + .filter_map(|r| ids.get(&number(r, "image")).copied()) + .collect(); + } + if s.catalog.album(id).is_some() { + // Reimport preserves the user's album name/hierarchy, merges only new membership. + if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) { + let mut photos = old.photos.clone(); + let mut seen: HashSet<_> = photos.iter().copied().collect(); + for photo in album.photos { + if seen.insert(photo) { + photos.push(photo); + } + } + ops.push(Op::SetAlbumPhotos { id, photos }); + } + } else { + ops.push(Op::AddAlbum { album }); + } + } + albums.len() +} + /// Turn the read catalog into library records in one undo step and return the import report /// (added, missing, failed, warnings and skipped images). pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result { @@ -1022,78 +1099,13 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co } by_path.entry(key(&src.path)).or_insert(photo_id); } - let mut albums = HashMap::new(); - for collection in &data.collections { - if number(collection, "systemOnly") != 0 { - continue; - } - let collection_key = format!("{}:{}", key(&data.source), number(collection, "id_local")); - let id = index - .collections - .get(&collection_key) - .copied() - .map(lightcraft_catalog::AlbumId) - .filter(|id| s.catalog.album(*id).is_some()) - .unwrap_or_else(|| s.catalog.alloc_album_id()); - index.collections.insert(collection_key, id.0); - albums.insert(number(collection, "id_local"), id); - } - let parents: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), number(r, "parent"))).collect(); - let mut grouped_members: HashMap> = HashMap::new(); - for member in &data.members { - grouped_members.entry(number(member, "collection")).or_default().push(member); - } - for members in grouped_members.values_mut() { - members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection"))); - } - let mut ordered: Vec<_> = data.collections.iter().collect(); - ordered.sort_by_key(|r| { - let mut depth = 0; - let mut at = number(r, "parent"); - while at != 0 && depth < 64 { - depth += 1; - at = parents.get(&at).copied().unwrap_or(0); - } - depth - }); - for collection in ordered { - let Some(id) = albums.get(&number(collection, "id_local")).copied() else { continue }; - let mut album = Album::new(id, text(collection, "name")); - album.parent = albums.get(&number(collection, "parent")).copied(); - album.folder = text(collection, "creationId").contains("group"); - if text(collection, "creationId").contains("smart") { - warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name)); - } - if !album.folder { - album.photos = grouped_members - .get(&number(collection, "id_local")) - .into_iter() - .flatten() - .filter_map(|r| ids.get(&number(r, "image")).copied()) - .collect(); - } - if s.catalog.album(id).is_some() { - // Reimport preserves the user's album name/hierarchy, merges only new membership. - if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) { - let mut photos = old.photos.clone(); - let mut seen: HashSet<_> = photos.iter().copied().collect(); - for photo in album.photos { - if seen.insert(photo) { - photos.push(photo); - } - } - ops.push(Op::SetAlbumPhotos { id, photos }); - } - } else { - ops.push(Op::AddAlbum { album }); - } - } + let album_count = stage_collections(s, &data, index, &ids, &mut ops, &mut warnings); if !ops.is_empty() { s.commit("Import Lightroom Catalog", Op::Batch { ops })?; } s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog"); Ok(AppliedImport { - report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), + report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":album_count,"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), index: index.clone(), }) } From 9c1ea73116551d5ff85f9afe925b7ce258883b5c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:23 +0000 Subject: [PATCH 53/91] refactor(gpu): extract neural plan validation --- crates/gpu/src/nn.rs | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 55420598b..c5df00a77 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -235,32 +235,35 @@ fn round_up(v: usize, to: usize) -> usize { v.div_ceil(to) * to } -/// Work out the steps, the buffers and the arranged weights for `net` on `tile × tile` input cells. -fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec), String> { +fn validate_plan(net: &Net, tile: usize, ops: &[Op]) -> Result<(), String> { if !net.fits_tile(tile) { return unsupported("the tile does not fit the network"); } if tile > MAX_TILE { return unsupported("the tile is larger than the kernels' dispatches reach"); } + if !net.in_channels.is_multiple_of(4) { + return unsupported("the input channel count is not a multiple of 4"); + } + let count = ops.len(); + let ends_right = matches!( + (count.checked_sub(2).and_then(|i| ops.get(i)), ops.last()), + (Some(Op::Conv(conv)), Some(Op::DepthToSpace2 { src })) + if conv.k == 1 && conv.src2.is_none() && *src == count - 1 && conv.cout == 12 && conv.leaky.is_none() + ); + if ends_right { Ok(()) } else { unsupported("the network does not end in a 1 × 1 convolution to 12 channels and depth-to-space") } +} + +/// Work out the steps, the buffers and the arranged weights for `net` on `tile × tile` input cells. +fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec), String> { + let ops = net.ops(); + validate_plan(net, tile, ops)?; let side = |t: usize| -> usize { let level = net.shape(t).map_or(0, |s| s.level); if level >= 0 { tile >> level } else { tile << (-level) } }; let chan = |t: usize| net.shape(t).map_or(0, |s| s.channels); - if !net.in_channels.is_multiple_of(4) { - return unsupported("the input channel count is not a multiple of 4"); - } - let ops = net.ops(); - // the network must end in a 1 × 1 convolution and a depth-to-space (they run as one kernel) let n_ops = ops.len(); - let ends_right = matches!( - (n_ops.checked_sub(2).and_then(|i| ops.get(i)), ops.last()), - (Some(Op::Conv(c)), Some(Op::DepthToSpace2 { src })) if c.k == 1 && c.src2.is_none() && *src == n_ops - 1 && c.cout == 12 && c.leaky.is_none() - ); - if !ends_right { - return unsupported("the network does not end in a 1 × 1 convolution to 12 channels and depth-to-space"); - } // the last layer that reads each tensor let mut last_use = vec![0usize; n_ops + 1]; for (i, op) in ops.iter().enumerate() { From 47839a61db4f21b11954d28639e1d64b93893039 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:26 +0000 Subject: [PATCH 54/91] refactor(bench): dispatch specialized render modes --- crates/engine/examples/render_bench.rs | 188 +++++++++++++------------ 1 file changed, 100 insertions(+), 88 deletions(-) diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs index 3d7dbc445..54b6cfe82 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -91,107 +91,119 @@ fn diff(a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8) -> (u8, f64) (max, sum as f64 / (a.data.len() * 3).max(1) as f64) } +fn benchmark_jpeg(args: &[String], n: usize) { + // a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes + for path in args { + let bytes = std::fs::read(path).expect("read"); + for edge in [256u32, 512, 1600, 2560] { + let o = lightcraft_codecs::DecodeOptions::fit(edge, edge); + let d = lightcraft_codecs::decode(&bytes, o).expect("decode"); + println!( + " {path} fit {edge}: {}×{} {}", + d.image.width, + d.image.height, + best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode"))) + ); + } + } +} + +fn benchmark_load(args: &[String], n: usize, only: &str) { + // opening a photo in the loupe, step by step: what the app's loaders cost (per file) + for path in args { + let bytes = std::fs::read(path).expect("read"); + println!("{path}"); + if std::env::var("LOADONLY").is_ok() { + // only the preview load (for a profiler) + println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); + continue; + } + println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read")))); + println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); + let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); + if let lightcraft_raw::RawData::U16(d) = &raw.data { + let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64)); + println!(" decode checksum: {sum:016x}"); + } + if only == "load1" { + println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); + continue; + } + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) { + println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99)))); + } + for edge in [2560usize, 1600] { + println!( + " {:<36} {}", + format!("embedded srgb, whole file ({edge}):"), + best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge))) + ); + let quick = lightcraft_engine::files::fs_preview_loader(); + println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge)))); + } + println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); + let (loader, _) = lightcraft_engine::files::fs_hooks(); + println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load")))); + } +} + +fn benchmark_source(args: &[String], n: usize) { + // opening a photo: where the time goes before the first sharp loupe (per file) + for path in args { + let bytes = std::fs::read(path).expect("read"); + println!("{path}"); + let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); + // as the loader does: lens corrections are applied by the pipeline, not in the raw develop + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name())); + println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); + println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm")))); + let norm = raw.normalized().expect("norm"); + println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd)))); + if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) { + println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99)))); + let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable"); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + println!( + " {:<36} {}", + "highlight reconstruct (binned):", + best(n, || { + let mut i = img.clone(); + lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99); + }) + ); + println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone()))); + println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box)))); + } + println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560)))); + for edge in [512usize, 2560, usize::MAX] { + println!( + " {:<36} {}", + format!("load_bytes({edge}):"), + best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load"))) + ); + } + } +} + fn main() { let args: Vec = std::env::args().skip(1).collect(); let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5); let only = std::env::var("ONLY").unwrap_or_default(); - let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o)); if only == "jpeg" { - // a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes - for path in &args { - let bytes = std::fs::read(path).expect("read"); - for edge in [256u32, 512, 1600, 2560] { - let o = lightcraft_codecs::DecodeOptions::fit(edge, edge); - let d = lightcraft_codecs::decode(&bytes, o).expect("decode"); - println!( - " {path} fit {edge}: {}×{} {}", - d.image.width, - d.image.height, - best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode"))) - ); - } - } + benchmark_jpeg(&args, n); return; } if only == "load" || only == "load1" { - // opening a photo in the loupe, step by step: what the app's loaders cost (per file) - for path in &args { - let bytes = std::fs::read(path).expect("read"); - println!("{path}"); - if std::env::var("LOADONLY").is_ok() { - // only the preview load (for a profiler) - println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); - continue; - } - println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read")))); - println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); - let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); - if let lightcraft_raw::RawData::U16(d) = &raw.data { - let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64)); - println!(" decode checksum: {sum:016x}"); - } - if only == "load1" { - println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); - continue; - } - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) { - println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99)))); - } - for edge in [2560usize, 1600] { - println!( - " {:<36} {}", - format!("embedded srgb, whole file ({edge}):"), - best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge))) - ); - let quick = lightcraft_engine::files::fs_preview_loader(); - println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge)))); - } - println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); - let (loader, _) = lightcraft_engine::files::fs_hooks(); - println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load")))); - } + benchmark_load(&args, n, &only); return; } if only == "source" { - // opening a photo: where the time goes before the first sharp loupe (per file) - for path in &args { - let bytes = std::fs::read(path).expect("read"); - println!("{path}"); - let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); - // as the loader does: lens corrections are applied by the pipeline, not in the raw develop - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name())); - println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); - println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm")))); - let norm = raw.normalized().expect("norm"); - println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd)))); - if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) { - println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99)))); - let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable"); - let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - println!( - " {:<36} {}", - "highlight reconstruct (binned):", - best(n, || { - let mut i = img.clone(); - lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99); - }) - ); - println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone()))); - println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box)))); - } - println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560)))); - for edge in [512usize, 2560, usize::MAX] { - println!( - " {:<36} {}", - format!("load_bytes({edge}):"), - best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load"))) - ); - } - } + benchmark_source(&args, n); return; } + let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o)); let (full, info) = match args.first() { Some(path) => { let bytes = std::fs::read(path).expect("read"); From 4d8369b09107fc7532140921a5083aebdedfe697 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:29 +0000 Subject: [PATCH 55/91] test(engine): factor complex regression scenarios --- crates/catalog/src/tests.rs | 43 ++-- crates/engine/src/crs/tests.rs | 18 +- crates/engine/src/export/tests.rs | 10 + .../src/sidecar/tests_repair_companions.rs | 146 ++++++------ .../src/sidecar/tests_repair_provenance.rs | 112 ++++----- .../engine/src/sidecar/tests_repair_saves.rs | 216 +++++++++--------- crates/engine/src/tests/import.rs | 198 +++++++++------- 7 files changed, 401 insertions(+), 342 deletions(-) diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 579175086..bf67b2495 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -904,17 +904,9 @@ fn search_text_finds_people_and_quoted_values() { /// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be /// said photo by photo. #[test] -fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() { +fn photo_ops_record_touched_ids_and_changed_aspects() { let mut c = Catalog::default(); - let photo = |c: &mut Catalog, name: &str| { - let id = c.alloc_photo_id(); - c.apply(Op::AddPhoto { - photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")), - }) - .unwrap(); - id - }; - let (a, b) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg")); + let (a, b) = (photo(&mut c, "a.jpg", "2026-01-01T10:00:00"), photo(&mut c, "b.jpg", "2026-01-01T10:00:00")); let start = c.revision; assert_eq!(c.touched_since(start), Some(vec![])); assert_eq!(c.aspects_since(start), Some(Aspects::NONE)); @@ -940,28 +932,25 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() assert_eq!(c.touched_since(before), None); assert_eq!(c.aspects_since(start), None); assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again"); - // a revision that is not this catalog's past - assert_eq!(c.touched_since(c.revision + 5), None); - // a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after - // the next op nothing of before it - let old = c.revision; +} + +#[test] +fn history_handles_loaded_revisions_failed_batches_and_retention() { + let mut c = Catalog::default(); + let a = photo(&mut c, "a.jpg", "2026-01-01T10:00:00"); + let album = c.alloc_album_id(); + c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap(); + // a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch + // with an op that changes more than photos is not + let before = c.revision; c.revision = 7 << 32; - assert_eq!(c.touched_since(old), None); + assert_eq!(c.touched_since(before), None); assert_eq!(c.aspects_since(c.revision), None); let loaded = c.revision; c.apply(Op::SetRating { id: a, rating: 1 }).unwrap(); assert_eq!(c.touched_since(loaded), Some(vec![a])); - assert_eq!(c.touched_since(old), None); - // an op that fails changes nothing and is not remembered - let before = c.revision; - assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err()); - assert_eq!(c.revision, before); - assert_eq!(c.touched_since(before), Some(vec![])); - c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap(); - assert_eq!(c.touched_since(before), Some(vec![a])); - assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG))); - // a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch - // with an op that changes more than photos is not + assert_eq!(c.touched_since(before), None); + let before = c.revision; assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err()); assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1))); diff --git a/crates/engine/src/crs/tests.rs b/crates/engine/src/crs/tests.rs index 2779194c1..9f46cf401 100644 --- a/crates/engine/src/crs/tests.rs +++ b/crates/engine/src/crs/tests.rs @@ -37,9 +37,16 @@ fn props(x: &str) -> Props { #[test] fn maps_common_fields() { let p = props(SIDECAR); - assert!(has_adjustments(&p)); let partial = to_partial(&p, Target::RawAbsolute); let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); + assert!(has_adjustments(&p)); + assert_common_tone_fields(&s); + assert_common_color_fields(&s); + assert_common_detail_fields(&s); + assert_common_geometry_fields(&s); +} + +fn assert_common_tone_fields(s: &DevelopSettings) { assert_eq!(s.wb.mode, WbMode::Custom); assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0)); assert_eq!(s.light.exposure, 0.65); @@ -54,15 +61,24 @@ fn maps_common_fields() { assert_eq!(s.curve.refine_saturation, 40.0); assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9); assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve"); +} + +fn assert_common_color_fields(s: &DevelopSettings) { assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0)); assert_eq!((s.grading.highlights.hue, s.grading.highlights.sat), (40.0, 9.0)); assert_eq!((s.grading.midtones.hue, s.grading.midtones.sat), (120.0, 6.0)); assert_eq!((s.grading.balance, s.grading.blending), (20.0, 70.0)); +} + +fn assert_common_detail_fields(s: &DevelopSettings) { assert_eq!((s.detail.sharpen_amount, s.detail.sharpen_radius, s.detail.sharpen_detail, s.detail.sharpen_masking), (55.0, 1.2, 30.0, 12.0)); assert_eq!((s.detail.nr_luminance, s.detail.nr_color), (18.0, 25.0)); assert_eq!(s.vignette.amount, -21.0); assert_eq!(s.vignette.style, VignetteStyle::ColorPriority); assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0)); +} + +fn assert_common_geometry_fields(s: &DevelopSettings) { assert_eq!(s.geometry.upright, Upright::Level); assert_eq!(s.geometry.vertical, -10.0); assert!(s.optics.remove_ca); diff --git a/crates/engine/src/export/tests.rs b/crates/engine/src/export/tests.rs index 80eb13430..139939a1d 100644 --- a/crates/engine/src/export/tests.rs +++ b/crates/engine/src/export/tests.rs @@ -1067,6 +1067,11 @@ fn avif_ten_bit() { /// unknown keys (naming the nearest known one) and values of the wrong kind or range. #[test] fn export_params_are_validated() { + assert_invalid_export_params(); + assert_export_options_round_trip(); +} + +fn assert_invalid_export_params() { use serde_json::json; let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); let e = err(json!({"longEdgee": 400})); @@ -1092,6 +1097,11 @@ fn export_params_are_validated() { .to_string() .starts_with("invalid parameters for `export.savePreset`") ); +} + +fn assert_export_options_round_trip() { + use serde_json::json; + let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); // everything the presets, the dialog, Export with Previous and `render --opt` send passes for p in builtin_presets() { ExportOptions::from_params(&p.params).unwrap_or_else(|e| panic!("{}: {e}", p.name)); diff --git a/crates/engine/src/sidecar/tests_repair_companions.rs b/crates/engine/src/sidecar/tests_repair_companions.rs index 94c7996a7..05402f975 100644 --- a/crates/engine/src/sidecar/tests_repair_companions.rs +++ b/crates/engine/src/sidecar/tests_repair_companions.rs @@ -73,85 +73,85 @@ fn partial_move_companions_allow_save_and_autowrite_without_weakening_collisions for upper_full in [false, true] { for naming in [SidecarNaming::Stem, SidecarNaming::Full] { for auto in [false, true] { - let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}")); - let a = dir.join("a.jpg"); - let b = dir.join("Trip.jpg"); - std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap(); - let path = |photo: &Path, style, upper| { - let path = sidecar_path(&photo.to_string_lossy(), style); - if upper { path.with_extension("XMP") } else { path } - }; - let stem_a = path(&a, SidecarNaming::Stem, upper_stem); - let full_a = path(&a, SidecarNaming::Full, upper_full); - let stem_b = path(&b, SidecarNaming::Stem, upper_stem); - let full_b = path(&b, SidecarNaming::Full, upper_full); - let stem_bytes = packet("stem-keep"); - let full_bytes = packet("full-keep"); - std::fs::write(&stem_a, &stem_bytes).unwrap(); - std::fs::write(&full_a, &full_bytes).unwrap(); - let mut s = session(&a); - s.xmp.naming = naming; - s.xmp.auto_write = auto; - let fs = Faults(vec![full_a.clone(), b.clone()]); - let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); - assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); - assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); - let pending = queues(&s); - assert_eq!(pending.len(), 1); - assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); - - // Missing packet and an unrelated packet at either case spelling remain protected. - let held = dir.join("held.xmp"); - std::fs::rename(&full_a, &held).unwrap(); - s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap(); - assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); - assert_eq!(queues(&s), pending); - assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); - std::fs::rename(&held, &full_a).unwrap(); - for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] { - std::fs::write(&collision, packet("unrelated-packet")).unwrap(); - let occupied = std::fs::read(&collision).unwrap(); - assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); - assert_eq!(queues(&s), pending); - assert_eq!(std::fs::read(&collision).unwrap(), occupied); - assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes); - assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); - std::fs::remove_file(collision).unwrap(); - } - // AutoWrite is the first successful repair when enabled; explicit save otherwise. - s.execute("photo.rate", &json!({"rating": 4})).unwrap(); - if !auto { - assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0); - } - assert!(queues(&s).is_empty()); - assert!(!full_a.exists()); - let (written, untouched, expected) = - if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) }; - assert_fresh(written); - assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); - assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4)); - for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] { - s.execute(command, &json!({})).unwrap(); - if !auto { - save(&mut s); - } - assert_fresh(written); - assert_eq!( - parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, - Some(rating) - ); - assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); - } - assert_eq!(s.xmp.naming, naming); - assert_eq!(s.xmp.auto_write, auto); - drop(s); - std::fs::remove_dir_all(dir).unwrap(); + check_companion_scenario(upper_stem, upper_full, naming, auto); } } } } } +fn check_companion_scenario(upper_stem: bool, upper_full: bool, naming: SidecarNaming, auto: bool) { + let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}")); + let a = dir.join("a.jpg"); + let b = dir.join("Trip.jpg"); + std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap(); + let path = |photo: &Path, style, upper| { + let path = sidecar_path(&photo.to_string_lossy(), style); + if upper { path.with_extension("XMP") } else { path } + }; + let stem_a = path(&a, SidecarNaming::Stem, upper_stem); + let full_a = path(&a, SidecarNaming::Full, upper_full); + let stem_b = path(&b, SidecarNaming::Stem, upper_stem); + let full_b = path(&b, SidecarNaming::Full, upper_full); + let stem_bytes = packet("stem-keep"); + let full_bytes = packet("full-keep"); + std::fs::write(&stem_a, &stem_bytes).unwrap(); + std::fs::write(&full_a, &full_bytes).unwrap(); + let mut s = session(&a); + s.xmp.naming = naming; + s.xmp.auto_write = auto; + let fs = Faults(vec![full_a.clone(), b.clone()]); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); + let pending = queues(&s); + assert_eq!(pending.len(), 1); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + + // Missing packet and an unrelated packet at either case spelling remain protected. + let held = dir.join("held.xmp"); + std::fs::rename(&full_a, &held).unwrap(); + s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap(); + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); + assert_eq!(queues(&s), pending); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + std::fs::rename(&held, &full_a).unwrap(); + for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] { + std::fs::write(&collision, packet("unrelated-packet")).unwrap(); + let occupied = std::fs::read(&collision).unwrap(); + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); + assert_eq!(queues(&s), pending); + assert_eq!(std::fs::read(&collision).unwrap(), occupied); + assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + std::fs::remove_file(collision).unwrap(); + } + // AutoWrite is the first successful repair when enabled; explicit save otherwise. + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + if !auto { + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0); + } + assert!(queues(&s).is_empty()); + assert!(!full_a.exists()); + let (written, untouched, expected) = + if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) }; + assert_fresh(written); + assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); + assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4)); + for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] { + s.execute(command, &json!({})).unwrap(); + if !auto { + save(&mut s); + } + assert_fresh(written); + assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(rating)); + assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); + } + assert_eq!(s.xmp.naming, naming); + assert_eq!(s.xmp.auto_write, auto); + drop(s); + std::fs::remove_dir_all(dir).unwrap(); +} #[test] fn rollback_repair_and_shared_copy_keep_known_companions() { for rollback in [false, true] { diff --git a/crates/engine/src/sidecar/tests_repair_provenance.rs b/crates/engine/src/sidecar/tests_repair_provenance.rs index 86f7d1dd6..9c9b7c084 100644 --- a/crates/engine/src/sidecar/tests_repair_provenance.rs +++ b/crates/engine/src/sidecar/tests_repair_provenance.rs @@ -348,64 +348,12 @@ fn detached_history_entry_is_checked_before_unrelated_global_retirement() { // A private-method contract, not a UI reproduction: application reachability of two // simultaneous repair owners is unproven. Native move failures and follow_stuck seed // both records; the second owner's normal apply_rename preflight is deliberately bypassed. - struct NativePartial { - id: PhotoId, - original: PathBuf, - moved: PathBuf, - stem: PathBuf, - full: PathBuf, - companion: PathBuf, - target: PathBuf, - } let root = crate::tests_xmp::temp_dir("detached-history-preflight-contract"); let image = lightcraft_raster::Rgba8::from_fn(4, 3, |_, _| [40, 80, 120, 255]); let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap(); let mut s = Session::new(); s.xmp.naming = SidecarNaming::Stem; - let mut native = Vec::new(); - for (i, name) in ["first", "second"].into_iter().enumerate() { - let dir = root.join(name); - std::fs::create_dir_all(&dir).unwrap(); - let original = dir.join("a.png"); - let moved = dir.join("Trip.png"); - std::fs::write(&original, &png).unwrap(); - let stem = path(&original, SidecarNaming::Stem, false); - let full = path(&original, SidecarNaming::Full, true); - for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] { - std::fs::write(p, packet(&marker)).unwrap(); - assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5)); - } - let id = PhotoId(i as u64 + 1); - let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, ""); - s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); - let fs = Faults(vec![full.clone(), moved.clone()]); - if i == 0 { - let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); - assert!(s.apply_rename_with(&fs, &plans).is_err()); - } else { - // Register the genuine second partial move without draining the first repair. - let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())]; - let err = crate::rename::move_all(&fs, &moves).unwrap_err(); - assert_eq!(err.stuck, moves); - assert_eq!(err.pending_sidecars.len(), 1); - let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() }; - s.follow_stuck(&op, err, true); - } - assert_eq!(s.undo.len(), i + 1); - assert_eq!(queued(&s).len(), i + 1); - assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() }); - let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap(); - assert_eq!((decoded.width, decoded.height), (4, 3)); - native.push(NativePartial { - id, - original, - companion: path(&moved, SidecarNaming::Stem, false), - target: path(&moved, SidecarNaming::Full, true), - moved, - stem, - full, - }); - } + let native = create_native_partials(&root, &png, &mut s); let first = &native[0]; let second = &native[1]; let first_stem = std::fs::read(&first.companion).unwrap(); @@ -463,3 +411,61 @@ fn detached_history_entry_is_checked_before_unrelated_global_retirement() { drop(s); std::fs::remove_dir_all(root).unwrap(); } + +struct NativePartial { + id: PhotoId, + original: PathBuf, + moved: PathBuf, + stem: PathBuf, + full: PathBuf, + companion: PathBuf, + target: PathBuf, +} + +fn create_native_partials(root: &Path, png: &[u8], s: &mut Session) -> Vec { + let mut native = Vec::new(); + for (i, name) in ["first", "second"].into_iter().enumerate() { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("a.png"); + let moved = dir.join("Trip.png"); + std::fs::write(&original, png).unwrap(); + let stem = path(&original, SidecarNaming::Stem, false); + let full = path(&original, SidecarNaming::Full, true); + for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] { + std::fs::write(p, packet(&marker)).unwrap(); + assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5)); + } + let id = PhotoId(i as u64 + 1); + let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, ""); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + let fs = Faults(vec![full.clone(), moved.clone()]); + if i == 0 { + let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); + assert!(s.apply_rename_with(&fs, &plans).is_err()); + } else { + // Register the genuine second partial move without draining the first repair. + let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())]; + let err = crate::rename::move_all(&fs, &moves).unwrap_err(); + assert_eq!(err.stuck, moves); + assert_eq!(err.pending_sidecars.len(), 1); + let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() }; + s.follow_stuck(&op, err, true); + } + assert_eq!(s.undo.len(), i + 1); + assert_eq!(queued(s).len(), i + 1); + assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() }); + let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap(); + assert_eq!((decoded.width, decoded.height), (4, 3)); + native.push(NativePartial { + id, + original, + companion: path(&moved, SidecarNaming::Stem, false), + target: path(&moved, SidecarNaming::Full, true), + moved, + stem, + full, + }); + } + native +} diff --git a/crates/engine/src/sidecar/tests_repair_saves.rs b/crates/engine/src/sidecar/tests_repair_saves.rs index 4b622e657..6fd50aea6 100644 --- a/crates/engine/src/sidecar/tests_repair_saves.rs +++ b/crates/engine/src/sidecar/tests_repair_saves.rs @@ -58,118 +58,122 @@ fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_sw for save_naming in [SidecarNaming::Stem, SidecarNaming::Full] { for upper in [false, true] { for switch in [false, true] { - let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}")); - let a = root.join("a.png"); - let b = root.join("Trip.png"); - png(&a); - let mut old = sidecar_path(&a.to_string_lossy(), naming); - if upper { - old = old.with_extension("XMP"); - } - std::fs::write(&old, FOREIGN).unwrap(); - let mut s = Session::new().with_fs(); - s.open_library(root.join("library-a"), false).unwrap(); - s.execute("library.import", &json!({"paths": [a]})).unwrap(); - let mut id = s.catalog.photos().next().unwrap().id; - s.selection = crate::Selection::single(id); - s.undo.clear(); - let fs = PartialRename { sidecar: old.clone(), photo: b.clone() }; - let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); - assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); - assert_eq!(queued(&s).len(), 1); - assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); - let pending = queued(&s); - let held = root.join("temporarily-unavailable.xmp"); - std::fs::rename(&old, &held).unwrap(); - if switch { - s.close_library().unwrap(); - s.open_library(root.join("library-b"), false).unwrap(); - assert_eq!(s.pending_sidecar_repairs, pending); - s.execute("library.import", &json!({"paths": [b]})).unwrap(); - id = s.catalog.photos().next().unwrap().id; - s.selection = crate::Selection::single(id); - } - s.xmp.naming = save_naming; - s.xmp.auto_write = true; - s.execute("photo.rate", &json!({"rating": 4})).unwrap(); - let preferred = sidecar_path(&b.to_string_lossy(), save_naming); - assert!(!preferred.exists() && !preferred.with_extension("XMP").exists()); - assert!(!pending[0].to.exists()); - assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite"); - let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap(); - assert!(saved["written"].as_array().unwrap().is_empty()); - assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair")); - assert_eq!(std::fs::read(&held).unwrap(), FOREIGN); - assert_eq!(queued(&s), pending); - - // A missing sidecar for another photo does not prevent this healthy save. - let healthy = root.join("healthy.png"); - png(&healthy); - let healthy_id = PhotoId(999); - s.commit( - "Healthy Photo", - Op::AddPhoto { - photo: Box::new(Photo::new( - healthy_id, - Source::File { path: healthy.to_string_lossy().into_owned() }, - "healthy.png", - "PNG", - 4, - 3, - "", - )), - }, - ) - .unwrap(); - s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap(); - let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap(); - assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save")); - assert_eq!(queued(&s), pending); - - // An existing destination is never overwritten and the original packet remains. - std::fs::rename(&held, &old).unwrap(); - let destination = pending[0].to.clone(); - let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes(); - let collision_path = if upper { preferred.clone() } else { destination.clone() }; - std::fs::write(&collision_path, &collision).unwrap(); - s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap(); - assert_eq!(std::fs::read(&old).unwrap(), FOREIGN); - assert_eq!(std::fs::read(&collision_path).unwrap(), collision); - assert_eq!(queued(&s), pending); - s.selection = crate::Selection::single(id); - assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1); - assert_eq!(std::fs::read(&collision_path).unwrap(), collision); - std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision - - // The next successful AutoWrite runs the real repair and production XMP merge. - s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap(); - assert!(queued(&s).is_empty()); - assert!(!old.exists()); - let packet = std::fs::read_to_string(&destination).unwrap(); - assert!(packet.contains("fixture:Marker=\"keep-me\"")); - let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap(); - assert_eq!(parsed.rating, Some(4)); - assert_eq!(parsed.title.as_deref(), Some("Final title")); - assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string())); - if upper { - // by listing: on a case-insensitive volume `preferred` answers for the upper-case packet - let listed = std::fs::read_dir(&root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name()); - assert!(!listed, "merge uses the repaired uppercase packet"); - } - for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] { - s.execute(step, &json!({})).unwrap(); - let packet = std::fs::read_to_string(&destination).unwrap(); - assert!(packet.contains("fixture:Marker=\"keep-me\"")); - assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title)); - } - drop(s); - std::fs::remove_dir_all(root).unwrap(); + check_autowrite_repair_scenario(naming, save_naming, upper, switch); } } } } } +fn check_autowrite_repair_scenario(naming: SidecarNaming, save_naming: SidecarNaming, upper: bool, switch: bool) { + let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}")); + let a = root.join("a.png"); + let b = root.join("Trip.png"); + png(&a); + let mut old = sidecar_path(&a.to_string_lossy(), naming); + if upper { + old = old.with_extension("XMP"); + } + std::fs::write(&old, FOREIGN).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(root.join("library-a"), false).unwrap(); + s.execute("library.import", &json!({"paths": [a]})).unwrap(); + let mut id = s.catalog.photos().next().unwrap().id; + s.selection = crate::Selection::single(id); + s.undo.clear(); + let fs = PartialRename { sidecar: old.clone(), photo: b.clone() }; + let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(queued(&s).len(), 1); + assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); + let pending = queued(&s); + let held = root.join("temporarily-unavailable.xmp"); + std::fs::rename(&old, &held).unwrap(); + if switch { + s.close_library().unwrap(); + s.open_library(root.join("library-b"), false).unwrap(); + assert_eq!(s.pending_sidecar_repairs, pending); + s.execute("library.import", &json!({"paths": [b]})).unwrap(); + id = s.catalog.photos().next().unwrap().id; + s.selection = crate::Selection::single(id); + } + s.xmp.naming = save_naming; + s.xmp.auto_write = true; + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + let preferred = sidecar_path(&b.to_string_lossy(), save_naming); + assert!(!preferred.exists() && !preferred.with_extension("XMP").exists()); + assert!(!pending[0].to.exists()); + assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite"); + let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap(); + assert!(saved["written"].as_array().unwrap().is_empty()); + assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair")); + assert_eq!(std::fs::read(&held).unwrap(), FOREIGN); + assert_eq!(queued(&s), pending); + + // A missing sidecar for another photo does not prevent this healthy save. + let healthy = root.join("healthy.png"); + png(&healthy); + let healthy_id = PhotoId(999); + s.commit( + "Healthy Photo", + Op::AddPhoto { + photo: Box::new(Photo::new(healthy_id, Source::File { path: healthy.to_string_lossy().into_owned() }, "healthy.png", "PNG", 4, 3, "")), + }, + ) + .unwrap(); + s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap(); + let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap(); + assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save")); + assert_eq!(queued(&s), pending); + + // An existing destination is never overwritten and the original packet remains. + std::fs::rename(&held, &old).unwrap(); + let destination = pending[0].to.clone(); + let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes(); + let collision_path = if upper { preferred.clone() } else { destination.clone() }; + std::fs::write(&collision_path, &collision).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap(); + assert_eq!(std::fs::read(&old).unwrap(), FOREIGN); + assert_eq!(std::fs::read(&collision_path).unwrap(), collision); + assert_eq!(queued(&s), pending); + s.selection = crate::Selection::single(id); + assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1); + assert_eq!(std::fs::read(&collision_path).unwrap(), collision); + std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision + + // The next successful AutoWrite runs the real repair and production XMP merge. + s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap(); + assert!(queued(&s).is_empty()); + assert!(!old.exists()); + let packet = std::fs::read_to_string(&destination).unwrap(); + assert!(packet.contains("fixture:Marker=\"keep-me\"")); + let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap(); + assert_eq!(parsed.rating, Some(4)); + assert_eq!(parsed.title.as_deref(), Some("Final title")); + assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string())); + assert_repaired_case_spelling(&root, &preferred, upper); + assert_undo_redo_preserve_foreign_packet(&mut s, &destination); + drop(s); + std::fs::remove_dir_all(root).unwrap(); +} + +fn assert_repaired_case_spelling(root: &Path, preferred: &Path, upper: bool) { + if !upper { + return; + } + // By listing, a case-insensitive volume answers for the uppercase packet. + let listed = std::fs::read_dir(root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name()); + assert!(!listed, "merge uses the repaired uppercase packet"); +} + +fn assert_undo_redo_preserve_foreign_packet(s: &mut Session, destination: &Path) { + for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] { + s.execute(step, &json!({})).unwrap(); + let packet = std::fs::read_to_string(destination).unwrap(); + assert!(packet.contains("fixture:Marker=\"keep-me\"")); + assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title)); + } +} #[test] fn unowned_shared_stem_repair_does_not_block_healthy_jpeg_full_sidecar_save() { let root = crate::tests_xmp::temp_dir("repair-save-shared-stem"); diff --git a/crates/engine/src/tests/import.rs b/crates/engine/src/tests/import.rs index 6e5585a82..dc1c6c8f2 100644 --- a/crates/engine/src/tests/import.rs +++ b/crates/engine/src/tests/import.rs @@ -1,7 +1,11 @@ //! Import: recursive folders, duplicate detection by content, add vs copy into the library. -use std::path::Path; +use std::{ + path::{Path, PathBuf}, + sync::atomic::AtomicUsize, +}; +use lightcraft_catalog::PhotoId; use serde_json::{Value, json}; use crate::Session; @@ -1711,109 +1715,139 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() { } fn smart_look_refresh_for(format: &str) { - use std::sync::atomic::{AtomicUsize, Ordering}; - let src = temp_dir(&format!("smartlook-src-{format}")); - let lib = temp_dir(&format!("smartlook-lib-{format}")); - write_png(&src.join("a.png"), 3); - write_png(&src.join("b.png"), 9); - let mut s = Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); - let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id; - let (id, png) = (find(&s, "a.png"), find(&s, "b.png")); - // `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels) - let mut raw = (**s.catalog.photo(id).unwrap()).clone(); - raw.format = format.into(); - raw.kind = lightcraft_catalog::MediaKind::Raw; - s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); - s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); - assert_eq!(s.catalog.photo(id).unwrap().relative_wb(), format == "ARW"); - assert!(!s.catalog.photo(png).unwrap().relative_wb()); - s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap(); - let dir = s.media.smart_dir.clone().unwrap(); - let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); - let png_file = crate::smart::sharded_file_path(&dir, s.catalog.photo(png).unwrap()); - let (old, new) = (test_tone(1.5), test_tone(3.5)); - let calls = std::sync::Arc::new(AtomicUsize::new(0)); - let plain = s.media.file_loader.clone().unwrap(); - s.media.file_loader = Some(look_loader(plain.clone(), old, calls.clone())); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + let mut case = SmartLookCase::new(format); + assert_current_smart_look_is_not_rebuilt(&mut case); + let (old_bytes, png_bytes) = keep_offline_previews(&mut case); + refresh_online_previews(&mut case, &old_bytes, &png_bytes); + let _ = std::fs::remove_dir_all(&case.src); + let _ = std::fs::remove_dir_all(&case.lib); +} + +struct SmartLookCase { + src: PathBuf, + lib: PathBuf, + session: Session, + id: PhotoId, + dir: PathBuf, + file: PathBuf, + png_file: PathBuf, + old: lightcraft_pipeline::tone::CameraTone, + new: lightcraft_pipeline::tone::CameraTone, + calls: std::sync::Arc, + plain_loader: crate::media::FileLoader, +} + +impl SmartLookCase { + fn new(format: &str) -> Self { + let src = temp_dir(&format!("smartlook-src-{format}")); + let lib = temp_dir(&format!("smartlook-lib-{format}")); + write_png(&src.join("a.png"), 3); + write_png(&src.join("b.png"), 9); + let mut session = Session::new().with_fs(); + session.open_library(&lib, false).unwrap(); + session.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); + let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id; + let (id, png_id) = (find(&session, "a.png"), find(&session, "b.png")); + // `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels) + let mut raw = (**session.catalog.photo(id).unwrap()).clone(); + raw.format = format.into(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + session.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); + session.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + assert_eq!(session.catalog.photo(id).unwrap().relative_wb(), format == "ARW"); + assert!(!session.catalog.photo(png_id).unwrap().relative_wb()); + session.execute("library.select", &json!({"ids": [id.0, png_id.0], "active": id.0})).unwrap(); + let dir = session.media.smart_dir.clone().unwrap(); + let file = crate::smart::sharded_file_path(&dir, session.catalog.photo(id).unwrap()); + let png_file = crate::smart::sharded_file_path(&dir, session.catalog.photo(png_id).unwrap()); + let (old, new) = (test_tone(1.5), test_tone(3.5)); + let calls = std::sync::Arc::new(AtomicUsize::new(0)); + let plain_loader = session.media.file_loader.clone().unwrap(); + session.media.file_loader = Some(look_loader(plain_loader.clone(), old, calls.clone())); + Self { src, lib, session, id, dir, file, png_file, old, new, calls, plain_loader } + } +} + +fn assert_current_smart_look_is_not_rebuilt(case: &mut SmartLookCase) { + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}"); - assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version"); - assert!(!crate::smart::is_stale(&file)); + assert_eq!(smart_header(&case.file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version"); + assert!(!crate::smart::is_stale(&case.file)); // current header: kept as it is, the original is not even read - let before = std::fs::read(&file).unwrap(); - calls.store(0, Ordering::SeqCst); - s.media.file_loader = Some(look_loader(plain.clone(), new, calls.clone())); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + let before = std::fs::read(&case.file).unwrap(); + case.calls.store(0, std::sync::atomic::Ordering::SeqCst); + case.session.media.file_loader = Some(look_loader(case.plain_loader.clone(), case.new, case.calls.clone())); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}"); - assert!(std::fs::read(&file).unwrap() == before, "a current proxy is not rebuilt"); - assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(std::fs::read(&case.file).unwrap(), before, "a current proxy is not rebuilt"); + assert_eq!(case.calls.load(std::sync::atomic::Ordering::SeqCst), 0); +} +fn keep_offline_previews(case: &mut SmartLookCase) -> (Vec, Vec) { // old headers (no field), originals offline: kept unchanged, and still marked stale - for f in [&file, &png_file] { + for f in [&case.file, &case.png_file] { edit_smart_header(f, |h| { h.as_object_mut().unwrap().remove("look_version"); }); } - assert!(crate::smart::is_stale(&file), "no field = version 0"); - let old_bytes = std::fs::read(&file).unwrap(); - let png_bytes = std::fs::read(&png_file).unwrap(); - std::fs::rename(&src, src.with_extension("offline")).unwrap(); - s.media.forget(id); - s.media.forget(png); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert!(crate::smart::is_stale(&case.file), "no field = version 0"); + let old_bytes = std::fs::read(&case.file).unwrap(); + let png_bytes = std::fs::read(&case.png_file).unwrap(); + std::fs::rename(&case.src, case.src.with_extension("offline")).unwrap(); + case.session.media.forget(case.id); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(2), Some(0), Some(1)), "{r}"); assert!(r["failed"].as_array().unwrap().is_empty(), "{r}"); - assert!(std::fs::read(&file).unwrap() == old_bytes, "offline: the stored preview and curve are untouched"); - assert!(crate::smart::is_stale(&file), "and it stays marked for the next time"); - assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(old), "the old look is still there"); + assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes, "offline: the stored preview and curve are untouched"); + assert!(crate::smart::is_stale(&case.file), "and it stays marked for the next time"); + assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(case.old), "the old look is still there"); // ... and the opening refresh keeps it too, then marks the folder checked - assert!(!crate::smart::look_checked(&dir)); - crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); - assert!(std::fs::read(&file).unwrap() == old_bytes); - assert!(crate::smart::look_checked(&dir)); + assert!(!crate::smart::look_checked(&case.dir)); + crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).unwrap().join().unwrap(); + assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes); + assert!(crate::smart::look_checked(&case.dir)); + (old_bytes, png_bytes) +} +fn refresh_online_previews(case: &mut SmartLookCase, old_bytes: &[u8], png_bytes: &[u8]) { // the original comes back: the scan has run at this version, so the next opening leaves it; // Build Smart Previews rebuilds it with the current look - std::fs::rename(src.with_extension("offline"), &src).unwrap(); - calls.store(0, Ordering::SeqCst); - assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none(), "checked once per version"); - assert!(std::fs::read(&file).unwrap() == old_bytes); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); - assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(1), 1), "{r}"); - assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION)); - assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, Some(new), "rebuilt with the current look"); - assert!(!crate::smart::is_stale(&file)); - assert!(std::fs::read(&png_file).unwrap() == png_bytes, "a PNG's proxy is never rebuilt for the look"); + std::fs::rename(case.src.with_extension("offline"), &case.src).unwrap(); + case.calls.store(0, std::sync::atomic::Ordering::SeqCst); + assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).is_none(), "checked once per version"); + assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["refreshed"].as_u64(), case.calls.load(std::sync::atomic::Ordering::SeqCst)), (Some(1), 1), "{r}"); + assert_eq!(smart_header(&case.file)["look_version"], json!(crate::camera_preview::LOOK_VERSION)); + assert_eq!(crate::smart::decode(&std::fs::read(&case.file).unwrap()).unwrap().1, Some(case.new), "rebuilt with the current look"); + assert!(!crate::smart::is_stale(&case.file)); + assert_eq!(std::fs::read(&case.png_file).unwrap(), png_bytes, "a PNG's proxy is never rebuilt for the look"); // a folder checked at an older version (or not at all) is scanned once at the next opening - edit_smart_header(&file, |h| h["look_version"] = json!(0)); - std::fs::write(dir.join("look-version"), b"0\n").unwrap(); - assert!(!crate::smart::look_checked(&dir)); - s.media.forget(id); - calls.store(0, Ordering::SeqCst); - crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); - assert_eq!(calls.load(Ordering::SeqCst), 1, "only the raw's original is read"); - assert!(!crate::smart::is_stale(&file)); - assert!(std::fs::read(&png_file).unwrap() == png_bytes); - assert!(crate::smart::look_checked(&dir)); + edit_smart_header(&case.file, |h| h["look_version"] = json!(0)); + std::fs::write(case.dir.join("look-version"), b"0\n").unwrap(); + assert!(!crate::smart::look_checked(&case.dir)); + case.session.media.forget(case.id); + case.calls.store(0, std::sync::atomic::Ordering::SeqCst); + crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).unwrap().join().unwrap(); + assert_eq!(case.calls.load(std::sync::atomic::Ordering::SeqCst), 1, "only the raw's original is read"); + assert!(!crate::smart::is_stale(&case.file)); + assert_eq!(std::fs::read(&case.png_file).unwrap(), png_bytes); + assert!(crate::smart::look_checked(&case.dir)); // once only - let rebuilt = std::fs::read(&file).unwrap(); - assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none()); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); - assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(0), 1), "{r}"); - assert!(std::fs::read(&file).unwrap() == rebuilt); + let rebuilt = std::fs::read(&case.file).unwrap(); + assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).is_none()); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["refreshed"].as_u64(), case.calls.load(std::sync::atomic::Ordering::SeqCst)), (Some(0), 1), "{r}"); + assert_eq!(std::fs::read(&case.file).unwrap(), rebuilt); // Build Smart Previews refreshes a stale one the same way - edit_smart_header(&file, |h| h["look_version"] = json!(0)); - s.media.file_loader = Some(plain); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + edit_smart_header(&case.file, |h| h["look_version"] = json!(0)); + case.session.media.file_loader = Some(case.plain_loader.clone()); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!(r["refreshed"], 1, "{r}"); - assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept"); - let _ = std::fs::remove_dir_all(&src); - let _ = std::fs::remove_dir_all(&lib); + assert_eq!(crate::smart::decode(&std::fs::read(&case.file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept"); } /// Issue #1534 (review): a proxy's encoding version is migrated independently of the per-file From c9d44f7804075fa4ecfb48a524bb57d8c1314564 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:34 +0000 Subject: [PATCH 56/91] style(engine): expand module imports explicitly --- crates/catalog/examples/persist_bench.rs | 2 +- crates/engine/src/camera_preview/colour.rs | 2 +- crates/engine/src/camera_preview/hue_sat.rs | 2 +- crates/engine/src/camera_preview/look_fit.rs | 2 +- crates/engine/src/camera_preview/mono.rs | 2 +- crates/engine/src/camera_preview/pairs.rs | 2 +- crates/engine/src/camera_preview/proxy.rs | 2 +- crates/engine/src/camera_preview/tone.rs | 2 +- crates/engine/src/cmd/geocode.rs | 5 ++++- crates/engine/src/cmd/library/filters.rs | 2 +- crates/engine/src/cmd/library/import.rs | 2 +- crates/engine/src/cmd/library/selection.rs | 2 +- crates/engine/src/cmd/library/specs.rs | 5 ++++- crates/engine/src/denoise/model.rs | 3 +-- crates/engine/src/denoise/products.rs | 5 ++++- crates/engine/src/denoise/session.rs | 5 ++++- crates/engine/src/denoise/settings.rs | 2 +- crates/engine/src/denoise/state.rs | 2 +- crates/engine/src/export/batch.rs | 2 +- crates/engine/src/export/encoding.rs | 5 ++++- crates/engine/src/export/options.rs | 2 +- crates/engine/src/export/params.rs | 2 +- crates/engine/src/export/place.rs | 2 +- crates/engine/src/export/prepare.rs | 2 +- crates/engine/src/export/watermark.rs | 2 +- crates/engine/src/files/fs.rs | 2 +- crates/engine/src/files/load.rs | 2 +- crates/engine/src/files/preview.rs | 2 +- crates/engine/src/files/probe.rs | 2 +- crates/engine/src/import/commit.rs | 2 +- crates/engine/src/import/defaults.rs | 2 +- crates/engine/src/import/files.rs | 3 ++- crates/engine/src/import/job.rs | 2 +- crates/engine/src/import/scan.rs | 2 +- crates/engine/src/import/types.rs | 2 +- crates/engine/src/media/cache.rs | 2 +- crates/engine/src/media/quick.rs | 2 +- crates/engine/src/media/session.rs | 2 +- crates/engine/src/media/settings_hash.rs | 2 +- crates/engine/src/media/source.rs | 2 +- crates/engine/src/rename/fs.rs | 2 +- crates/engine/src/rename/moves.rs | 2 +- crates/engine/src/rename/plan.rs | 2 +- crates/engine/src/rename/sidecar.rs | 2 +- crates/engine/src/rename/tokens.rs | 2 +- xtask/src/ci.rs | 2 +- xtask/src/corpus.rs | 2 +- xtask/src/layers.rs | 2 +- xtask/src/wasm.rs | 2 +- 49 files changed, 65 insertions(+), 50 deletions(-) diff --git a/crates/catalog/examples/persist_bench.rs b/crates/catalog/examples/persist_bench.rs index 693e862d0..1fc05588c 100644 --- a/crates/catalog/examples/persist_bench.rs +++ b/crates/catalog/examples/persist_bench.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use std::time::Instant; -use lightcraft_catalog::*; +use lightcraft_catalog::{Catalog, DEFAULT_FORGET_DAYS, FsStore, HistoryStep, Journal, MediaKind, Op, Photo, PhotoId, Source, folder_of}; use lightcraft_develop::DevelopSettings; fn ms(t: Instant) -> f64 { diff --git a/crates/engine/src/camera_preview/colour.rs b/crates/engine/src/camera_preview/colour.rs index a33a5c7a5..2681466a9 100644 --- a/crates/engine/src/camera_preview/colour.rs +++ b/crates/engine/src/camera_preview/colour.rs @@ -1,7 +1,7 @@ use super::look_fit::{fit_dng_tone, fit_preview}; use super::mono::{fit_dark_tone, monochrome_look}; use super::proxy::{LensGeometry, proxies}; -use super::*; +use super::{CameraLook, CameraTone, CameraTransform, ColorData, D50, D65, Mat3, ProfileLook, REC2020, RawFormat, RawImage, ToneCurve, Xy, bradford}; /// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates /// below were set at this size). diff --git a/crates/engine/src/camera_preview/hue_sat.rs b/crates/engine/src/camera_preview/hue_sat.rs index 7e92ba519..b3692dc4f 100644 --- a/crates/engine/src/camera_preview/hue_sat.rs +++ b/crates/engine/src/camera_preview/hue_sat.rs @@ -1,6 +1,6 @@ use super::mono::to_prophoto; use super::pairs::{displayed, luma}; -use super::*; +use super::{CameraLook, CameraTone, HsvTable, Mat3, ToneMap, luminance_2020}; /// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour /// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps diff --git a/crates/engine/src/camera_preview/look_fit.rs b/crates/engine/src/camera_preview/look_fit.rs index c1588093b..544db5f5f 100644 --- a/crates/engine/src/camera_preview/look_fit.rs +++ b/crates/engine/src/camera_preview/look_fit.rs @@ -3,7 +3,7 @@ use super::mono::{fit_tone_only_with, monochrome}; use super::pairs::{Attempt, TONE_FITS, fit_attempt, fit_partial, search_ordered}; use super::proxy::{CLIP_TOLERANCE, LensGeometry, MIN_PREVIEW_FRACTION, proxies, structure_agrees}; use super::tone::{HighlightCheck, highlights}; -use super::*; +use super::{CameraLook, CameraTone, CameraTransform, HsvTable, Mat3, RawImage, Rgb32f, ToneMap, luminance_2020}; /// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]). pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option, bool) { diff --git a/crates/engine/src/camera_preview/mono.rs b/crates/engine/src/camera_preview/mono.rs index cfac90c34..3c0bd5736 100644 --- a/crates/engine/src/camera_preview/mono.rs +++ b/crates/engine/src/camera_preview/mono.rs @@ -7,7 +7,7 @@ use super::pairs::{ }; use super::proxy::{LensGeometry, proxies}; use super::tone::{HighlightCheck, ToneFit, fit_tone, highlights, highlights_hold}; -use super::*; +use super::{CameraLook, CameraTransform, D50, D65, HsvTable, Mat3, PROPHOTO, REC2020, RawImage, Rgb32f, ToneMap, bradford, luminance_2020}; /// A camera JPEG pixel's channels may differ by this much (linear) and still count as grey. const GREY_SPREAD: f32 = 0.002; diff --git a/crates/engine/src/camera_preview/pairs.rs b/crates/engine/src/camera_preview/pairs.rs index 2fce1a871..425f267da 100644 --- a/crates/engine/src/camera_preview/pairs.rs +++ b/crates/engine/src/camera_preview/pairs.rs @@ -3,7 +3,7 @@ use super::proxy::DARK_FLOOR; #[cfg(test)] use super::tone::highlights; use super::tone::{HighlightCheck, ToneFit, highlights_hold}; -use super::*; +use super::{CameraLook, CameraTone, HsvTable, Mat3, Rgb32f, ToneMap, luminance_2020}; /// A fit must cut the held-out squared error to below this share of the fallback's. pub(super) const MIN_IMPROVEMENT: f64 = 0.7; diff --git a/crates/engine/src/camera_preview/proxy.rs b/crates/engine/src/camera_preview/proxy.rs index 81f170a87..b3db0a160 100644 --- a/crates/engine/src/camera_preview/proxy.rs +++ b/crates/engine/src/camera_preview/proxy.rs @@ -2,7 +2,7 @@ use super::colour::{PROFILE_PROXY, PROXY, file_local_look}; use super::hue_sat::fit_hue_sat; use super::look_fit::{FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, clip_mask, without_clipped}; use super::pairs::{collect_pairs, fit_matrix}; -use super::*; +use super::{CameraTransform, Filter, HsvTable, Mat3, RawFormat, RawImage, Rgb32f, fit, luminance_2020, resize}; /// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a /// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview diff --git a/crates/engine/src/camera_preview/tone.rs b/crates/engine/src/camera_preview/tone.rs index 19b66948c..f16b8e899 100644 --- a/crates/engine/src/camera_preview/tone.rs +++ b/crates/engine/src/camera_preview/tone.rs @@ -1,6 +1,6 @@ use super::look_fit::dro_off; use super::pairs::{Pairs, displayed, luma}; -use super::*; +use super::{CameraTone, Rgb32f, ToneMap, luminance_2020}; /// How the per-photo tone curve is fitted ([`fit_pairs_ordered`] tries them in order). #[derive(Clone, Copy, PartialEq, Eq, Debug)] diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs index feb9b033e..63f8d8f17 100644 --- a/crates/engine/src/cmd/geocode.rs +++ b/crates/engine/src/cmd/geocode.rs @@ -188,7 +188,10 @@ mod download { use lightcraft_geocode::{GEONAMES_BASE, GEONAMES_ZIP}; - use super::*; + use super::{ + Cancel, DownloadOutcome, GEONAMES_ATTRIBUTION, GEONAMES_FILES, GeoNames, PoisonError, Result, Session, Value, bad, dataset_dir, json, + str_param, + }; use crate::activity::Unit; const MAX_ZIP: u64 = 64 * 1024 * 1024; diff --git a/crates/engine/src/cmd/library/filters.rs b/crates/engine/src/cmd/library/filters.rs index 1eaa5223f..f0a3a504f 100644 --- a/crates/engine/src/cmd/library/filters.rs +++ b/crates/engine/src/cmd/library/filters.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AlbumId, LibrarySource, PhotoId, Result, Session, Value, bad}; /// `base` with a partial Filter (JSON) merged on top. /// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see diff --git a/crates/engine/src/cmd/library/import.rs b/crates/engine/src/cmd/library/import.rs index 81b82671f..8437bf165 100644 --- a/crates/engine/src/cmd/library/import.rs +++ b/crates/engine/src/cmd/library/import.rs @@ -1,5 +1,5 @@ use super::selection::strs; -use super::*; +use super::{AlbumId, PhotoId, Result, Selection, Session, Value, bad, bool_or, json, str_param}; /// The auto-import folder's visible files with their sizes (the file-system half of /// `library.autoImportScan`; the app lists on a worker thread and passes `listing`). diff --git a/crates/engine/src/cmd/library/selection.rs b/crates/engine/src/cmd/library/selection.rs index 1ed1868d7..723b7c331 100644 --- a/crates/engine/src/cmd/library/selection.rs +++ b/crates/engine/src/cmd/library/selection.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AlbumId, Flag, Op, PhotoId, Result, Selection, Session, Value, bad, bool_or, json, ok}; /// The selection after photos left the view (deleted, or rated, flagged or labelled out of it). /// Its callers end an open edit (a slider drag) before their own change, as moving to the next diff --git a/crates/engine/src/cmd/library/specs.rs b/crates/engine/src/cmd/library/specs.rs index 07ab6f221..2e7c1311d 100644 --- a/crates/engine/src/cmd/library/specs.rs +++ b/crates/engine/src/cmd/library/specs.rs @@ -5,7 +5,10 @@ use super::selection::{ album_param, album_targets, album_targets_or_err, change_flag, change_rating, cull, flip, for_targets, has_library, mark_deleted, opt_album_param, rotate, row_step, select_after_deletion, select_by, step, strs, }; -use super::*; +use super::{ + Album, AlbumId, ColorLabel, CommandSpec, CopyrightStatus, Filter, Flag, GroupBy, LibrarySource, Op, PhotoId, Result, Selection, Sort, SortKey, + Value, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, json, ok, str_param, +}; /// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote. const MAX_SEED: u64 = 1 << 53; diff --git a/crates/engine/src/denoise/model.rs b/crates/engine/src/denoise/model.rs index 28ff953bf..fbd13d79c 100644 --- a/crates/engine/src/denoise/model.rs +++ b/crates/engine/src/denoise/model.rs @@ -4,8 +4,7 @@ use super::gpu::{GPU_CRASHED, GPU_MAX_FALLBACKS, GPU_PARALLEL, GPU_SLOW, GPU_STU use super::settings::{installed_models, read_settings}; #[cfg(feature = "denoise")] use super::state::Pace; -use super::*; - +use super::{Arc, DenoiserManifest, Mutex, Path, TileRunner, Value, json}; /// Stack of the threads that run the model (the runtime recurses through its graph). pub(super) const STACK: usize = 16 << 20; diff --git a/crates/engine/src/denoise/products.rs b/crates/engine/src/denoise/products.rs index 499e8fd6e..bf601b14d 100644 --- a/crates/engine/src/denoise/products.rs +++ b/crates/engine/src/denoise/products.rs @@ -1,6 +1,9 @@ use super::model::{Loader, Model, ModelSlot, RunOn, STACK}; use super::settings::EXT; -use super::*; +use super::{ + Arc, AtomicBool, AtomicUsize, ByteReader, Condvar, Control, DenoiserManifest, HashSet, Hasher128, Layout, Mutex, OnceLock, Ordering, Params, + Path, PathBuf, Photo, PoisonError, RunError, Source, SystemTime, denoise_bayer, product, +}; /// Samples at or above this (white = 1) are clipped: the model leaves them as the camera recorded them. const CLIP: f32 = 0.99; diff --git a/crates/engine/src/denoise/session.rs b/crates/engine/src/denoise/session.rs index 6a452f693..10529d61d 100644 --- a/crates/engine/src/denoise/session.rs +++ b/crates/engine/src/denoise/session.rs @@ -4,7 +4,10 @@ use super::model::STACK; use super::products::{Active, JobSpec, MakeError, Progress, eligible, make_product, product_key, product_path, products_in}; use super::settings::{DIR, SETTINGS_TTL, installed_models, read_settings}; use super::state::{AUTO_LOOK, Failure, MAX_QUEUE, Outcome, Pace, PhotoState, Running}; -use super::*; +use super::{ + Arc, DenoiseSpec, DevelopSettings, HashMap, HashSet, MakeProduct, Mutex, Ordering, PairLoader, Path, PathBuf, Photo, PhotoId, PoisonError, + Selection, Session, Source, Value, json, product, +}; impl Session { /// Where the active model's tiles run (see [`Model::device`]); `{kind: "none"}` while no model is loaded. diff --git a/crates/engine/src/denoise/settings.rs b/crates/engine/src/denoise/settings.rs index f8acd5320..d71c7b074 100644 --- a/crates/engine/src/denoise/settings.rs +++ b/crates/engine/src/denoise/settings.rs @@ -1,6 +1,6 @@ use super::gpu::GPU_SETUP_MARKER; use super::model::RunOn; -use super::*; +use super::{DenoiserManifest, Deserialize, Duration, Path, PathBuf, Serialize, Value}; /// Extension of a product file. pub const EXT: &str = "lcdn"; diff --git a/crates/engine/src/denoise/state.rs b/crates/engine/src/denoise/state.rs index 76a4b6130..37b79cf8b 100644 --- a/crates/engine/src/denoise/state.rs +++ b/crates/engine/src/denoise/state.rs @@ -1,7 +1,7 @@ use super::model::{Loader, default_loader}; use super::products::{Active, Progress}; use super::settings::Settings; -use super::*; +use super::{Arc, Downloads, HashMap, Mutex, PathBuf, PhotoId, Serialize, VecDeque}; /// Most photos waiting in the queue. pub(super) const MAX_QUEUE: usize = 5000; diff --git a/crates/engine/src/export/batch.rs b/crates/engine/src/export/batch.rs index a4bb859e2..b5d45b125 100644 --- a/crates/engine/src/export/batch.rs +++ b/crates/engine/src/export/batch.rs @@ -1,6 +1,6 @@ +use super::ExportOptions; use super::place::Placer; use super::prepare::{Destination, Exported, PreparedExport}; -use super::*; /// Run prepared exports in order: pick each one's path, and hand the bytes (and sidecars) to /// `write`. `exists` tells whether a path is taken. The conflict policy applies to a file and its diff --git a/crates/engine/src/export/encoding.rs b/crates/engine/src/export/encoding.rs index 3cb3be3cb..862c72cd0 100644 --- a/crates/engine/src/export/encoding.rs +++ b/crates/engine/src/export/encoding.rs @@ -1,6 +1,9 @@ use super::options::{ExportFormat, MetadataPolicy, SharpenAmount, SharpenFor}; use super::watermark::{Watermark, draw_watermark, draw_watermark_deep}; -use super::*; +use super::{ + ChromaSubsampling, DateTime, DeepImage, DeepSamples, EncodeImage, EncodeMeta, ExportOptions, Gps, Metadata, NamedSpace, OutputDepth, OutputSpace, + Rgba8, Samples, encode, icc, +}; use std::borrow::Cow; /// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian). diff --git a/crates/engine/src/export/options.rs b/crates/engine/src/export/options.rs index d1b767be2..5edf078aa 100644 --- a/crates/engine/src/export/options.rs +++ b/crates/engine/src/export/options.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] diff --git a/crates/engine/src/export/params.rs b/crates/engine/src/export/params.rs index 3ef9316da..795d8f367 100644 --- a/crates/engine/src/export/params.rs +++ b/crates/engine/src/export/params.rs @@ -1,7 +1,7 @@ use super::options::{Conflict, DestinationMode, ExportFormat, HdrCurve, MetadataPolicy, Resize, ResizeMode, SharpenAmount, SharpenFor}; use super::prepare::DngCompression; use super::watermark::{Anchor, Watermark}; -use super::*; +use super::{ExportOptions, JxlColour, JxlEffort, JxlOptions, OutputDepth, OutputSpace, TiffCompression}; impl Default for ExportOptions { fn default() -> Self { diff --git a/crates/engine/src/export/place.rs b/crates/engine/src/export/place.rs index f0b1ace61..d223a3a79 100644 --- a/crates/engine/src/export/place.rs +++ b/crates/engine/src/export/place.rs @@ -1,6 +1,6 @@ +use super::ExportOptions; use super::options::{Conflict, DestinationMode}; use super::prepare::{Destination, Exported}; -use super::*; /// Write an exported or rendered file on disk: its folder is created if needed, and the file is /// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic_nosync`]: a temp file diff --git a/crates/engine/src/export/prepare.rs b/crates/engine/src/export/prepare.rs index 1fe988832..0a3dce119 100644 --- a/crates/engine/src/export/prepare.rs +++ b/crates/engine/src/export/prepare.rs @@ -2,7 +2,7 @@ use super::batch::run_batch; use super::encoding::{encode_gain_map_jpeg, encode_rendered, export_metadata, removes_location}; use super::options::ExportFormat; use super::params::apple_photos_unsupported; -use super::*; +use super::{ExportOptions, Metadata, OutputDepth}; /// One exported file. pub struct Exported { diff --git a/crates/engine/src/export/watermark.rs b/crates/engine/src/export/watermark.rs index 68633794b..c074808ea 100644 --- a/crates/engine/src/export/watermark.rs +++ b/crates/engine/src/export/watermark.rs @@ -1,5 +1,5 @@ use super::encoding::srgb8_in; -use super::*; +use super::{DeepImage, DeepSamples, Deserialize, OutputSpace, Rgba8, Serialize}; /// Where a watermark sits. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/engine/src/files/fs.rs b/crates/engine/src/files/fs.rs index dd1f5273c..7012211af 100644 --- a/crates/engine/src/files/fs.rs +++ b/crates/engine/src/files/fs.rs @@ -1,7 +1,7 @@ use super::load::{load_vec, load_vec_pair, load_vec_reduced}; use super::preview::{fit_box, fs_preview_loader, preview_orientation}; use super::probe::probe_bytes; -use super::*; +use super::{Arc, DenoiseSpec, FileLoader, FileProbe, Filter, PairLoader, ReducedLoader, fit}; /// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter), /// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in. diff --git a/crates/engine/src/files/load.rs b/crates/engine/src/files/load.rs index 52b01d7f7..428a84eda 100644 --- a/crates/engine/src/files/load.rs +++ b/crates/engine/src/files/load.rs @@ -1,7 +1,7 @@ use super::colour::bin_factor; use super::preview::{fit_box, load_embedded_preview}; use super::probe::preview_reason; -use super::*; +use super::{Arc, CameraColourModel, DenoiseSpec, Filter, HIGHLIGHT_CLIP, Orientation, Rgb32f, SourceInfo, fit, xy_to_temp_tint}; /// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented. /// diff --git a/crates/engine/src/files/preview.rs b/crates/engine/src/files/preview.rs index 49430e62a..825167f5c 100644 --- a/crates/engine/src/files/preview.rs +++ b/crates/engine/src/files/preview.rs @@ -1,5 +1,5 @@ use super::fs::{quick_image_srgb, raf_preview_extent, read_prefix, read_prefix_at}; -use super::*; +use super::{Arc, Filter, Orientation, PreviewLoader, Rgb32f, SourceInfo, fit}; /// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's. pub(super) fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation { diff --git a/crates/engine/src/files/probe.rs b/crates/engine/src/files/probe.rs index aff0b884c..12913bc6f 100644 --- a/crates/engine/src/files/probe.rs +++ b/crates/engine/src/files/probe.rs @@ -1,6 +1,6 @@ use super::load::WbScaleOf; use super::preview::embedded_preview_size; -use super::*; +use super::{MediaKind, Meta, Orientation, ProbeInfo}; pub(super) fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default(); diff --git a/crates/engine/src/import/commit.rs b/crates/engine/src/import/commit.rs index bed8356fb..7d1a69fdd 100644 --- a/crates/engine/src/import/commit.rs +++ b/crates/engine/src/import/commit.rs @@ -2,7 +2,7 @@ use super::defaults::apply_import_defaults; use super::files::copy_into; use super::job::{Prepared, PreparedItem, ReadyFile}; use super::types::{Duplicate, ImportOptions, ImportReport, Kept, Moved, OnDeleted}; -use super::*; +use super::{HashMap, Op, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Source}; /// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]). #[derive(Default)] diff --git a/crates/engine/src/import/defaults.rs b/crates/engine/src/import/defaults.rs index 7ca5cecc4..6fd3feb00 100644 --- a/crates/engine/src/import/defaults.rs +++ b/crates/engine/src/import/defaults.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Photo, Serialize, Session}; /// The develop settings a photo gets on import: raws start from their as-shot white balance with /// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the diff --git a/crates/engine/src/import/files.rs b/crates/engine/src/import/files.rs index 2cbfc85e7..099794597 100644 --- a/crates/engine/src/import/files.rs +++ b/crates/engine/src/import/files.rs @@ -5,7 +5,7 @@ use super::job::{ImportJob, Prepared, PreparedItem, ReadyFile}; use super::scan::ScanProgress; #[cfg(test)] use super::types::{ImportMode, ImportOptions}; -use super::*; +use super::{Path, PathBuf, ProbeInfo, Session}; /// File extensions offered by import discovery and file pickers (lower case). /// This includes preview-only raws; the file probe decides whether sensor decoding is available. @@ -253,6 +253,7 @@ pub(super) fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: O #[cfg(test)] mod prepared_tests { use super::*; + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; /// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the /// files this build cannot import (see [`accept_list`]). diff --git a/crates/engine/src/import/job.rs b/crates/engine/src/import/job.rs index 47bc0756f..1730518d5 100644 --- a/crates/engine/src/import/job.rs +++ b/crates/engine/src/import/job.rs @@ -1,7 +1,7 @@ use super::commit::{How, Plan, Slot, Transfer, commit_prepared, name_family, run_transfers, system_clock}; use super::files::{expand, probe_all}; use super::types::{ImportCandidate, ImportMode, ImportOptions, ImportReport, Kept, OnDeleted}; -use super::*; +use super::{HashMap, MediaKind, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Source}; /// Import files/folders. See the module docs. pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Result { diff --git a/crates/engine/src/import/scan.rs b/crates/engine/src/import/scan.rs index 61d8d4690..ef925fa05 100644 --- a/crates/engine/src/import/scan.rs +++ b/crates/engine/src/import/scan.rs @@ -1,6 +1,6 @@ use super::files::{expand, probe_all}; use super::types::ImportCandidate; -use super::*; +use super::{HashMap, Path, PathBuf, PhotoId, ProbeInfo, Session, Source}; /// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network /// share can take minutes to read). diff --git a/crates/engine/src/import/types.rs b/crates/engine/src/import/types.rs index 4a848015b..6ad3e2153 100644 --- a/crates/engine/src/import/types.rs +++ b/crates/engine/src/import/types.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, MediaKind, Photo, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ImportMode { diff --git a/crates/engine/src/media/cache.rs b/crates/engine/src/media/cache.rs index c3d323e38..238870c07 100644 --- a/crates/engine/src/media/cache.rs +++ b/crates/engine/src/media/cache.rs @@ -1,6 +1,6 @@ use super::settings_hash::SettingsHashes; use super::source::{DecodedSource, DenoiseSpec, PairLoader, RENDERED_MEM_BYTES, SourceLevel, SourceRef, THUMB_SOURCE_BYTES}; -use super::*; +use super::{Arc, DevelopSettings, Hasher128, Lru, MediaCache, Photo, PhotoId, PreviewCache, Rgb32f, Source, SourceInfo}; /// The photos that have a cached denoised picture for the denoise model in use. Only memory: nothing here touches the /// file system, so it can be asked on the UI thread. diff --git a/crates/engine/src/media/quick.rs b/crates/engine/src/media/quick.rs index 6098ed4dd..ca399b561 100644 --- a/crates/engine/src/media/quick.rs +++ b/crates/engine/src/media/quick.rs @@ -1,6 +1,6 @@ use super::render::{RenderJob, RenderResult}; use super::source::{PreviewLoader, SourceLevel}; -use super::*; +use super::{Arc, Deserialize, Hash128, Histogram, Photo, PhotoId, PreviewCache, Rendered, Rgba8, Serialize, Source, SourceInfo}; /// Where a [`QuickJob`]'s stand-in image came from. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/engine/src/media/session.rs b/crates/engine/src/media/session.rs index d160b216d..b222739fe 100644 --- a/crates/engine/src/media/session.rs +++ b/crates/engine/src/media/session.rs @@ -1,7 +1,7 @@ use super::quick::{QuickJob, content_key, source_info}; use super::render::{RenderJob, RenderResult}; use super::source::{PreviewLoader, RENDER_CACHE_VERSION, SourceLevel, SourceRef, THUMB_SIZES, face_edge, source_edge_needed, thumb_bucket}; -use super::*; +use super::{Arc, DevelopSettings, Hash128, Hasher128, MediaKind, Op, Photo, PhotoId, RenderRequest, Rendered, Rgb32f, Source, SourceInfo}; impl crate::Session { /// Build a render job for `id` fitting `max_w × max_h`. `before` renders the unedited look. diff --git a/crates/engine/src/media/settings_hash.rs b/crates/engine/src/media/settings_hash.rs index 5d9c08b93..802918fb9 100644 --- a/crates/engine/src/media/settings_hash.rs +++ b/crates/engine/src/media/settings_hash.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, DevelopSettings, HashMap, PhotoId, VecDeque, Weak}; pub(super) const SETTINGS_HASH_ENTRIES: usize = 1024; diff --git a/crates/engine/src/media/source.rs b/crates/engine/src/media/source.rs index 51c54c60a..8f6a9c28c 100644 --- a/crates/engine/src/media/source.rs +++ b/crates/engine/src/media/source.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. pub const RENDER_CACHE_VERSION: u64 = 70; diff --git a/crates/engine/src/rename/fs.rs b/crates/engine/src/rename/fs.rs index 352140b38..71de1a784 100644 --- a/crates/engine/src/rename/fs.rs +++ b/crates/engine/src/rename/fs.rs @@ -1,5 +1,5 @@ use super::sidecar::PacketVersion; -use super::*; +use super::{Path, PathBuf, SidecarNaming}; /// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`). pub(super) fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> { diff --git a/crates/engine/src/rename/moves.rs b/crates/engine/src/rename/moves.rs index 6f7c245c9..07d7fdfc3 100644 --- a/crates/engine/src/rename/moves.rs +++ b/crates/engine/src/rename/moves.rs @@ -1,6 +1,6 @@ use super::fs::{MoveFs, RealFs, folded_path, is_respelling, move_one, sidecars}; use super::sidecar::{RetainedPacket, SidecarMove, advance_packets, note_companions, packet_aliases}; -use super::*; +use super::{HashMap, HashSet, Op, Path, PathBuf, SidecarNaming, Source}; /// Why [`move_file`] failed, and what it left behind. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/rename/plan.rs b/crates/engine/src/rename/plan.rs index 331297ba4..4c9620bf1 100644 --- a/crates/engine/src/rename/plan.rs +++ b/crates/engine/src/rename/plan.rs @@ -2,7 +2,7 @@ use super::fs::{MoveFs, RealFs, folded_path}; use super::moves::{BatchMoveError, move_all, set_file_ops_to}; use super::sidecar::{PacketVersion, retry_sidecars, retry_sidecars_for_paths}; use super::tokens::{expand, split_ext}; -use super::*; +use super::{EngineError, HashMap, HashSet, Op, Path, PathBuf, Photo, PhotoId, RenamePlan, Result, Session, Source}; /// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies) /// rename once. No file-system calls. diff --git a/crates/engine/src/rename/sidecar.rs b/crates/engine/src/rename/sidecar.rs index 24ebcd3f9..85f9302d2 100644 --- a/crates/engine/src/rename/sidecar.rs +++ b/crates/engine/src/rename/sidecar.rs @@ -1,5 +1,5 @@ use super::fs::{MoveFs, is_respelling, move_one}; -use super::*; +use super::{Path, PathBuf}; /// An unfinished sidecar operation. Keep its exact paths and any recorded copy requirement: the photo may /// already have followed its new path, so a retry cannot infer this work from the catalog. diff --git a/crates/engine/src/rename/tokens.rs b/crates/engine/src/rename/tokens.rs index 3490e272b..ee15836e8 100644 --- a/crates/engine/src/rename/tokens.rs +++ b/crates/engine/src/rename/tokens.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{MONTHS, Path, Photo, PhotoId, Serialize, Source}; pub(super) fn split_ext(name: &str) -> (&str, &str) { match name.rsplit_once('.') { diff --git a/xtask/src/ci.rs b/xtask/src/ci.rs index 61e179ce0..bc3a9b706 100644 --- a/xtask/src/ci.rs +++ b/xtask/src/ci.rs @@ -1,7 +1,7 @@ use super::layers::cmd_layers; use super::raw_samples::RAW_SAMPLES; use super::wasm::{cmd_wasm, cmd_wasm_for}; -use super::*; +use super::{Command, Path, PathBuf, USAGE, assets, cargo, ci_lock, ci_quick, ci_report, metadata, parity, root, run}; /// CI build jobs: one per 1.5 GB of RAM available when CI starts, at most one per CPU, 4 when it /// is unknown. With line-table debuginfo, 4 jobs measured about 3 GB of RAM in use and 2 jobs diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs index 67de8c1ff..434d48b27 100644 --- a/xtask/src/corpus.rs +++ b/xtask/src/corpus.rs @@ -1,5 +1,5 @@ use super::raw_samples::RAW_SAMPLES; -use super::*; +use super::{Command, Path, root, run}; pub(crate) fn cmd_corpus(download: bool) -> Result<(), String> { let corpus = root().join("corpus"); diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 13756002d..2a881155b 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -3,7 +3,7 @@ //! The rule engine works on a small, metadata-independent model so it can be //! unit-tested; `from_metadata` builds that model from `cargo metadata`. -use super::*; +use super::{layers, metadata}; use serde_json::Value; /// Where a workspace crate sits. diff --git a/xtask/src/wasm.rs b/xtask/src/wasm.rs index 4f16b2d75..4330769a9 100644 --- a/xtask/src/wasm.rs +++ b/xtask/src/wasm.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{cargo, layers, metadata, run}; /// Workspace packages that must build for wasm32: all L0–L5 crates plus /// the egui shell and the web app. From ca69e74b62c819e4dee07fb50eb674ef39b8557a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 18:02:32 +0000 Subject: [PATCH 57/91] fix(raw): check opcode byte counts without overflow --- crates/raw/src/opcodes.rs | 4 ++-- crates/raw/src/opcodes/tests.rs | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index f7750aaac..4d3e463b0 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -381,7 +381,7 @@ fn parse_gain_map(area: Area, reader: &mut Rd<'_>) -> Option { let origin = [reader.finite_f64()?, reader.finite_f64()?]; let map_planes = reader.u32()?; let count = (points_v as usize).checked_mul(points_h as usize)?.checked_mul(map_planes as usize)?; - if count == 0 || count * 4 > reader.remaining() { + if count == 0 || count > reader.remaining() / 4 { return None; } let gains = (0..count).map(|_| reader.f32()).collect::>()?; @@ -390,7 +390,7 @@ fn parse_gain_map(area: Area, reader: &mut Rd<'_>) -> Option { fn parse_delta_scale(id: u32, area: Area, reader: &mut Rd<'_>) -> Option { let count = reader.u32()? as usize; - if count * 4 > reader.remaining() { + if count > reader.remaining() / 4 { return None; } let values: Vec = (0..count).map(|_| reader.f32()).collect::>()?; diff --git a/crates/raw/src/opcodes/tests.rs b/crates/raw/src/opcodes/tests.rs index e63a0bf56..d1be999e9 100644 --- a/crates/raw/src/opcodes/tests.rs +++ b/crates/raw/src/opcodes/tests.rs @@ -1,5 +1,19 @@ use super::*; +#[test] +fn reject_opcode_counts_whose_bytes_exceed_the_remaining_data() { + let mut gain_map = Vec::new(); + for value in [u32::MAX, 1] { + gain_map.extend_from_slice(&value.to_be_bytes()); + } + gain_map.extend(be(&[1.0, 1.0, 0.0, 0.0])); + gain_map.extend_from_slice(&1u32.to_be_bytes()); + assert!(parse_gain_map(Area::default(), &mut Rd { d: &gain_map, p: 0 }).is_none()); + + let delta_count = u32::MAX.to_be_bytes(); + assert!(parse_delta_scale(10, Area::default(), &mut Rd { d: &delta_count, p: 0 }).is_none()); +} + pub(crate) struct ListWriter(pub Vec, pub u32); impl ListWriter { From b298c79a7d85d47773675ace2e229bcc49da92c6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 18:04:04 +0000 Subject: [PATCH 58/91] docs: correct clippy indexing guidance --- AGENTS.md | 2 +- CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 959d2808f..31dfd70f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native. - **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix. -- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate +- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic in tests only; indexing is not denied. Every production crate root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit integration tests, examples and benches. New crates start with the attribute. diff --git a/CLAUDE.md b/CLAUDE.md index 1ff3defb0..5b116781a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native. - **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix. -- Enforced by clippy: root `clippy.toml` keeps unwrap/expect/panic/indexing allowances scoped to tests. The workspace +- Enforced by clippy: root `clippy.toml` keeps unwrap/expect/panic allowances scoped to tests; indexing is not denied. The workspace denies `cognitive_complexity`, `too_many_arguments` and `wildcard_imports`; members inherit them with `[lints] workspace = true`, and `sysmem` repeats those denies in its local lint table. Every production crate root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, From eeb63636e80d6e9f44cf9133ce70e4ebb47cda9d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 18:12:55 +0000 Subject: [PATCH 59/91] docs: clarify crate lint attribute placement --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5b116781a..7005cac3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,8 +57,8 @@ of it. Full standard: `../craftrules/standards/never-crash.md` `[lints] workspace = true`, and `sysmem` repeats those denies in its local lint table. Every production crate root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Every new workspace crate must include - `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`, and new production crate roots start with - the attribute. + `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`, and new production crate roots include + the attribute before modules or other crate items; crate-level `//!` documentation may precede it. ## Non-negotiables - **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…). From 5b5ddc39049ba7fb5c9532cd6f1f3c33aa4fc83a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 19:49:11 +0000 Subject: [PATCH 60/91] fix(engine): import denoise model and gpu names explicitly --- crates/engine/src/denoise/gpu.rs | 2 +- crates/engine/src/denoise/model.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/denoise/gpu.rs b/crates/engine/src/denoise/gpu.rs index 1577b4edc..a2a493fab 100644 --- a/crates/engine/src/denoise/gpu.rs +++ b/crates/engine/src/denoise/gpu.rs @@ -1,7 +1,7 @@ #[cfg(feature = "denoise")] use super::model::{GpuSide, STACK}; #[cfg(feature = "denoise")] -use super::*; +use super::{Arc, Duration, Mutex, Path, PathBuf, PoisonError, SystemTime, TileRunner}; /// Tiles run at once on a graphics card: enough to keep it fed while others are packed and blended. #[cfg(feature = "denoise")] diff --git a/crates/engine/src/denoise/model.rs b/crates/engine/src/denoise/model.rs index fbd13d79c..b9f75023e 100644 --- a/crates/engine/src/denoise/model.rs +++ b/crates/engine/src/denoise/model.rs @@ -4,6 +4,8 @@ use super::gpu::{GPU_CRASHED, GPU_MAX_FALLBACKS, GPU_PARALLEL, GPU_SLOW, GPU_STU use super::settings::{installed_models, read_settings}; #[cfg(feature = "denoise")] use super::state::Pace; +#[cfg(feature = "denoise")] +use super::{AtomicBool, AtomicUsize, Ordering, OnceLock, PathBuf, PoisonError, RunError}; use super::{Arc, DenoiserManifest, Mutex, Path, TileRunner, Value, json}; /// Stack of the threads that run the model (the runtime recurses through its graph). pub(super) const STACK: usize = 16 << 20; From 6774d1419f694f138956a87e0a6366c72969e287 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 19:56:22 +0000 Subject: [PATCH 61/91] refactor(ui-egui): replace wildcard imports with explicit lists --- crates/engine/src/denoise/model.rs | 4 ++-- crates/ui-egui/src/app.rs | 8 ++++++- crates/ui-egui/src/app/control.rs | 2 +- crates/ui-egui/src/app/frame.rs | 5 +++- crates/ui-egui/src/app/input.rs | 24 +++++++++---------- crates/ui-egui/src/app/prefs.rs | 2 +- crates/ui-egui/src/app/screenshot.rs | 5 +++- crates/ui-egui/src/caches.rs | 2 +- crates/ui-egui/src/dialogs/confirm.rs | 2 +- crates/ui-egui/src/dialogs/cull.rs | 2 +- crates/ui-egui/src/dialogs/export.rs | 2 +- crates/ui-egui/src/dialogs/frame.rs | 2 +- crates/ui-egui/src/dialogs/import/body.rs | 2 +- crates/ui-egui/src/dialogs/import/scan.rs | 2 +- crates/ui-egui/src/dialogs/import/tags.rs | 2 +- crates/ui-egui/src/dialogs/import/task.rs | 2 +- crates/ui-egui/src/dialogs/rename.rs | 2 +- crates/ui-egui/src/dialogs/sam_model.rs | 2 +- .../src/dialogs/settings/appearance.rs | 2 +- .../ui-egui/src/dialogs/settings/display.rs | 2 +- .../ui-egui/src/dialogs/settings/general.rs | 2 +- crates/ui-egui/src/dialogs/settings/import.rs | 2 +- .../ui-egui/src/dialogs/settings/libraries.rs | 2 +- .../src/dialogs/settings/performance.rs | 2 +- .../ui-egui/src/dialogs/settings/slideshow.rs | 2 +- .../ui-egui/src/dialogs/settings/widgets.rs | 2 +- .../ui-egui/src/dialogs/slideshow_export.rs | 2 +- crates/ui-egui/src/menus/files.rs | 2 +- crates/ui-egui/src/menus/run.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/detail/crop.rs | 4 +++- crates/ui-egui/src/panels/detail/filmstrip.rs | 2 +- crates/ui-egui/src/panels/detail/geometry.rs | 4 ++-- crates/ui-egui/src/panels/detail/info.rs | 2 +- crates/ui-egui/src/panels/detail/masks.rs | 5 +++- crates/ui-egui/src/panels/detail/retouch.rs | 2 +- crates/ui-egui/src/panels/detail/window.rs | 2 +- crates/ui-egui/src/panels/edit/curve.rs | 5 +++- crates/ui-egui/src/panels/edit/grading.rs | 5 +++- crates/ui-egui/src/panels/edit/histogram.rs | 5 +++- crates/ui-egui/src/panels/edit/mixer.rs | 2 +- crates/ui-egui/src/panels/edit/sliders.rs | 5 +++- crates/ui-egui/src/panels/left/albums.rs | 2 +- crates/ui-egui/src/panels/left/dates.rs | 2 +- crates/ui-egui/src/panels/left/folders.rs | 2 +- crates/ui-egui/src/panels/left/keywords.rs | 2 +- crates/ui-egui/src/panels/left/local.rs | 2 +- crates/ui-egui/src/panels/left/rows.rs | 2 +- crates/ui-egui/src/panels/left/sections.rs | 2 +- crates/ui-egui/src/panels/right/crop.rs | 2 +- crates/ui-egui/src/panels/right/history.rs | 2 +- crates/ui-egui/src/panels/right/info.rs | 2 +- crates/ui-egui/src/panels/right/keywords.rs | 2 +- crates/ui-egui/src/services.rs | 2 +- crates/ui-egui/src/state/dialog.rs | 2 +- crates/ui-egui/src/state/settings.rs | 2 +- crates/ui-egui/src/state/view.rs | 2 +- 57 files changed, 99 insertions(+), 70 deletions(-) diff --git a/crates/engine/src/denoise/model.rs b/crates/engine/src/denoise/model.rs index b9f75023e..c90641574 100644 --- a/crates/engine/src/denoise/model.rs +++ b/crates/engine/src/denoise/model.rs @@ -4,9 +4,9 @@ use super::gpu::{GPU_CRASHED, GPU_MAX_FALLBACKS, GPU_PARALLEL, GPU_SLOW, GPU_STU use super::settings::{installed_models, read_settings}; #[cfg(feature = "denoise")] use super::state::Pace; -#[cfg(feature = "denoise")] -use super::{AtomicBool, AtomicUsize, Ordering, OnceLock, PathBuf, PoisonError, RunError}; use super::{Arc, DenoiserManifest, Mutex, Path, TileRunner, Value, json}; +#[cfg(feature = "denoise")] +use super::{AtomicBool, AtomicUsize, OnceLock, Ordering, PathBuf, PoisonError, RunError}; /// Stack of the threads that run the model (the runtime recurses through its graph). pub(super) const STACK: usize = 16 << 20; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 98f8458c9..fa10210f0 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -1,7 +1,13 @@ use super::clock::{AppClock, monotonic_ms}; use super::services::Services; use super::state::{GestureFrame, InjectedDrop, InjectedPress}; -use super::*; +use super::{ + AiModel, Caches, CommandError, ControlRequest, ControlResponse, ErrorKind, GESTURE_TIMEOUT_MS, LightcraftApp, MAX_PENDING_GESTURES, + MAX_PENDING_SHOTS, MAX_SHOT_BYTES, MAX_SHOT_TOTAL_BYTES, MAX_TEXTURE_SIDE, PendingGesture, PendingShot, Perf, Receiver, SCREEN_PROFILE, + SCREENSHOT_FALLBACK_MS, SCREENSHOT_SETTLE_MS, SHOT_FORCE_BUSY_MS, SHOT_SETTLE_FRAMES, SHOT_SETTLE_MS, Sender, Session, ShotGeometry, + TOAST_SECONDS, UiState, Value, docking, drop_overlay, export_task, focus_scan, frame_rate, headless, i18n, import, lightroom_import, menus, + merge, model_setup, panels, pick, recheck, render, shortcuts, slideshow_export, state, sync, tasks, theme, updates_ui, widgets, +}; mod control; mod frame; diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 00b66e4b0..525c5419c 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ControlResponse, GESTURE_TIMEOUT_MS, GestureFrame, LightcraftApp, MAX_PENDING_GESTURES, PendingGesture, Sender, Value}; use crate::control; impl LightcraftApp { diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index efae98a9b..6e92091c7 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + LightcraftApp, docking, drop_overlay, export_task, focus_scan, import, lightroom_import, merge, model_setup, monotonic_ms, panels, pick, recheck, + shortcuts, slideshow_export, state, sync, tasks, theme, updates_ui, widgets, +}; impl LightcraftApp { /// Install/refresh fonts once and watch the device and availability caches. Setup, not input. diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index f94108ce0..35b7991b4 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{GESTURE_TIMEOUT_MS, InjectedDrop, InjectedPress, LightcraftApp, drop_overlay, import, panels}; impl LightcraftApp { /// Preserve the currently queued handles while a prompt or blocking library window owns @@ -262,7 +262,7 @@ mod drop_tests { fn photo_sidecars_are_not_presets() { let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); let dropped = v(&["/a/IMG_1.CR2", "/a/IMG_1.xmp", "/a/IMG_2.jpg", "/a/IMG_2.jpg.XMP", "/a/Look.xmp", "/b/IMG_1.xmp"]); - let (presets, photos) = super::split_presets(dropped); + let (presets, photos) = crate::split_presets(dropped); assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"])); assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"])); } @@ -271,31 +271,31 @@ mod drop_tests { fn detection_results_are_summarised_in_words() { let r = |counts: &[usize]| serde_json::json!({"photos": counts.iter().map(|n| serde_json::json!({"faces": vec![0; *n]})).collect::>()}); - assert_eq!(super::detect_summary(&r(&[0])), "No faces found"); - assert_eq!(super::detect_summary(&r(&[1])), "Found 1 face"); - assert_eq!(super::detect_summary(&r(&[3])), "Found 3 faces"); - assert_eq!(super::detect_summary(&r(&[0, 0])), "No faces found in 2 photos"); - assert_eq!(super::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos"); - assert_eq!(super::detect_summary(&serde_json::json!({})), "No faces found in 0 photos"); + assert_eq!(crate::app::detect_summary(&r(&[0])), "No faces found"); + assert_eq!(crate::app::detect_summary(&r(&[1])), "Found 1 face"); + assert_eq!(crate::app::detect_summary(&r(&[3])), "Found 3 faces"); + assert_eq!(crate::app::detect_summary(&r(&[0, 0])), "No faces found in 2 photos"); + assert_eq!(crate::app::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos"); + assert_eq!(crate::app::detect_summary(&serde_json::json!({})), "No faces found in 0 photos"); } #[test] fn dropped_face_models_are_told_apart() { for p in ["/a/model.onnx", "/a/dir/M.ONNX"] { - assert!(super::is_model_file(p), "{p}"); + assert!(crate::is_model_file(p), "{p}"); } for p in ["/a/model.onnx.jpg", "/a/onnx", "/a/b.png"] { - assert!(!super::is_model_file(p), "{p}"); + assert!(!crate::is_model_file(p), "{p}"); } } #[test] fn dropped_presets_are_told_apart_from_photos() { for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] { - assert!(super::is_preset_file(p), "{p}"); + assert!(crate::is_preset_file(p), "{p}"); } for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] { - assert!(!super::is_preset_file(p), "{p}"); + assert!(!crate::is_preset_file(p), "{p}"); } } } diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index a85a5f751..3dcafeb14 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LightcraftApp, SCREEN_PROFILE, theme}; impl LightcraftApp { /// The system's appearance: the host's answer first, then egui's (none: unknown). diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index c2de3aae6..79b463995 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + ControlResponse, LightcraftApp, MAX_PENDING_SHOTS, MAX_SHOT_BYTES, MAX_SHOT_TOTAL_BYTES, PendingShot, SCREENSHOT_FALLBACK_MS, + SCREENSHOT_SETTLE_MS, SHOT_FORCE_BUSY_MS, SHOT_SETTLE_FRAMES, SHOT_SETTLE_MS, Sender, ShotGeometry, headless, +}; use crate::control; impl LightcraftApp { diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 0fb5a4b2c..93fe55184 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Caches, album_picker, i18n, panels}; /// The left panel's counts. #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index 196018e55..6b8998c33 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -1,6 +1,6 @@ use super::cull::cull_params; use super::export::export_dialog_params; -use super::*; +use super::{Dialog, LightcraftApp, json}; /// Runs a command that creates an album and asks the Albums tree to open the folders down to it. fn created_in(app: &mut LightcraftApp, command: &str, params: serde_json::Value) -> Result { diff --git a/crates/ui-egui/src/dialogs/cull.rs b/crates/ui-egui/src/dialogs/cull.rs index 2c3d6497a..a43276a64 100644 --- a/crates/ui-egui/src/dialogs/cull.rs +++ b/crates/ui-egui/src/dialogs/cull.rs @@ -1,4 +1,4 @@ -use super::*; +use super::json; /// Apply a dialog's action (also used by `ui.dialog.confirm`). /// What [`fmt_gap`] writes, read back: `90`, `90 s`, `1 min 30 s`, `2 h 5 min`, `1 day` diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs index 56292605c..ce9cffe77 100644 --- a/crates/ui-egui/src/dialogs/export.rs +++ b/crates/ui-egui/src/dialogs/export.rs @@ -1,5 +1,5 @@ use super::frame::{field, num}; -use super::*; +use super::{ControlSpec, LightcraftApp, Section, SettingsGroup, Tokens, Track}; /// The Export dialog's watermark preview: the first exported photo, as wide as the dialog whatever /// its shape, with the watermark drawn on it as the file will have it at size `resize` (`None` = diff --git a/crates/ui-egui/src/dialogs/frame.rs b/crates/ui-egui/src/dialogs/frame.rs index 3d8eb4ef8..007c733cb 100644 --- a/crates/ui-egui/src/dialogs/frame.rs +++ b/crates/ui-egui/src/dialogs/frame.rs @@ -1,5 +1,5 @@ use super::export::LABEL_W; -use super::*; +use super::{BACKDROP_SHOWN, ControlSpec, Dialog, LightcraftApp, Tokens}; /// A click on the dimmed backdrop around the dialog: it closes the dialog like Esc. Pressed and /// released outside the window, so a slider dragged past its edge doesn't close it; a press while a diff --git a/crates/ui-egui/src/dialogs/import/body.rs b/crates/ui-egui/src/dialogs/import/body.rs index 9ba470660..8f82ae51d 100644 --- a/crates/ui-egui/src/dialogs/import/body.rs +++ b/crates/ui-egui/src/dialogs/import/body.rs @@ -1,5 +1,5 @@ use super::tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning}; -use super::*; +use super::{Align2, Color32, ImportDialog, LightcraftApp, Rect, Sense, Slot, Stroke, StrokeKind, Tokens, Vec2, pos2, register, vec2}; /// The review dialog's first size (points). It can be resized; the photo grid takes the height. pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0]; diff --git a/crates/ui-egui/src/dialogs/import/scan.rs b/crates/ui-egui/src/dialogs/import/scan.rs index a930af00c..67b1fa176 100644 --- a/crates/ui-egui/src/dialogs/import/scan.rs +++ b/crates/ui-egui/src/dialogs/import/scan.rs @@ -1,5 +1,5 @@ use super::task::ImportTask; -use super::*; +use super::{ImportDialog, LightcraftApp, Ordering, ScanInput, ScanOutput, ScanProgress, Value, json, scan_with}; /// A folder scan running on a worker thread (a network share can take minutes to read; the /// window must keep answering meanwhile). diff --git a/crates/ui-egui/src/dialogs/import/tags.rs b/crates/ui-egui/src/dialogs/import/tags.rs index 770bb5b30..1ae10ca02 100644 --- a/crates/ui-egui/src/dialogs/import/tags.rs +++ b/crates/ui-egui/src/dialogs/import/tags.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Tokens, register}; /// Reserve the actual translated tag button width beside a template field. pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 { diff --git a/crates/ui-egui/src/dialogs/import/task.rs b/crates/ui-egui/src/dialogs/import/task.rs index bcef55b11..c41a941a3 100644 --- a/crates/ui-egui/src/dialogs/import/task.rs +++ b/crates/ui-egui/src/dialogs/import/task.rs @@ -1,5 +1,5 @@ use super::scan::{busy_synchronizing, running}; -use super::*; +use super::{ImportDialog, ImportUprightMode, ImportWarning, ImportWarningCode, LightcraftApp, Ordering, Value, json}; /// Files per batch in the browser build (each batch joins the catalog as it is ready, so the /// progress window updates); the desktop's worker uses [`lightcraft_engine::import::batch_size`]. diff --git a/crates/ui-egui/src/dialogs/rename.rs b/crates/ui-egui/src/dialogs/rename.rs index f178ba6eb..8632daf41 100644 --- a/crates/ui-egui/src/dialogs/rename.rs +++ b/crates/ui-egui/src/dialogs/rename.rs @@ -1,4 +1,4 @@ -use super::*; +use super::LightcraftApp; /// Rows the Rename dialog previews. pub(super) const RENAME_PREVIEW_ROWS: usize = 6; diff --git a/crates/ui-egui/src/dialogs/sam_model.rs b/crates/ui-egui/src/dialogs/sam_model.rs index fa578440b..fafa91ceb 100644 --- a/crates/ui-egui/src/dialogs/sam_model.rs +++ b/crates/ui-egui/src/dialogs/sam_model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LightcraftApp, Tokens, json}; /// No SAM 3 model, no download running and nowhere to download it from: installing it by hand is /// all the dialog can offer. diff --git a/crates/ui-egui/src/dialogs/settings/appearance.rs b/crates/ui-egui/src/dialogs/settings/appearance.rs index 51159df6b..571db16fc 100644 --- a/crates/ui-egui/src/dialogs/settings/appearance.rs +++ b/crates/ui-egui/src/dialogs/settings/appearance.rs @@ -1,5 +1,5 @@ use super::widgets::{choices, heading}; -use super::*; +use super::{RichText, Tokens, register}; /// Appearance Mode (`button:settingsAppearance-{0,1,2}`: Auto, Dark, Light) above the light and /// dark theme cards (`radio:theme-{id}`), each with a preview of the chosen theme. The card in diff --git a/crates/ui-egui/src/dialogs/settings/display.rs b/crates/ui-egui/src/dialogs/settings/display.rs index a54b05ced..a5b1974d2 100644 --- a/crates/ui-egui/src/dialogs/settings/display.rs +++ b/crates/ui-egui/src/dialogs/settings/display.rs @@ -1,6 +1,6 @@ use super::appearance::appearance; use super::widgets::{check, choices, heading, hint, path_label, row}; -use super::*; +use super::{GridBadges, LightcraftApp, RichText, Tokens, Value, json, register}; // ---------------------------------------------------------------------------------- Interface diff --git a/crates/ui-egui/src/dialogs/settings/general.rs b/crates/ui-egui/src/dialogs/settings/general.rs index 21b94f150..85866b5fa 100644 --- a/crates/ui-egui/src/dialogs/settings/general.rs +++ b/crates/ui-egui/src/dialogs/settings/general.rs @@ -1,5 +1,5 @@ use super::widgets::{check, choices, heading, hint, path_label, row}; -use super::*; +use super::{LightcraftApp, RichText, StartupView, Tokens, json, register}; // ------------------------------------------------------------------------------------- General diff --git a/crates/ui-egui/src/dialogs/settings/import.rs b/crates/ui-egui/src/dialogs/settings/import.rs index c7272d366..97f34d6ab 100644 --- a/crates/ui-egui/src/dialogs/settings/import.rs +++ b/crates/ui-egui/src/dialogs/settings/import.rs @@ -1,6 +1,6 @@ use super::general::{cameras, preset_combo}; use super::widgets::{button_width, check, choices, heading, hint, path_label, row}; -use super::*; +use super::{LightcraftApp, RichText, Tokens, json, register}; pub(super) fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let d = app.session.import_defaults.clone(); diff --git a/crates/ui-egui/src/dialogs/settings/libraries.rs b/crates/ui-egui/src/dialogs/settings/libraries.rs index 389f79f11..8e41e8dd4 100644 --- a/crates/ui-egui/src/dialogs/settings/libraries.rs +++ b/crates/ui-egui/src/dialogs/settings/libraries.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LightcraftApp, Value, json}; // ------------------------------------------------------------------------------- Open Library… diff --git a/crates/ui-egui/src/dialogs/settings/performance.rs b/crates/ui-egui/src/dialogs/settings/performance.rs index daf2fa435..e65402c20 100644 --- a/crates/ui-egui/src/dialogs/settings/performance.rs +++ b/crates/ui-egui/src/dialogs/settings/performance.rs @@ -1,7 +1,7 @@ use super::widgets::{check, choices, heading, hint, row}; #[cfg(not(target_arch = "wasm32"))] use super::widgets::{label_width, path_label}; -use super::*; +use super::{CACHE_SIZES, LABEL_W, LightcraftApp, PREVIEW_LIMITS, RichText, Tokens, Value, json, register}; // --------------------------------------------------------------------------------- Performance diff --git a/crates/ui-egui/src/dialogs/settings/slideshow.rs b/crates/ui-egui/src/dialogs/settings/slideshow.rs index b67427c40..a11bbfff6 100644 --- a/crates/ui-egui/src/dialogs/settings/slideshow.rs +++ b/crates/ui-egui/src/dialogs/settings/slideshow.rs @@ -1,6 +1,6 @@ use super::display::duration_slider; use super::widgets::{check, choices, heading, hint, row}; -use super::*; +use super::{LightcraftApp, Tokens, Value, json, register}; /// The Slideshow tab: the slide preview, then the settings, in one column the width of the page /// (the page scrolls; a column beside the preview would be wider than the window). Without diff --git a/crates/ui-egui/src/dialogs/settings/widgets.rs b/crates/ui-egui/src/dialogs/settings/widgets.rs index 3aea00b44..7607b1251 100644 --- a/crates/ui-egui/src/dialogs/settings/widgets.rs +++ b/crates/ui-egui/src/dialogs/settings/widgets.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LABEL_W, RichText, Tokens, register}; /// `path` shortened to fit `max` (as `width` measures it) by dropping leading folders, after /// either kind of separator, so that its end stays (`…/Pictures/Library`, diff --git a/crates/ui-egui/src/dialogs/slideshow_export.rs b/crates/ui-egui/src/dialogs/slideshow_export.rs index 7a898b5db..0218d613e 100644 --- a/crates/ui-egui/src/dialogs/slideshow_export.rs +++ b/crates/ui-egui/src/dialogs/slideshow_export.rs @@ -5,8 +5,8 @@ use serde_json::{Value, json}; +use super::Tokens; use super::frame::{choices, field}; -use super::*; use crate::LightcraftApp; use crate::pick::{PickKind, PickRequest, Picked}; diff --git a/crates/ui-egui/src/menus/files.rs b/crates/ui-egui/src/menus/files.rs index 52389d930..d75282e57 100644 --- a/crates/ui-egui/src/menus/files.rs +++ b/crates/ui-egui/src/menus/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, LightcraftApp, Value, json}; /// The Delete key in an album: a normal album loses the selected photos (`album.removePhotos`), /// which stay in the library and their folder; a smart album keeps them (its rules decide) and diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index c550ff0d4..9977affac 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -1,6 +1,6 @@ use super::files::{edit_in, open_log_folder, open_original_in, show_in_finder, show_in_folders}; use super::ui_commands::{UI_COMMANDS, language_from_command}; -use super::*; +use super::{BeforeAfter, Dialog, LightcraftApp, PickRequest, Picked, RightPanel, Value, ViewMode, Zoom, json, thumb_size_view}; fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) { if app.ui.right == p && crate::docking::exposed(app, crate::docking::Panel::Tools) { diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 7236b5528..2759d2197 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1,4 +1,4 @@ -use super::*; +use super::UiCommand; /// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language /// has a matching menu command (also listed through the control channel). Tests check coverage. diff --git a/crates/ui-egui/src/panels/detail/crop.rs b/crates/ui-egui/src/panels/detail/crop.rs index 295b8f819..f88b23fdd 100644 --- a/crates/ui-egui/src/panels/detail/crop.rs +++ b/crates/ui-egui/src/panels/detail/crop.rs @@ -1,5 +1,7 @@ use super::geometry::CanvasMap; -use super::*; +use super::{ + Affine, Color32, DevelopSettings, Frame, Gesture, LightcraftApp, PhotoId, Point, Pos2, Rect, Stroke, Tokens, json, pos2, register, vec2, +}; // ------------------------------------------------------------------------ crop diff --git a/crates/ui-egui/src/panels/detail/filmstrip.rs b/crates/ui-egui/src/panels/detail/filmstrip.rs index c39fc4442..4a0de28cf 100644 --- a/crates/ui-egui/src/panels/detail/filmstrip.rs +++ b/crates/ui-egui/src/panels/detail/filmstrip.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Color32, LightcraftApp, PhotoId, Pos2, Rect, Sense, Slot, Stroke, StrokeKind, Tokens, json, pos2, register, vec2}; /// Size of the Navigator mini map (points). const NAV_W: f32 = 180.0; diff --git a/crates/ui-egui/src/panels/detail/geometry.rs b/crates/ui-egui/src/panels/detail/geometry.rs index 6424dabd8..d679010f1 100644 --- a/crates/ui-egui/src/panels/detail/geometry.rs +++ b/crates/ui-egui/src/panels/detail/geometry.rs @@ -1,6 +1,6 @@ #[cfg(test)] use super::info::label_badge_rect; -use super::*; +use super::{Affine, Frame, LightcraftApp, PhotoId, Point, Pos2, Rect, Zoom, json, pos2, vec2}; /// Screen ↔ normalized-image mapping for the displayed frame. #[derive(Clone, Copy)] @@ -296,7 +296,7 @@ mod preview_geometry_tests { fn before_label_badge_keeps_clear_of_the_filter_pill_and_the_right_edge() { let text = vec2(40.0, 14.0); let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)); - let inset = super::FILTER_PILL_INSET + super::FILTER_PILL_HEIGHT; + let inset = crate::panels::detail::FILTER_PILL_INSET + crate::panels::detail::FILTER_PILL_HEIGHT; let past = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, inset); assert_eq!(past.min, pos2(108.0, 50.0 + inset + 8.0)); let under = label_badge_rect(Rect::from_min_size(pos2(200.0, 60.0), vec2(400.0, 300.0)), pane, text, inset); diff --git a/crates/ui-egui/src/panels/detail/info.rs b/crates/ui-egui/src/panels/detail/info.rs index 520ad6213..da5ae1bed 100644 --- a/crates/ui-egui/src/panels/detail/info.rs +++ b/crates/ui-egui/src/panels/detail/info.rs @@ -1,5 +1,5 @@ use super::geometry::focus_box; -use super::*; +use super::{Color32, LightcraftApp, PhotoId, Pos2, Rect, Sense, Stroke, Tokens, json, pos2, register, register_geometry, vec2}; /// Draw the camera's focus box over a photo drawn in `img` (View ▸ Focus Point): green corner /// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt. diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index 073de8aa2..50424f6b3 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -1,5 +1,8 @@ use super::geometry::CanvasMap; -use super::*; +use super::{ + Color32, DevelopSettings, Gesture, LightcraftApp, MaskShape, Point, Pos2, Rect, Sense, Stroke, StrokeKind, Tokens, ToolOverlay, json, pos2, + register, vec2, +}; /// What the user did to a face box this frame. pub(super) enum RegionEdit { diff --git a/crates/ui-egui/src/panels/detail/retouch.rs b/crates/ui-egui/src/panels/detail/retouch.rs index 64f4c2281..d74ead9ba 100644 --- a/crates/ui-egui/src/panels/detail/retouch.rs +++ b/crates/ui-egui/src/panels/detail/retouch.rs @@ -1,6 +1,6 @@ use super::geometry::CanvasMap; use super::masks::{pin, pins_live}; -use super::*; +use super::{Color32, DevelopSettings, Gesture, LightcraftApp, Point, Pos2, Rect, Stroke, ToolOverlay, json, register, vec2}; // ------------------------------------------------------------------------ remove diff --git a/crates/ui-egui/src/panels/detail/window.rs b/crates/ui-egui/src/panels/detail/window.rs index 0b441913b..f4ec89c5b 100644 --- a/crates/ui-egui/src/panels/detail/window.rs +++ b/crates/ui-egui/src/panels/detail/window.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, LightcraftApp, PhotoId, Rect, Slot, pos2, vec2}; /// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask (one or all) /// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize diff --git a/crates/ui-egui/src/panels/edit/curve.rs b/crates/ui-egui/src/panels/edit/curve.rs index 856211e66..0eec3a5e4 100644 --- a/crates/ui-egui/src/panels/edit/curve.rs +++ b/crates/ui-egui/src/panels/edit/curve.rs @@ -1,6 +1,9 @@ use super::histogram::preview_histogram; use super::sliders::control; -use super::*; +use super::{ + Align2, Color32, DevelopSettings, Icon, LightcraftApp, Mesh, PARAM_AMOUNTS, ParametricCurve, PhotoId, Point, Pos2, Rect, RichText, Sense, Stroke, + Tokens, Vertex, controls, hex, json, paint, pos2, register, text_button, vec2, +}; // ------------------------------------------------------------------------------ tone curve diff --git a/crates/ui-egui/src/panels/edit/grading.rs b/crates/ui-egui/src/panels/edit/grading.rs index a0b430acd..61f154300 100644 --- a/crates/ui-egui/src/panels/edit/grading.rs +++ b/crates/ui-egui/src/panels/edit/grading.rs @@ -1,5 +1,8 @@ use super::sliders::control; -use super::*; +use super::{ + Align2, Color32, DevelopSettings, Icon, LightcraftApp, Mesh, PhotoId, Pos2, Rect, Sense, Stroke, Tokens, Vertex, json, paint, pos2, register, + vec2, +}; // ------------------------------------------------------------------------------ colour grading diff --git a/crates/ui-egui/src/panels/edit/histogram.rs b/crates/ui-egui/src/panels/edit/histogram.rs index 44d791c4a..085fbda5d 100644 --- a/crates/ui-egui/src/panels/edit/histogram.rs +++ b/crates/ui-egui/src/panels/edit/histogram.rs @@ -1,6 +1,9 @@ #[cfg(test)] use super::sliders::reset_tip; -use super::*; +use super::{ + Align2, Color32, Histogram, Icon, LightcraftApp, Mesh, PhotoId, Pos2, Rect, RichText, RightPanel, Sense, Slot, Stroke, Tokens, Vertex, divider, + hex, json, paint, pos2, register, vec2, +}; // ------------------------------------------------------------------------------ histogram diff --git a/crates/ui-egui/src/panels/edit/mixer.rs b/crates/ui-egui/src/panels/edit/mixer.rs index b869b32dd..0bddba375 100644 --- a/crates/ui-egui/src/panels/edit/mixer.rs +++ b/crates/ui-egui/src/panels/edit/mixer.rs @@ -1,6 +1,6 @@ use super::curve::set_tat; use super::sliders::{control, sub_title}; -use super::*; +use super::{BAND_COLORS, Color32, DevelopSettings, Icon, LightcraftApp, Sense, Stroke, Tokens, hex, json, paint, register, text_button, vec2}; // ------------------------------------------------------------------------------ colour mixer diff --git a/crates/ui-egui/src/panels/edit/sliders.rs b/crates/ui-egui/src/panels/edit/sliders.rs index aad9cdf6a..00adb9928 100644 --- a/crates/ui-egui/src/panels/edit/sliders.rs +++ b/crates/ui-egui/src/panels/edit/sliders.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align2, ControlSpec, DevelopSettings, Icon, LightcraftApp, PhotoId, Section, Sense, SliderOut, Tokens, Track, Value, controls, divider, json, + pos2, register, section_header, slider, vec2, +}; /// Commit a slider interaction: begin → live updates → end, so a drag is one undo step. pub fn apply_slider_out( diff --git a/crates/ui-egui/src/panels/left/albums.rs b/crates/ui-egui/src/panels/left/albums.rs index e0204c826..bfcc6a219 100644 --- a/crates/ui-egui/src/panels/left/albums.rs +++ b/crates/ui-egui/src/panels/left/albums.rs @@ -1,6 +1,6 @@ use super::folders::{folder_menu, is_within}; use super::rows::{INDENT_STEP, indent_x, row_sensed, set_tree_open, tree_open, tree_toggle}; -use super::*; +use super::{Album, AlbumId, Align2, Icon, LibrarySource, LightcraftApp, Rect, Sense, Tokens, json, pos2, register, vec2}; /// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made, /// whose folders open down to it. diff --git a/crates/ui-egui/src/panels/left/dates.rs b/crates/ui-egui/src/panels/left/dates.rs index 49d8cf898..9b822c6e5 100644 --- a/crates/ui-egui/src/panels/left/dates.rs +++ b/crates/ui-egui/src/panels/left/dates.rs @@ -1,5 +1,5 @@ use super::rows::{INDENT_STEP, row, tree_open, tree_toggle}; -use super::*; +use super::{Icon, LibrarySource, LightcraftApp, json}; /// By Date: year → month → day; a click filters by that prefix, the triangle opens a level. pub(super) fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs index 95915b740..1ded12f07 100644 --- a/crates/ui-egui/src/panels/left/folders.rs +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -1,7 +1,7 @@ use super::albums::pointer_over_row; use super::local::{same_folder, subfolders}; use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, out_of_sight, row, row_sensed, set_tree_open, tree_open, tree_toggle}; -use super::*; +use super::{Album, FolderNode, Icon, LibrarySource, LightcraftApp, Sense, Tokens, json, register, vec2}; /// A folder on disk with a disclosure triangle: click browses it, the triangle lists its /// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the diff --git a/crates/ui-egui/src/panels/left/keywords.rs b/crates/ui-egui/src/panels/left/keywords.rs index 81997f2e2..4342efcc6 100644 --- a/crates/ui-egui/src/panels/left/keywords.rs +++ b/crates/ui-egui/src/panels/left/keywords.rs @@ -1,6 +1,6 @@ use super::dates::browse_all_photos; use super::rows::{INDENT_STEP, out_of_sight, row, tree_open, tree_toggle}; -use super::*; +use super::{Icon, KeywordNode, LightcraftApp, json}; /// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click /// filters the grid by the keyword (children included), the triangle opens a level, and the diff --git a/crates/ui-egui/src/panels/left/local.rs b/crates/ui-egui/src/panels/left/local.rs index e1a62d8ea..6d3549570 100644 --- a/crates/ui-egui/src/panels/left/local.rs +++ b/crates/ui-egui/src/panels/left/local.rs @@ -1,6 +1,6 @@ use super::folders::folder_tree; use super::rows::row; -use super::*; +use super::{Icon, LibrarySource, LightcraftApp, SidebarSection, json, register}; /// Local's folders as they stand this frame, and the folder being browsed among them. pub(super) struct LocalNow { diff --git a/crates/ui-egui/src/panels/left/rows.rs b/crates/ui-egui/src/panels/left/rows.rs index 541d98b06..edfdff167 100644 --- a/crates/ui-egui/src/panels/left/rows.rs +++ b/crates/ui-egui/src/panels/left/rows.rs @@ -1,5 +1,5 @@ use super::albums::MARK_W; -use super::*; +use super::{Align2, Icon, LightcraftApp, Rect, Sense, Tokens, paint, pos2, register, vec2}; /// How wide the sidebar's content needs to be, from the widest row of the last frame: rows are /// drawn at this width (or the panel's, if wider), and the sidebar scrolls sideways when it diff --git a/crates/ui-egui/src/panels/left/sections.rs b/crates/ui-egui/src/panels/left/sections.rs index 42cc631b5..5597904a8 100644 --- a/crates/ui-egui/src/panels/left/sections.rs +++ b/crates/ui-egui/src/panels/left/sections.rs @@ -1,6 +1,6 @@ use super::albums::top_level_drop_target; use super::rows::{ROW_HEIGHT, note_width}; -use super::*; +use super::{Align2, Icon, LightcraftApp, Rect, Sense, SidebarSection, Tokens, icon_button, json, paint, pos2, register, vec2}; /// Space above a section's header. pub(super) const SECTION_GAP: f32 = 10.0; diff --git a/crates/ui-egui/src/panels/right/crop.rs b/crates/ui-egui/src/panels/right/crop.rs index 67efd91f6..0b71fffe8 100644 --- a/crates/ui-egui/src/panels/right/crop.rs +++ b/crates/ui-egui/src/panels/right/crop.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LightcraftApp, PhotoId, Tokens, divider, header, json, padded, register, slider, text_button}; /// The aspect presets of the Crop panel: (menu label, `crop.aspect` value). const ASPECT_PRESETS: [(&str, &str); 10] = [ diff --git a/crates/ui-egui/src/panels/right/history.rs b/crates/ui-egui/src/panels/right/history.rs index 49229783b..d434075ad 100644 --- a/crates/ui-egui/src/panels/right/history.rs +++ b/crates/ui-egui/src/panels/right/history.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Icon, LightcraftApp, PhotoId, Rect, Sense, Tokens, header, json, padded, paint, pos2, register, text_button, vec2}; pub(super) fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let Some(p) = app.session.catalog.photo(id).cloned() else { return }; diff --git a/crates/ui-egui/src/panels/right/info.rs b/crates/ui-egui/src/panels/right/info.rs index 4d7c198a0..5810a0f27 100644 --- a/crates/ui-egui/src/panels/right/info.rs +++ b/crates/ui-egui/src/panels/right/info.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, LightcraftApp, PhotoId, Sense, Tokens, divider, header, json, padded, register, text_button, vec2}; pub(super) fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let Some(p) = app.session.catalog.photo(id).cloned() else { return }; diff --git a/crates/ui-egui/src/panels/right/keywords.rs b/crates/ui-egui/src/panels/right/keywords.rs index 6d7d5868b..b6598476a 100644 --- a/crates/ui-egui/src/panels/right/keywords.rs +++ b/crates/ui-egui/src/panels/right/keywords.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{HOLD, LightcraftApp, PhotoId, Rect, Tokens, fold_header, json, padded, register, text_button, vec2}; pub(super) fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // (the box is the selection's: the active photo only says that there is one) diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 8eb13aa85..cc0802248 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Session, Value, pick}; pub type PickFiles = Box Vec>; diff --git a/crates/ui-egui/src/state/dialog.rs b/crates/ui-egui/src/state/dialog.rs index edc4946cd..7f9226b6e 100644 --- a/crates/ui-egui/src/state/dialog.rs +++ b/crates/ui-egui/src/state/dialog.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything /// but crop, masks, spots and red eye. diff --git a/crates/ui-egui/src/state/settings.rs b/crates/ui-egui/src/state/settings.rs index 2d748f5a7..fff943b4c 100644 --- a/crates/ui-egui/src/state/settings.rs +++ b/crates/ui-egui/src/state/settings.rs @@ -1,7 +1,7 @@ use super::view::LoupeInfo; #[cfg(test)] use super::view::{InfoOverlay, LoupeInfoField}; -use super::*; +use super::{Deserialize, Serialize}; /// Which view the app opens in. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ui-egui/src/state/view.rs b/crates/ui-egui/src/state/view.rs index 1bbfd4349..611002647 100644 --- a/crates/ui-egui/src/state/view.rs +++ b/crates/ui-egui/src/state/view.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] From 71bf82f7bba0aa6399e8cde4b924ce5163ecbd30 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:08:31 +0000 Subject: [PATCH 62/91] refactor(ui-egui): split run_ui_command into parts and per-command functions --- crates/ui-egui/src/menus/run.rs | 1512 +++++++++++++++++-------------- 1 file changed, 827 insertions(+), 685 deletions(-) diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 9977affac..52e6006eb 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -168,6 +168,25 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Option>; 12] = [ + ui_commands_1, + ui_commands_2, + ui_commands_3, + ui_commands_4, + ui_commands_5, + ui_commands_6, + ui_commands_7, + ui_commands_8, + ui_commands_9, + ui_commands_10, + ui_commands_11, + ui_commands_12, + ]; + PARTS.iter().find_map(|part| part(app, &ctx, id, p)) +} + +/// Part 1 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_1(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { let r: Result = match id { "view.photoGrid" => { app.ui.view = ViewMode::PhotoGrid; @@ -203,7 +222,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Option return None, + }; + Some(r) +} + +/// Part 2 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_2(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "view.detail" => { app.ui.view = ViewMode::Detail; Ok(Value::Null) @@ -369,47 +396,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::make_select(app), "view.autoAdvance" => { app.ui.auto_advance = !app.ui.auto_advance; - app.toast(&ctx, crate::i18n::tr(if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" })); + app.toast(ctx, crate::i18n::tr(if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" })); Ok(json!({"autoAdvance": app.ui.auto_advance})) } - "view.back" => { - if app.ui.dialog.is_some() { - app.ui.dialog = None; - } else if app.ui.lights_out != crate::state::LightsOut::On { - app.ui.lights_out = crate::state::LightsOut::On; - } else if app.ui.keyword_painter.is_some() { - app.ui.keyword_painter = None; - } else if app.ui.fullscreen { - // Through `slideshow::end`: it also releases the held slide textures. Clearing the - // two fields directly left every rendered slide in the renderer. - crate::panels::slideshow::end(app); - } else if !app.ui.tool.is_empty() { - app.ui.tool.clear(); - } else if app.ui.right == RightPanel::Crop && app.ui.view == ViewMode::Detail { - // Esc leaves the crop tool (the crop stays), as C does; the next Esc goes back - panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); - } else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() { - app.ui.person_page = None; - } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey | ViewMode::Reference) { - app.ui.view = ViewMode::Detail; - } else if app.ui.view == ViewMode::Detail { - // a photo opened from a person's page (or the unnamed faces) goes back there, any other to the grid - match app.ui.person_from.take() { - Some((name, photo)) if app.session.active().is_some_and(|a| a.0 == photo) => { - app.ui.view = ViewMode::People; - app.ui.person_page = name; - } - _ => app.ui.view = ViewMode::PhotoGrid, - } - } - Ok(Value::Null) - } + "view.back" => view_back_command(app, ctx), "view.slideshow" => { // the photos in view, full screen, with the playback settings (see panels::slideshow) let now = app.tick_now(); let r = crate::panels::slideshow::start(app, p, now); if r.is_ok() { - app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); + app.toast(ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); } r } @@ -417,6 +413,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 3 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_3(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "view.slideshowOptions" => { app.ui.dialog = Some(Dialog::Settings { tab: "slideshow".into() }); Ok(Value::Null) @@ -464,51 +468,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option "Info Overlay: File", crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", }; - app.toast(&ctx, crate::i18n::tr(label)); + app.toast(ctx, crate::i18n::tr(label)); Ok(json!({"infoOverlay": app.ui.info_overlay})) } - "view.viewOptions" => { - // `info1` / `info2` set the lines, `briefly` the Info that Show Briefly When Photo Changes - // shows and `loupeMessages` Show message when loading or rendering photos, for an agent; - // without any of them the window opens - let mut info = app.ui.settings.loupe_info; - let mut set = false; - // `loupeMessages` sets Show message when loading or rendering photos - let messages = match p.get("loupeMessages") { - None => app.ui.settings.loupe_messages, - Some(v) => match v.as_bool() { - Some(on) => { - set = true; - on - } - None => return Some(Err("`loupeMessages`: true|false".into())), - }, - }; - for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] { - let Some(v) = p.get(key) else { continue }; - match serde_json::from_value(v.clone()) { - Ok(v) => *lines = v, - Err(_) => { - return Some(Err(format!("`{key}`: three of none|fileName|captureDate|dimensions|exposure|camera|lens|cameraAndLens"))); - } - } - set = true; - } - if let Some(v) = p.get("briefly") { - match serde_json::from_value(v.clone()) { - Ok(v) => info.briefly = v, - Err(_) => return Some(Err("`briefly`: off|basic|exposure (neither, Info 1 or Info 2)".into())), - } - set = true; - } - if set { - app.ui.settings.loupe_info = info; - app.ui.settings.loupe_messages = messages; - } else { - app.ui.dialog = Some(Dialog::ViewOptions); - } - Ok(json!({"loupeInfo": app.ui.settings.loupe_info, "loupeMessages": app.ui.settings.loupe_messages})) - } + "view.viewOptions" => view_view_options_command(app, p), "view.chooseLayoutImage" => { let path = match p.get("path").and_then(Value::as_str) { Some(path) => path.trim().to_string(), @@ -531,7 +494,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { @@ -541,7 +504,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { @@ -561,6 +524,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 4 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_4(app: &mut LightcraftApp, _ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "app.settings" => { let tab = p.get("tab").and_then(Value::as_str).unwrap_or("general"); if !crate::panels::settings::TABS.iter().any(|(id, _)| *id == tab) { @@ -665,6 +636,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 5 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_5(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "view.clickZoom" => { // {ratio?: 1|2|3|4|8} → {ratio} if let Some(r) = p.get("ratio").and_then(Value::as_f64) { @@ -733,67 +712,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // {on?, space?, profile?: ICC path | null, intent?: perceptual|relative, paperInk?, - // destWarning?, displayWarning?}; no params toggles - let has = |k: &str| p.get(k).is_some(); - if let Some(s) = p.get("space").and_then(Value::as_str) { - match lightcraft_engine::pipeline::OutputSpace::parse(s) { - Some(sp) => { - app.ui.proof.space = sp; - // a standard space replaces a device profile (unless one is given too) - app.ui.proof_profile = None; - } - None => return Some(Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)"))), - } - } - if let Some(i) = p.get("intent") { - match i.as_str().and_then(lightcraft_engine::proof::ProofIntent::parse) { - Some(i) => app.ui.proof_intent = i, - None => return Some(Err("view.softProof: intent must be perceptual or relative".into())), - } - } - if let Some(b) = p.get("paperInk").and_then(Value::as_bool) { - app.ui.proof_paper_ink = b; - } - let path = match p.get("profile") { - Some(Value::String(s)) => Some(s.clone()), - Some(Value::Null) => { - app.ui.proof_profile = None; - None - } - Some(_) => return Some(Err("view.softProof: profile must be an ICC file path or null".into())), - None => app.ui.proof_profile.as_ref().map(|(f, _)| f.clone()), - }; - match path { - Some(path) => match lightcraft_engine::proof::load(&path, app.ui.proof_intent, app.ui.proof_paper_ink) { - Ok(pp) => { - app.ui.proof.profile = pp.id; - app.ui.proof_profile = Some((path, pp.name)); - } - Err(e) => return Some(Err(format!("view.softProof: {e}"))), - }, - None => app.ui.proof.profile = 0, - } - if let Some(b) = p.get("destWarning").and_then(Value::as_bool) { - app.ui.proof.dest_warning = b; - } - if let Some(b) = p.get("displayWarning").and_then(Value::as_bool) { - app.ui.proof.display_warning = b; - } - app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) { - Some(b) => b, - None if ["space", "destWarning", "displayWarning", "profile", "intent", "paperInk"].iter().any(|k| has(k)) => app.ui.soft_proof, - None => !app.ui.soft_proof, - }; - if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) { - app.ui.view = ViewMode::Detail; - } - let pr = app.ui.proof; - let profile = app.ui.proof_profile.as_ref().map(|(f, n)| json!({"path": f, "name": n})); - Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "profile": profile, "intent": app.ui.proof_intent.name(), - "paperInk": app.ui.proof_paper_ink, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) - } + "view.softProof" => view_soft_proof_command(app, id, p), "view.histogram" => { app.ui.histogram = !app.ui.histogram; Ok(Value::Null) @@ -821,9 +740,17 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 6 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_6(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "view.maskOverlayColor" => { // no params: the next of the panel's swatch colours if p.get("color").is_none() && p.get("opacity").is_none() { @@ -876,7 +803,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { @@ -932,6 +859,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Err("local.hide needs a path".into()), }, + _ => return None, + }; + Some(r) +} + +/// Part 7 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_7(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "local.restoreHidden" => { // one path, or (no path) every hidden location match p.get("path").and_then(Value::as_str) { @@ -952,7 +887,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - panel(app, &ctx, RightPanel::Edit, "Edit"); + panel(app, ctx, RightPanel::Edit, "Edit"); Ok(Value::Null) } "panel.profiles" => { @@ -968,47 +903,47 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); + panel(app, ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); Ok(Value::Null) } "panel.remove" => { - panel(app, &ctx, RightPanel::Remove, "Remove"); + panel(app, ctx, RightPanel::Remove, "Remove"); if app.ui.right == RightPanel::Remove && app.ui.tool.is_empty() { app.ui.tool = "remove".into(); } Ok(Value::Null) } "panel.masking" => { - panel(app, &ctx, RightPanel::Masking, "Masking"); + panel(app, ctx, RightPanel::Masking, "Masking"); Ok(Value::Null) } "panel.redeye" => { - panel(app, &ctx, RightPanel::RedEye, "Red Eye"); + panel(app, ctx, RightPanel::RedEye, "Red Eye"); Ok(Value::Null) } "panel.info" => { - panel(app, &ctx, RightPanel::Info, "Info"); + panel(app, ctx, RightPanel::Info, "Info"); Ok(Value::Null) } "panel.keywords" => { - panel(app, &ctx, RightPanel::Keywords, "Keywords"); + panel(app, ctx, RightPanel::Keywords, "Keywords"); Ok(Value::Null) } "panel.addKeywords" => { // opens Keywording when it is folded shut; the panel stays open when pressed again if app.ui.right != RightPanel::Keywords { - panel(app, &ctx, RightPanel::Keywords, "Keywords"); + panel(app, ctx, RightPanel::Keywords, "Keywords"); } app.ui.folded_sections.retain(|s| s != "keywording"); app.ui.focus_keywords = Some(ctx.cumulative_pass_nr()); Ok(Value::Null) } "panel.versions" => { - panel(app, &ctx, RightPanel::Versions, "Versions"); + panel(app, ctx, RightPanel::Versions, "Versions"); Ok(Value::Null) } "panel.activity" => { - panel(app, &ctx, RightPanel::Activity, "History"); + panel(app, ctx, RightPanel::Activity, "History"); Ok(Value::Null) } "panel.presets" => { @@ -1033,6 +968,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 8 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_8(app: &mut LightcraftApp, _ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { s if s.starts_with("section.") => bool_param(s, p, "solo").and_then(|solo| { // only the Edit sections listed above: an unknown one would close the real ones if !UI_COMMANDS.iter().any(|c| c.0 == s) { @@ -1133,41 +1076,19 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3 - let album = p - .get("id") - .and_then(Value::as_u64) - .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned()); - app.ui.dialog = Some(match album { - Some(a) => Dialog::SmartRules { - id: Some(a.id.0), - name: a.name.clone(), - // older rules read as they always matched (RuleSet::upgrade) - rules: a - .smart - .as_ref() - .and_then(|f| f.rule_set.clone()) - .map(|mut r| { - r.upgrade(); - r - }) - .unwrap_or_default(), - parent: None, - }, - None => Dialog::SmartRules { - id: None, - name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), - rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, - parent: parent_param(p), - }, - }); - Ok(Value::Null) - } + "dialog.smartAlbum" => dialog_smart_album_command(app, id, p), "dialog.newSmartAlbum" => { app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), parent: parent_param(p) }); Ok(Value::Null) } + _ => return None, + }; + Some(r) +} + +/// Part 9 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_9(app: &mut LightcraftApp, _ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "dialog.captureTime" => { // a time that doesn't fully read isn't offered as the starting point (`10:0` would read as midnight) let time = app @@ -1268,6 +1189,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 10 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_10(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "dialog.export" => { let prev = app.session.last_export.clone().unwrap_or_default(); let u = |k: &str, d: u64| prev.get(k).and_then(Value::as_u64).unwrap_or(d); @@ -1303,9 +1232,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::updates_ui::check(app), - "photo.editInPhotoCraft" => edit_in(app, &ctx, p, true), - "photo.editInExternal" => edit_in(app, &ctx, p, false), - "photo.openOriginalInExternal" => open_original_in(app, &ctx, p), + "photo.editInPhotoCraft" => edit_in(app, ctx, p, true), + "photo.editInExternal" => edit_in(app, ctx, p, false), + "photo.openOriginalInExternal" => open_original_in(app, ctx, p), "dialog.cull" => { let (burst_gap, similarity) = app.ui.cull_burst.unwrap_or((10.0, 0.93)); app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true, burst_gap, similarity }); @@ -1315,51 +1244,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let info = app.session.execute("library.info", &json!({})).unwrap_or_default(); - let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(); - let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64); - let mut rows = vec![ - ("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()), - ("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)), - ("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())), - ( - "GPU".to_string(), - gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() { - Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why), - None => "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(), - match app.ui.settings.preview_limit { - 0 => "automatic".to_string(), - n => format!("{n} px"), - }, - ), - ("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(( - "Frame update".into(), - format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms), - )); - rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms))); - if let Some(f) = lightcraft_engine::gpu::last_fallback() { - rows.push(("Last GPU fallback".into(), f)); - } - let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::>()); - if p.get("open").and_then(Value::as_bool).unwrap_or(true) { - app.ui.dialog = Some(Dialog::SystemInfo { rows }); - } - Ok(r) - } + "app.systemInfo" => app_system_info_command(app, p), "app.shortcuts" => { app.ui.dialog = Some(Dialog::Shortcuts); Ok(Value::Null) @@ -1420,6 +1305,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Ok(Value::Null), } } + _ => return None, + }; + Some(r) +} + +/// Part 11 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_11(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "app.quit" => { app.ui.quit = true; Ok(Value::Null) @@ -1429,162 +1322,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let within = match p.get("within") { - None | Some(Value::Null) => None, - Some(Value::String(path)) if !path.is_empty() => Some(path.clone()), - Some(Value::String(_)) => return Some(Err("file.findMissing: `within` must not be empty".into())), - Some(value) => return Some(Err(format!("file.findMissing: `within` must be a folder path, not {value}"))), - }; - let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); - let timeout = match p.get("timeout") { - None | Some(Value::Null) => std::time::Duration::from_secs(600), - Some(Value::Number(n)) => { - if let Some(s) = n.as_f64() { - if s.is_finite() && s >= 0.0 { - match std::time::Duration::try_from_secs_f64(s) { - Ok(d) => d, - Err(_) => return Some(Err("file.findMissing: `timeout` is too large".into())), - } - } else { - return Some(Err("file.findMissing: `timeout` must be a non-negative number of seconds".into())); - } - } else { - return Some(Err("file.findMissing: `timeout` must be a number of seconds".into())); - } - } - Some(other) => return Some(Err(format!("file.findMissing: `timeout` must be a number of seconds, not {other}"))), - }; - let title = if within.is_some() { "Locate Missing Files…" } else { "Find Missing Photos…" }; - let folder = match p.get("folder").and_then(Value::as_str) { - Some(f) => Some(f.to_string()), - None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr(title)), |s| { - s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) - }) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - }, - }; - let Some(folder) = folder else { return Some(Ok(Value::Null)) }; - // the search (checking every photo's file, walking the folder) runs on a worker thread; - // the relinking happens back here, as one undo step - const LABEL: &str = "Find Missing Photos"; - if app.tasks.is_running(LABEL) { - return Some(Err("Find Missing Photos is already searching".into())); - } - let mut candidates = lightcraft_engine::cmd::missing::find_candidates(&app.session.catalog); - if let Some(within) = within { - candidates.retain(|candidate| lightcraft_catalog::query::folder_within(&candidate.path, &within)); - } - #[cfg(test)] - let test_delay = app.ui.find_missing_test_delay; - let work = move || { - #[cfg(test)] - if let Some(d) = test_delay { - std::thread::sleep(d); - } - lightcraft_engine::cmd::missing::plan_find_missing(&candidates, &folder) - }; - let done = |app: &mut LightcraftApp, ctx: &egui::Context, plan: Result| { - // relinked under the session as it is now: photos relinked meanwhile and files - // now in use are skipped - let r = plan.and_then(|plan| app.run("library.findMissing", plan.to_json())); - match r { - Ok(v) => { - let n = v["found"].as_array().map_or(0, Vec::len); - let left = v["missing"].as_u64().unwrap_or(0); - let unsure = v["ambiguous"].as_array().map_or(0, Vec::len); - let unsure = if unsure > 0 { - crate::i18n::tr_format!(" ({unsure} with several look-alike files: use Locate)", unsure = unsure) - } else { - String::new() - }; - app.toast( - ctx, - crate::i18n::tr_format!( - "Found {n} missing photo{}; {left} still missing{unsure}", - if n == 1 { "" } else { "s" }, - left = left, - n = n, - unsure = unsure - ), - ); - app.ui.last_find_missing = Some(v.clone()); - app.ui.last_find_missing_result = Some(Ok(v)); - } - Err(e) => { - app.toast(ctx, e.clone()); - app.ui.last_find_missing_result = Some(Err(e)); - } - } - }; - app.ui.last_find_missing = None; - app.ui.last_find_missing_result = None; - if let Err(e) = crate::tasks::spawn(app, LABEL, Some("findMissing"), work, done) { - return Some(Err(e)); - } - if wait { - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - let completed = crate::tasks::wait_for(app, &ctx, LABEL, timeout); - if !completed { - return Some(Err("Find Missing Photos: search timed out (search continues in the background)".into())); - } - return match app.ui.last_find_missing_result.take() { - Some(Ok(v)) => Some(Ok(v)), - Some(Err(e)) => Some(Err(e)), - None => Some(Err("Find Missing Photos: worker terminated unexpectedly".into())), - }; - } - Ok(json!({"background": true})) - } - "photo.tagFromTracklog" => { - // a GPX file → GPS for the selected photos by capture time (one undo step) - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = p.as_object().cloned().unwrap_or_default(); - params.insert("path".into(), json!(path)); - let ask_zone = !params.contains_key("offset"); - let params = Value::Object(params); - if ask_zone { - // GPX times are UTC, camera clocks are local: ask for the camera's zone, then come back here - crate::panels::dialogs::prompt( - app, - "Auto-Tag from Tracklog", - "Camera time zone, e.g. -07:00 (empty: UTC)", - "", - "photo.tagFromTracklog", - params, - "offset", - ); - return Some(Ok(Value::Null)); - } - let r = app.run("photo.autoTagTracklog", params); - if let Ok(v) = &r { - let n = v["tagged"].as_u64().unwrap_or(0); - let sk = &v["skipped"]; - let mut msg = crate::i18n::tr_format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" }, n = n); - let outside = sk["outside"].as_u64().unwrap_or(0); - if outside > 0 { - msg += &crate::i18n::tr_format!("; {outside} outside its time range", outside = outside); - } - let kept = sk["hasGps"].as_u64().unwrap_or(0); - if kept > 0 { - msg += &crate::i18n::tr_format!("; {kept} already had a location", kept = kept); - } - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - app.toast(&ctx, msg); - } - r - } + "file.findMissing" => file_find_missing_command(app, id, p), + "photo.tagFromTracklog" => photo_tag_from_tracklog_command(app, ctx, id, p), "photo.locate" => { let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) }; let path = match p.get("path").and_then(Value::as_str) { @@ -1640,208 +1379,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Ok(Value::Null), } } - "file.importPresets" => { - let paths = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Presets").into()), - crate::i18n::tr("Presets & Profiles"), - crate::pick::PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - if paths.is_empty() { - return Some(Ok(Value::Null)); - } - // .cube LUTs (and those inside zips / folders) become profiles - let lut_paths: Vec<&String> = paths - .iter() - .filter(|p| { - let l = p.to_ascii_lowercase(); - l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir() - }) - .collect(); - // one toast for both halves (a second toast would replace the first) - let mut parts: Vec = Vec::new(); - // `name: reason` for each file that couldn't be imported - let mut problems: Vec = Vec::new(); - let mut profiles_result = Value::Null; - if !lut_paths.is_empty() { - match app.session.execute("profile.import", &json!({"paths": lut_paths})) { - Ok(v) => { - let profiles = v["imported"].as_array().map_or(0, Vec::len); - if profiles > 0 { - parts.push(crate::i18n::tr_format!( - "Imported {profiles} profile{} (Profile browser ▸ their groups)", - if profiles == 1 { "" } else { "s" }, - profiles = profiles - )); - } - problems.extend(v["failed"].as_array().into_iter().flatten().map(|f| { - let name = f.get(0).and_then(Value::as_str).unwrap_or_default(); - let reason = f.get(1).and_then(Value::as_str).unwrap_or_default(); - format!("{name}: {reason}") - })); - profiles_result = v; - } - Err(e) => { - problems.push(e.to_string()); - profiles_result = json!({"error": e.to_string()}); - } - } - } - let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); - let r = if preset_paths.is_empty() { - Ok(json!({"imported": [], "failed": [], "skipped": 0})) - } else { - app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()) - }; - if let (false, Ok(v)) = (preset_paths.is_empty(), &r) { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast - let effects = v["effects"].as_array().map_or(0, Vec::len); - if effects > 0 { - parts.push(crate::panels::mask_effects::import_toast(v)); - } - let profile_news = !parts.is_empty() || !problems.is_empty(); - let msg = match (n, failed) { - // a zip of LUTs (or only local presets): the other half already says what happened - (0, 0) if profile_news => None, - (0, 0) => Some("No new presets".to_string()), - (n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)), - (n, f) => Some(crate::i18n::tr_format!( - "Imported {n} preset{}, {f} file{} not readable", - if n == 1 { "" } else { "s" }, - if f == 1 { "" } else { "s" }, - f = f, - n = n - )), - }; - if let Some(mut msg) = msg { - // settings with no counterpart here (the other editor's profiles, masks…) - let mut skipped: Vec<&str> = v["imported"] - .as_array() - .into_iter() - .flatten() - .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) - .filter_map(Value::as_str) - .collect(); - skipped.sort_unstable(); - skipped.dedup(); - if !skipped.is_empty() { - let names: Vec<&str> = skipped.iter().take(3).copied().collect(); - msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }); - } - parts.push(msg); - } - if n > 0 { - app.ui.presets = true; - } - } - let mut msg = parts.join(" · "); - if !problems.is_empty() { - let detail = - format!("{}{}", problems.iter().take(2).cloned().collect::>().join("; "), if problems.len() > 2 { "…" } else { "" }); - if msg.is_empty() { - msg = crate::i18n::tr_format!("Import failed: {e}", e = detail); - } else { - msg += &crate::i18n::tr_format!(" · {} failed: {}", problems.len(), detail); - } - app.toast_error(&ctx, msg); - } else if !msg.is_empty() { - app.toast(&ctx, msg); - } - let r = r.map(|mut v| { - if !lut_paths.is_empty() - && let Some(o) = v.as_object_mut() - { - o.insert("profiles".into(), profiles_result); - } - v - }); - return Some(r); - } - "file.importKeywords" => { - // a keyword list file: Lightroom Classic's, Capture One's, Photo Supreme's (.utf8) - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => { - let req = PickRequest::file(crate::i18n::tr("Import Keywords"), crate::i18n::tr("Keyword Lists"), &["txt", "utf8"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_keyword_list.as_mut().map(|f| f())) { - Picked::Now(v) => match v.into_iter().next() { - Some(x) => x, - None => return Some(Ok(Value::Null)), - }, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let r = app.session.execute("keyword.import", &json!({"path": path})).map_err(|e| e.to_string()); - match &r { - Ok(v) => { - let (added, updated) = (v["added"].as_u64().unwrap_or(0), v["updated"].as_u64().unwrap_or(0)); - let mut msg = crate::i18n::tr_format!("Added {n} keyword{}", if added == 1 { "" } else { "s" }, n = added); - if updated > 0 { - msg.push_str(&crate::i18n::tr_format!("; {n} gained synonyms", n = updated)); - } - app.toast(&ctx, msg); - } - Err(e) => app.toast(&ctx, e.clone()), - } - return Some(r); - } - "file.exportKeywords" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => { - let name = "Keywords.txt"; - let req = PickRequest::save(crate::i18n::tr("Export Keywords"), crate::i18n::tr("Keyword Lists"), &["txt"], name); - match crate::pick::ask(app, id, p, "path", req, |s| s.save_keyword_list.as_mut().map(|f| f(name).into_iter().collect())) { - Picked::Now(v) => match v.into_iter().next() { - Some(x) => x, - None => return Some(Ok(Value::Null)), - }, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let r = app.session.execute("keyword.export", &json!({"path": path})).map_err(|e| e.to_string()); - if let Err(e) = &r { - app.toast(&ctx, e.clone()); - } - if let Ok(v) = &r { - let n = v["keywords"].as_u64().unwrap_or(0); - let mut msg = crate::i18n::tr_format!("Exported {n} keyword{}", if n == 1 { "" } else { "s" }, n = n); - // Capture One's importer refuses ; , < > in a list: say which to rename first - let refuses: Vec<&str> = v["captureOneRefuses"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - if !refuses.is_empty() { - msg.push_str(&crate::i18n::tr_format!( - " — Capture One won't import a list with ; , < or > in a keyword: {names}", - names = refuses.iter().take(5).copied().collect::>().join(" · ") - )); - } - // what the format can't hold (a name in brackets, a line break…) isn't in the file - let left: Vec<&str> = v["unwritable"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - if !left.is_empty() { - msg.push_str(&crate::i18n::tr_format!( - " — left out, as a keyword list can't hold them: {names}", - // escaped: a line break in a name would break the toast - names = left.iter().take(5).map(|n| n.escape_debug().to_string()).collect::>().join(" · ") - )); - } - app.toast(&ctx, msg); - } - return Some(r); - } + "file.importPresets" => file_import_presets_command(app, ctx, id, p), + "file.importKeywords" => file_import_keywords_command(app, ctx, id, p), + "file.exportKeywords" => file_export_keywords_command(app, ctx, id, p), "dialog.saveMaskEffect" => { if app.session.active_mask.is_none() { return Some(Err("select a mask first".into())); @@ -1853,38 +1393,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let name = format!("{}.lceffect", p.get("group").and_then(Value::as_str).unwrap_or("Effect Presets")); - let req = PickRequest::save( - crate::i18n::tr("Export Effect Presets"), - crate::i18n::tr("LightCraft Effect Presets"), - &["lceffect"], - name.clone(), - ); - match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = json!({"path": path}); - for k in ["names", "group"] { - if let Some(v) = p.get(k) { - params[k] = v.clone(); - } - } - let r = app.session.execute("mask.exportEffects", ¶ms).map_err(|e| e.to_string()); - match &r { - Ok(v) => app.toast(&ctx, crate::i18n::tr_format!("Exported {} effect preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })), - Err(e) => app.toast_error(&ctx, e.clone()), - } - return Some(r); - } + "file.exportMaskEffects" => file_export_mask_effects_command(app, ctx, id, p), "file.exportPresets" => { let group = p.get("group").and_then(Value::as_str).map(str::to_string); let path = match p.get("path").and_then(Value::as_str) { @@ -1909,71 +1418,20 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let paths: Vec = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Point Curve Presets").into()), - crate::i18n::tr("Point Curve Presets"), - crate::pick::CURVE_PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - if paths.is_empty() { - return Some(Ok(Value::Null)); - } - let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string()); - if let Ok(v) = &r { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - let mut msg = crate::i18n::tr_format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }, n = n); - if failed > 0 { - msg += &crate::i18n::tr_format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }, failed = failed); - } - app.toast(&ctx, msg); - } - return Some(r); - } - "file.exportCurvePresets" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = PickRequest::save( - crate::i18n::tr("Export Point Curve Presets"), - crate::i18n::tr("Point Curve Presets"), - &["lccurve"], - "Point Curves.lccurve", - ); - match crate::pick::ask(app, id, p, "path", req, |s| { - s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect()) - }) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = json!({"path": path}); - if let Some(n) = p.get("names") { - params["names"] = n.clone(); - } - let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string()); - if let Ok(v) = &r { - app.toast(&ctx, crate::i18n::tr_format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); - } - return Some(r); + r } + "file.importCurvePresets" => file_import_curve_presets_command(app, ctx, id, p), + _ => return None, + }; + Some(r) +} + +/// Part 12 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_12(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { + "file.exportCurvePresets" => file_export_curve_presets_command(app, ctx, id, p), "app.export" => crate::control::export_active(app, p), "app.showInFinder" => show_in_finder(app), "app.showInFolders" => show_in_folders(app, p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId)), @@ -1990,3 +1448,687 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Result { + if app.ui.dialog.is_some() { + app.ui.dialog = None; + } else if app.ui.lights_out != crate::state::LightsOut::On { + app.ui.lights_out = crate::state::LightsOut::On; + } else if app.ui.keyword_painter.is_some() { + app.ui.keyword_painter = None; + } else if app.ui.fullscreen { + // Through `slideshow::end`: it also releases the held slide textures. Clearing the + // two fields directly left every rendered slide in the renderer. + crate::panels::slideshow::end(app); + } else if !app.ui.tool.is_empty() { + app.ui.tool.clear(); + } else if app.ui.right == RightPanel::Crop && app.ui.view == ViewMode::Detail { + // Esc leaves the crop tool (the crop stays), as C does; the next Esc goes back + panel(app, ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); + } else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() { + app.ui.person_page = None; + } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey | ViewMode::Reference) { + app.ui.view = ViewMode::Detail; + } else if app.ui.view == ViewMode::Detail { + // a photo opened from a person's page (or the unnamed faces) goes back there, any other to the grid + match app.ui.person_from.take() { + Some((name, photo)) if app.session.active().is_some_and(|a| a.0 == photo) => { + app.ui.view = ViewMode::People; + app.ui.person_page = name; + } + _ => app.ui.view = ViewMode::PhotoGrid, + } + } + Ok(Value::Null) +} + +/// The `view.viewOptions` command. +fn view_view_options_command(app: &mut LightcraftApp, p: &Value) -> Result { + // `info1` / `info2` set the lines, `briefly` the Info that Show Briefly When Photo Changes + // shows and `loupeMessages` Show message when loading or rendering photos, for an agent; + // without any of them the window opens + let mut info = app.ui.settings.loupe_info; + let mut set = false; + // `loupeMessages` sets Show message when loading or rendering photos + let messages = match p.get("loupeMessages") { + None => app.ui.settings.loupe_messages, + Some(v) => match v.as_bool() { + Some(on) => { + set = true; + on + } + None => return Err("`loupeMessages`: true|false".into()), + }, + }; + for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] { + let Some(v) = p.get(key) else { continue }; + match serde_json::from_value(v.clone()) { + Ok(v) => *lines = v, + Err(_) => { + return Err(format!("`{key}`: three of none|fileName|captureDate|dimensions|exposure|camera|lens|cameraAndLens")); + } + } + set = true; + } + if let Some(v) = p.get("briefly") { + match serde_json::from_value(v.clone()) { + Ok(v) => info.briefly = v, + Err(_) => return Err("`briefly`: off|basic|exposure (neither, Info 1 or Info 2)".into()), + } + set = true; + } + if set { + app.ui.settings.loupe_info = info; + app.ui.settings.loupe_messages = messages; + } else { + app.ui.dialog = Some(Dialog::ViewOptions); + } + Ok(json!({"loupeInfo": app.ui.settings.loupe_info, "loupeMessages": app.ui.settings.loupe_messages})) +} + +/// The `view.softProof` command. +fn view_soft_proof_command(app: &mut LightcraftApp, _id: &str, p: &Value) -> Result { + // {on?, space?, profile?: ICC path | null, intent?: perceptual|relative, paperInk?, + // destWarning?, displayWarning?}; no params toggles + let has = |k: &str| p.get(k).is_some(); + if let Some(s) = p.get("space").and_then(Value::as_str) { + match lightcraft_engine::pipeline::OutputSpace::parse(s) { + Some(sp) => { + app.ui.proof.space = sp; + // a standard space replaces a device profile (unless one is given too) + app.ui.proof_profile = None; + } + None => return Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)")), + } + } + if let Some(i) = p.get("intent") { + match i.as_str().and_then(lightcraft_engine::proof::ProofIntent::parse) { + Some(i) => app.ui.proof_intent = i, + None => return Err("view.softProof: intent must be perceptual or relative".into()), + } + } + if let Some(b) = p.get("paperInk").and_then(Value::as_bool) { + app.ui.proof_paper_ink = b; + } + let path = match p.get("profile") { + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Null) => { + app.ui.proof_profile = None; + None + } + Some(_) => return Err("view.softProof: profile must be an ICC file path or null".into()), + None => app.ui.proof_profile.as_ref().map(|(f, _)| f.clone()), + }; + match path { + Some(path) => match lightcraft_engine::proof::load(&path, app.ui.proof_intent, app.ui.proof_paper_ink) { + Ok(pp) => { + app.ui.proof.profile = pp.id; + app.ui.proof_profile = Some((path, pp.name)); + } + Err(e) => return Err(format!("view.softProof: {e}")), + }, + None => app.ui.proof.profile = 0, + } + if let Some(b) = p.get("destWarning").and_then(Value::as_bool) { + app.ui.proof.dest_warning = b; + } + if let Some(b) = p.get("displayWarning").and_then(Value::as_bool) { + app.ui.proof.display_warning = b; + } + app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) { + Some(b) => b, + None if ["space", "destWarning", "displayWarning", "profile", "intent", "paperInk"].iter().any(|k| has(k)) => app.ui.soft_proof, + None => !app.ui.soft_proof, + }; + if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) { + app.ui.view = ViewMode::Detail; + } + let pr = app.ui.proof; + let profile = app.ui.proof_profile.as_ref().map(|(f, n)| json!({"path": f, "name": n})); + Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "profile": profile, "intent": app.ui.proof_intent.name(), + "paperInk": app.ui.proof_paper_ink, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) +} + +/// The `dialog.smartAlbum` command. +fn dialog_smart_album_command(app: &mut LightcraftApp, _id: &str, p: &Value) -> Result { + // {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3 + let album = p + .get("id") + .and_then(Value::as_u64) + .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned()); + app.ui.dialog = Some(match album { + Some(a) => Dialog::SmartRules { + id: Some(a.id.0), + name: a.name.clone(), + // older rules read as they always matched (RuleSet::upgrade) + rules: a + .smart + .as_ref() + .and_then(|f| f.rule_set.clone()) + .map(|mut r| { + r.upgrade(); + r + }) + .unwrap_or_default(), + parent: None, + }, + None => Dialog::SmartRules { + id: None, + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + parent: parent_param(p), + }, + }); + Ok(Value::Null) +} + +/// The `app.systemInfo` command. +fn app_system_info_command(app: &mut LightcraftApp, p: &Value) -> Result { + 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(|| match lightcraft_engine::gpu::unavailable_reason() { + Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why), + None => "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(), + match app.ui.settings.preview_limit { + 0 => "automatic".to_string(), + n => format!("{n} px"), + }, + ), + ("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(( + "Frame update".into(), + format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms), + )); + rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms))); + if let Some(f) = lightcraft_engine::gpu::last_fallback() { + rows.push(("Last GPU fallback".into(), f)); + } + let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::>()); + if p.get("open").and_then(Value::as_bool).unwrap_or(true) { + app.ui.dialog = Some(Dialog::SystemInfo { rows }); + } + Ok(r) +} + +/// `file.findMissing {within?}`: the folder the search is limited to, if any. +fn find_missing_within(p: &Value) -> Result, String> { + match p.get("within") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(path)) if !path.is_empty() => Ok(Some(path.clone())), + Some(Value::String(_)) => Err("file.findMissing: `within` must not be empty".into()), + Some(value) => Err(format!("file.findMissing: `within` must be a folder path, not {value}")), + } +} + +/// `file.findMissing {timeout?}`: how long `wait` waits for the search (ten minutes when omitted). +fn find_missing_timeout(p: &Value) -> Result { + let n = match p.get("timeout") { + None | Some(Value::Null) => return Ok(std::time::Duration::from_secs(600)), + Some(Value::Number(n)) => n, + Some(other) => return Err(format!("file.findMissing: `timeout` must be a number of seconds, not {other}")), + }; + let s = n.as_f64().ok_or("file.findMissing: `timeout` must be a number of seconds")?; + if !s.is_finite() || s < 0.0 { + return Err("file.findMissing: `timeout` must be a non-negative number of seconds".into()); + } + std::time::Duration::try_from_secs_f64(s).map_err(|_| "file.findMissing: `timeout` is too large".to_string()) +} + +/// The toast for a finished search, and the result it leaves for a caller that waits. +fn find_missing_done(app: &mut LightcraftApp, ctx: &egui::Context, plan: Result) { + // relinked under the session as it is now: photos relinked meanwhile and files + // now in use are skipped + let r = plan.and_then(|plan| app.run("library.findMissing", plan.to_json())); + match r { + Ok(v) => { + let n = v["found"].as_array().map_or(0, Vec::len); + let left = v["missing"].as_u64().unwrap_or(0); + let unsure = v["ambiguous"].as_array().map_or(0, Vec::len); + let unsure = if unsure > 0 { + crate::i18n::tr_format!(" ({unsure} with several look-alike files: use Locate)", unsure = unsure) + } else { + String::new() + }; + app.toast( + ctx, + crate::i18n::tr_format!( + "Found {n} missing photo{}; {left} still missing{unsure}", + if n == 1 { "" } else { "s" }, + left = left, + n = n, + unsure = unsure + ), + ); + app.ui.last_find_missing = Some(v.clone()); + app.ui.last_find_missing_result = Some(Ok(v)); + } + Err(e) => { + app.toast(ctx, e.clone()); + app.ui.last_find_missing_result = Some(Err(e)); + } + } +} + +/// The `file.findMissing` command. +fn file_find_missing_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Result { + let within = find_missing_within(p)?; + let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); + let timeout = find_missing_timeout(p)?; + let title = if within.is_some() { "Locate Missing Files…" } else { "Find Missing Photos…" }; + let folder = match p.get("folder").and_then(Value::as_str) { + Some(f) => Some(f.to_string()), + None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr(title)), |s| { + s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + }, + }; + let Some(folder) = folder else { return Ok(Value::Null) }; + // the search (checking every photo's file, walking the folder) runs on a worker thread; + // the relinking happens back here, as one undo step + const LABEL: &str = "Find Missing Photos"; + if app.tasks.is_running(LABEL) { + return Err("Find Missing Photos is already searching".into()); + } + let mut candidates = lightcraft_engine::cmd::missing::find_candidates(&app.session.catalog); + if let Some(within) = within { + candidates.retain(|candidate| lightcraft_catalog::query::folder_within(&candidate.path, &within)); + } + #[cfg(test)] + let test_delay = app.ui.find_missing_test_delay; + let work = move || { + #[cfg(test)] + if let Some(d) = test_delay { + std::thread::sleep(d); + } + lightcraft_engine::cmd::missing::plan_find_missing(&candidates, &folder) + }; + app.ui.last_find_missing = None; + app.ui.last_find_missing_result = None; + crate::tasks::spawn(app, LABEL, Some("findMissing"), work, find_missing_done)?; + if !wait { + return Ok(json!({"background": true})); + } + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + if !crate::tasks::wait_for(app, &ctx, LABEL, timeout) { + return Err("Find Missing Photos: search timed out (search continues in the background)".into()); + } + app.ui.last_find_missing_result.take().unwrap_or_else(|| Err("Find Missing Photos: worker terminated unexpectedly".into())) +} + +/// The `photo.tagFromTracklog` command. +fn photo_tag_from_tracklog_command(app: &mut LightcraftApp, _ctx: &egui::Context, id: &str, p: &Value) -> Result { + // a GPX file → GPS for the selected photos by capture time (one undo step) + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; + let Some(path) = path else { return Ok(Value::Null) }; + let mut params = p.as_object().cloned().unwrap_or_default(); + params.insert("path".into(), json!(path)); + let ask_zone = !params.contains_key("offset"); + let params = Value::Object(params); + if ask_zone { + // GPX times are UTC, camera clocks are local: ask for the camera's zone, then come back here + crate::panels::dialogs::prompt( + app, + "Auto-Tag from Tracklog", + "Camera time zone, e.g. -07:00 (empty: UTC)", + "", + "photo.tagFromTracklog", + params, + "offset", + ); + return Ok(Value::Null); + } + let r = app.run("photo.autoTagTracklog", params); + if let Ok(v) = &r { + let n = v["tagged"].as_u64().unwrap_or(0); + let sk = &v["skipped"]; + let mut msg = crate::i18n::tr_format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" }, n = n); + let outside = sk["outside"].as_u64().unwrap_or(0); + if outside > 0 { + msg += &crate::i18n::tr_format!("; {outside} outside its time range", outside = outside); + } + let kept = sk["hasGps"].as_u64().unwrap_or(0); + if kept > 0 { + msg += &crate::i18n::tr_format!("; {kept} already had a location", kept = kept); + } + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + app.toast(&ctx, msg); + } + r +} + +/// The `file.importPresets` command. +fn file_import_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let paths = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + None => { + let req = PickRequest::files( + Some(crate::i18n::tr("Import Presets").into()), + crate::i18n::tr("Presets & Profiles"), + crate::pick::PRESET_EXTENSIONS, + ); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + if paths.is_empty() { + return Ok(Value::Null); + } + // .cube LUTs (and those inside zips / folders) become profiles + let lut_paths: Vec<&String> = paths + .iter() + .filter(|p| { + let l = p.to_ascii_lowercase(); + l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir() + }) + .collect(); + // one toast for both halves (a second toast would replace the first) + let mut parts: Vec = Vec::new(); + // `name: reason` for each file that couldn't be imported + let mut problems: Vec = Vec::new(); + let mut profiles_result = Value::Null; + if !lut_paths.is_empty() { + match app.session.execute("profile.import", &json!({"paths": lut_paths})) { + Ok(v) => { + let profiles = v["imported"].as_array().map_or(0, Vec::len); + if profiles > 0 { + parts.push(crate::i18n::tr_format!( + "Imported {profiles} profile{} (Profile browser ▸ their groups)", + if profiles == 1 { "" } else { "s" }, + profiles = profiles + )); + } + problems.extend(v["failed"].as_array().into_iter().flatten().map(|f| { + let name = f.get(0).and_then(Value::as_str).unwrap_or_default(); + let reason = f.get(1).and_then(Value::as_str).unwrap_or_default(); + format!("{name}: {reason}") + })); + profiles_result = v; + } + Err(e) => { + problems.push(e.to_string()); + profiles_result = json!({"error": e.to_string()}); + } + } + } + let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); + let r = if preset_paths.is_empty() { + Ok(json!({"imported": [], "failed": [], "skipped": 0})) + } else { + app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()) + }; + if let (false, Ok(v)) = (preset_paths.is_empty(), &r) { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast + let effects = v["effects"].as_array().map_or(0, Vec::len); + if effects > 0 { + parts.push(crate::panels::mask_effects::import_toast(v)); + } + let profile_news = !parts.is_empty() || !problems.is_empty(); + let msg = match (n, failed) { + // a zip of LUTs (or only local presets): the other half already says what happened + (0, 0) if profile_news => None, + (0, 0) => Some("No new presets".to_string()), + (n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)), + (n, f) => Some(crate::i18n::tr_format!( + "Imported {n} preset{}, {f} file{} not readable", + if n == 1 { "" } else { "s" }, + if f == 1 { "" } else { "s" }, + f = f, + n = n + )), + }; + if let Some(mut msg) = msg { + // settings with no counterpart here (the other editor's profiles, masks…) + let mut skipped: Vec<&str> = v["imported"] + .as_array() + .into_iter() + .flatten() + .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) + .filter_map(Value::as_str) + .collect(); + skipped.sort_unstable(); + skipped.dedup(); + if !skipped.is_empty() { + let names: Vec<&str> = skipped.iter().take(3).copied().collect(); + msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }); + } + parts.push(msg); + } + if n > 0 { + app.ui.presets = true; + } + } + let mut msg = parts.join(" · "); + if !problems.is_empty() { + let detail = format!("{}{}", problems.iter().take(2).cloned().collect::>().join("; "), if problems.len() > 2 { "…" } else { "" }); + if msg.is_empty() { + msg = crate::i18n::tr_format!("Import failed: {e}", e = detail); + } else { + msg += &crate::i18n::tr_format!(" · {} failed: {}", problems.len(), detail); + } + app.toast_error(ctx, msg); + } else if !msg.is_empty() { + app.toast(ctx, msg); + } + + r.map(|mut v| { + if !lut_paths.is_empty() + && let Some(o) = v.as_object_mut() + { + o.insert("profiles".into(), profiles_result); + } + v + }) +} + +/// The `file.importKeywords` command. +fn file_import_keywords_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + // a keyword list file: Lightroom Classic's, Capture One's, Photo Supreme's (.utf8) + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => { + let req = PickRequest::file(crate::i18n::tr("Import Keywords"), crate::i18n::tr("Keyword Lists"), &["txt", "utf8"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_keyword_list.as_mut().map(|f| f())) { + Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Ok(Value::Null), + }, + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + let r = app.session.execute("keyword.import", &json!({"path": path})).map_err(|e| e.to_string()); + match &r { + Ok(v) => { + let (added, updated) = (v["added"].as_u64().unwrap_or(0), v["updated"].as_u64().unwrap_or(0)); + let mut msg = crate::i18n::tr_format!("Added {n} keyword{}", if added == 1 { "" } else { "s" }, n = added); + if updated > 0 { + msg.push_str(&crate::i18n::tr_format!("; {n} gained synonyms", n = updated)); + } + app.toast(ctx, msg); + } + Err(e) => app.toast(ctx, e.clone()), + } + r +} + +/// The `file.exportKeywords` command. +fn file_export_keywords_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => { + let name = "Keywords.txt"; + let req = PickRequest::save(crate::i18n::tr("Export Keywords"), crate::i18n::tr("Keyword Lists"), &["txt"], name); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_keyword_list.as_mut().map(|f| f(name).into_iter().collect())) { + Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Ok(Value::Null), + }, + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + let r = app.session.execute("keyword.export", &json!({"path": path})).map_err(|e| e.to_string()); + if let Err(e) = &r { + app.toast(ctx, e.clone()); + } + if let Ok(v) = &r { + let n = v["keywords"].as_u64().unwrap_or(0); + let mut msg = crate::i18n::tr_format!("Exported {n} keyword{}", if n == 1 { "" } else { "s" }, n = n); + // Capture One's importer refuses ; , < > in a list: say which to rename first + let refuses: Vec<&str> = v["captureOneRefuses"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + if !refuses.is_empty() { + msg.push_str(&crate::i18n::tr_format!( + " — Capture One won't import a list with ; , < or > in a keyword: {names}", + names = refuses.iter().take(5).copied().collect::>().join(" · ") + )); + } + // what the format can't hold (a name in brackets, a line break…) isn't in the file + let left: Vec<&str> = v["unwritable"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + if !left.is_empty() { + msg.push_str(&crate::i18n::tr_format!( + " — left out, as a keyword list can't hold them: {names}", + // escaped: a line break in a name would break the toast + names = left.iter().take(5).map(|n| n.escape_debug().to_string()).collect::>().join(" · ") + )); + } + app.toast(ctx, msg); + } + r +} + +/// The `file.exportMaskEffects` command. +fn file_export_mask_effects_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let name = format!("{}.lceffect", p.get("group").and_then(Value::as_str).unwrap_or("Effect Presets")); + let req = PickRequest::save( + crate::i18n::tr("Export Effect Presets"), + crate::i18n::tr("LightCraft Effect Presets"), + &["lceffect"], + name.clone(), + ); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + let Some(path) = path else { return Ok(Value::Null) }; + let mut params = json!({"path": path}); + for k in ["names", "group"] { + if let Some(v) = p.get(k) { + params[k] = v.clone(); + } + } + let r = app.session.execute("mask.exportEffects", ¶ms).map_err(|e| e.to_string()); + match &r { + Ok(v) => app.toast(ctx, crate::i18n::tr_format!("Exported {} effect preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })), + Err(e) => app.toast_error(ctx, e.clone()), + } + r +} + +/// The `file.importCurvePresets` command. +fn file_import_curve_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let paths: Vec = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + None => { + let req = PickRequest::files( + Some(crate::i18n::tr("Import Point Curve Presets").into()), + crate::i18n::tr("Point Curve Presets"), + crate::pick::CURVE_PRESET_EXTENSIONS, + ); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + if paths.is_empty() { + return Ok(Value::Null); + } + let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string()); + if let Ok(v) = &r { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + let mut msg = crate::i18n::tr_format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }, n = n); + if failed > 0 { + msg += &crate::i18n::tr_format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }, failed = failed); + } + app.toast(ctx, msg); + } + r +} + +/// The `file.exportCurvePresets` command. +fn file_export_curve_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = PickRequest::save( + crate::i18n::tr("Export Point Curve Presets"), + crate::i18n::tr("Point Curve Presets"), + &["lccurve"], + "Point Curves.lccurve", + ); + match crate::pick::ask(app, id, p, "path", req, |s| { + s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect()) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + let Some(path) = path else { return Ok(Value::Null) }; + let mut params = json!({"path": path}); + if let Some(n) = p.get("names") { + params["names"] = n.clone(); + } + let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string()); + if let Ok(v) = &r { + app.toast(ctx, crate::i18n::tr_format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); + } + r +} From 2f3002bc5cc3873bec404919876ea9f34df29a6d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:18:02 +0000 Subject: [PATCH 63/91] refactor(ui-egui): split preset import and find-missing commands --- crates/ui-egui/src/menus/run.rs | 337 ++++++++++++++++++-------------- 1 file changed, 187 insertions(+), 150 deletions(-) diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 52e6006eb..ed45cbcb4 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -1324,24 +1324,7 @@ fn ui_commands_11(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Va } "file.findMissing" => file_find_missing_command(app, id, p), "photo.tagFromTracklog" => photo_tag_from_tracklog_command(app, ctx, id, p), - "photo.locate" => { - let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) }; - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = - PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); - match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - } - } - }; - match path { - Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})), - None => Ok(Value::Null), - } - } + "photo.locate" => photo_locate_command(app, p), "file.addFromDevice" => { // a camera / card: review its DCIM folder, copying into the library by default let path = match p.get("path").and_then(Value::as_str) { @@ -1729,6 +1712,25 @@ fn find_missing_done(app: &mut LightcraftApp, ctx: &egui::Context, plan: Result< } } +/// The `photo.locate` command. +fn photo_locate_command(app: &mut LightcraftApp, p: &Value) -> Result { + let Some(id) = app.session.active() else { return Err("no photo selected".into()) }; + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); + match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; + match path { + Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})), + None => Ok(Value::Null), + } +} + /// The `file.findMissing` command. fn file_find_missing_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Result { let within = find_missing_within(p)?; @@ -1827,27 +1829,152 @@ fn photo_tag_from_tracklog_command(app: &mut LightcraftApp, _ctx: &egui::Context r } -/// The `file.importPresets` command. -fn file_import_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { - let paths = match p.get("paths").and_then(Value::as_array) { +/// The files a command names in `paths`, else the ones picked in a dialog. `Err` is the command's own answer when +/// there is nothing to import (the dialog is still open or was cancelled, or none is available). +fn paths_or_pick( + app: &mut LightcraftApp, + id: &str, + p: &Value, + request: PickRequest, + now: impl FnOnce(&mut crate::services::Services) -> Option>, +) -> Result, Result> { + let paths: Vec = match p.get("paths").and_then(Value::as_array) { Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Presets").into()), - crate::i18n::tr("Presets & Profiles"), - crate::pick::PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Ok(Value::Null), - Picked::Unavailable => return Err("no file dialog on this platform".into()), - } - } + None => match crate::pick::ask(app, id, p, "paths", request, now) { + Picked::Now(paths) => paths, + Picked::Later => return Err(Ok(Value::Null)), + Picked::Unavailable => return Err(Err("no file dialog on this platform".into())), + }, }; if paths.is_empty() { - return Ok(Value::Null); + return Err(Ok(Value::Null)); } - // .cube LUTs (and those inside zips / folders) become profiles + Ok(paths) +} + +/// What an import of presets and profiles has to say: one toast for both halves (a second toast would replace the first). +#[derive(Default)] +struct ImportNews { + parts: Vec, + /// `name: reason` for each file that couldn't be imported + problems: Vec, +} + +/// `name: reason` for each `[name, reason]` of a `failed` list. +fn failed_files(v: &Value) -> impl Iterator + '_ { + v["failed"].as_array().into_iter().flatten().map(|f| { + let name = f.get(0).and_then(Value::as_str).unwrap_or_default(); + let reason = f.get(1).and_then(Value::as_str).unwrap_or_default(); + format!("{name}: {reason}") + }) +} + +/// `s` for a count other than one. +fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + +/// Import the `.cube` LUTs (and those inside zips / folders) as profiles; the answer to report back. +fn import_luts(app: &mut LightcraftApp, lut_paths: &[&String], news: &mut ImportNews) -> Value { + match app.session.execute("profile.import", &json!({"paths": lut_paths})) { + Ok(v) => { + let profiles = v["imported"].as_array().map_or(0, Vec::len); + if profiles > 0 { + news.parts.push(crate::i18n::tr_format!( + "Imported {profiles} profile{} (Profile browser ▸ their groups)", + plural(profiles), + profiles = profiles + )); + } + news.problems.extend(failed_files(&v)); + v + } + Err(e) => { + news.problems.push(e.to_string()); + json!({"error": e.to_string()}) + } + } +} + +/// The settings of imported presets that have no counterpart here (the other editor's profiles, masks…), as a note. +fn not_carried_over(v: &Value) -> String { + let mut skipped: Vec<&str> = v["imported"] + .as_array() + .into_iter() + .flatten() + .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) + .filter_map(Value::as_str) + .collect(); + skipped.sort_unstable(); + skipped.dedup(); + if skipped.is_empty() { + return String::new(); + } + let names: Vec<&str> = skipped.iter().take(3).copied().collect(); + crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }) +} + +/// The sentence for the presets of an import (none when the other half already says what happened). +fn presets_sentence(v: &Value, profile_news: bool) -> Option { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + let msg = match (n, failed) { + // a zip of LUTs (or only local presets): the other half already says what happened + (0, 0) if profile_news => return None, + (0, 0) => "No new presets".to_string(), + (n, 0) => crate::i18n::tr_format!("Imported {n} preset{}", plural(n), n = n), + (n, f) => crate::i18n::tr_format!("Imported {n} preset{}, {f} file{} not readable", plural(n), plural(f), f = f, n = n), + }; + Some(msg + ¬_carried_over(v)) +} + +/// Import the preset files; adds to what `news` reports. +fn import_preset_files(app: &mut LightcraftApp, preset_paths: &[&String], news: &mut ImportNews) -> Result { + if preset_paths.is_empty() { + return Ok(json!({"imported": [], "failed": [], "skipped": 0})); + } + let r = app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()); + if let Ok(v) = &r { + // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast + if v["effects"].as_array().is_some_and(|e| !e.is_empty()) { + news.parts.push(crate::panels::mask_effects::import_toast(v)); + } + let profile_news = !news.parts.is_empty() || !news.problems.is_empty(); + news.parts.extend(presets_sentence(v, profile_news)); + if v["imported"].as_array().is_some_and(|i| !i.is_empty()) { + app.ui.presets = true; + } + } + r +} + +/// Show what an import did: errors if any file failed, else the parts. +fn toast_import_news(app: &mut LightcraftApp, ctx: &egui::Context, news: &ImportNews) { + let mut msg = news.parts.join(" · "); + if news.problems.is_empty() { + if !msg.is_empty() { + app.toast(ctx, msg); + } + return; + } + let more = if news.problems.len() > 2 { "…" } else { "" }; + let detail = format!("{}{more}", news.problems.iter().take(2).cloned().collect::>().join("; ")); + if msg.is_empty() { + msg = crate::i18n::tr_format!("Import failed: {e}", e = detail); + } else { + msg += &crate::i18n::tr_format!(" · {} failed: {}", news.problems.len(), detail); + } + app.toast_error(ctx, msg); +} + +/// The `file.importPresets` command. +fn file_import_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let request = + PickRequest::files(Some(crate::i18n::tr("Import Presets").into()), crate::i18n::tr("Presets & Profiles"), crate::pick::PRESET_EXTENSIONS); + let paths = match paths_or_pick(app, id, p, request, |s| s.pick_preset_files.as_mut().map(|f| f())) { + Ok(paths) => paths, + Err(answer) => return answer, + }; let lut_paths: Vec<&String> = paths .iter() .filter(|p| { @@ -1855,102 +1982,16 @@ fn file_import_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir() }) .collect(); - // one toast for both halves (a second toast would replace the first) - let mut parts: Vec = Vec::new(); - // `name: reason` for each file that couldn't be imported - let mut problems: Vec = Vec::new(); - let mut profiles_result = Value::Null; - if !lut_paths.is_empty() { - match app.session.execute("profile.import", &json!({"paths": lut_paths})) { - Ok(v) => { - let profiles = v["imported"].as_array().map_or(0, Vec::len); - if profiles > 0 { - parts.push(crate::i18n::tr_format!( - "Imported {profiles} profile{} (Profile browser ▸ their groups)", - if profiles == 1 { "" } else { "s" }, - profiles = profiles - )); - } - problems.extend(v["failed"].as_array().into_iter().flatten().map(|f| { - let name = f.get(0).and_then(Value::as_str).unwrap_or_default(); - let reason = f.get(1).and_then(Value::as_str).unwrap_or_default(); - format!("{name}: {reason}") - })); - profiles_result = v; - } - Err(e) => { - problems.push(e.to_string()); - profiles_result = json!({"error": e.to_string()}); - } - } - } + let mut news = ImportNews::default(); + let profiles = if lut_paths.is_empty() { Value::Null } else { import_luts(app, &lut_paths, &mut news) }; let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); - let r = if preset_paths.is_empty() { - Ok(json!({"imported": [], "failed": [], "skipped": 0})) - } else { - app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()) - }; - if let (false, Ok(v)) = (preset_paths.is_empty(), &r) { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast - let effects = v["effects"].as_array().map_or(0, Vec::len); - if effects > 0 { - parts.push(crate::panels::mask_effects::import_toast(v)); - } - let profile_news = !parts.is_empty() || !problems.is_empty(); - let msg = match (n, failed) { - // a zip of LUTs (or only local presets): the other half already says what happened - (0, 0) if profile_news => None, - (0, 0) => Some("No new presets".to_string()), - (n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)), - (n, f) => Some(crate::i18n::tr_format!( - "Imported {n} preset{}, {f} file{} not readable", - if n == 1 { "" } else { "s" }, - if f == 1 { "" } else { "s" }, - f = f, - n = n - )), - }; - if let Some(mut msg) = msg { - // settings with no counterpart here (the other editor's profiles, masks…) - let mut skipped: Vec<&str> = v["imported"] - .as_array() - .into_iter() - .flatten() - .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) - .filter_map(Value::as_str) - .collect(); - skipped.sort_unstable(); - skipped.dedup(); - if !skipped.is_empty() { - let names: Vec<&str> = skipped.iter().take(3).copied().collect(); - msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }); - } - parts.push(msg); - } - if n > 0 { - app.ui.presets = true; - } - } - let mut msg = parts.join(" · "); - if !problems.is_empty() { - let detail = format!("{}{}", problems.iter().take(2).cloned().collect::>().join("; "), if problems.len() > 2 { "…" } else { "" }); - if msg.is_empty() { - msg = crate::i18n::tr_format!("Import failed: {e}", e = detail); - } else { - msg += &crate::i18n::tr_format!(" · {} failed: {}", problems.len(), detail); - } - app.toast_error(ctx, msg); - } else if !msg.is_empty() { - app.toast(ctx, msg); - } - + let r = import_preset_files(app, &preset_paths, &mut news); + toast_import_news(app, ctx, &news); r.map(|mut v| { if !lut_paths.is_empty() && let Some(o) = v.as_object_mut() { - o.insert("profiles".into(), profiles_result); + o.insert("profiles".into(), profiles); } v }) @@ -2070,37 +2111,33 @@ fn file_export_mask_effects_command(app: &mut LightcraftApp, ctx: &egui::Context /// The `file.importCurvePresets` command. fn file_import_curve_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { - let paths: Vec = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Point Curve Presets").into()), - crate::i18n::tr("Point Curve Presets"), - crate::pick::CURVE_PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Ok(Value::Null), - Picked::Unavailable => return Err("no file dialog on this platform".into()), - } - } + let request = PickRequest::files( + Some(crate::i18n::tr("Import Point Curve Presets").into()), + crate::i18n::tr("Point Curve Presets"), + crate::pick::CURVE_PRESET_EXTENSIONS, + ); + let paths = match paths_or_pick(app, id, p, request, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { + Ok(paths) => paths, + Err(answer) => return answer, }; - if paths.is_empty() { - return Ok(Value::Null); - } let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string()); if let Ok(v) = &r { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - let mut msg = crate::i18n::tr_format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }, n = n); - if failed > 0 { - msg += &crate::i18n::tr_format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }, failed = failed); - } - app.toast(ctx, msg); + app.toast(ctx, curve_import_message(v)); } r } +/// What an import of point curve presets did. +fn curve_import_message(v: &Value) -> String { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + let imported = crate::i18n::tr_format!("Imported {n} point curve preset{}", plural(n), n = n); + if failed == 0 { + return imported; + } + imported + &crate::i18n::tr_format!(", {failed} file{} not readable", plural(failed), failed = failed) +} + /// The `file.exportCurvePresets` command. fn file_export_curve_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { let path = match p.get("path").and_then(Value::as_str) { From 63755f8a2e74c90b86011a514b4e8f5d23ad9b0e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:19:42 +0000 Subject: [PATCH 64/91] refactor(ui-egui): split the global shortcut dispatcher --- crates/ui-egui/src/shortcuts.rs | 607 ++++++++++++++++++-------------- 1 file changed, 340 insertions(+), 267 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c36bcec98..2f0b07d45 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -386,10 +386,22 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if keyboard_owned(app, ctx) || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() { return; } + let (fire, aliased) = pressed_commands(app, ctx); + for (id, params) in aliased { + fire_alias(app, ctx, id, params); + } + let rotate_fires = fire.iter().any(|f| f == "crop.rotateAspect"); + for f in fire { + fire_command(app, ctx, &f, rotate_fires); + } +} + +/// The keys pressed this frame: the commands to fire, and the aliases (command and params) to run. +fn pressed_commands(app: &LightcraftApp, ctx: &egui::Context) -> (Vec, Vec<(&'static str, serde_json::Value)>) { let mut fire: Vec = Vec::new(); // shortcuts the native menu bar handles (it consumes those key presses itself) let native = |sc: &str| app.native_shortcuts.contains(sc); - let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new(); + let mut aliased: Vec<(&'static str, serde_json::Value)> = Vec::new(); let keymap = &app.ui.settings.keymap; let grid = library_grid(app); // The grid navigation keys act on the photos in view, so they wait while the full-screen preview @@ -433,281 +445,342 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { aliased.push((id, serde_json::from_str(params).unwrap_or_default())); } } - // rating 0-5, colour labels 6-9 (with Shift: and advance) - for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() { - if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) && !taken.contains(&(Modifiers::NONE, *key)) { - fire.push(format!("rate:{n}:0")); - } - if matches(i, Modifiers::SHIFT, *key) && !taken.contains(&(Modifiers::SHIFT, *key)) { - fire.push(format!("rate:{n}:1")); - } - } - for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] { - if matches(i, Modifiers::NONE, key) && !native(sc) && !taken.contains(&(Modifiers::NONE, key)) { - fire.push(format!("label:{label}")); - } - } - if grid_navigation { - // Home / End: the first / last photo of the view - for (key, id) in [(Key::Home, "grid:first"), (Key::End, "grid:last")] { - if matches(i, Modifiers::NONE, key) && !taken.contains(&(Modifiers::NONE, key)) { - fire.push(id.to_string()); - } - } - // Shift+← / Shift+→: the previous / next photo of the view joins the selection - for (key, step) in [(Key::ArrowLeft, -1i64), (Key::ArrowRight, 1)] { - if matches(i, Modifiers::SHIFT, key) && !taken.contains(&(Modifiers::SHIFT, key)) { - fire.push(format!("grid:extend:{step}")); - } - } - } + fixed_photo_keys(i, &native, &taken, grid_navigation, &mut fire); }); - use crate::panels::compare; - // rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or - // Auto Advance then moves on (next candidate in Compare, next photo elsewhere) - let cull = |app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool| { - compare::target_active(app, &mut params); - let was = app.session.active(); - let ok = app.run(id, params).is_ok(); - if ok && (advance || app.ui.auto_advance) { - compare::advance(app, was); + (fire, aliased) +} + +/// The fixed photo keys pressed: ratings 0-5, colour labels 6-9 (with Shift: and advance), and in a grid the +/// navigation keys. Keys the user gave to a command (`taken`) and the native menu's (`native`) are left alone. +fn fixed_photo_keys(i: &egui::InputState, native: &dyn Fn(&str) -> bool, taken: &[(Modifiers, Key)], grid_navigation: bool, fire: &mut Vec) { + // rating 0-5, colour labels 6-9 (with Shift: and advance) + for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() { + if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) && !taken.contains(&(Modifiers::NONE, *key)) { + fire.push(format!("rate:{n}:0")); } - }; - for (id, params) in aliased { - // a slideshow: Space pauses / resumes - if id == "view.zoomToggle" && app.ui.slideshow.is_some() { - if let Ok(v) = app.run("slideshow.togglePause", json!({})) { - app.toast(ctx, crate::i18n::tr(if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" })); - } - 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") { - let mut params = params; - let advance = params.get("advance").and_then(serde_json::Value::as_bool).unwrap_or(false); - if let Some(o) = params.as_object_mut() { - o.remove("advance"); - } - cull(app, id, params, advance); - } else if let Err(e) = app.run(id, params) - && matches!(id, "app.export" | "app.exportPrevious") - { - // an export that can't start (e.g. no folder) says why instead of doing nothing - app.toast(ctx, e); + if matches(i, Modifiers::SHIFT, *key) && !taken.contains(&(Modifiers::SHIFT, *key)) { + fire.push(format!("rate:{n}:1")); } } - let rotate_fires = fire.iter().any(|f| f == "crop.rotateAspect"); - for f in fire { - // a slideshow: ←/→ step (a cut) - if app.ui.slideshow.is_some() && matches!(f.as_str(), "library.next" | "library.previous") { - let _ = app.run(if f == "library.next" { "slideshow.next" } else { "slideshow.previous" }, json!({})); - continue; + for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] { + if matches(i, Modifiers::NONE, key) && !native(sc) && !taken.contains(&(Modifiers::NONE, key)) { + fire.push(format!("label:{label}")); } - if let Some(rest) = f.strip_prefix("rate:") { - let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); - cull(app, "photo.rate", json!({"rating": n.parse::().unwrap_or(0)}), adv == "1"); - let label = if n == "0" { - crate::i18n::tr("Rating cleared").to_string() - } else { - crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) - }; - app.toast(ctx, label); - } else if matches!(f.as_str(), "photo.decreaseRating" | "photo.increaseRating") { - cull(app, &f, json!({}), false); - } else if let Some(l) = f.strip_prefix("label:") { - cull(app, "photo.label", json!({"label": l}), false); - } else if f == "panel.presets" && library_grid(app) { - // Classic's Shift+P picks and advances in Library; keep the Presets binding elsewhere. - cull(app, "photo.flag", json!({"flag": "pick"}), true); - } else if f == "view.softProof" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { - // S in a grid: expand / collapse the stack (Lightroom's Library binding) - let _ = app.run("stack.toggle", json!({})); - } else if f == "grid:first" || f == "grid:last" { - // Home / End in a grid: make the first / last photo of the view the active, selected one - let vis = app.session.visible_cloned(); - let edge = if f == "grid:first" { vis.first() } else { vis.last() }; - if let Some(id) = edge { - let _ = app.run("library.select", json!({"ids": [id.0], "active": id.0})); - } - } else if let Some(step) = f.strip_prefix("grid:extend:") { - // Shift+← / Shift+→ in a grid: the neighbour of the active photo joins the selection and - // becomes the active one. At either end of the view the key does nothing. - let step = step.parse::().unwrap_or(0); - let vis = app.session.visible_cloned(); - let neighbour = app - .session - .active() - .and_then(|active| vis.iter().position(|p| *p == active)) - .and_then(|pos| i64::try_from(pos).ok()) - .and_then(|pos| pos.checked_add(step)) - .and_then(|pos| usize::try_from(pos).ok()) - .and_then(|pos| vis.get(pos)); - if let Some(id) = neighbour { - let _ = app.run("library.select", json!({"ids": [id.0], "mode": "add"})); - } - } else if compare::culling(app) && (f == "library.next" || f == "library.previous") { - let d = if f == "library.next" { 1 } else { -1 }; - let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) }; - } else if matches!(f.as_str(), "photo.cycleFlag" | "photo.increaseFlag" | "photo.decreaseFlag") { - // `, ⌘↑, ⌘↓: step the flag; the toast names the active photo's new state (read before Auto Advance moves on) - let active = app.session.active(); - cull(app, &f, json!({}), false); - let flag = active.and_then(|id| app.session.catalog.photo(id)).map(|p| p.flag); - match flag { - Some(lightcraft_catalog::Flag::Pick) => app.toast(ctx, crate::i18n::tr("Flagged as Pick")), - Some(lightcraft_catalog::Flag::Reject) => app.toast(ctx, crate::i18n::tr("Flagged as Reject")), - Some(lightcraft_catalog::Flag::None) => app.toast(ctx, crate::i18n::tr("Unflagged")), - None => {} - } - } else if matches!(f.as_str(), "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop { - cull(app, &f, json!({}), false); - match f.as_str() { - "photo.pick" => app.toast(ctx, crate::i18n::tr("Flagged as Pick")), - "photo.reject" => app.toast(ctx, crate::i18n::tr("Flagged as Reject")), - _ => app.toast(ctx, crate::i18n::tr("Unflagged")), - } + } + if !grid_navigation { + return; + } + // Home / End: the first / last photo of the view + for (key, id) in [(Key::Home, "grid:first"), (Key::End, "grid:last")] { + if matches(i, Modifiers::NONE, key) && !taken.contains(&(Modifiers::NONE, key)) { + fire.push(id.to_string()); + } + } + // Shift+← / Shift+→: the previous / next photo of the view joins the selection + for (key, step) in [(Key::ArrowLeft, -1i64), (Key::ArrowRight, 1)] { + if matches(i, Modifiers::SHIFT, key) && !taken.contains(&(Modifiers::SHIFT, key)) { + fire.push(format!("grid:extend:{step}")); + } + } +} + +/// Rating/flag/label keys: in Compare/Survey they act on the active photo only; `advance` (Shift+key) or +/// Auto Advance then moves on (next candidate in Compare, next photo elsewhere). +fn cull(app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool) { + use crate::panels::compare; + compare::target_active(app, &mut params); + let was = app.session.active(); + let ok = app.run(id, params).is_ok(); + if ok && (advance || app.ui.auto_advance) { + compare::advance(app, was); + } +} + +/// Run a fixed alias (a second key for a command). +fn fire_alias(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, params: serde_json::Value) { + // a slideshow: Space pauses / resumes + if id == "view.zoomToggle" && app.ui.slideshow.is_some() { + if let Ok(v) = app.run("slideshow.togglePause", json!({})) { + app.toast(ctx, crate::i18n::tr(if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" })); + } + return; + } + // 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") { + let mut params = params; + let advance = params.get("advance").and_then(serde_json::Value::as_bool).unwrap_or(false); + if let Some(o) = params.as_object_mut() { + o.remove("advance"); + } + cull(app, id, params, advance); + } else if let Err(e) = app.run(id, params) + && matches!(id, "app.export" | "app.exportPrevious") + { + // an export that can't start (e.g. no folder) says why instead of doing nothing + app.toast(ctx, e); + } +} + +/// The toast naming a flag. +fn flag_toast(app: &mut LightcraftApp, ctx: &egui::Context, flag: lightcraft_catalog::Flag) { + use lightcraft_catalog::Flag; + app.toast( + ctx, + crate::i18n::tr(match flag { + Flag::Pick => "Flagged as Pick", + Flag::Reject => "Flagged as Reject", + Flag::None => "Unflagged", + }), + ); +} + +/// Run one fired command. +fn fire_command(app: &mut LightcraftApp, ctx: &egui::Context, f: &str, rotate_fires: bool) { + // a slideshow: ←/→ step (a cut) + if app.ui.slideshow.is_some() && matches!(f, "library.next" | "library.previous") { + let _ = app.run(if f == "library.next" { "slideshow.next" } else { "slideshow.previous" }, json!({})); + return; + } + if fire_rating_key(app, ctx, f) || fire_grid_key(app, f) || fire_flag_key(app, ctx, f) { + return; + } + // in the full-screen preview (no panels) I cycles the info overlay instead + if f == "panel.info" && app.ui.fullscreen { + let _ = app.run("view.infoOverlay", json!({})); + return; + } + if (f == "photo.delete" && delete_key(app, ctx)) || (f == "tool.brush" && brush_key(app, ctx)) || context_key_skipped(app, f, rotate_fires) { + return; + } + if matches!(f, "library.up" | "library.down") { + up_down_key(app, ctx, f); + return; + } + // an export that can't start (e.g. no folder) says why instead of doing nothing + if let Err(e) = app.run(f, json!({})) + && matches!(f, "app.export" | "app.exportPrevious") + { + app.toast(ctx, e); + } + match f { + "photo.pick" => flag_toast(app, ctx, lightcraft_catalog::Flag::Pick), + "photo.reject" => flag_toast(app, ctx, lightcraft_catalog::Flag::Reject), + "photo.unflag" => flag_toast(app, ctx, lightcraft_catalog::Flag::None), + "edit.undo" => app.toast(ctx, crate::i18n::tr("Undo")), + "edit.redo" => app.toast(ctx, crate::i18n::tr("Redo")), + _ => {} + } +} + +/// Rating and colour label keys; whether `f` was one. +fn fire_rating_key(app: &mut LightcraftApp, ctx: &egui::Context, f: &str) -> bool { + if let Some(rest) = f.strip_prefix("rate:") { + let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); + cull(app, "photo.rate", json!({"rating": n.parse::().unwrap_or(0)}), adv == "1"); + let label = if n == "0" { + crate::i18n::tr("Rating cleared").to_string() } else { - // in the full-screen preview (no panels) I cycles the info overlay instead - if f == "panel.info" && app.ui.fullscreen { - let _ = app.run("view.infoOverlay", json!({})); - continue; + crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) + }; + app.toast(ctx, label); + } else if matches!(f, "photo.decreaseRating" | "photo.increaseRating") { + cull(app, f, json!({}), false); + } else if let Some(l) = f.strip_prefix("label:") { + cull(app, "photo.label", json!({"label": l}), false); + } else { + return false; + } + true +} + +/// The keys of a grid or of Compare / Survey that move the active photo or the selection; whether `f` was one. +fn fire_grid_key(app: &mut LightcraftApp, f: &str) -> bool { + use crate::panels::compare; + if f == "panel.presets" && library_grid(app) { + // Classic's Shift+P picks and advances in Library; keep the Presets binding elsewhere. + cull(app, "photo.flag", json!({"flag": "pick"}), true); + } else if f == "view.softProof" && library_grid(app) { + // S in a grid: expand / collapse the stack (Lightroom's Library binding) + let _ = app.run("stack.toggle", json!({})); + } else if f == "grid:first" || f == "grid:last" { + // Home / End in a grid: make the first / last photo of the view the active, selected one + let vis = app.session.visible_cloned(); + let edge = if f == "grid:first" { vis.first() } else { vis.last() }; + if let Some(id) = edge { + let _ = app.run("library.select", json!({"ids": [id.0], "active": id.0})); + } + } else if let Some(step) = f.strip_prefix("grid:extend:") { + extend_selection(app, step.parse::().unwrap_or(0)); + } else if compare::culling(app) && (f == "library.next" || f == "library.previous") { + let d = if f == "library.next" { 1 } else { -1 }; + let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) }; + } else { + return false; + } + true +} + +/// Shift+← / Shift+→ in a grid: the neighbour of the active photo joins the selection and +/// becomes the active one. At either end of the view the key does nothing. +fn extend_selection(app: &mut LightcraftApp, step: i64) { + let vis = app.session.visible_cloned(); + let neighbour = app + .session + .active() + .and_then(|active| vis.iter().position(|p| *p == active)) + .and_then(|pos| i64::try_from(pos).ok()) + .and_then(|pos| pos.checked_add(step)) + .and_then(|pos| usize::try_from(pos).ok()) + .and_then(|pos| vis.get(pos)); + if let Some(id) = neighbour { + let _ = app.run("library.select", json!({"ids": [id.0], "mode": "add"})); + } +} + +/// The flag keys; whether `f` was one. +fn fire_flag_key(app: &mut LightcraftApp, ctx: &egui::Context, f: &str) -> bool { + if matches!(f, "photo.cycleFlag" | "photo.increaseFlag" | "photo.decreaseFlag") { + // `, ⌘↑, ⌘↓: step the flag; the toast names the active photo's new state (read before Auto Advance moves on) + let active = app.session.active(); + cull(app, f, json!({}), false); + if let Some(flag) = active.and_then(|id| app.session.catalog.photo(id)).map(|p| p.flag) { + flag_toast(app, ctx, flag); + } + true + } else if matches!(f, "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop { + cull(app, f, json!({}), false); + let flag = match f { + "photo.pick" => lightcraft_catalog::Flag::Pick, + "photo.reject" => lightcraft_catalog::Flag::Reject, + _ => lightcraft_catalog::Flag::None, + }; + flag_toast(app, ctx, flag); + true + } else { + false + } +} + +/// Delete acts on what's being edited: the active mask in the Masking panel; never the photo while retouching (spots +/// are removed from their own panel). Whether the key was used up. +fn delete_key(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + use crate::state::RightPanel as R; + match app.ui.right { + R::Masking => { + if app.session.active_mask.is_some() { + let _ = app.run("mask.delete", json!({})); } - // Delete acts on what's being edited: the active mask in the Masking panel; never the - // photo while retouching (spots are removed from their own panel). - if f == "photo.delete" { - use crate::state::RightPanel as R; - match app.ui.right { - R::Masking => { - if app.session.active_mask.is_some() { - let _ = app.run("mask.delete", json!({})); - } - continue; - } - R::Remove => { - if app.session.active_spot.is_some() { - let _ = app.run("spot.delete", json!({})); - } - continue; - } - R::Crop if app.ui.tool == "guidedUpright" => { - // Delete removes the selected guide alone; with none selected it removes - // nothing (the photo itself is never deleted while the tool is open) - if let (Some((photo, i)), Some(active)) = (app.ui.selected_guide, app.session.active()) { - if photo == active.0 - && let Some(d) = app.session.develop_of(active) - && i < d.geometry.guides.len() - { - let guides: Vec<[f64; 4]> = - d.geometry.guides.iter().enumerate().filter(|(k, _)| *k != i).map(|(_, (a, b))| [a.x, a.y, b.x, b.y]).collect(); - let _ = app.run("geometry.guides", json!({"guides": guides})); - } - app.ui.selected_guide = None; - } - continue; - } - R::RedEye => continue, - _ => {} - } - // in an album, Delete takes the photos out of the album only, as in Lightroom: they - // stay in the library and their folder (issue #1805) - if crate::menus::delete_in_album(app, ctx) { - continue; - } - if crate::menus::confirm_delete(app) { - continue; - } + return true; + } + R::Remove => { + if app.session.active_spot.is_some() { + let _ = app.run("spot.delete", json!({})); } - // B: the brush while editing; in the grids, add to the target album (Quick Collection) - if f == "tool.brush" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { - if let Ok(r) = app.run("album.toggleTarget", json!({})) { - let n = app.session.selected().len(); - let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); - let name = r["name"].as_str().unwrap_or("Quick Collection").to_string(); - app.toast( - ctx, - if r["added"] == true { - crate::i18n::tr_format!("Added {what} to {name}", name = name, what = what) - } else { - crate::i18n::tr_format!("Removed {what} from {name}", name = name, what = what) - }, - ); - } - continue; - } - // X is both reject (library) and swap crop aspect (crop tool). When both bindings fire, - // swap once: twice would put the crop straight back (issue #1866). - if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop { - if !rotate_fires { - let _ = app.run("crop.rotateAspect", json!({})); - } - continue; - } - if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop { - continue; - } - // A is both Visualize Spots and the crop aspect lock: each key only in its own context - if f == "view.visualizeSpots" && app.ui.right == crate::state::RightPanel::Crop { - continue; - } - if f == "crop.lockAspect" && app.ui.right != crate::state::RightPanel::Crop { - continue; - } - // / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere) - if f == "view.filmstrip" && app.ui.right == crate::state::RightPanel::Remove && app.session.active_spot.is_some() { - let _ = app.run("spot.refreshSource", json!({})); - continue; - } - // Shift+O cycles the mask overlay colour while masking (the crop overlay elsewhere) - if f == "view.cropOverlay" && app.ui.right == crate::state::RightPanel::Masking { - let _ = app.run("view.maskOverlayColor", json!({})); - continue; - } - // while cropping: O cycles the guides, Shift+O their orientation - if app.ui.right == crate::state::RightPanel::Crop { - let crop_key = match f.as_str() { - "view.maskOverlay" => Some(("view.cropOverlay", json!({}))), - "view.cropOverlay" => Some(("view.cropOverlayOrientation", json!({}))), - _ => None, - }; - if let Some((cmd, p)) = crop_key { - let _ = app.run(cmd, p); - continue; - } - } - // Up / Down in the grids go to the cell above / below (the filmstrip steps one photo) - if matches!(f.as_str(), "library.up" | "library.down") { - // over a slider the arrows nudge it (widgets::slider takes the key) - let over_slider = - ctx.pointer_hover_pos().is_some_and(|p| app.widgets.iter().any(|(id, r)| id.starts_with("slider:") && r.contains(p))); - if over_slider { - continue; - } - match crate::panels::grid::row_distance(app, f == "library.down") { - Some(0) => {} - Some(rows) => { - let _ = app.run(&f, json!({"rows": rows})); - } - None => { - let _ = app.run(&f, json!({})); - } - } - continue; - } - // an export that can't start (e.g. no folder) says why instead of doing nothing - if let Err(e) = app.run(&f, json!({})) - && matches!(f.as_str(), "app.export" | "app.exportPrevious") - { - app.toast(ctx, e); - } - match f.as_str() { - "photo.pick" => app.toast(ctx, crate::i18n::tr("Flagged as Pick")), - "photo.reject" => app.toast(ctx, crate::i18n::tr("Flagged as Reject")), - "photo.unflag" => app.toast(ctx, crate::i18n::tr("Unflagged")), - "edit.undo" => app.toast(ctx, crate::i18n::tr("Undo")), - "edit.redo" => app.toast(ctx, crate::i18n::tr("Redo")), - _ => {} + return true; + } + R::Crop if app.ui.tool == "guidedUpright" => { + delete_selected_guide(app); + return true; + } + R::RedEye => return true, + _ => {} + } + // in an album, Delete takes the photos out of the album only, as in Lightroom: they + // stay in the library and their folder (issue #1805) + crate::menus::delete_in_album(app, ctx) || crate::menus::confirm_delete(app) +} + +/// Delete removes the selected guide alone; with none selected it removes nothing (the photo itself is never deleted +/// while the tool is open). +fn delete_selected_guide(app: &mut LightcraftApp) { + let (Some((photo, i)), Some(active)) = (app.ui.selected_guide, app.session.active()) else { return }; + if photo == active.0 + && let Some(d) = app.session.develop_of(active) + && i < d.geometry.guides.len() + { + let guides: Vec<[f64; 4]> = d.geometry.guides.iter().enumerate().filter(|(k, _)| *k != i).map(|(_, (a, b))| [a.x, a.y, b.x, b.y]).collect(); + let _ = app.run("geometry.guides", json!({"guides": guides})); + } + app.ui.selected_guide = None; +} + +/// B: the brush while editing; in the grids, add to the target album (Quick Collection). Whether the key was used up. +fn brush_key(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + if !library_grid(app) { + return false; + } + if let Ok(r) = app.run("album.toggleTarget", json!({})) { + let n = app.session.selected().len(); + let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); + let name = r["name"].as_str().unwrap_or("Quick Collection").to_string(); + app.toast( + ctx, + if r["added"] == true { + crate::i18n::tr_format!("Added {what} to {name}", name = name, what = what) + } else { + crate::i18n::tr_format!("Removed {what} from {name}", name = name, what = what) + }, + ); + } + true +} + +/// Keys that two bindings share, and that mean something only in one panel: whether `f` was used up (or ignored) +/// here, so nothing else runs for it. +fn context_key_skipped(app: &mut LightcraftApp, f: &str, rotate_fires: bool) -> bool { + use crate::state::RightPanel as R; + let right = app.ui.right; + match f { + // X is both reject (library) and swap crop aspect (crop tool). When both bindings fire, + // swap once: twice would put the crop straight back (issue #1866). + "photo.reject" if right == R::Crop => { + if !rotate_fires { + let _ = app.run("crop.rotateAspect", json!({})); } + true + } + "crop.rotateAspect" => right != R::Crop, + // A is both Visualize Spots and the crop aspect lock: each key only in its own context + "view.visualizeSpots" => right == R::Crop, + "crop.lockAspect" => right != R::Crop, + // / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere) + "view.filmstrip" if right == R::Remove && app.session.active_spot.is_some() => { + let _ = app.run("spot.refreshSource", json!({})); + true + } + // Shift+O cycles the mask overlay colour while masking (the crop overlay elsewhere) + "view.cropOverlay" if right == R::Masking => { + let _ = app.run("view.maskOverlayColor", json!({})); + true + } + // while cropping: O cycles the guides, Shift+O their orientation + "view.maskOverlay" if right == R::Crop => { + let _ = app.run("view.cropOverlay", json!({})); + true + } + "view.cropOverlay" if right == R::Crop => { + let _ = app.run("view.cropOverlayOrientation", json!({})); + true + } + _ => false, + } +} + +/// Up / Down in the grids go to the cell above / below (the filmstrip steps one photo). +fn up_down_key(app: &mut LightcraftApp, ctx: &egui::Context, f: &str) { + // over a slider the arrows nudge it (widgets::slider takes the key) + let over_slider = ctx.pointer_hover_pos().is_some_and(|p| app.widgets.iter().any(|(id, r)| id.starts_with("slider:") && r.contains(p))); + if over_slider { + return; + } + match crate::panels::grid::row_distance(app, f == "library.down") { + Some(0) => {} + Some(rows) => { + let _ = app.run(f, json!({"rows": rows})); + } + None => { + let _ = app.run(f, json!({})); } } } From 627804c161ae0b001ac88f01194e6b6b7253d25c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:20:44 +0000 Subject: [PATCH 65/91] refactor(ui-egui): split import finish and print settings --- crates/ui-egui/src/dialogs/import/task.rs | 74 +++++++++++++---------- crates/ui-egui/src/dialogs/print_view.rs | 46 +++++++++----- 2 files changed, 73 insertions(+), 47 deletions(-) diff --git a/crates/ui-egui/src/dialogs/import/task.rs b/crates/ui-egui/src/dialogs/import/task.rs index c41a941a3..da2c1574f 100644 --- a/crates/ui-egui/src/dialogs/import/task.rs +++ b/crates/ui-egui/src/dialogs/import/task.rs @@ -422,33 +422,29 @@ fn xmp_warnings_text(warnings: &[ImportWarning]) -> String { ) } -/// The import is done (or cancelled): one undo step, show and select the first photo (unless the -/// user chose another meanwhile), say what happened. -fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { - let steps = app.session.undo.len().saturating_sub(task.undo0); - let plural = |n: usize| if n == 1 { "" } else { "s" }; - let label = if task.imported == 0 && task.restored > 0 { +/// `s` for a count other than one. +fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + +/// The name of the import's one undo step. +fn undo_label(task: &ImportTask) -> String { + if task.imported == 0 && task.restored > 0 { crate::i18n::tr_format!("Restore {} Photo{}", task.restored, plural(task.restored)) } else if task.restored > 0 { crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, plural(task.imported), task.restored) } else { - crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" }) - }; - app.session.merge_undo(steps, &label); - if task.auto { - if !task.warnings.is_empty() { - app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); - } else if task.imported > 0 { - app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" })); - } - return; + crate::i18n::tr_format!("Add {} Photo{}", task.imported, plural(task.imported)) } - if task.browse { - if !task.warnings.is_empty() { - app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); - } else if task.failed > 0 { - app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" })); - } +} + +/// The import is done (or cancelled): one undo step, show and select the first photo (unless the +/// user chose another meanwhile), say what happened. +fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { + let steps = app.session.undo.len().saturating_sub(task.undo0); + app.session.merge_undo(steps, &undo_label(&task)); + if task.auto || task.browse { + quiet_finish(app, ctx, &task); return; } if let Some(f) = task.first.filter(|_| !task.moved_on) { @@ -459,7 +455,6 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } let _ = app.run("library.select", json!({"ids": [f]})); } - let plural = |n: usize| if n == 1 { "" } else { "s" }; // nothing new, only files the library already has: show where they are (Recently Deleted is // easy to miss, and the side panel that lists it starts collapsed) if task.imported == 0 @@ -471,6 +466,29 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { { return; } + let mut msg = import_summary(app, &task); + if task.warnings.is_empty() { + app.toast(ctx, msg); + } else { + msg.push_str("\n\n"); + msg.push_str(&xmp_warnings_text(&task.warnings)); + app.toast_for(ctx, msg, 8.0); + } +} + +/// The end of an automatic import or of one that only browses: a toast only when there is something to warn about. +fn quiet_finish(app: &mut LightcraftApp, ctx: &egui::Context, task: &ImportTask) { + if !task.warnings.is_empty() { + app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); + } else if task.auto && task.imported > 0 { + app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, plural(task.imported))); + } else if !task.auto && task.failed > 0 { + app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, plural(task.failed))); + } +} + +/// What an import did, in a sentence. +fn import_summary(app: &LightcraftApp, task: &ImportTask) -> String { let mut msg = if task.cancelled { crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) } else if task.imported == 0 && task.restored > 0 { @@ -488,7 +506,7 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } } if task.duplicates > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); + msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, plural(task.duplicates))); let deleted = deleted_of(app, &task.existing).len(); if deleted > 0 { msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); @@ -497,13 +515,7 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { if task.failed > 0 { msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed)); } - if task.warnings.is_empty() { - app.toast(ctx, msg); - } else { - msg.push_str("\n\n"); - msg.push_str(&xmp_warnings_text(&task.warnings)); - app.toast_for(ctx, msg, 8.0); - } + msg } /// The photos among `ids` that are in Recently Deleted (each once). diff --git a/crates/ui-egui/src/dialogs/print_view.rs b/crates/ui-egui/src/dialogs/print_view.rs index 82a2b564d..c9af1d776 100644 --- a/crates/ui-egui/src/dialogs/print_view.rs +++ b/crates/ui-egui/src/dialogs/print_view.rs @@ -196,13 +196,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens) { app.ui.print = pu; } -fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, photos: usize) { +/// The print templates: save the settings as one, import, and the saved ones to apply, export and delete. +fn templates_section(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi) { let ctx = ui.ctx().clone(); - let l = &st.layout; - let unit = st.guides.units; - ui.spacing_mut().item_spacing.y = 5.0; - ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if photos == 1 { "" } else { "s" }, n = photos)).color(t.text_dim)); - heading(ui, t, "Templates"); ui.horizontal(|ui| { crate::text_field::TextField::singleline("field:printTemplateName", &mut pu.template_name) @@ -246,17 +242,13 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, }); }); } +} - heading(ui, t, "Layout Style"); - let styles = - [(Style::SingleImage, "Single"), (Style::ContactSheet, "Contact"), (Style::PicturePackage, "Package"), (Style::CustomPackage, "Custom")]; - ui.horizontal_wrapped(|ui| { - if let Some(s) = choice(ui, "printStyle", &styles, l.style) { - patch(app, &ctx, json!({"layout": {"style": s}})); - pu.page = 0; - } - }); - +/// The image settings: zoom, rotation, border and stroke. +fn image_section(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, st: &PrintSettings) { + let ctx = ui.ctx().clone(); + let l = &st.layout; + let unit = st.guides.units; heading(ui, t, "Image Settings"); let im = &st.image; if let Some(v) = check(ui, "printZoomFill", "Zoom to Fill Frame", im.zoom_to_fill) { @@ -292,6 +284,28 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, patch(app, &ctx, json!({"image": {"strokeColor": c}})); } }); +} + +fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, photos: usize) { + let ctx = ui.ctx().clone(); + let l = &st.layout; + let unit = st.guides.units; + ui.spacing_mut().item_spacing.y = 5.0; + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if photos == 1 { "" } else { "s" }, n = photos)).color(t.text_dim)); + + templates_section(app, ui, t, pu); + + heading(ui, t, "Layout Style"); + let styles = + [(Style::SingleImage, "Single"), (Style::ContactSheet, "Contact"), (Style::PicturePackage, "Package"), (Style::CustomPackage, "Custom")]; + ui.horizontal_wrapped(|ui| { + if let Some(s) = choice(ui, "printStyle", &styles, l.style) { + patch(app, &ctx, json!({"layout": {"style": s}})); + pu.page = 0; + } + }); + + image_section(app, ui, t, st); heading(ui, t, "Layout"); match l.style { From 335defee474b9563f3b19ac630e5b7104d2832ec Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:25:31 +0000 Subject: [PATCH 66/91] refactor(ui-egui): one function per dialog body --- crates/ui-egui/src/dialogs.rs | 2309 ++++++++++++++++++--------------- 1 file changed, 1266 insertions(+), 1043 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 772303ffb..1e5ae5e2d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -220,1049 +220,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { ui.disable(); } ui.spacing_mut().item_spacing.y = 8.0; - body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| match &mut dlg { - Dialog::AutoStack { gap } => { - ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); - let r = ui.add( - egui::Slider::new(gap, 0.0..=86400.0) - .logarithmic(true) - .smallest_positive(1.0) - .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) - .custom_parser(crate::panels::dialogs::parse_gap), - ); - crate::widgets::named(r, crate::i18n::tr("Stack photos taken within this time of each other:")); - let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default(); - let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" }; - ui.label( - egui::RichText::new(crate::i18n::tr_format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"], scope = crate::i18n::tr(scope))).color(t.text_dim), - ); - } - Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => { - let n = app.session.selection.ids.len(); - let scope = if n > 1 { crate::i18n::tr_format!("the {n} selected photos", n = n) } else { crate::i18n::tr_format!("the {} photos in view", app.session.visible_cloned().len()) }; - ui.label(egui::RichText::new(crate::i18n::tr_format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).", scope = scope)).color(t.text_label)); - ui.add_space(6.0); - let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text(crate::i18n::tr("Reject below focus")).step_by(1.0)); - crate::widgets::register(ui, "field:cullReject", r.rect); - ui.label(egui::RichText::new(crate::i18n::tr(if *reject_below > 0.0 { "Blurry photos below the score are flagged as rejects." } else { "0: nothing is rejected." })).color(t.text_dim)); - let r = ui.checkbox(pick_best, crate::i18n::tr("Pick the sharpest photo of each burst")); - crate::widgets::register(ui, "check:cullPick", r.rect); - ui.add_space(6.0); - // the sliders drag over the usual range; their value fields take whatever - // `photo.analyze` accepts (a gap of 0 s to a day, a similarity of 0 to 1) - let r = ui.add( - egui::Slider::new(burst_gap, 1.0..=600.0) - .logarithmic(true) - .smallest_positive(1.0) - .clamping(egui::SliderClamping::Never) - .text(crate::i18n::tr("Burst: shots at most this far apart")) - .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) - .custom_parser(crate::panels::dialogs::parse_gap), - ); - crate::widgets::register(ui, "field:cullGap", r.rect); - let r = ui.add( - egui::Slider::new(similarity, 0.70..=0.99) - .clamping(egui::SliderClamping::Never) - .text(crate::i18n::tr("Burst: how alike (loose … strict)")) - // a typed 0.995 is kept (whole hundredths would round it to 1.00) - .step_by(0.001) - .min_decimals(2) - .max_decimals(3), - ); - crate::widgets::register(ui, "field:cullSimilar", r.rect); - clamp_cull_burst(burst_gap, similarity); - ui.label(egui::RichText::new(crate::i18n::tr("Lower it when a panning or moving burst splits into several groups.")).color(t.text_dim)); - ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim)); - } - Dialog::WhatsNew => { - egui::ScrollArea::vertical().max_height(list_height(ui, 460.0)).auto_shrink([false, true]).show(ui, |ui| { - for line in WHATS_NEW.lines() { - let l = line.trim_end(); - if let Some(h) = l.strip_prefix("### ") { - ui.add_space(6.0); - ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(12.5)).color(t.text)); - } else if let Some(h) = l.strip_prefix("## ") { - ui.add_space(8.0); - ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(14.0)).color(t.text)); - } else if l.starts_with("# ") || l.is_empty() { - } else if let Some(b) = l.strip_prefix("- ") { - ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(b).replace('`', ""))).color(t.text_label)); - } else { - ui.label(egui::RichText::new(crate::i18n::tr(l.trim()).replace('`', "")).color(t.text_label)); - } - } - }); - } - Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::model_dialog(app, ui, &t, info, accepted), - Dialog::FaceModel { info, accepted, .. } => crate::panels::faces::model_dialog(app, ui, &t, info, accepted), - Dialog::LightroomResult { summary, library, details, .. } => { - ui.label(summary.as_str()); - ui.add_space(8.0); - if let Some(library) = library { - ui.label(crate::i18n::tr("Destination library")); - ui.add(egui::Label::new(library.as_str()).wrap().selectable(true)); - ui.label(crate::i18n::tr("Lightroom catalogs import into the open library; no separate catalog folder is created.")); - } else { - ui.label(crate::i18n::tr("These photos are only in memory. Open a library on disk and import the catalog again to save them.")); - } - for detail in details.iter() { - ui.add(egui::Label::new(detail).wrap().selectable(true)); - } - } - 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(crate::i18n::tr(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(crate::i18n::tr("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 = crate::widgets::named(ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY)), crate::i18n::tr("Filter fields")); - crate::widgets::register(ui, "field:metadataSearch", r.rect); - let q = search.trim().to_lowercase(); - let keep = |n: &str, v: &str| q.is_empty() || n.to_lowercase().contains(&q) || v.to_lowercase().contains(&q); - let mut groups: Vec<(String, Vec<(String, String)>)> = Vec::new(); - for row in rows["exif"].as_array().into_iter().flatten() { - let (g, n, v) = (row["group"].as_str().unwrap_or(""), row["name"].as_str().unwrap_or(""), row["value"].as_str().unwrap_or("")); - if !keep(n, v) { - continue; - } - match groups.iter_mut().find(|(x, _)| x == g) { - Some((_, list)) => list.push((n.into(), v.into())), - None => groups.push((g.into(), vec![(n.into(), v.into())])), - } - } - let xmp: Vec<(String, String)> = rows["xmp"] - .as_array() - .into_iter() - .flatten() - .map(|r| (r["name"].as_str().unwrap_or("").to_string(), r["value"].as_str().unwrap_or("").to_string())) - .filter(|(n, v)| keep(n, v)) - .collect(); - if !xmp.is_empty() { - groups.push(("XMP".into(), xmp)); - } - egui::ScrollArea::vertical().max_height(list_height(ui, 440.0)).auto_shrink([false, true]).show(ui, |ui| { - if groups.is_empty() { - ui.label(egui::RichText::new(crate::i18n::tr(rows["note"].as_str().unwrap_or("No metadata found"))).color(t.text_dim)); - } - for (g, list) in &groups { - ui.add_space(6.0); - ui.label(egui::RichText::new(g).font(t.semibold(12.5)).color(t.text)); - egui::Grid::new(format!("meta-{g}")).num_columns(2).spacing([16.0, 3.0]).striped(true).show(ui, |ui| { - for (n, v) in list { - ui.label(egui::RichText::new(n).color(t.text_dim)); - ui.add(egui::Label::new(egui::RichText::new(v).color(t.text_label)).wrap()); - ui.end_row(); - } - }); - } - }); - } - Dialog::SmartRules { id, name, rules, .. } => { - let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)), crate::i18n::tr("Name")); - crate::widgets::register(ui, "field:smartName", r.rect); - ui.add_space(6.0); - // the folder an album made from a folder view carries is not in the editor, but it counts - let folder = id.and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id))).and_then(|a| a.smart.as_deref().and_then(|f| f.library_folder.clone())); - // problems, count and albums: cached until the catalog or the rules change (not per frame) - let now = (app.session.clock)(); - let view = app.caches.rules_view(&app.session.catalog, rules, *id, folder, &now); - let env = crate::panels::rules_editor::Env { problems: view.problems.clone(), today: now, albums: view.albums.clone() }; - egui::ScrollArea::vertical().max_height(list_height(ui, 360.0)).auto_shrink([false, true]).show(ui, |ui| { - crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); - }); - let n = view.count; - ui.add_space(4.0); - if view.problems.is_empty() { - ui.label( - egui::RichText::new(crate::i18n::tr_format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) - .color(t.text_dim), - ); - } else { - ui.label(egui::RichText::new(crate::i18n::tr("Fix the marked rules to save this album.")).color(t.caution)); - } - } - Dialog::NewSmartAlbum { name, .. } => { - if dialog_field(ui, "field:smartAlbumName", name, "Name") { - confirm = true; - } - let rules = app.session.view_rules(); - // counted when the library or the rules change (or, for "in the last…" rules, the minute), not every frame - let now = (app.session.clock)(); - let minute = if rules.depends_on_now() { now.get(..16).unwrap_or(&now) } else { "" }; - let n = app.caches.dialog_count(&app.session.catalog, ("smartAlbum", format!("{rules:?}"), minute), || { - lightcraft_catalog::rules::set_now(Some(now.clone())); - app.session.catalog.query(&rules, &Default::default()).len() - }); - ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label)); - ui.label( - egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) - .color(t.text_dim), - ); - } - Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => { - let n = app.session.selected().len(); - field(ui, "Change", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (i, (label, m)) in [("Set date & time", "set"), ("Shift by", "shift"), ("Time zone", "zone")].iter().enumerate() { - if crate::widgets::text_button(ui, &format!("captureMode-{i}"), label, mode == m).clicked() { - *mode = m.to_string(); - } - } - }); - let params = match mode.as_str() { - "set" => { - field(ui, "New time", |ui| { - let r = ui.add(egui::TextEdit::singleline(time).hint_text("2026-09-30 14:05:00").desired_width(f32::INFINITY)); - crate::widgets::register(ui, "field:captureTime", r.rect); - }); - json!({"time": time}) - } - "shift" => { - field(ui, "Shift", |ui| { - ui.add(egui::DragValue::new(days).suffix(" d")); - ui.add(egui::DragValue::new(hours).suffix(" h")); - ui.add(egui::DragValue::new(minutes).suffix(" min")); - }); - json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60}) - } - _ => { - field(ui, "Hours", |ui| ui.add(egui::DragValue::new(zone).range(-26.0..=26.0).speed(0.25).fixed_decimals(2))); - json!({"hours": zone}) - } - }; - // preview on the active photo (the others move by the same amount) - let active = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| (p.file_name.clone(), p.date().to_string())); - if let Some((name, cur)) = active { - let delta = match mode.as_str() { - "set" => lightcraft_catalog::dates::normalize_iso(time) - .and_then(|t| Some(lightcraft_catalog::dates::iso_seconds(&t)? - lightcraft_catalog::dates::iso_seconds(&cur)?)), - "shift" => params["shift"].as_i64(), - _ => Some((*zone as f64 * 3600.0).round() as i64), - }; - // the photo's own time is the problem (a Lightroom catalog's capture time - // is copied verbatim), not what was typed - let unreadable = lightcraft_catalog::dates::shift_iso(&cur, 0).is_none(); - let after = match (mode.as_str(), unreadable && n == 1) { - // alone, it simply gets the time typed (photo.setCaptureTime repairs it) - ("set", true) => lightcraft_catalog::dates::normalize_iso(time), - _ => delta.and_then(|d| lightcraft_catalog::dates::shift_iso(&cur, d)), - }; - ui.label( - egui::RichText::new(match after { - Some(a) => format!("{name}: {} → {}", cur.replace('T', " "), a.replace('T', " ")), - None if unreadable && !(mode == "set" && n == 1) => crate::i18n::tr("The active photo's capture time can't be read").to_string(), - None => "Enter a date as YYYY-MM-DD HH:MM:SS".into(), - }) - .color(t.text_label), - ); - } - if n > 1 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("All {n} selected photos move by the same amount.", n = n)).color(t.text_dim)); - } - } - Dialog::LabelNames { names, save_as } => { - names.resize(5, String::new()); - // start from a set - let sets = lightcraft_engine::cmd::manage::label_sets_json(&app.session); - field(ui, "Set", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for set in sets["sets"].as_array().into_iter().flatten() { - let name = set["name"].as_str().unwrap_or_default(); - if crate::widgets::text_button(ui, &format!("labelSet-{name}"), name, false).clicked() { - *names = set["names"].as_array().into_iter().flatten().map(|n| n.as_str().unwrap_or_default().to_string()).collect(); - } - } - }); - for (i, l) in lightcraft_catalog::ColorLabel::ALL.iter().enumerate() { - let colour = format!("{l:?}"); - field(ui, &colour, |ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); - ui.painter().circle_filled(r.center(), 6.0, crate::panels::grid::label_color(*l)); - let te = crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut names[i]).hint_text(colour.as_str()).desired_width(f32::INFINITY)), crate::i18n::tr(&colour)); - crate::widgets::register(ui, format!("field:labelName-{}", colour.to_lowercase()), te.rect); - }); - } - field(ui, "Save as set", |ui| { - let te = crate::widgets::named(ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY)), crate::i18n::tr("Save as set")); - crate::widgets::register(ui, "field:labelSetName", te.rect); - }); - ui.label( - egui::RichText::new(crate::i18n::tr("Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.")) - .color(t.text_dim), - ); - } - Dialog::Rename { template, start } => { - let n = app.session.selected().len(); - ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); - let template_id = egui::Id::unique("rename-template"); - let tags_open = field(ui, "Template", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let w = crate::import::tag_field_width(ui); - let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w)), crate::i18n::tr("Template")); - crate::widgets::register(ui, "field:renameTemplate", r.rect); - crate::import::tag_toggle(ui, "renameTemplate") - }); - if tags_open { - crate::import::tag_help(ui, "renameTemplate", template, template_id); - } - crate::import::unknown_tags_warning(ui, template); - field(ui, "Presets", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (i, (label, tpl)) in - [("Name", "{name}"), ("Date–Name", "{date}_{name}"), ("Title–Seq", "{title}-{seq:3}"), ("Custom–Seq", "Photo-{seq:3}")].iter().enumerate() - { - if crate::widgets::text_button(ui, &format!("renamePreset-{i}"), label, template == tpl).clicked() { - *template = tpl.to_string(); - } - } - }); - field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999))); - ui.label( - egui::RichText::new(crate::i18n::tr("Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…")) - .color(t.text_dim), - ); - let (preview, total) = rename_preview(app, ui.ctx(), template, *start as usize); - match &preview { - Some(rows) => { - egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| { - for pl in rows { - ui.label(egui::RichText::new(&pl.from).color(t.text_dim)); - let (arrow, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); - crate::icons::paint(ui.painter(), arrow, crate::icons::Icon::ChevronRight, t.text_dim); - ui.label(egui::RichText::new(&pl.to).color(t.text)); - ui.end_row(); - } - }); - } - None => { - ui.horizontal(|ui| { - crate::progress::spinner(ui, 12.0); - ui.label(egui::RichText::new(crate::i18n::tr("Checking names…")).color(t.text_dim)); - }); - } - } - let more = total.saturating_sub(RENAME_PREVIEW_ROWS); - if more > 0 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("… and {more} more", more = more)).color(t.text_dim)); - } - } - Dialog::RenamePerson { from, to, merge } => { - let submitted = dialog_field(ui, "field:personName", to, "Name"); - let plan = person_rename_plan(&app.session.catalog, from, to); - if !plan.needs_merge { - *merge = false; - } - if submitted && plan.block.is_none() && (!plan.needs_merge || *merge) { - confirm = true; - } - if let Some(why) = plan.block { - ui.label(egui::RichText::new(crate::i18n::tr(why)).color(t.caution)); - } else { - ui.label( - egui::RichText::new(crate::i18n::tr_format!( - "Renames {n} face{} across the library. The files are not changed.", - if plan.faces == 1 { "" } else { "s" }, - n = plan.faces - )) - .color(t.text_dim), - ); - } - if plan.needs_merge { - ui.label( - egui::RichText::new(crate::i18n::tr_format!("“{to}” is already a person. Combining them makes one person; Undo separates them again.", to = to.trim())) - .color(t.caution), - ); - let r = ui.checkbox(merge, crate::i18n::tr("Combine with the existing person")); - crate::widgets::register(ui, "check:personMerge", r.rect); - } - } - Dialog::RenameKeyword { from, to } => { - let n = app.caches.dialog_count(&app.session.catalog, ("renameKeyword", from.as_str()), || { - app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count() - }); - if dialog_field(ui, "field:keywordName", to, "New name") { - confirm = true; - } - ui.label( - egui::RichText::new(crate::i18n::tr_format!("Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.", - if n == 1 { "" } else { "s" }, from = from, n = n)) - .color(t.text_dim), - ); - } - Dialog::MergeKeywords { from, into } => { - ui.label(egui::RichText::new(crate::i18n::tr_format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::>().join(", "))).color(t.text_label)); - if dialog_field(ui, "field:keywordInto", into, "Keyword") { - confirm = true; - } - let options: Vec = app - .caches - .suggestions(&app.session.catalog, from, into, 12) - .iter() - .filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f))) - .cloned() - .collect(); - ui.horizontal_wrapped(|ui| { - for k in options { - if ui.add(egui::Button::new(egui::RichText::new(&k).color(t.text_label)).corner_radius(10.0)).clicked() { - *into = k; - } - } - }); - } - Dialog::TextPrompt { value, hint, .. } => { - if dialog_field(ui, "field:prompt", value, hint) { - confirm = true; - } - } - Dialog::NewAlbum { name, folder, include_selected, .. } => { - if dialog_field(ui, "field:albumName", name, "Name") { - confirm = true; - } - // as in Lightroom, the option is only there when something is selected - if !*folder && !app.session.selected().is_empty() { - ui.add_space(6.0); - let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos")); - crate::widgets::register(ui, "check:albumIncludeSelected", r.rect); - } - } - Dialog::RenameAlbum { name, .. } => { - if dialog_field(ui, "field:albumName", name, "Name") { - confirm = true; - } - } - Dialog::CreatePreset { name, group, groups } => { - crate::widgets::named(ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY)), crate::i18n::tr("Preset name")); - crate::widgets::named(ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY)), crate::i18n::tr("Group")); - ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim)); - group_checklist(ui, "presetInclude", groups); - } - Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), - Dialog::PasteSettings { groups } => { - let n = app.session.selected().len(); - ui.label( - egui::RichText::new(crate::i18n::tr_format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" }, n = n)) - .color(t.text_dim), - ); - group_checklist(ui, "pasteGroup", groups); - } - Dialog::SlideshowExport { settings } => slideshow_export_body(app, ui, settings), - Dialog::ContactSheet { options } => { - options.columns = options.columns.clamp(1, 8); - options.rows = options.rows.clamp(1, 10); - let n = app.session.selected().len(); - ui.label(crate::i18n::tr_format!("{n} selected photos · {pages} pages", n = n, pages = n.div_ceil(options.columns * options.rows))); - field(ui, "Paper", |ui| { - let c = crate::widgets::combo_box("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { - ui.selectable_value(&mut options.paper, "a4".into(), "A4"); - ui.selectable_value(&mut options.paper, "letter".into(), crate::i18n::tr("Letter")); - }); - crate::widgets::name_combo(&c.response, crate::i18n::tr("Paper")); - }); - ui.checkbox(&mut options.landscape, crate::i18n::tr("Landscape")); - field(ui, "Columns", |ui| { ui.add(egui::DragValue::new(&mut options.columns).range(1..=8)); }); - field(ui, "Rows", |ui| { ui.add(egui::DragValue::new(&mut options.rows).range(1..=10)); }); - ui.checkbox(&mut options.captions, crate::i18n::tr("Filename captions")); - ui.label(crate::i18n::tr("150 dpi · sRGB · current edits · fit without cropping")); - ui.label(crate::i18n::tr("Choose a PDF destination after confirming. Print the PDF from your viewer.")); - } - Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => { - use lightcraft_engine::export::{ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; - let n = app.session.selection.ids.len().max(1); - ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); - // Preset: load a built-in or saved set of options into the dialog - field(ui, "Preset", |ui| { - let mut chosen = None; - let c = crate::widgets::combo_box("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| { - let mut builtin = true; - for (p, b) in app.session.all_export_presets() { - if builtin && !b { - ui.separator(); - } - builtin = b; - if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() { - chosen = Some(p.name); - } - } - }); - crate::widgets::name_combo(&c.response, crate::i18n::tr("Preset")); - crate::widgets::register(ui, "combo:exportPreset", c.response.rect); - if let Some(name) = chosen - && let Ok(params) = app.session.export_params(&json!({"preset": name})) - { - let o = lightcraft_engine::export::ExportOptions::from_json(¶ms); - *full_size = o.resize.is_none(); - *resize = o.resize.unwrap_or_default(); - *limit_kb = o.limit_kb.unwrap_or(0); - *opts = o; - } - }); - ui.add_space(4.0); - let before = opts.format; - // "Rendered DNG" is a DNG option (below), not a format of its own in this list - let mut shown = if opts.format == F::RenderedDng { F::Dng } else { opts.format }; - choices( - ui, - "Format", - "exportFormat", - &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Psd, "PSD"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Jxl, "JPEG XL"), (F::Dng, "DNG"), (F::Original, "Original files")], - &mut shown, - ); - if shown != opts.format && !(shown == F::Dng && opts.format == F::RenderedDng) { - opts.format = shown; - } - if matches!(opts.format, F::Dng | F::RenderedDng) { - choices(ui, "DNG of", "exportDngKind", &[(F::Dng, "Raw data"), (F::RenderedDng, "Rendered photo")], &mut opts.format); - } - if !opts.format.is_rendered() { - let note = if opts.format == F::Dng { - "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply." - } else { - "The original files, unchanged, each with an XMP sidecar holding its edits." - }; - ui.label(egui::RichText::new(note).color(t.text_dim)); - } - if opts.format != before { - // each format starts at its own default depth (TIFF 16-bit, others 8-bit) - opts.bit_depth = None; - } - let rendered = opts.format.is_rendered(); - let depths = lightcraft_engine::export::ExportOptions::bit_depths(opts.format); - if rendered && depths.len() > 1 { - let mut bd = opts.bit_depth.filter(|b| depths.iter().any(|d| d.0 == *b)).unwrap_or(depths[0].0); - choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd); - opts.bit_depth = Some(bd); - } - if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png | F::Jxl) { - let tip = crate::i18n::tr("Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float"); - ui.checkbox(&mut opts.hdr, crate::i18n::tr("HDR output")).on_hover_text(tip); - if opts.hdr && !opts.hdr_output() { - ui.label(egui::RichText::new(crate::i18n::tr("HDR TIFF needs 32-bit float.")).color(t.text_dim)); - } - if opts.hdr && opts.format == F::Avif { - use lightcraft_engine::export::HdrCurve as H; - choices(ui, "HDR curve", "exportHdrCurve", &[(H::Pq, "PQ"), (H::Hlg, "HLG")], &mut opts.hdr_transfer); - } - } - if !rendered { - } else if opts.format == F::Avif && opts.hdr { - let label = if opts.hdr_transfer == lightcraft_engine::export::HdrCurve::Hlg { - crate::i18n::tr("Color space: Rec. 2020 HLG (HDR AVIF)") - } else { - crate::i18n::tr("Color space: Rec. 2020 PQ (HDR AVIF)") - }; - ui.label(egui::RichText::new(label).color(t.text_dim)); - } else if opts.format == F::Png && opts.hdr { - ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)")).color(t.text_dim)) - .on_hover_text(crate::i18n::tr("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).")); - } else if opts.format == F::Jxl && opts.hdr { - ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ (HDR JPEG XL)")).color(t.text_dim)); - } else if opts.format == F::Avif { - ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim)); - } else { - use lightcraft_engine::export::OutputSpace as C; - choices( - ui, - "Color space", - "exportColorSpace", - &[ - (C::Srgb, "sRGB"), - (C::DisplayP3, "P3"), - (C::AdobeRgb, "Adobe RGB"), - (C::ProPhoto, "ProPhoto"), - (C::Rec2020, "Rec.2020"), - ], - &mut opts.color_space, - ); - if opts.effective_space() != opts.color_space { - let note = "Lossy JPEG XL writes Adobe RGB and ProPhoto as Rec. 2020; choose Lossless to keep them."; - ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); - } - } - if opts.format == F::Jxl { - let tip = "Mathematically lossless: every pixel is kept exactly (larger files)"; - ui.checkbox(&mut opts.jxl_lossless, crate::i18n::tr("Lossless")).on_hover_text(crate::i18n::tr(tip)); - } - if matches!(opts.format, F::Jpeg | F::Avif) || (opts.format == F::Jxl && !opts.jxl_lossless) { - let mut q = opts.quality as f64; - if num(ui, &QUALITY, &mut q) { - opts.quality = q as u8; - } - } - if opts.format == F::Jxl { - use lightcraft_engine::export::JxlEffort as E; - choices(ui, "Effort", "exportJxlEffort", &[(E::Fast, "Fast"), (E::Normal, "Normal")], &mut opts.jxl_effort); - let tip = "Shows a coarse version first and refines it as the file loads"; - ui.checkbox(&mut opts.jxl_progressive, crate::i18n::tr("Progressive")).on_hover_text(crate::i18n::tr(tip)); - } - if opts.format == F::Jpeg { - let mut k = *limit_kb as f64; - if num(ui, &LIMIT_KB, &mut k) { - *limit_kb = k as u32; - } - } - if rendered { - export_size(ui, full_size, resize, &mut opts.ppi); - } - if rendered { - choices( - ui, - "Sharpen", - "exportSharpen", - &[(S::None, "None"), (S::Screen, "Screen"), (S::Matte, "Matte"), (S::Glossy, "Glossy")], - &mut opts.sharpen, - ); - if opts.sharpen != S::None { - choices( - ui, - "Amount", - "exportSharpenAmount", - &[(A::Low, "Low"), (A::Standard, "Standard"), (A::High, "High")], - &mut opts.sharpen_amount, - ); - } - } - if rendered { - choices( - ui, - "Metadata", - "exportMetadata", - &[(M::All, "All"), (M::AllExceptCamera, "No camera"), (M::Copyright, "Copyright"), (M::None, "None")], - &mut opts.metadata, - ); - if !matches!(opts.metadata, M::None | M::Copyright) { - ui.checkbox(&mut opts.remove_location, crate::i18n::tr("Remove location info")); - } - let mut wm_on = opts.watermark.is_some(); - if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() { - opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); - } - let wm_shown = opts.watermark.clone(); - if let Some(wm) = &wm_shown { - watermark_preview(app, ui, wm, (!*full_size).then_some(*resize)); - } - if let Some(wm) = &mut opts.watermark { - // text, or a graphic (a logo with transparency) - let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::(egui::Id::unique("wm-graphic"))).unwrap_or(false); - let before = graphic; - choices(ui, "Style", "exportWmStyle", &[(false, "Text"), (true, "Graphic")], &mut graphic); - if graphic != before { - ui.data_mut(|d| d.insert_temp(egui::Id::unique("wm-graphic"), graphic)); - if !graphic { - wm.image.clear(); - } - } - if graphic { - field(ui, "Graphic", |ui| { - trailing_button_row(ui, |ui| { - if app.services.pick_files.is_some() - && crate::widgets::text_button(ui, "exportWmChoose", crate::i18n::tr("Choose…"), false).clicked() - && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) - { - wm.image = f; - } - crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())), crate::i18n::tr("Image")); - }); - }); - let mut width = wm.image_width as f64 * 100.0; - if num(ui, &WM_IMAGE_WIDTH, &mut width) { - wm.image_width = (width / 100.0) as f32; - } - } else { - choices(ui, app.ui.language.tr("Text direction"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], &mut wm.vertical); - field(ui, "Text", |ui| { - crate::widgets::named(ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text(crate::i18n::tr("© Your Name")).desired_width(f32::INFINITY)), crate::i18n::tr("Text")) - }); - } - choices( - ui, - "Position", - "exportWmAnchor", - &WM_POSITIONS, - &mut wm.anchor, - ); - choices(ui, "Rotate", "exportWmRotation", &[(0u16, "0°"), (90, "90°"), (180, "180°"), (270, "270°")], &mut wm.rotation); - let mut size = wm.size as f64 * 100.0; - if !graphic && num(ui, &WM_SIZE, &mut size) { - wm.size = (size / 100.0) as f32; - } - let mut op = wm.opacity as f64 * 100.0; - if num(ui, &WM_OPACITY, &mut op) { - wm.opacity = (op / 100.0) as f32; - } - // like Lightroom Classic: a margin from the side edges and one from the top and bottom - // edges, each unused (greyed) when the position is centred along that axis - use lightcraft_engine::export::Anchor as P; - let across = !matches!(wm.anchor, P::Top | P::Center | P::Bottom); - if let Some(v) = crate::widgets::slider(ui, &WM_INSET_X, wm.inset_x as f64 * 100.0, across, None).value { - wm.inset_x = (v / 100.0) as f32; - } - let down = !matches!(wm.anchor, P::Left | P::Center | P::Right); - if let Some(v) = crate::widgets::slider(ui, &WM_INSET_Y, wm.inset_y as f64 * 100.0, down, None).value { - wm.inset_y = (v / 100.0) as f32; - } - if !graphic { - ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); - } - } - if opts.watermark != wm_shown { - // the preview above was drawn with the old settings - ui.ctx().request_repaint(); - } - } - ui.add_space(4.0); - if opts.format == F::Tiff { - use lightcraft_engine::export::TiffCompression as Z; - choices(ui, "Compression", "exportTiffCompression", &[(Z::None, "None"), (Z::Lzw, "LZW"), (Z::Deflate, "ZIP")], &mut opts.tiff_compression); - } - if opts.format == F::Dng { - use lightcraft_engine::export::DngCompression as Z; - choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None"), (Z::Lossy, "Lossy")], &mut opts.dng_compression); - } - let naming_id = egui::Id::unique("export-naming"); - let tags_open = field(ui, "File name", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let w = crate::import::tag_field_width(ui); - crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w)), crate::i18n::tr("File name")); - crate::import::tag_toggle(ui, "exportNaming") - }); - if tags_open { - crate::import::tag_help(ui, "exportNaming", &mut opts.naming, naming_id); - } - crate::import::unknown_tags_warning(ui, &opts.naming); - if opts.naming.contains("{seq") { - let mut v = opts.start_number as f64; - if num(ui, &START_NUMBER, &mut v) { - opts.start_number = v as u32; - } - } - // like Lightroom Classic's Export To: one folder, or each photo's own folder (not on - // the web, where originals live in the browser's storage, not in folders) - use lightcraft_engine::export::DestinationMode as D; - if !cfg!(target_arch = "wasm32") || opts.destination_mode != D::SpecificFolder { - choices( - ui, - "Export to", - "exportDestinationMode", - &[(D::SpecificFolder, "Specific folder"), (D::SameAsOriginal, "Same folder as original photo")], - &mut opts.destination_mode, - ); - } - if opts.destination_mode == D::SpecificFolder { - field(ui, "Folder", |ui| { - trailing_button_row(ui, |ui| { - if app.services.pick_folder.is_some() - && crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked() - && let Some(pick) = app.services.pick_folder.as_mut() - && let Some(d) = pick() - { - *dir = d; - } - crate::widgets::named(ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())), crate::i18n::tr("Folder")); - }); - }); - } - field(ui, "Subfolder", |ui| { - crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Subfolder")) - }); - use lightcraft_engine::export::Conflict as K; - choices( - ui, - "If file exists", - "exportConflict", - &[(K::Unique, "Add number"), (K::Overwrite, "Overwrite"), (K::Skip, "Skip")], - &mut opts.conflict, - ); - // After export: Apple Photos imports the files written (macOS only) - #[cfg(target_os = "macos")] - { - let r = ui.checkbox(&mut opts.add_to_photos, crate::i18n::tr("Add to Apple Photos")); - crate::widgets::register(ui, "exportAddToPhotos", r.rect); - if opts.add_to_photos { - field(ui, "Album", |ui| { - crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Album")) - }); - ui.label( - egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos.")) - .size(11.0) - .color(t.text_dim), - ); - } - } - // nowhere to add them: a setting carried over from a Mac is dropped, not refused - #[cfg(not(target_os = "macos"))] - { - opts.add_to_photos = false; - } - ui.add_space(4.0); - field(ui, "Save preset", |ui| { - trailing_button_row(ui, |ui| { - let named = !preset_name.trim().is_empty(); - if crate::widgets::text_button(ui, "exportSavePreset", crate::i18n::tr("Save"), false).clicked() && named { - let params = export_dialog_params(opts, *full_size, resize, *limit_kb); - match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) { - Ok(_) => { - app.toast(ui.ctx(), crate::i18n::tr_format!("Saved export preset “{}”", preset_name.trim())); - preset_name.clear(); - } - Err(e) => app.toast(ui.ctx(), e), - } - } - crate::widgets::named(ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width())), crate::i18n::tr("Preset name")); - }); - }); - } - Dialog::Merge { opts } => crate::merge::body(app, ui, opts), - Dialog::Import { opts } => crate::import::body(app, ui, opts), - // (Settings is never taller than `body_max`, so the scroll area round it never - // scrolls: its tab bar stays put and its pages scroll by themselves) - Dialog::Settings { tab } => crate::panels::settings::window(app, ui, tab, body_max), - Dialog::SamModel { error, .. } => sam_model_body(app, ui, error.as_deref()), - Dialog::KeywordTag { - editing, - name, - parent, - inside, - synonyms, - include_on_export, - export_containing, - export_synonyms, - person, - add_to_selected, - .. - } => { - ui.label(egui::RichText::new(crate::i18n::tr("Keyword Name")).color(t.text_dim)); - // several fields: the name takes the focus when the dialog opens, not after - let r = crate::text_field::TextField::singleline("field:keywordTagName", name) - .hint(crate::i18n::tr("Keyword Name")) - .width(f32::INFINITY) - .select_on_focus(true) - .show(ui); - if ui.is_enabled() && just_opened(ui, "keyword-tag") { - r.response.request_focus(); - } - if r.ending == Some(crate::text_field::Ending::Return) { - confirm = true; - } - ui.add_space(4.0); - ui.label(egui::RichText::new(crate::i18n::tr("Synonyms")).color(t.text_dim)); - // Return in either field creates (or saves) the keyword - let r = crate::text_field::TextField::singleline("field:keywordSynonyms", synonyms) - .hint(crate::i18n::tr("Separated by commas")) - .width(f32::INFINITY) - .show(ui); - if r.ending == Some(crate::text_field::Ending::Return) { - confirm = true; - } - ui.add_space(6.0); - if editing.is_none() { - if let Some(p) = parent { - let r = ui.checkbox(inside, crate::i18n::tr_format!("Put inside “{parent}”", parent = p.replace('|', " › "))); - crate::widgets::register(ui, "check:keywordInside", r.rect); - } - let n = app.session.selection.ids.len(); - let r = ui.add_enabled(n > 0, egui::Checkbox::new(add_to_selected, crate::i18n::tr("Add to Selected Photos"))); - crate::widgets::register(ui, "check:keywordAddToSelected", r.rect); - ui.add_space(4.0); - } - ui.label(egui::RichText::new(crate::i18n::tr("Keyword Tag Options")).color(t.text_dim)); - for (on, label, id) in [ - (include_on_export, "Include on Export", "check:keywordIncludeOnExport"), - (export_containing, "Export Containing Keywords", "check:keywordExportContaining"), - (export_synonyms, "Export Synonyms", "check:keywordExportSynonyms"), - (person, "Person", "check:keywordPerson"), - ] { - let r = ui.checkbox(on, crate::i18n::tr(label)); - crate::widgets::register(ui, id, r.rect); - } - } - Dialog::KeywordSet { replaces, name, slots, as_new } => { - ui.label(egui::RichText::new(crate::i18n::tr("Set name")).color(t.text_dim)); - let r = crate::text_field::TextField::singleline("field:keywordSetName", name) - .hint(crate::i18n::tr("Set name")) - .width(f32::INFINITY) - .select_on_focus(true) - .show(ui); - if ui.is_enabled() && just_opened(ui, "keyword-set") { - r.response.request_focus(); - } - let mut returned = r.ending == Some(crate::text_field::Ending::Return); - ui.add_space(6.0); - ui.label(egui::RichText::new(crate::i18n::tr("Keywords (⌥1–⌥9)")).color(t.text_dim)); - slots.resize(9, String::new()); - // equal columns of a set width (the window is still finding its own) - let w = 130.0; - egui::Grid::new("keyword-set-slots").num_columns(3).min_col_width(w).spacing([6.0, 4.0]).show(ui, |ui| { - for (i, slot) in slots.iter_mut().enumerate() { - let r = crate::text_field::TextField::singleline(&format!("field:keywordSetSlot:{}", i + 1), slot) - .hint(format!("⌥{}", i + 1)) - .width(w) - .show(ui); - returned |= r.ending == Some(crate::text_field::Ending::Return); - if i % 3 == 2 { - ui.end_row(); - } - } - }); - if replaces.is_some() { - ui.add_space(6.0); - let r = ui.checkbox(as_new, crate::i18n::tr("Save as a new set")); - crate::widgets::register(ui, "check:keywordSetAsNew", r.rect); - } - // Return in any field saves the set - if returned { - confirm = true; - } - } - Dialog::MoveKeyword { keyword, parent } => { - let name = keyword.rsplit('|').next().unwrap_or(keyword); - match parent { - Some(p) => ui.label(crate::i18n::tr_format!( - "“{parent}” has a “{name}” already. Merge the two?", - parent = p.replace('|', " › "), - name = name - )), - None => ui.label(crate::i18n::tr_format!("There is a “{name}” at the top level already. Merge the two?", name = name)), - }; - ui.label(egui::RichText::new(crate::i18n::tr("Their photos and the keywords below them come together under one keyword.")).color(t.text_dim)); - } - Dialog::DeleteKeyword { keyword, count } => { - let name = keyword.replace('|', " › "); - ui.label(crate::i18n::tr_format!("Delete the keyword “{name}”?", name = name)); - ui.label( - egui::RichText::new(if *count == 0 { - crate::i18n::tr("No photo has it; the keywords below it go too.").to_string() - } else { - crate::i18n::tr_format!( - "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.", - if *count == 1 { "" } else { "s" }, - count = count - ) - }) - .color(t.text_dim), - ); - } - Dialog::SaveEffect { name, group, fields, amount, brush } => { - if crate::panels::mask_effects::save_body(app, ui, name, group, fields, amount, brush) { - confirm = true; - } - } - Dialog::ManageEffects { search, selected, rename, confirm_delete, new_group } => { - crate::panels::mask_effects::manage_body(app, ui, search, selected, rename, confirm_delete, new_group) - } - Dialog::DeleteEffects { names } => { - let what = match names.as_slice() { - [one] => crate::i18n::tr_format!("Delete the effect preset “{name}”?", name = one), - _ => crate::i18n::tr_format!("Delete these {n} effect presets?", n = names.len()), - }; - ui.label(what); - ui.label(egui::RichText::new(crate::i18n::tr("Masks keep their sliders. Export presets first to keep a copy.")).color(t.text_dim)); - } - Dialog::ConfirmDelete { count } => { - let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; - ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); - ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim)); - } - Dialog::MovePhotos { ids, name, dont_ask, .. } => { - let n = ids.len(); - ui.label(crate::i18n::tr_format!("Move {n} photo{} to “{}”?", if n == 1 { "" } else { "s" }, name, n = n)); - ui.label(egui::RichText::new(crate::i18n::tr("Their files and XMP sidecars move on disk. Edit → Undo moves them back.")).color(t.text_dim)); - ui.add_space(4.0); - let c = ui.checkbox(dont_ask, crate::i18n::tr("Don't ask again")); - crate::widgets::register(ui, "check:moveDontAsk", c.rect); - c.on_hover_text(crate::i18n::tr("Settings → Confirm before moving photos to another folder on disk turns it back on")); - } - d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d), - Dialog::LightroomCatalog { folder, catalog } => { - ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?")); - ui.label(egui::RichText::new(catalog.as_deref().unwrap_or(folder.as_str())).color(t.text_dim)); - // (the import then asks where the catalog goes, unless Settings says to merge, #2672) - let asks = app.ui.settings.lightroom_import == crate::state::LightroomImport::Ask - && app.session.library.as_ref().is_some_and(|l| l.on_disk); - let what = if asks { - "The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library." - } else { - "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now." - }; - ui.label(egui::RichText::new(crate::i18n::tr(what)).color(t.text_dim)); - } - d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::body(app, ui, d), - Dialog::RemoveFolder { name, count, path, disk } => { - ui.label(crate::i18n::tr_format!( - "Remove “{name}” and its {count} photo{} from the library?", - if *count == 1 { "" } else { "s" }, - name = name, - count = count - )); - ui.label(egui::RichText::new(path.as_str()).color(t.text_dim)); - let scope = if *disk { "All the photos imported from this disk are included." } else { "That includes the photos in the folders inside it." }; - ui.label(egui::RichText::new(crate::i18n::tr(scope)).color(t.text_dim)); - ui.label( - egui::RichText::new(crate::i18n::tr("They move to Recently Deleted and can be restored; no file on disk is touched.")) - .color(t.text_dim), - ); - } - Dialog::About => { - ui.set_min_width(680.0); - let tab_id = egui::Id::unique("about_tab"); - let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (i, (id, label)) in ABOUT_TABS.iter().enumerate() { - if crate::widgets::text_button(ui, &format!("aboutTab-{id}"), label, usize::from(tab) == i).clicked() { - tab = u8::try_from(i).unwrap_or(0); - } - } - }); - ui.data_mut(|d| d.insert_temp(tab_id, tab)); - ui.separator(); - match tab { - 1 => crate::credits::contributors_ui(app, ui), - 2 => crate::credits::models_ui(ui), - _ => { - ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); - ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); - ui.label(crate::i18n::tr_format!( - "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", - crate::theme::font_credits(app.chinese_font.is_some()) - )); - ui.add_space(10.0); - let r = crate::button::Button::new("Join the ArtCraft Discord") - .primary() - .large() - .min_width(260.0) - .widget("button:aboutDiscord") - .tooltip(crate::links::DISCORD) - .show(ui); - if r.clicked() { - let _ = crate::links::open(app, crate::links::DISCORD); - } - ui.add_space(6.0); - for (label, url) in [ - ("LightCraft website", crate::links::APP_PAGE), - ("Source code on GitHub", crate::links::GITHUB), - ("ArtCraft — more creative apps", crate::links::WEBSITE), - ] { - let r = ui.link(crate::i18n::tr(label)).on_hover_text(url); - if r.clicked() { - let _ = crate::links::open(app, url); - } - } - } - } - } - Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), - Dialog::ViewOptions => crate::panels::settings::view_options(app, ui, &t), - Dialog::Print => crate::print_view::body(app, ui, &t), - }); + body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| dialog_content(app, ui, &t, body_max, &mut confirm, &mut dlg)); ui.add_space(4.0); // the confirm on the right with Cancel to its left (macOS and GNOME's order), filled with // the accent when it is the dialog's primary action. The row spans the width the window @@ -1495,3 +453,1268 @@ const BACKDROP_SHOWN: &str = "dialog-backdrop-shown"; pub fn keeps_open(app: &LightcraftApp, dlg: &Dialog) -> bool { matches!(dlg, Dialog::SamModel { .. }) && !app.session.segmenter.installed() } + +/// The body of the AutoStack dialog. +fn auto_stack_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::AutoStack { gap } = dlg else { return }; + ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); + let r = ui.add( + egui::Slider::new(gap, 0.0..=86400.0) + .logarithmic(true) + .smallest_positive(1.0) + .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) + .custom_parser(crate::panels::dialogs::parse_gap), + ); + crate::widgets::named(r, crate::i18n::tr("Stack photos taken within this time of each other:")); + let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default(); + let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" }; + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Creates {} stacks from {} of {scope}", + preview["stacks"], + preview["photos"], + scope = crate::i18n::tr(scope) + )) + .color(t.text_dim), + ); +} + +/// The body of the Cull dialog. +fn cull_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::Cull { reject_below, pick_best, burst_gap, similarity } = dlg else { return }; + let n = app.session.selection.ids.len(); + let scope = if n > 1 { + crate::i18n::tr_format!("the {n} selected photos", n = n) + } else { + crate::i18n::tr_format!("the {} photos in view", app.session.visible_cloned().len()) + }; + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).", + scope = scope + )) + .color(t.text_label), + ); + ui.add_space(6.0); + let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text(crate::i18n::tr("Reject below focus")).step_by(1.0)); + crate::widgets::register(ui, "field:cullReject", r.rect); + ui.label( + egui::RichText::new(crate::i18n::tr(if *reject_below > 0.0 { + "Blurry photos below the score are flagged as rejects." + } else { + "0: nothing is rejected." + })) + .color(t.text_dim), + ); + let r = ui.checkbox(pick_best, crate::i18n::tr("Pick the sharpest photo of each burst")); + crate::widgets::register(ui, "check:cullPick", r.rect); + ui.add_space(6.0); + // the sliders drag over the usual range; their value fields take whatever + // `photo.analyze` accepts (a gap of 0 s to a day, a similarity of 0 to 1) + let r = ui.add( + egui::Slider::new(burst_gap, 1.0..=600.0) + .logarithmic(true) + .smallest_positive(1.0) + .clamping(egui::SliderClamping::Never) + .text(crate::i18n::tr("Burst: shots at most this far apart")) + .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) + .custom_parser(crate::panels::dialogs::parse_gap), + ); + crate::widgets::register(ui, "field:cullGap", r.rect); + let r = ui.add( + egui::Slider::new(similarity, 0.70..=0.99) + .clamping(egui::SliderClamping::Never) + .text(crate::i18n::tr("Burst: how alike (loose … strict)")) + // a typed 0.995 is kept (whole hundredths would round it to 1.00) + .step_by(0.001) + .min_decimals(2) + .max_decimals(3), + ); + crate::widgets::register(ui, "field:cullSimilar", r.rect); + clamp_cull_burst(burst_gap, similarity); + ui.label(egui::RichText::new(crate::i18n::tr("Lower it when a panning or moving burst splits into several groups.")).color(t.text_dim)); + ui.label( + egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")) + .color(t.text_dim), + ); +} + +/// The body of the WhatsNew dialog. +fn whats_new_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::WhatsNew = dlg else { return }; + egui::ScrollArea::vertical().max_height(list_height(ui, 460.0)).auto_shrink([false, true]).show(ui, |ui| { + for line in WHATS_NEW.lines() { + let l = line.trim_end(); + if let Some(h) = l.strip_prefix("### ") { + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(12.5)).color(t.text)); + } else if let Some(h) = l.strip_prefix("## ") { + ui.add_space(8.0); + ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(14.0)).color(t.text)); + } else if l.starts_with("# ") || l.is_empty() { + } else if let Some(b) = l.strip_prefix("- ") { + ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(b).replace('`', ""))).color(t.text_label)); + } else { + ui.label(egui::RichText::new(crate::i18n::tr(l.trim()).replace('`', "")).color(t.text_label)); + } + } + }); +} + +/// The body of the LightroomResult dialog. +fn lightroom_result_body(ui: &mut egui::Ui, dlg: &mut Dialog) { + let Dialog::LightroomResult { summary, library, details, .. } = dlg else { return }; + ui.label(summary.as_str()); + ui.add_space(8.0); + if let Some(library) = library { + ui.label(crate::i18n::tr("Destination library")); + ui.add(egui::Label::new(library.as_str()).wrap().selectable(true)); + ui.label(crate::i18n::tr("Lightroom catalogs import into the open library; no separate catalog folder is created.")); + } else { + ui.label(crate::i18n::tr("These photos are only in memory. Open a library on disk and import the catalog again to save them.")); + } + for detail in details.iter() { + ui.add(egui::Label::new(detail).wrap().selectable(true)); + } +} + +/// The body of the SystemInfo dialog. +fn system_info_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::SystemInfo { rows } = dlg else { return }; + 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(crate::i18n::tr(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(crate::i18n::tr("Copy to Clipboard")).clicked() { + let text: String = rows.iter().map(|(k, v)| format!("{k}: {v}\n")).collect(); + ui.ctx().copy_text(text); + } +} + +/// The body of the AllMetadata dialog. +fn all_metadata_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::AllMetadata { title, rows, search } = dlg else { return }; + ui.label(egui::RichText::new(title.as_str()).color(t.text_label)); + let r = crate::widgets::named( + ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY)), + crate::i18n::tr("Filter fields"), + ); + crate::widgets::register(ui, "field:metadataSearch", r.rect); + let q = search.trim().to_lowercase(); + let keep = |n: &str, v: &str| q.is_empty() || n.to_lowercase().contains(&q) || v.to_lowercase().contains(&q); + let mut groups: Vec<(String, Vec<(String, String)>)> = Vec::new(); + for row in rows["exif"].as_array().into_iter().flatten() { + let (g, n, v) = (row["group"].as_str().unwrap_or(""), row["name"].as_str().unwrap_or(""), row["value"].as_str().unwrap_or("")); + if !keep(n, v) { + continue; + } + match groups.iter_mut().find(|(x, _)| x == g) { + Some((_, list)) => list.push((n.into(), v.into())), + None => groups.push((g.into(), vec![(n.into(), v.into())])), + } + } + let xmp: Vec<(String, String)> = rows["xmp"] + .as_array() + .into_iter() + .flatten() + .map(|r| (r["name"].as_str().unwrap_or("").to_string(), r["value"].as_str().unwrap_or("").to_string())) + .filter(|(n, v)| keep(n, v)) + .collect(); + if !xmp.is_empty() { + groups.push(("XMP".into(), xmp)); + } + egui::ScrollArea::vertical().max_height(list_height(ui, 440.0)).auto_shrink([false, true]).show(ui, |ui| { + if groups.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr(rows["note"].as_str().unwrap_or("No metadata found"))).color(t.text_dim)); + } + for (g, list) in &groups { + ui.add_space(6.0); + ui.label(egui::RichText::new(g).font(t.semibold(12.5)).color(t.text)); + egui::Grid::new(format!("meta-{g}")).num_columns(2).spacing([16.0, 3.0]).striped(true).show(ui, |ui| { + for (n, v) in list { + ui.label(egui::RichText::new(n).color(t.text_dim)); + ui.add(egui::Label::new(egui::RichText::new(v).color(t.text_label)).wrap()); + ui.end_row(); + } + }); + } + }); +} + +/// The body of the SmartRules dialog. +fn smart_rules_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::SmartRules { id, name, rules, .. } = dlg else { return }; + let r = crate::widgets::named( + ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)), + crate::i18n::tr("Name"), + ); + crate::widgets::register(ui, "field:smartName", r.rect); + ui.add_space(6.0); + // the folder an album made from a folder view carries is not in the editor, but it counts + let folder = id + .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id))) + .and_then(|a| a.smart.as_deref().and_then(|f| f.library_folder.clone())); + // problems, count and albums: cached until the catalog or the rules change (not per frame) + let now = (app.session.clock)(); + let view = app.caches.rules_view(&app.session.catalog, rules, *id, folder, &now); + let env = crate::panels::rules_editor::Env { problems: view.problems.clone(), today: now, albums: view.albums.clone() }; + egui::ScrollArea::vertical().max_height(list_height(ui, 360.0)).auto_shrink([false, true]).show(ui, |ui| { + crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); + }); + let n = view.count; + ui.add_space(4.0); + if view.problems.is_empty() { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "{n} photo{} match · updates automatically as photos change", + if n == 1 { "" } else { "s" }, + n = n + )) + .color(t.text_dim), + ); + } else { + ui.label(egui::RichText::new(crate::i18n::tr("Fix the marked rules to save this album.")).color(t.caution)); + } +} + +/// The body of the NewSmartAlbum dialog. +fn new_smart_album_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::NewSmartAlbum { name, .. } = dlg else { return }; + if dialog_field(ui, "field:smartAlbumName", name, "Name") { + *confirm = true; + } + let rules = app.session.view_rules(); + // counted when the library or the rules change (or, for "in the last…" rules, the minute), not every frame + let now = (app.session.clock)(); + let minute = if rules.depends_on_now() { now.get(..16).unwrap_or(&now) } else { "" }; + let n = app.caches.dialog_count(&app.session.catalog, ("smartAlbum", format!("{rules:?}"), minute), || { + lightcraft_catalog::rules::set_now(Some(now.clone())); + app.session.catalog.query(&rules, &Default::default()).len() + }); + ui.label( + egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label), + ); + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "{n} photo{} now · updates automatically as photos change", + if n == 1 { "" } else { "s" }, + n = n + )) + .color(t.text_dim), + ); +} + +/// The body of the CaptureTime dialog. +fn capture_time_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::CaptureTime { mode, time, days, hours, minutes, zone } = dlg else { return }; + let n = app.session.selected().len(); + field(ui, "Change", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (label, m)) in [("Set date & time", "set"), ("Shift by", "shift"), ("Time zone", "zone")].iter().enumerate() { + if crate::widgets::text_button(ui, &format!("captureMode-{i}"), label, mode == m).clicked() { + *mode = m.to_string(); + } + } + }); + let params = match mode.as_str() { + "set" => { + field(ui, "New time", |ui| { + let r = ui.add(egui::TextEdit::singleline(time).hint_text("2026-09-30 14:05:00").desired_width(f32::INFINITY)); + crate::widgets::register(ui, "field:captureTime", r.rect); + }); + json!({"time": time}) + } + "shift" => { + field(ui, "Shift", |ui| { + ui.add(egui::DragValue::new(days).suffix(" d")); + ui.add(egui::DragValue::new(hours).suffix(" h")); + ui.add(egui::DragValue::new(minutes).suffix(" min")); + }); + json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60}) + } + _ => { + field(ui, "Hours", |ui| ui.add(egui::DragValue::new(zone).range(-26.0..=26.0).speed(0.25).fixed_decimals(2))); + json!({"hours": zone}) + } + }; + // preview on the active photo (the others move by the same amount) + let active = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| (p.file_name.clone(), p.date().to_string())); + if let Some((name, cur)) = active { + let delta = match mode.as_str() { + "set" => lightcraft_catalog::dates::normalize_iso(time) + .and_then(|t| Some(lightcraft_catalog::dates::iso_seconds(&t)? - lightcraft_catalog::dates::iso_seconds(&cur)?)), + "shift" => params["shift"].as_i64(), + _ => Some((*zone as f64 * 3600.0).round() as i64), + }; + // the photo's own time is the problem (a Lightroom catalog's capture time + // is copied verbatim), not what was typed + let unreadable = lightcraft_catalog::dates::shift_iso(&cur, 0).is_none(); + let after = match (mode.as_str(), unreadable && n == 1) { + // alone, it simply gets the time typed (photo.setCaptureTime repairs it) + ("set", true) => lightcraft_catalog::dates::normalize_iso(time), + _ => delta.and_then(|d| lightcraft_catalog::dates::shift_iso(&cur, d)), + }; + ui.label( + egui::RichText::new(match after { + Some(a) => format!("{name}: {} → {}", cur.replace('T', " "), a.replace('T', " ")), + None if unreadable && !(mode == "set" && n == 1) => crate::i18n::tr("The active photo's capture time can't be read").to_string(), + None => "Enter a date as YYYY-MM-DD HH:MM:SS".into(), + }) + .color(t.text_label), + ); + } + if n > 1 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("All {n} selected photos move by the same amount.", n = n)).color(t.text_dim)); + } +} + +/// The body of the LabelNames dialog. +fn label_names_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::LabelNames { names, save_as } = dlg else { return }; + names.resize(5, String::new()); + // start from a set + let sets = lightcraft_engine::cmd::manage::label_sets_json(&app.session); + field(ui, "Set", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for set in sets["sets"].as_array().into_iter().flatten() { + let name = set["name"].as_str().unwrap_or_default(); + if crate::widgets::text_button(ui, &format!("labelSet-{name}"), name, false).clicked() { + *names = set["names"].as_array().into_iter().flatten().map(|n| n.as_str().unwrap_or_default().to_string()).collect(); + } + } + }); + for (i, l) in lightcraft_catalog::ColorLabel::ALL.iter().enumerate() { + let colour = format!("{l:?}"); + field(ui, &colour, |ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); + ui.painter().circle_filled(r.center(), 6.0, crate::panels::grid::label_color(*l)); + let te = crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut names[i]).hint_text(colour.as_str()).desired_width(f32::INFINITY)), + crate::i18n::tr(&colour), + ); + crate::widgets::register(ui, format!("field:labelName-{}", colour.to_lowercase()), te.rect); + }); + } + field(ui, "Save as set", |ui| { + let te = crate::widgets::named( + ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY)), + crate::i18n::tr("Save as set"), + ); + crate::widgets::register(ui, "field:labelSetName", te.rect); + }); + ui.label( + egui::RichText::new(crate::i18n::tr( + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.", + )) + .color(t.text_dim), + ); +} + +/// The body of the Rename dialog. +fn rename_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::Rename { template, start } = dlg else { return }; + let n = app.session.selected().len(); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); + let template_id = egui::Id::unique("rename-template"); + let tags_open = field(ui, "Template", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = crate::import::tag_field_width(ui); + let r = crate::widgets::named( + ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w)), + crate::i18n::tr("Template"), + ); + crate::widgets::register(ui, "field:renameTemplate", r.rect); + crate::import::tag_toggle(ui, "renameTemplate") + }); + if tags_open { + crate::import::tag_help(ui, "renameTemplate", template, template_id); + } + crate::import::unknown_tags_warning(ui, template); + field(ui, "Presets", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (label, tpl)) in + [("Name", "{name}"), ("Date–Name", "{date}_{name}"), ("Title–Seq", "{title}-{seq:3}"), ("Custom–Seq", "Photo-{seq:3}")].iter().enumerate() + { + if crate::widgets::text_button(ui, &format!("renamePreset-{i}"), label, template == tpl).clicked() { + *template = tpl.to_string(); + } + } + }); + field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999))); + ui.label( + egui::RichText::new(crate::i18n::tr( + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…", + )) + .color(t.text_dim), + ); + let (preview, total) = rename_preview(app, ui.ctx(), template, *start as usize); + match &preview { + Some(rows) => { + egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| { + for pl in rows { + ui.label(egui::RichText::new(&pl.from).color(t.text_dim)); + let (arrow, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), arrow, crate::icons::Icon::ChevronRight, t.text_dim); + ui.label(egui::RichText::new(&pl.to).color(t.text)); + ui.end_row(); + } + }); + } + None => { + ui.horizontal(|ui| { + crate::progress::spinner(ui, 12.0); + ui.label(egui::RichText::new(crate::i18n::tr("Checking names…")).color(t.text_dim)); + }); + } + } + let more = total.saturating_sub(RENAME_PREVIEW_ROWS); + if more > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("… and {more} more", more = more)).color(t.text_dim)); + } +} + +/// The body of the RenamePerson dialog. +fn rename_person_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::RenamePerson { from, to, merge } = dlg else { return }; + let submitted = dialog_field(ui, "field:personName", to, "Name"); + let plan = person_rename_plan(&app.session.catalog, from, to); + if !plan.needs_merge { + *merge = false; + } + if submitted && plan.block.is_none() && (!plan.needs_merge || *merge) { + *confirm = true; + } + if let Some(why) = plan.block { + ui.label(egui::RichText::new(crate::i18n::tr(why)).color(t.caution)); + } else { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Renames {n} face{} across the library. The files are not changed.", + if plan.faces == 1 { "" } else { "s" }, + n = plan.faces + )) + .color(t.text_dim), + ); + } + if plan.needs_merge { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "“{to}” is already a person. Combining them makes one person; Undo separates them again.", + to = to.trim() + )) + .color(t.caution), + ); + let r = ui.checkbox(merge, crate::i18n::tr("Combine with the existing person")); + crate::widgets::register(ui, "check:personMerge", r.rect); + } +} + +/// The body of the RenameKeyword dialog. +fn rename_keyword_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::RenameKeyword { from, to } = dlg else { return }; + let n = app.caches.dialog_count(&app.session.catalog, ("renameKeyword", from.as_str()), || { + app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count() + }); + if dialog_field(ui, "field:keywordName", to, "New name") { + *confirm = true; + } + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.", + if n == 1 { "" } else { "s" }, + from = from, + n = n + )) + .color(t.text_dim), + ); +} + +/// The body of the MergeKeywords dialog. +fn merge_keywords_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::MergeKeywords { from, into } = dlg else { return }; + ui.label( + egui::RichText::new(crate::i18n::tr_format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::>().join(", "))) + .color(t.text_label), + ); + if dialog_field(ui, "field:keywordInto", into, "Keyword") { + *confirm = true; + } + let options: Vec = app + .caches + .suggestions(&app.session.catalog, from, into, 12) + .iter() + .filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f))) + .cloned() + .collect(); + ui.horizontal_wrapped(|ui| { + for k in options { + if ui.add(egui::Button::new(egui::RichText::new(&k).color(t.text_label)).corner_radius(10.0)).clicked() { + *into = k; + } + } + }); +} + +/// The body of the NewAlbum dialog. +fn new_album_body(app: &mut LightcraftApp, ui: &mut egui::Ui, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::NewAlbum { name, folder, include_selected, .. } = dlg else { return }; + if dialog_field(ui, "field:albumName", name, "Name") { + *confirm = true; + } + // as in Lightroom, the option is only there when something is selected + if !*folder && !app.session.selected().is_empty() { + ui.add_space(6.0); + let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos")); + crate::widgets::register(ui, "check:albumIncludeSelected", r.rect); + } +} + +/// The body of the PasteSettings dialog. +fn paste_settings_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::PasteSettings { groups } = dlg else { return }; + let n = app.session.selected().len(); + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Paste into {n} photo{} — only settings that were copied are pasted", + if n == 1 { "" } else { "s" }, + n = n + )) + .color(t.text_dim), + ); + group_checklist(ui, "pasteGroup", groups); +} + +/// The body of the ContactSheet dialog. +fn contact_sheet_body(app: &mut LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { + let Dialog::ContactSheet { options } = dlg else { return }; + options.columns = options.columns.clamp(1, 8); + options.rows = options.rows.clamp(1, 10); + let n = app.session.selected().len(); + ui.label(crate::i18n::tr_format!("{n} selected photos · {pages} pages", n = n, pages = n.div_ceil(options.columns * options.rows))); + field(ui, "Paper", |ui| { + let c = crate::widgets::combo_box("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { + ui.selectable_value(&mut options.paper, "a4".into(), "A4"); + ui.selectable_value(&mut options.paper, "letter".into(), crate::i18n::tr("Letter")); + }); + crate::widgets::name_combo(&c.response, crate::i18n::tr("Paper")); + }); + ui.checkbox(&mut options.landscape, crate::i18n::tr("Landscape")); + field(ui, "Columns", |ui| { + ui.add(egui::DragValue::new(&mut options.columns).range(1..=8)); + }); + field(ui, "Rows", |ui| { + ui.add(egui::DragValue::new(&mut options.rows).range(1..=10)); + }); + ui.checkbox(&mut options.captions, crate::i18n::tr("Filename captions")); + ui.label(crate::i18n::tr("150 dpi · sRGB · current edits · fit without cropping")); + ui.label(crate::i18n::tr("Choose a PDF destination after confirming. Print the PDF from your viewer.")); +} + +/// The body of the Export dialog. +fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } = dlg else { return }; + use lightcraft_engine::export::{ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; + let n = app.session.selection.ids.len().max(1); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); + // Preset: load a built-in or saved set of options into the dialog + field(ui, "Preset", |ui| { + let mut chosen = None; + let c = crate::widgets::combo_box("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| { + let mut builtin = true; + for (p, b) in app.session.all_export_presets() { + if builtin && !b { + ui.separator(); + } + builtin = b; + if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() { + chosen = Some(p.name); + } + } + }); + crate::widgets::name_combo(&c.response, crate::i18n::tr("Preset")); + crate::widgets::register(ui, "combo:exportPreset", c.response.rect); + if let Some(name) = chosen + && let Ok(params) = app.session.export_params(&json!({"preset": name})) + { + let o = lightcraft_engine::export::ExportOptions::from_json(¶ms); + *full_size = o.resize.is_none(); + *resize = o.resize.unwrap_or_default(); + *limit_kb = o.limit_kb.unwrap_or(0); + *opts = o; + } + }); + ui.add_space(4.0); + let before = opts.format; + // "Rendered DNG" is a DNG option (below), not a format of its own in this list + let mut shown = if opts.format == F::RenderedDng { F::Dng } else { opts.format }; + choices( + ui, + "Format", + "exportFormat", + &[ + (F::Jpeg, "JPEG"), + (F::Png, "PNG"), + (F::Tiff, "TIFF"), + (F::Psd, "PSD"), + (F::Webp, "WebP"), + (F::Avif, "AVIF"), + (F::Jxl, "JPEG XL"), + (F::Dng, "DNG"), + (F::Original, "Original files"), + ], + &mut shown, + ); + if shown != opts.format && !(shown == F::Dng && opts.format == F::RenderedDng) { + opts.format = shown; + } + if matches!(opts.format, F::Dng | F::RenderedDng) { + choices(ui, "DNG of", "exportDngKind", &[(F::Dng, "Raw data"), (F::RenderedDng, "Rendered photo")], &mut opts.format); + } + if !opts.format.is_rendered() { + let note = if opts.format == F::Dng { + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply." + } else { + "The original files, unchanged, each with an XMP sidecar holding its edits." + }; + ui.label(egui::RichText::new(note).color(t.text_dim)); + } + if opts.format != before { + // each format starts at its own default depth (TIFF 16-bit, others 8-bit) + opts.bit_depth = None; + } + let rendered = opts.format.is_rendered(); + let depths = lightcraft_engine::export::ExportOptions::bit_depths(opts.format); + if rendered && depths.len() > 1 { + let mut bd = opts.bit_depth.filter(|b| depths.iter().any(|d| d.0 == *b)).unwrap_or(depths[0].0); + choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd); + opts.bit_depth = Some(bd); + } + if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png | F::Jxl) { + let tip = crate::i18n::tr( + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float", + ); + ui.checkbox(&mut opts.hdr, crate::i18n::tr("HDR output")).on_hover_text(tip); + if opts.hdr && !opts.hdr_output() { + ui.label(egui::RichText::new(crate::i18n::tr("HDR TIFF needs 32-bit float.")).color(t.text_dim)); + } + if opts.hdr && opts.format == F::Avif { + use lightcraft_engine::export::HdrCurve as H; + choices(ui, "HDR curve", "exportHdrCurve", &[(H::Pq, "PQ"), (H::Hlg, "HLG")], &mut opts.hdr_transfer); + } + } + if !rendered { + } else if opts.format == F::Avif && opts.hdr { + let label = if opts.hdr_transfer == lightcraft_engine::export::HdrCurve::Hlg { + crate::i18n::tr("Color space: Rec. 2020 HLG (HDR AVIF)") + } else { + crate::i18n::tr("Color space: Rec. 2020 PQ (HDR AVIF)") + }; + ui.label(egui::RichText::new(label).color(t.text_dim)); + } else if opts.format == F::Png && opts.hdr { + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)")).color(t.text_dim)) + .on_hover_text(crate::i18n::tr("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).")); + } else if opts.format == F::Jxl && opts.hdr { + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ (HDR JPEG XL)")).color(t.text_dim)); + } else if opts.format == F::Avif { + ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim)); + } else { + use lightcraft_engine::export::OutputSpace as C; + choices( + ui, + "Color space", + "exportColorSpace", + &[(C::Srgb, "sRGB"), (C::DisplayP3, "P3"), (C::AdobeRgb, "Adobe RGB"), (C::ProPhoto, "ProPhoto"), (C::Rec2020, "Rec.2020")], + &mut opts.color_space, + ); + if opts.effective_space() != opts.color_space { + let note = "Lossy JPEG XL writes Adobe RGB and ProPhoto as Rec. 2020; choose Lossless to keep them."; + ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + } + } + if opts.format == F::Jxl { + let tip = "Mathematically lossless: every pixel is kept exactly (larger files)"; + ui.checkbox(&mut opts.jxl_lossless, crate::i18n::tr("Lossless")).on_hover_text(crate::i18n::tr(tip)); + } + if matches!(opts.format, F::Jpeg | F::Avif) || (opts.format == F::Jxl && !opts.jxl_lossless) { + let mut q = opts.quality as f64; + if num(ui, &QUALITY, &mut q) { + opts.quality = q as u8; + } + } + if opts.format == F::Jxl { + use lightcraft_engine::export::JxlEffort as E; + choices(ui, "Effort", "exportJxlEffort", &[(E::Fast, "Fast"), (E::Normal, "Normal")], &mut opts.jxl_effort); + let tip = "Shows a coarse version first and refines it as the file loads"; + ui.checkbox(&mut opts.jxl_progressive, crate::i18n::tr("Progressive")).on_hover_text(crate::i18n::tr(tip)); + } + if opts.format == F::Jpeg { + let mut k = *limit_kb as f64; + if num(ui, &LIMIT_KB, &mut k) { + *limit_kb = k as u32; + } + } + if rendered { + export_size(ui, full_size, resize, &mut opts.ppi); + } + if rendered { + choices( + ui, + "Sharpen", + "exportSharpen", + &[(S::None, "None"), (S::Screen, "Screen"), (S::Matte, "Matte"), (S::Glossy, "Glossy")], + &mut opts.sharpen, + ); + if opts.sharpen != S::None { + choices(ui, "Amount", "exportSharpenAmount", &[(A::Low, "Low"), (A::Standard, "Standard"), (A::High, "High")], &mut opts.sharpen_amount); + } + } + if rendered { + choices( + ui, + "Metadata", + "exportMetadata", + &[(M::All, "All"), (M::AllExceptCamera, "No camera"), (M::Copyright, "Copyright"), (M::None, "None")], + &mut opts.metadata, + ); + if !matches!(opts.metadata, M::None | M::Copyright) { + ui.checkbox(&mut opts.remove_location, crate::i18n::tr("Remove location info")); + } + let mut wm_on = opts.watermark.is_some(); + if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() { + opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); + } + let wm_shown = opts.watermark.clone(); + if let Some(wm) = &wm_shown { + watermark_preview(app, ui, wm, (!*full_size).then_some(*resize)); + } + if let Some(wm) = &mut opts.watermark { + // text, or a graphic (a logo with transparency) + let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::(egui::Id::unique("wm-graphic"))).unwrap_or(false); + let before = graphic; + choices(ui, "Style", "exportWmStyle", &[(false, "Text"), (true, "Graphic")], &mut graphic); + if graphic != before { + ui.data_mut(|d| d.insert_temp(egui::Id::unique("wm-graphic"), graphic)); + if !graphic { + wm.image.clear(); + } + } + if graphic { + field(ui, "Graphic", |ui| { + trailing_button_row(ui, |ui| { + if app.services.pick_files.is_some() + && crate::widgets::text_button(ui, "exportWmChoose", crate::i18n::tr("Choose…"), false).clicked() + && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) + { + wm.image = f; + } + crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())), + crate::i18n::tr("Image"), + ); + }); + }); + let mut width = wm.image_width as f64 * 100.0; + if num(ui, &WM_IMAGE_WIDTH, &mut width) { + wm.image_width = (width / 100.0) as f32; + } + } else { + choices( + ui, + app.ui.language.tr("Text direction"), + "exportWmOrientation", + &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], + &mut wm.vertical, + ); + field(ui, "Text", |ui| { + crate::widgets::named( + ui.add( + egui::TextEdit::multiline(&mut wm.text) + .desired_rows(2) + .hint_text(crate::i18n::tr("© Your Name")) + .desired_width(f32::INFINITY), + ), + crate::i18n::tr("Text"), + ) + }); + } + choices(ui, "Position", "exportWmAnchor", &WM_POSITIONS, &mut wm.anchor); + choices(ui, "Rotate", "exportWmRotation", &[(0u16, "0°"), (90, "90°"), (180, "180°"), (270, "270°")], &mut wm.rotation); + let mut size = wm.size as f64 * 100.0; + if !graphic && num(ui, &WM_SIZE, &mut size) { + wm.size = (size / 100.0) as f32; + } + let mut op = wm.opacity as f64 * 100.0; + if num(ui, &WM_OPACITY, &mut op) { + wm.opacity = (op / 100.0) as f32; + } + // like Lightroom Classic: a margin from the side edges and one from the top and bottom + // edges, each unused (greyed) when the position is centred along that axis + use lightcraft_engine::export::Anchor as P; + let across = !matches!(wm.anchor, P::Top | P::Center | P::Bottom); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_X, wm.inset_x as f64 * 100.0, across, None).value { + wm.inset_x = (v / 100.0) as f32; + } + let down = !matches!(wm.anchor, P::Left | P::Center | P::Right); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_Y, wm.inset_y as f64 * 100.0, down, None).value { + wm.inset_y = (v / 100.0) as f32; + } + if !graphic { + ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); + } + } + if opts.watermark != wm_shown { + // the preview above was drawn with the old settings + ui.ctx().request_repaint(); + } + } + ui.add_space(4.0); + if opts.format == F::Tiff { + use lightcraft_engine::export::TiffCompression as Z; + choices(ui, "Compression", "exportTiffCompression", &[(Z::None, "None"), (Z::Lzw, "LZW"), (Z::Deflate, "ZIP")], &mut opts.tiff_compression); + } + if opts.format == F::Dng { + use lightcraft_engine::export::DngCompression as Z; + choices( + ui, + "Compression", + "exportDngCompression", + &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None"), (Z::Lossy, "Lossy")], + &mut opts.dng_compression, + ); + } + let naming_id = egui::Id::unique("export-naming"); + let tags_open = field(ui, "File name", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = crate::import::tag_field_width(ui); + crate::widgets::named( + ui.add( + egui::TextEdit::singleline(&mut opts.naming) + .id(naming_id) + .hint_text("{name}-{seq} · {date} · {title} · {folder}") + .desired_width(w), + ), + crate::i18n::tr("File name"), + ); + crate::import::tag_toggle(ui, "exportNaming") + }); + if tags_open { + crate::import::tag_help(ui, "exportNaming", &mut opts.naming, naming_id); + } + crate::import::unknown_tags_warning(ui, &opts.naming); + if opts.naming.contains("{seq") { + let mut v = opts.start_number as f64; + if num(ui, &START_NUMBER, &mut v) { + opts.start_number = v as u32; + } + } + // like Lightroom Classic's Export To: one folder, or each photo's own folder (not on + // the web, where originals live in the browser's storage, not in folders) + use lightcraft_engine::export::DestinationMode as D; + if !cfg!(target_arch = "wasm32") || opts.destination_mode != D::SpecificFolder { + choices( + ui, + "Export to", + "exportDestinationMode", + &[(D::SpecificFolder, "Specific folder"), (D::SameAsOriginal, "Same folder as original photo")], + &mut opts.destination_mode, + ); + } + if opts.destination_mode == D::SpecificFolder { + field(ui, "Folder", |ui| { + trailing_button_row(ui, |ui| { + if app.services.pick_folder.is_some() + && crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked() + && let Some(pick) = app.services.pick_folder.as_mut() + && let Some(d) = pick() + { + *dir = d; + } + crate::widgets::named(ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())), crate::i18n::tr("Folder")); + }); + }); + } + field(ui, "Subfolder", |ui| { + crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), + crate::i18n::tr("Subfolder"), + ) + }); + use lightcraft_engine::export::Conflict as K; + choices(ui, "If file exists", "exportConflict", &[(K::Unique, "Add number"), (K::Overwrite, "Overwrite"), (K::Skip, "Skip")], &mut opts.conflict); + // After export: Apple Photos imports the files written (macOS only) + #[cfg(target_os = "macos")] + { + let r = ui.checkbox(&mut opts.add_to_photos, crate::i18n::tr("Add to Apple Photos")); + crate::widgets::register(ui, "exportAddToPhotos", r.rect); + if opts.add_to_photos { + field(ui, "Album", |ui| { + crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), + crate::i18n::tr("Album"), + ) + }); + ui.label( + egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos.")) + .size(11.0) + .color(t.text_dim), + ); + } + } + // nowhere to add them: a setting carried over from a Mac is dropped, not refused + #[cfg(not(target_os = "macos"))] + { + opts.add_to_photos = false; + } + ui.add_space(4.0); + field(ui, "Save preset", |ui| { + trailing_button_row(ui, |ui| { + let named = !preset_name.trim().is_empty(); + if crate::widgets::text_button(ui, "exportSavePreset", crate::i18n::tr("Save"), false).clicked() && named { + let params = export_dialog_params(opts, *full_size, resize, *limit_kb); + match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) { + Ok(_) => { + app.toast(ui.ctx(), crate::i18n::tr_format!("Saved export preset “{}”", preset_name.trim())); + preset_name.clear(); + } + Err(e) => app.toast(ui.ctx(), e), + } + } + crate::widgets::named( + ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width())), + crate::i18n::tr("Preset name"), + ); + }); + }); +} + +/// The body of the KeywordTag dialog. +fn keyword_tag_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::KeywordTag { + editing, + name, + parent, + inside, + synonyms, + include_on_export, + export_containing, + export_synonyms, + person, + add_to_selected, + .. + } = dlg + else { + return; + }; + ui.label(egui::RichText::new(crate::i18n::tr("Keyword Name")).color(t.text_dim)); + // several fields: the name takes the focus when the dialog opens, not after + let r = crate::text_field::TextField::singleline("field:keywordTagName", name) + .hint(crate::i18n::tr("Keyword Name")) + .width(f32::INFINITY) + .select_on_focus(true) + .show(ui); + if ui.is_enabled() && just_opened(ui, "keyword-tag") { + r.response.request_focus(); + } + if r.ending == Some(crate::text_field::Ending::Return) { + *confirm = true; + } + ui.add_space(4.0); + ui.label(egui::RichText::new(crate::i18n::tr("Synonyms")).color(t.text_dim)); + // Return in either field creates (or saves) the keyword + let r = crate::text_field::TextField::singleline("field:keywordSynonyms", synonyms) + .hint(crate::i18n::tr("Separated by commas")) + .width(f32::INFINITY) + .show(ui); + if r.ending == Some(crate::text_field::Ending::Return) { + *confirm = true; + } + ui.add_space(6.0); + if editing.is_none() { + if let Some(p) = parent { + let r = ui.checkbox(inside, crate::i18n::tr_format!("Put inside “{parent}”", parent = p.replace('|', " › "))); + crate::widgets::register(ui, "check:keywordInside", r.rect); + } + let n = app.session.selection.ids.len(); + let r = ui.add_enabled(n > 0, egui::Checkbox::new(add_to_selected, crate::i18n::tr("Add to Selected Photos"))); + crate::widgets::register(ui, "check:keywordAddToSelected", r.rect); + ui.add_space(4.0); + } + ui.label(egui::RichText::new(crate::i18n::tr("Keyword Tag Options")).color(t.text_dim)); + for (on, label, id) in [ + (include_on_export, "Include on Export", "check:keywordIncludeOnExport"), + (export_containing, "Export Containing Keywords", "check:keywordExportContaining"), + (export_synonyms, "Export Synonyms", "check:keywordExportSynonyms"), + (person, "Person", "check:keywordPerson"), + ] { + let r = ui.checkbox(on, crate::i18n::tr(label)); + crate::widgets::register(ui, id, r.rect); + } +} + +/// The body of the KeywordSet dialog. +fn keyword_set_body(ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::KeywordSet { replaces, name, slots, as_new } = dlg else { return }; + ui.label(egui::RichText::new(crate::i18n::tr("Set name")).color(t.text_dim)); + let r = crate::text_field::TextField::singleline("field:keywordSetName", name) + .hint(crate::i18n::tr("Set name")) + .width(f32::INFINITY) + .select_on_focus(true) + .show(ui); + if ui.is_enabled() && just_opened(ui, "keyword-set") { + r.response.request_focus(); + } + let mut returned = r.ending == Some(crate::text_field::Ending::Return); + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::tr("Keywords (⌥1–⌥9)")).color(t.text_dim)); + slots.resize(9, String::new()); + // equal columns of a set width (the window is still finding its own) + let w = 130.0; + egui::Grid::new("keyword-set-slots").num_columns(3).min_col_width(w).spacing([6.0, 4.0]).show(ui, |ui| { + for (i, slot) in slots.iter_mut().enumerate() { + let r = crate::text_field::TextField::singleline(&format!("field:keywordSetSlot:{}", i + 1), slot) + .hint(format!("⌥{}", i + 1)) + .width(w) + .show(ui); + returned |= r.ending == Some(crate::text_field::Ending::Return); + if i % 3 == 2 { + ui.end_row(); + } + } + }); + if replaces.is_some() { + ui.add_space(6.0); + let r = ui.checkbox(as_new, crate::i18n::tr("Save as a new set")); + crate::widgets::register(ui, "check:keywordSetAsNew", r.rect); + } + // Return in any field saves the set + if returned { + *confirm = true; + } +} + +/// The body of the MoveKeyword dialog. +fn move_keyword_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::MoveKeyword { keyword, parent } = dlg else { return }; + let name = keyword.rsplit('|').next().unwrap_or(keyword); + match parent { + Some(p) => { + ui.label(crate::i18n::tr_format!("“{parent}” has a “{name}” already. Merge the two?", parent = p.replace('|', " › "), name = name)) + } + None => ui.label(crate::i18n::tr_format!("There is a “{name}” at the top level already. Merge the two?", name = name)), + }; + ui.label(egui::RichText::new(crate::i18n::tr("Their photos and the keywords below them come together under one keyword.")).color(t.text_dim)); +} + +/// The body of the DeleteKeyword dialog. +fn delete_keyword_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::DeleteKeyword { keyword, count } = dlg else { return }; + let name = keyword.replace('|', " › "); + ui.label(crate::i18n::tr_format!("Delete the keyword “{name}”?", name = name)); + ui.label( + egui::RichText::new(if *count == 0 { + crate::i18n::tr("No photo has it; the keywords below it go too.").to_string() + } else { + crate::i18n::tr_format!( + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.", + if *count == 1 { "" } else { "s" }, + count = count + ) + }) + .color(t.text_dim), + ); +} + +/// The body of the DeleteEffects dialog. +fn delete_effects_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::DeleteEffects { names } = dlg else { return }; + let what = match names.as_slice() { + [one] => crate::i18n::tr_format!("Delete the effect preset “{name}”?", name = one), + _ => crate::i18n::tr_format!("Delete these {n} effect presets?", n = names.len()), + }; + ui.label(what); + ui.label(egui::RichText::new(crate::i18n::tr("Masks keep their sliders. Export presets first to keep a copy.")).color(t.text_dim)); +} + +/// The body of the MovePhotos dialog. +fn move_photos_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::MovePhotos { ids, name, dont_ask, .. } = dlg else { return }; + let n = ids.len(); + ui.label(crate::i18n::tr_format!("Move {n} photo{} to “{}”?", if n == 1 { "" } else { "s" }, name, n = n)); + ui.label(egui::RichText::new(crate::i18n::tr("Their files and XMP sidecars move on disk. Edit → Undo moves them back.")).color(t.text_dim)); + ui.add_space(4.0); + let c = ui.checkbox(dont_ask, crate::i18n::tr("Don't ask again")); + crate::widgets::register(ui, "check:moveDontAsk", c.rect); + c.on_hover_text(crate::i18n::tr("Settings → Confirm before moving photos to another folder on disk turns it back on")); +} + +/// The body of the LightroomCatalog dialog. +fn lightroom_catalog_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::LightroomCatalog { folder, catalog } = dlg else { return }; + ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?")); + ui.label(egui::RichText::new(catalog.as_deref().unwrap_or(folder.as_str())).color(t.text_dim)); + // (the import then asks where the catalog goes, unless Settings says to merge, #2672) + let asks = app.ui.settings.lightroom_import == crate::state::LightroomImport::Ask && app.session.library.as_ref().is_some_and(|l| l.on_disk); + let what = if asks { + "The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library." + } else { + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now." + }; + ui.label(egui::RichText::new(crate::i18n::tr(what)).color(t.text_dim)); +} + +/// The body of the RemoveFolder dialog. +fn remove_folder_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::RemoveFolder { name, count, path, disk } = dlg else { return }; + ui.label(crate::i18n::tr_format!( + "Remove “{name}” and its {count} photo{} from the library?", + if *count == 1 { "" } else { "s" }, + name = name, + count = count + )); + ui.label(egui::RichText::new(path.as_str()).color(t.text_dim)); + let scope = if *disk { "All the photos imported from this disk are included." } else { "That includes the photos in the folders inside it." }; + ui.label(egui::RichText::new(crate::i18n::tr(scope)).color(t.text_dim)); + ui.label( + egui::RichText::new(crate::i18n::tr("They move to Recently Deleted and can be restored; no file on disk is touched.")).color(t.text_dim), + ); +} + +/// The body of the About dialog. +fn about_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::About = dlg else { return }; + ui.set_min_width(680.0); + let tab_id = egui::Id::unique("about_tab"); + let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (id, label)) in ABOUT_TABS.iter().enumerate() { + if crate::widgets::text_button(ui, &format!("aboutTab-{id}"), label, usize::from(tab) == i).clicked() { + tab = u8::try_from(i).unwrap_or(0); + } + } + }); + ui.data_mut(|d| d.insert_temp(tab_id, tab)); + ui.separator(); + match tab { + 1 => crate::credits::contributors_ui(app, ui), + 2 => crate::credits::models_ui(ui), + _ => { + ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); + ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); + ui.label(crate::i18n::tr_format!( + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", + crate::theme::font_credits(app.chinese_font.is_some()) + )); + ui.add_space(10.0); + let r = crate::button::Button::new("Join the ArtCraft Discord") + .primary() + .large() + .min_width(260.0) + .widget("button:aboutDiscord") + .tooltip(crate::links::DISCORD) + .show(ui); + if r.clicked() { + let _ = crate::links::open(app, crate::links::DISCORD); + } + ui.add_space(6.0); + for (label, url) in [ + ("LightCraft website", crate::links::APP_PAGE), + ("Source code on GitHub", crate::links::GITHUB), + ("ArtCraft — more creative apps", crate::links::WEBSITE), + ] { + let r = ui.link(crate::i18n::tr(label)).on_hover_text(url); + if r.clicked() { + let _ = crate::links::open(app, url); + } + } + } + } +} + +/// The body of the open dialog: what each kind of dialog shows between its title and its buttons. `confirm` is set +/// by a dialog that is confirmed from its body (a text field's Return). +fn dialog_content(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, body_max: f32, confirm: &mut bool, dlg: &mut Dialog) { + match dlg { + Dialog::AutoStack { .. } => auto_stack_body(app, ui, t, dlg), + Dialog::Cull { .. } => cull_body(app, ui, t, dlg), + Dialog::WhatsNew => whats_new_body(ui, t, dlg), + Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::model_dialog(app, ui, t, info, accepted), + Dialog::FaceModel { info, accepted, .. } => crate::panels::faces::model_dialog(app, ui, t, info, accepted), + Dialog::LightroomResult { .. } => lightroom_result_body(ui, dlg), + Dialog::SystemInfo { .. } => system_info_body(ui, t, dlg), + Dialog::AllMetadata { .. } => all_metadata_body(ui, t, dlg), + Dialog::SmartRules { .. } => smart_rules_body(app, ui, t, dlg), + Dialog::NewSmartAlbum { .. } => new_smart_album_body(app, ui, t, confirm, dlg), + Dialog::CaptureTime { .. } => capture_time_body(app, ui, t, dlg), + Dialog::LabelNames { .. } => label_names_body(app, ui, t, dlg), + Dialog::Rename { .. } => rename_body(app, ui, t, dlg), + Dialog::RenamePerson { .. } => rename_person_body(app, ui, t, confirm, dlg), + Dialog::RenameKeyword { .. } => rename_keyword_body(app, ui, t, confirm, dlg), + Dialog::MergeKeywords { .. } => merge_keywords_body(app, ui, t, confirm, dlg), + Dialog::TextPrompt { value, hint, .. } => { + if dialog_field(ui, "field:prompt", value, hint) { + *confirm = true; + } + } + Dialog::NewAlbum { .. } => new_album_body(app, ui, confirm, dlg), + Dialog::RenameAlbum { name, .. } => { + if dialog_field(ui, "field:albumName", name, "Name") { + *confirm = true; + } + } + Dialog::CreatePreset { name, group, groups } => { + crate::widgets::named( + ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY)), + crate::i18n::tr("Preset name"), + ); + crate::widgets::named( + ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY)), + crate::i18n::tr("Group"), + ); + ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim)); + group_checklist(ui, "presetInclude", groups); + } + Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), + Dialog::PasteSettings { .. } => paste_settings_body(app, ui, t, dlg), + Dialog::SlideshowExport { settings } => slideshow_export_body(app, ui, settings), + Dialog::ContactSheet { .. } => contact_sheet_body(app, ui, dlg), + Dialog::Export { .. } => export_body(app, ui, t, dlg), + Dialog::Merge { opts } => crate::merge::body(app, ui, opts), + Dialog::Import { opts } => crate::import::body(app, ui, opts), + // (Settings is never taller than `body_max`, so the scroll area round it never + // scrolls: its tab bar stays put and its pages scroll by themselves) + Dialog::Settings { tab } => crate::panels::settings::window(app, ui, tab, body_max), + Dialog::SamModel { error, .. } => sam_model_body(app, ui, error.as_deref()), + Dialog::KeywordTag { .. } => keyword_tag_body(app, ui, t, confirm, dlg), + Dialog::KeywordSet { .. } => keyword_set_body(ui, t, confirm, dlg), + Dialog::MoveKeyword { .. } => move_keyword_body(ui, t, dlg), + Dialog::DeleteKeyword { .. } => delete_keyword_body(ui, t, dlg), + Dialog::SaveEffect { name, group, fields, amount, brush } => { + if crate::panels::mask_effects::save_body(app, ui, name, group, fields, amount, brush) { + *confirm = true; + } + } + Dialog::ManageEffects { search, selected, rename, confirm_delete, new_group } => { + crate::panels::mask_effects::manage_body(app, ui, search, selected, rename, confirm_delete, new_group) + } + Dialog::DeleteEffects { .. } => delete_effects_body(ui, t, dlg), + Dialog::ConfirmDelete { count } => { + let what = + if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; + ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); + ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim)); + } + Dialog::MovePhotos { .. } => move_photos_body(ui, t, dlg), + d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d), + Dialog::LightroomCatalog { .. } => lightroom_catalog_body(app, ui, t, dlg), + d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::body(app, ui, d), + Dialog::RemoveFolder { .. } => remove_folder_body(ui, t, dlg), + Dialog::About => about_body(app, ui, t, dlg), + Dialog::Shortcuts => crate::panels::keymap::body(app, ui, t), + Dialog::ViewOptions => crate::panels::settings::view_options(app, ui, t), + Dialog::Print => crate::print_view::body(app, ui, t), + } +} From 8c3dd7ebd2170e51366eaf2b816c6fb26c7b5f0a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:28:52 +0000 Subject: [PATCH 67/91] refactor(ui-egui): split dialog frame into title, buttons and confirm --- crates/ui-egui/src/dialogs.rs | 605 +++++++++++++++++++--------------- 1 file changed, 334 insertions(+), 271 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1e5ae5e2d..4278404ff 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -109,7 +109,127 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { { confirm = true; } - let title: String = match &dlg { + let title = dialog_title(app, &dlg); + let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12)); + // The import review can be resized (its photo grid takes the room); it keeps a window id of its + // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls + // rather than growing the window when the options below the grid get taller (e.g. Copy). + let import = matches!(dlg, Dialog::Import { .. }); + // The Print dialog sizes itself to the window and scrolls its settings column on its own. + let print = matches!(dlg, Dialog::Print); + let window_id = window_id(&dlg); + // Every other dialog fits the window (#781): its options scroll when they don't, and the title + // bar and the button row stay outside the scroll area. The room they take is measured on the + // previous frame (a guess on the first one). + let chrome_id = window_id.with("chrome"); + let chrome = ctx.data(|d| d.get_temp::(chrome_id)).unwrap_or(DIALOG_CHROME_GUESS); + // Settings keeps its top edge, and the widest it has been, while tabs of different sizes are shown, so its tab + // row stays where it is (centred like the others, the row jumped with each tab's height) + let settings = matches!(dlg, Dialog::Settings { .. }); + let top = (screen.height() * 0.08).clamp(DIALOG_SCREEN_MARGIN, 96.0); + let body_max = + if settings { screen.height() - top - DIALOG_SCREEN_MARGIN - chrome } else { screen.height() - 2.0 * DIALOG_SCREEN_MARGIN - chrome } + .max(DIALOG_MIN_BODY); + let widest_id = window_id.with("widest"); + let widest = if settings { ctx.data(|d| d.get_temp::(widest_id)).unwrap_or(0.0).min(screen.width() * 0.9) } else { 0.0 }; + let mut content_w = 0.0f32; + let mut body_h = None; + // before the window: drawing it closes a dropdown the click lands outside of + let popup_was_open = egui::Popup::is_any_open(ctx); + let shown = egui::Window::new(crate::i18n::tr(&title)) + .id(window_id) + .collapsible(false) + .resizable(import) + .vscroll(import) + .resize(|r| if import { r.default_height(crate::import::DIALOG_SIZE[1]) } else { r }) + .frame(frame) + .anchor(if settings { egui::Align2::CENTER_TOP } else { egui::Align2::CENTER_CENTER }, [0.0, if settings { top } else { 0.0 }]) + .min_width(widest) + .default_width(match dlg { + Dialog::Import { .. } => crate::import::DIALOG_SIZE[0], + Dialog::SmartRules { .. } => 680.0, + Dialog::AllMetadata { .. } => 620.0, + Dialog::Print => 1100.0, + Dialog::FaceModel { .. } => 460.0, + Dialog::LightroomCatalog { .. } => 440.0, + Dialog::LightroomDestination { .. } => 480.0, + Dialog::ManageEffects { .. } => 560.0, + _ => 380.0, + }) + .show(ctx, |ui| { + // Disabled, not skipped: the dialog keeps its layout and state, but a raw event in the + // first prompt frame cannot toggle a checkbox or press a button underneath it. The + // blocking library window gets the same treatment: it owns input while it is on screen, + // so the dialog behind it must not take focus or accept typed text either. + if raw_blocked { + ui.disable(); + } + ui.spacing_mut().item_spacing.y = 8.0; + body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| { + dialog_content(app, ui, &t, body_max, &mut confirm, &mut dlg) + }); + ui.add_space(4.0); + // the confirm on the right with Cancel to its left (macOS and GNOME's order), filled with + // the accent when it is the dialog's primary action. The row spans the width the window + // gives its content this frame (its default width, or its content's if wider), so it + // reaches the edge without widening the window. + let row_w = ui.available_width().max(ui.min_rect().width()).max(1.0); + let row_h = ui.spacing().interact_size.y; + ui.allocate_ui_with_layout(egui::vec2(row_w, row_h), egui::Layout::left_to_right(egui::Align::Center), |ui| { + button_row(app, ui, &dlg, &mut close, &mut confirm); + }); + if import { + crate::import::note_dialog_bottom(ui); + } + content_w = ui.min_rect().width(); + }); + if settings && content_w > widest + 0.5 { + ctx.data_mut(|d| d.insert_temp(widest_id, content_w)); + } + if let Some(w) = &shown { + // (the import review keeps its hand-picked photos however the click lands) + if !raw_blocked && app.ui.settings.close_dialogs_outside && !import && clicked_backdrop(ctx, w.response.rect, popup_was_open) { + close = true; + } else if raw_blocked { + // A press made under the prompt or library window must not be remembered as a + // backdrop press for a release that arrives after that covering window goes away. + ctx.data_mut(|d| d.remove_temp::(egui::Id::unique("dialog-backdrop-press"))); + } + } + if let Some(w) = shown { + ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); + if let Some(body_h) = body_h { + let measured = (w.response.rect.height() - body_h).max(0.0); + if (measured - chrome).abs() > 0.5 { + ctx.data_mut(|d| d.insert_temp(chrome_id, measured)); + ctx.request_repaint(); + } + } + ctx.move_to_top(w.response.layer_id); + crate::widgets::register_response("dialog:window", w.response.rect, &w.response); + } + // (while the shortcuts editor records a key, it takes Esc itself to cancel; an open popup in + // the dialog, such as a date picker's calendar, takes it to close itself) + if !raw_blocked && ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::widgets::escape_taken(ctx) { + close = true; + } + if !raw_blocked && confirm { + close |= confirmed(app, ctx, &mut dlg); + } + // a dialog opened from inside this one (a button in Settings that shows a licence) takes its place + if app.ui.dialog.as_ref().is_some_and(|now| *now != at_start) { + return; + } + if !close && !blocked { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(BACKDROP_SHOWN), frame)); + } + app.ui.dialog = if close { None } else { Some(dlg) }; +} + +/// The title of a dialog. +fn dialog_title(app: &LightcraftApp, dlg: &Dialog) -> String { + match dlg { Dialog::TextPrompt { title, .. } => title.as_str(), Dialog::NewAlbum { folder: true, .. } => "Create Folder", Dialog::NewAlbum { .. } => "Create Album", @@ -165,284 +285,227 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { Dialog::Shortcuts => "Keyboard Shortcuts", Dialog::ViewOptions => "View Options", } - .to_string(); - let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12)); - // The import review can be resized (its photo grid takes the room); it keeps a window id of its - // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls - // rather than growing the window when the options below the grid get taller (e.g. Copy). - let import = matches!(dlg, Dialog::Import { .. }); - // The Print dialog sizes itself to the window and scrolls its settings column on its own. - let print = matches!(dlg, Dialog::Print); - let window_id = window_id(&dlg); - // Every other dialog fits the window (#781): its options scroll when they don't, and the title - // bar and the button row stay outside the scroll area. The room they take is measured on the - // previous frame (a guess on the first one). - let chrome_id = window_id.with("chrome"); - let chrome = ctx.data(|d| d.get_temp::(chrome_id)).unwrap_or(DIALOG_CHROME_GUESS); - // Settings keeps its top edge, and the widest it has been, while tabs of different sizes are shown, so its tab - // row stays where it is (centred like the others, the row jumped with each tab's height) - let settings = matches!(dlg, Dialog::Settings { .. }); - let top = (screen.height() * 0.08).clamp(DIALOG_SCREEN_MARGIN, 96.0); - let body_max = - if settings { screen.height() - top - DIALOG_SCREEN_MARGIN - chrome } else { screen.height() - 2.0 * DIALOG_SCREEN_MARGIN - chrome } - .max(DIALOG_MIN_BODY); - let widest_id = window_id.with("widest"); - let widest = if settings { ctx.data(|d| d.get_temp::(widest_id)).unwrap_or(0.0).min(screen.width() * 0.9) } else { 0.0 }; - let mut content_w = 0.0f32; - let mut body_h = None; - // before the window: drawing it closes a dropdown the click lands outside of - let popup_was_open = egui::Popup::is_any_open(ctx); - let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id) - .collapsible(false) - .resizable(import) - .vscroll(import) - .resize(|r| if import { r.default_height(crate::import::DIALOG_SIZE[1]) } else { r }) - .frame(frame) - .anchor(if settings { egui::Align2::CENTER_TOP } else { egui::Align2::CENTER_CENTER }, [0.0, if settings { top } else { 0.0 }]) - .min_width(widest) - .default_width(match dlg { - Dialog::Import { .. } => crate::import::DIALOG_SIZE[0], - Dialog::SmartRules { .. } => 680.0, - Dialog::AllMetadata { .. } => 620.0, - Dialog::Print => 1100.0, - Dialog::FaceModel { .. } => 460.0, - Dialog::LightroomCatalog { .. } => 440.0, - Dialog::LightroomDestination { .. } => 480.0, - Dialog::ManageEffects { .. } => 560.0, - _ => 380.0, - }) - .show(ctx, |ui| { - // Disabled, not skipped: the dialog keeps its layout and state, but a raw event in the - // first prompt frame cannot toggle a checkbox or press a button underneath it. The - // blocking library window gets the same treatment: it owns input while it is on screen, - // so the dialog behind it must not take focus or accept typed text either. - if raw_blocked { - ui.disable(); - } - ui.spacing_mut().item_spacing.y = 8.0; - body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| dialog_content(app, ui, &t, body_max, &mut confirm, &mut dlg)); - ui.add_space(4.0); - // the confirm on the right with Cancel to its left (macOS and GNOME's order), filled with - // the accent when it is the dialog's primary action. The row spans the width the window - // gives its content this frame (its default width, or its content's if wider), so it - // reaches the edge without widening the window. - let row_w = ui.available_width().max(ui.min_rect().width()).max(1.0); - let row_h = ui.spacing().interact_size.y; - ui.allocate_ui_with_layout(egui::vec2(row_w, row_h), egui::Layout::left_to_right(egui::Align::Center), |ui| { - // a model file LightCraft cannot use has nothing to confirm, nor has a dialog that - // only shows something (one Close, no Cancel / OK) - let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported"); - let informational = unusable_model || matches!(dlg, Dialog::LightroomResult { first: None, .. }) || only_shows(&dlg); - let sam = &app.session.segmenter; - let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some()); - // no download location in this build: nothing to offer but the manual install - let sam_nowhere = sam_by_hand(sam); - let cancel = match &dlg { - Dialog::SamModel { .. } if sam_running || sam_installed || sam_nowhere => "Close", - Dialog::SamModel { .. } => "Not Now", - Dialog::LightroomResult { .. } => "Close", - _ => "Cancel", - }; - let add_label; - let ok = match &dlg { - Dialog::Import { opts } => { - let n = opts.selected_paths().len(); - let verb = if opts.copy && opts.move_files { "Move" } else { "Import" }; - add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n); - add_label.as_str() - } - Dialog::LightroomResult { first: Some(_), .. } => "Show in Library", - Dialog::ContactSheet { .. } => "Choose PDF…", - Dialog::SlideshowExport { .. } => "Export…", - Dialog::Print => "Print to PDF…", - Dialog::Merge { .. } => "Merge", - Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", - Dialog::DenoiseModel { .. } => "Install", - Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", - Dialog::FaceModel { .. } if !informational => "Install", - Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete", - Dialog::SaveEffect { .. } => "Create", - Dialog::MovePhotos { .. } => "Move", - // (not "Merge": that is photo merging, HDR / panorama, in some languages) - Dialog::MoveKeyword { .. } => "Merge Keywords", - Dialog::KeywordSet { .. } => "Save", - Dialog::KeywordTag { editing: None, .. } => "Create", - Dialog::KeywordTag { .. } => "Save", - Dialog::RemoveFolder { .. } => "Remove", - Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…", - d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::ok_label(d), - Dialog::SynchronizeFolder { .. } if informational => "Close", - Dialog::SynchronizeFolder { .. } => "Synchronize", - Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue", - Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", - Dialog::SamModel { error, .. } if error.is_some() || sam_failed => "Try Again", - Dialog::SamModel { .. } => "Download", - Dialog::RenamePerson { from, to, .. } if person_rename_plan(&app.session.catalog, from, to).needs_merge => "Merge", - _ if informational => "Close", - _ => "OK", - }; - // installing waits for the licence to be accepted - // (and Synchronize waits for the scan) - let rename_blocked = match &dlg { - Dialog::RenamePerson { from, to, merge } => { - let plan = person_rename_plan(&app.session.catalog, from, to); - plan.block.is_some() || (plan.needs_merge && !*merge) - } - _ => false, - }; - let can_confirm = !rename_blocked - && (informational - || !matches!( - &dlg, - Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } - ) - // smart-album rules that can't mean anything wait to be fixed - && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty())); - let primary = !informational && can_confirm && confirm_style(&dlg) == ConfirmStyle::Primary; - use crate::button::{Button, Kind}; - // (each kind of dialog its own buttons: the keyboard's place doesn't carry from a - // dialog to the one it opens) - let kind = std::mem::discriminant(&dlg); - let cancel_button = Button::new(cancel).widget("button:dialogCancel").id_salt(kind).min_width(72.0); - let ok_button = Button::new(ok) - .kind(if primary { Kind::Primary } else { Kind::Secondary }) - .enabled(can_confirm) - .widget("button:dialogOk") - .id_salt(kind) - .min_width(72.0); - // (Cancel first, then the confirm: the keyboard goes through them as the eye does) - let gap = ui.spacing().item_spacing.x; - let mut buttons_w = 0.0; - if !informational { - buttons_w += cancel_button.desired_size(ui).x + gap; - } - if !ok.is_empty() { - buttons_w += ok_button.desired_size(ui).x; - } - ui.add_space((ui.available_width() - buttons_w).max(0.0)); - if !informational && cancel_button.show(ui).clicked() { - close = true; - } - let r = (!ok.is_empty()).then(|| ok_button.show(ui)); - if r.is_some_and(|r| r.clicked()) { - if informational { - close = true; - } else { - confirm = true; - } - } - }); - if import { - crate::import::note_dialog_bottom(ui); - } - content_w = ui.min_rect().width(); - }); - if settings && content_w > widest + 0.5 { - ctx.data_mut(|d| d.insert_temp(widest_id, content_w)); - } - if let Some(w) = &shown { - // (the import review keeps its hand-picked photos however the click lands) - if !raw_blocked && app.ui.settings.close_dialogs_outside && !import && clicked_backdrop(ctx, w.response.rect, popup_was_open) { - close = true; - } else if raw_blocked { - // A press made under the prompt or library window must not be remembered as a - // backdrop press for a release that arrives after that covering window goes away. - ctx.data_mut(|d| d.remove_temp::(egui::Id::unique("dialog-backdrop-press"))); + .to_string() +} + +/// Whether a dialog only shows something: a model file LightCraft cannot use has nothing to confirm, nor has a +/// dialog that only shows something (one Close, no Cancel / OK). +fn is_informational(dlg: &Dialog) -> bool { + let unusable_model = matches!(dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported"); + unusable_model || matches!(dlg, Dialog::LightroomResult { first: None, .. }) || only_shows(dlg) +} + +/// What the SAM 3 download looks like: installed, running, failed, and whether there is nowhere to download from. +struct SamState { + installed: bool, + running: bool, + failed: bool, + /// no download location in this build: nothing to offer but the manual install + by_hand: bool, +} + +impl SamState { + fn of(app: &LightcraftApp) -> Self { + let sam = &app.session.segmenter; + SamState { + installed: sam.installed(), + running: sam.download_status().running, + failed: sam.download_status().error.is_some(), + by_hand: sam_by_hand(sam), } } - if let Some(w) = shown { - ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); - if let Some(body_h) = body_h { - let measured = (w.response.rect.height() - body_h).max(0.0); - if (measured - chrome).abs() > 0.5 { - ctx.data_mut(|d| d.insert_temp(chrome_id, measured)); - ctx.request_repaint(); - } +} + +/// The label of a dialog's cancel button. +fn cancel_label(dlg: &Dialog, sam: &SamState) -> &'static str { + match dlg { + Dialog::SamModel { .. } if sam.running || sam.installed || sam.by_hand => "Close", + Dialog::SamModel { .. } => "Not Now", + Dialog::LightroomResult { .. } => "Close", + _ => "Cancel", + } +} + +/// The label of a dialog's confirm button (empty: it has none). +fn ok_label(app: &LightcraftApp, dlg: &Dialog, informational: bool, sam: &SamState) -> String { + let label = match dlg { + Dialog::Import { opts } => { + let n = opts.selected_paths().len(); + let verb = if opts.copy && opts.move_files { "Move" } else { "Import" }; + return crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n); } - ctx.move_to_top(w.response.layer_id); - crate::widgets::register_response("dialog:window", w.response.rect, &w.response); + Dialog::LightroomResult { first: Some(_), .. } => "Show in Library", + Dialog::ContactSheet { .. } => "Choose PDF…", + Dialog::SlideshowExport { .. } => "Export…", + Dialog::Print => "Print to PDF…", + Dialog::Merge { .. } => "Merge", + Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", + Dialog::DenoiseModel { .. } => "Install", + Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", + Dialog::FaceModel { .. } if !informational => "Install", + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete", + Dialog::SaveEffect { .. } => "Create", + Dialog::MovePhotos { .. } => "Move", + // (not "Merge": that is photo merging, HDR / panorama, in some languages) + Dialog::MoveKeyword { .. } => "Merge Keywords", + Dialog::KeywordSet { .. } => "Save", + Dialog::KeywordTag { editing: None, .. } => "Create", + Dialog::KeywordTag { .. } => "Save", + Dialog::RemoveFolder { .. } => "Remove", + Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…", + d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::ok_label(d), + Dialog::SynchronizeFolder { .. } if informational => "Close", + Dialog::SynchronizeFolder { .. } => "Synchronize", + Dialog::SamModel { .. } => return sam_ok_label(dlg, sam).to_string(), + Dialog::RenamePerson { from, to, .. } if person_rename_plan(&app.session.catalog, from, to).needs_merge => "Merge", + _ if informational => "Close", + _ => "OK", + }; + label.to_string() +} + +/// The label of the SAM 3 dialog's confirm button. +fn sam_ok_label(dlg: &Dialog, sam: &SamState) -> &'static str { + match dlg { + Dialog::SamModel { then: Some(_), .. } if sam.installed => "Continue", + Dialog::SamModel { .. } if sam.installed || sam.running || sam.by_hand => "", + Dialog::SamModel { error, .. } if error.is_some() || sam.failed => "Try Again", + _ => "Download", } - // (while the shortcuts editor records a key, it takes Esc itself to cancel; an open popup in - // the dialog, such as a date picker's calendar, takes it to close itself) - if !raw_blocked && ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::widgets::escape_taken(ctx) { - close = true; +} + +/// Whether the confirm button works: installing waits for the licence to be accepted (and Synchronize waits for the +/// scan), a rename waits for its conflicts to be settled, smart-album rules that can't mean anything wait to be fixed. +fn can_confirm(app: &LightcraftApp, dlg: &Dialog, informational: bool) -> bool { + let rename_blocked = match dlg { + Dialog::RenamePerson { from, to, merge } => { + let plan = person_rename_plan(&app.session.catalog, from, to); + plan.block.is_some() || (plan.needs_merge && !*merge) + } + _ => false, + }; + let waiting = matches!( + dlg, + Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } + ); + let bad_rules = + matches!(dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()); + !rename_blocked && (informational || (!waiting && !bad_rules)) +} + +/// The row of buttons under a dialog: Cancel and the confirm; `close` and `confirm` are set when one is clicked. +fn button_row(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &Dialog, close: &mut bool, confirm: &mut bool) { + let informational = is_informational(dlg); + let sam = SamState::of(app); + let cancel = cancel_label(dlg, &sam); + let ok = ok_label(app, dlg, informational, &sam); + let ok = ok.as_str(); + let can_confirm = can_confirm(app, dlg, informational); + let primary = !informational && can_confirm && confirm_style(dlg) == ConfirmStyle::Primary; + use crate::button::{Button, Kind}; + // (each kind of dialog its own buttons: the keyboard's place doesn't carry from a + // dialog to the one it opens) + let kind = std::mem::discriminant(dlg); + let cancel_button = Button::new(cancel).widget("button:dialogCancel").id_salt(kind).min_width(72.0); + let ok_button = Button::new(ok) + .kind(if primary { Kind::Primary } else { Kind::Secondary }) + .enabled(can_confirm) + .widget("button:dialogOk") + .id_salt(kind) + .min_width(72.0); + // (Cancel first, then the confirm: the keyboard goes through them as the eye does) + let gap = ui.spacing().item_spacing.x; + let mut buttons_w = 0.0; + if !informational { + buttons_w += cancel_button.desired_size(ui).x + gap; } - if !raw_blocked && confirm { - match confirm_dialog(app, &dlg) { - // the import review stays open on an error (e.g. an unusable folder template), and so - // does Export (e.g. no folder chosen) so the choices aren't lost, and Edit Capture Time - // (a time that isn't a date, or a shift it refuses) - Err(e) - if matches!( - dlg, - Dialog::Import { .. } - | Dialog::Export { .. } - | Dialog::ContactSheet { .. } - | Dialog::SlideshowExport { .. } - | Dialog::Print - | Dialog::NewAlbum { .. } - | Dialog::NewSmartAlbum { .. } - | Dialog::SmartRules { .. } - | Dialog::KeywordTag { .. } - | Dialog::KeywordSet { .. } - | Dialog::SynchronizeFolder { .. } - | Dialog::SaveEffect { .. } - | Dialog::CaptureTime { .. } - ) => - { - app.toast(ctx, e) - } - // the SAM 3 dialog stays open to show the download (or why it can't start) - Err(e) if matches!(dlg, Dialog::SamModel { .. }) => { - if let Dialog::SamModel { error, .. } = &mut dlg { - *error = Some(e); - } - } - Ok(r) if matches!(dlg, Dialog::NewAlbum { .. }) && r.get("skipped").and_then(serde_json::Value::as_u64).is_some_and(|n| n > 0) => { - // albums hold library photos (issue #684): say which part of the selection stayed out - crate::panels::grid::toast_skipped(app, ctx, &r); - close = true; - } - // a move says how it went, or why nothing moved - Ok(r) if matches!(dlg, Dialog::MovePhotos { .. }) => { - if let Dialog::MovePhotos { name, .. } = &dlg { - let text = crate::panels::left::moved_text(r["moved"].as_u64().unwrap_or(0), name); - app.toast(ctx, text); - } - close = true; - } - Err(e) if matches!(dlg, Dialog::MovePhotos { .. }) => { - app.toast(ctx, e); - close = true; - } - // photos whose capture time doesn't read are left as they were: say so - Ok(r) if matches!(dlg, Dialog::CaptureTime { .. }) && r["unreadable"].as_u64().is_some_and(|n| n > 0) => { - app.toast(ctx, crate::i18n::tr("Photos whose capture time can't be read were left as they were")); - close = true; - } - // where a Lightroom catalog goes: stays open to be corrected (a folder that isn't - // empty), unless the library it names has gone - Err(e) if matches!(dlg, Dialog::LightroomDestination { .. }) => { - close = !crate::lightroom_destination::keeps_open_on_error(app, &dlg); - app.toast(ctx, e); - } - Ok(_) if keeps_open(app, &dlg) => { - if let Dialog::SamModel { error, .. } = &mut dlg { - *error = None; - } - } - _ => close = true, + if !ok.is_empty() { + buttons_w += ok_button.desired_size(ui).x; + } + ui.add_space((ui.available_width() - buttons_w).max(0.0)); + if !informational && cancel_button.show(ui).clicked() { + *close = true; + } + let r = (!ok.is_empty()).then(|| ok_button.show(ui)); + if r.is_some_and(|r| r.clicked()) { + if informational { + *close = true; + } else { + *confirm = true; } } - // a dialog opened from inside this one (a button in Settings that shows a licence) takes its place - if app.ui.dialog.as_ref().is_some_and(|now| *now != at_start) { - return; +} + +/// The dialog's confirm was pressed: do it. Whether the dialog closes. +fn confirmed(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &mut Dialog) -> bool { + let mut closed = false; + match confirm_dialog(app, dlg) { + // the import review stays open on an error (e.g. an unusable folder template), and so + // does Export (e.g. no folder chosen) so the choices aren't lost, and Edit Capture Time + // (a time that isn't a date, or a shift it refuses) + Err(e) + if matches!( + dlg, + Dialog::Import { .. } + | Dialog::Export { .. } + | Dialog::ContactSheet { .. } + | Dialog::SlideshowExport { .. } + | Dialog::Print + | Dialog::NewAlbum { .. } + | Dialog::NewSmartAlbum { .. } + | Dialog::SmartRules { .. } + | Dialog::KeywordTag { .. } + | Dialog::KeywordSet { .. } + | Dialog::SynchronizeFolder { .. } + | Dialog::SaveEffect { .. } + | Dialog::CaptureTime { .. } + ) => + { + app.toast(ctx, e) + } + // the SAM 3 dialog stays open to show the download (or why it can't start) + Err(e) if matches!(dlg, Dialog::SamModel { .. }) => { + if let Dialog::SamModel { error, .. } = dlg { + *error = Some(e); + } + } + Ok(r) if matches!(dlg, Dialog::NewAlbum { .. }) && r.get("skipped").and_then(serde_json::Value::as_u64).is_some_and(|n| n > 0) => { + // albums hold library photos (issue #684): say which part of the selection stayed out + crate::panels::grid::toast_skipped(app, ctx, &r); + closed = true; + } + // a move says how it went, or why nothing moved + Ok(r) if matches!(dlg, Dialog::MovePhotos { .. }) => { + if let Dialog::MovePhotos { name, .. } = dlg { + let text = crate::panels::left::moved_text(r["moved"].as_u64().unwrap_or(0), name); + app.toast(ctx, text); + } + closed = true; + } + Err(e) if matches!(dlg, Dialog::MovePhotos { .. }) => { + app.toast(ctx, e); + closed = true; + } + // photos whose capture time doesn't read are left as they were: say so + Ok(r) if matches!(dlg, Dialog::CaptureTime { .. }) && r["unreadable"].as_u64().is_some_and(|n| n > 0) => { + app.toast(ctx, crate::i18n::tr("Photos whose capture time can't be read were left as they were")); + closed = true; + } + // where a Lightroom catalog goes: stays open to be corrected (a folder that isn't + // empty), unless the library it names has gone + Err(e) if matches!(dlg, Dialog::LightroomDestination { .. }) => { + closed = !crate::lightroom_destination::keeps_open_on_error(app, dlg); + app.toast(ctx, e); + } + Ok(_) if keeps_open(app, dlg) => { + if let Dialog::SamModel { error, .. } = dlg { + *error = None; + } + } + _ => closed = true, } - if !close && !blocked { - let frame = ctx.cumulative_frame_nr(); - ctx.data_mut(|d| d.insert_temp(egui::Id::unique(BACKDROP_SHOWN), frame)); - } - app.ui.dialog = if close { None } else { Some(dlg) }; + + closed } /// The last frame a dialog was drawn and stayed open, for [`clicked_backdrop`]. From b74612513c36b640bcc8a15acd7feaabdacfaeb4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:32:45 +0000 Subject: [PATCH 68/91] refactor(ui-egui): split the Export dialog body into sections --- crates/ui-egui/src/dialogs.rs | 332 ++++++++++++++++++++-------------- 1 file changed, 198 insertions(+), 134 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4278404ff..bca04fe0c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6,6 +6,7 @@ use serde_json::json; use crate::LightcraftApp; use crate::state::Dialog; use crate::theme::Tokens; +use lightcraft_engine::export::{ExportOptions, Resize}; mod confirm; mod cull; @@ -1079,9 +1080,33 @@ fn contact_sheet_body(app: &mut LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dial /// The body of the Export dialog. fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { let Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } = dlg else { return }; - use lightcraft_engine::export::{ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; let n = app.session.selection.ids.len().max(1); ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); + export_preset_row(app, ui, opts, full_size, resize, limit_kb); + ui.add_space(4.0); + export_format_rows(ui, t, opts); + export_color_rows(ui, t, opts); + export_quality_rows(ui, opts, limit_kb); + if opts.format.is_rendered() { + export_size(ui, full_size, resize, &mut opts.ppi); + export_output_rows(app, ui, opts, *full_size, *resize); + } + ui.add_space(4.0); + export_naming_rows(ui, opts); + export_destination_rows(app, ui, t, opts, dir); + ui.add_space(4.0); + export_save_preset_row(app, ui, opts, *full_size, resize, *limit_kb, preset_name); +} + +/// Export dialog: load a built-in or saved set of options. +fn export_preset_row( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + opts: &mut ExportOptions, + full_size: &mut bool, + resize: &mut Resize, + limit_kb: &mut u32, +) { // Preset: load a built-in or saved set of options into the dialog field(ui, "Preset", |ui| { let mut chosen = None; @@ -1109,7 +1134,11 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut *opts = o; } }); - ui.add_space(4.0); +} + +/// Export dialog: the format, its variants and the bit depth. +fn export_format_rows(ui: &mut egui::Ui, t: &Tokens, opts: &mut ExportOptions) { + use lightcraft_engine::export::ExportFormat as F; let before = opts.format; // "Rendered DNG" is a DNG option (below), not a format of its own in this list let mut shown = if opts.format == F::RenderedDng { F::Dng } else { opts.format }; @@ -1155,6 +1184,12 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd); opts.bit_depth = Some(bd); } +} + +/// Export dialog: HDR output and the color space it implies (or the choice of one). +fn export_color_rows(ui: &mut egui::Ui, t: &Tokens, opts: &mut ExportOptions) { + use lightcraft_engine::export::ExportFormat as F; + let rendered = opts.format.is_rendered(); if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png | F::Jxl) { let tip = crate::i18n::tr( "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float", @@ -1197,6 +1232,11 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); } } +} + +/// Export dialog: quality and the other options of a lossy or JPEG XL file. +fn export_quality_rows(ui: &mut egui::Ui, opts: &mut ExportOptions, limit_kb: &mut u32) { + use lightcraft_engine::export::ExportFormat as F; if opts.format == F::Jxl { let tip = "Mathematically lossless: every pixel is kept exactly (larger files)"; ui.checkbox(&mut opts.jxl_lossless, crate::i18n::tr("Lossless")).on_hover_text(crate::i18n::tr(tip)); @@ -1219,121 +1259,128 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut *limit_kb = k as u32; } } - if rendered { - export_size(ui, full_size, resize, &mut opts.ppi); +} + +/// Export dialog: sharpening, metadata and the watermark of a rendered photo. +fn export_output_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut ExportOptions, full_size: bool, resize: Resize) { + use lightcraft_engine::export::{MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; + choices( + ui, + "Sharpen", + "exportSharpen", + &[(S::None, "None"), (S::Screen, "Screen"), (S::Matte, "Matte"), (S::Glossy, "Glossy")], + &mut opts.sharpen, + ); + if opts.sharpen != S::None { + choices(ui, "Amount", "exportSharpenAmount", &[(A::Low, "Low"), (A::Standard, "Standard"), (A::High, "High")], &mut opts.sharpen_amount); } - if rendered { - choices( - ui, - "Sharpen", - "exportSharpen", - &[(S::None, "None"), (S::Screen, "Screen"), (S::Matte, "Matte"), (S::Glossy, "Glossy")], - &mut opts.sharpen, - ); - if opts.sharpen != S::None { - choices(ui, "Amount", "exportSharpenAmount", &[(A::Low, "Low"), (A::Standard, "Standard"), (A::High, "High")], &mut opts.sharpen_amount); - } + choices( + ui, + "Metadata", + "exportMetadata", + &[(M::All, "All"), (M::AllExceptCamera, "No camera"), (M::Copyright, "Copyright"), (M::None, "None")], + &mut opts.metadata, + ); + if !matches!(opts.metadata, M::None | M::Copyright) { + ui.checkbox(&mut opts.remove_location, crate::i18n::tr("Remove location info")); } - if rendered { - choices( - ui, - "Metadata", - "exportMetadata", - &[(M::All, "All"), (M::AllExceptCamera, "No camera"), (M::Copyright, "Copyright"), (M::None, "None")], - &mut opts.metadata, - ); - if !matches!(opts.metadata, M::None | M::Copyright) { - ui.checkbox(&mut opts.remove_location, crate::i18n::tr("Remove location info")); - } - let mut wm_on = opts.watermark.is_some(); - if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() { - opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); - } - let wm_shown = opts.watermark.clone(); - if let Some(wm) = &wm_shown { - watermark_preview(app, ui, wm, (!*full_size).then_some(*resize)); - } - if let Some(wm) = &mut opts.watermark { - // text, or a graphic (a logo with transparency) - let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::(egui::Id::unique("wm-graphic"))).unwrap_or(false); - let before = graphic; - choices(ui, "Style", "exportWmStyle", &[(false, "Text"), (true, "Graphic")], &mut graphic); - if graphic != before { - ui.data_mut(|d| d.insert_temp(egui::Id::unique("wm-graphic"), graphic)); - if !graphic { - wm.image.clear(); - } - } - if graphic { - field(ui, "Graphic", |ui| { - trailing_button_row(ui, |ui| { - if app.services.pick_files.is_some() - && crate::widgets::text_button(ui, "exportWmChoose", crate::i18n::tr("Choose…"), false).clicked() - && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) - { - wm.image = f; - } - crate::widgets::named( - ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())), - crate::i18n::tr("Image"), - ); - }); - }); - let mut width = wm.image_width as f64 * 100.0; - if num(ui, &WM_IMAGE_WIDTH, &mut width) { - wm.image_width = (width / 100.0) as f32; - } - } else { - choices( - ui, - app.ui.language.tr("Text direction"), - "exportWmOrientation", - &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], - &mut wm.vertical, - ); - field(ui, "Text", |ui| { - crate::widgets::named( - ui.add( - egui::TextEdit::multiline(&mut wm.text) - .desired_rows(2) - .hint_text(crate::i18n::tr("© Your Name")) - .desired_width(f32::INFINITY), - ), - crate::i18n::tr("Text"), - ) - }); - } - choices(ui, "Position", "exportWmAnchor", &WM_POSITIONS, &mut wm.anchor); - choices(ui, "Rotate", "exportWmRotation", &[(0u16, "0°"), (90, "90°"), (180, "180°"), (270, "270°")], &mut wm.rotation); - let mut size = wm.size as f64 * 100.0; - if !graphic && num(ui, &WM_SIZE, &mut size) { - wm.size = (size / 100.0) as f32; - } - let mut op = wm.opacity as f64 * 100.0; - if num(ui, &WM_OPACITY, &mut op) { - wm.opacity = (op / 100.0) as f32; - } - // like Lightroom Classic: a margin from the side edges and one from the top and bottom - // edges, each unused (greyed) when the position is centred along that axis - use lightcraft_engine::export::Anchor as P; - let across = !matches!(wm.anchor, P::Top | P::Center | P::Bottom); - if let Some(v) = crate::widgets::slider(ui, &WM_INSET_X, wm.inset_x as f64 * 100.0, across, None).value { - wm.inset_x = (v / 100.0) as f32; - } - let down = !matches!(wm.anchor, P::Left | P::Center | P::Right); - if let Some(v) = crate::widgets::slider(ui, &WM_INSET_Y, wm.inset_y as f64 * 100.0, down, None).value { - wm.inset_y = (v / 100.0) as f32; - } + export_watermark(app, ui, opts, full_size, resize); +} + +/// Export dialog: the watermark switch, its preview and its settings. +fn export_watermark(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut ExportOptions, full_size: bool, resize: Resize) { + let mut wm_on = opts.watermark.is_some(); + if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() { + opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); + } + let wm_shown = opts.watermark.clone(); + if let Some(wm) = &wm_shown { + watermark_preview(app, ui, wm, (!full_size).then_some(resize)); + } + if let Some(wm) = &mut opts.watermark { + // text, or a graphic (a logo with transparency) + let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::(egui::Id::unique("wm-graphic"))).unwrap_or(false); + let before = graphic; + choices(ui, "Style", "exportWmStyle", &[(false, "Text"), (true, "Graphic")], &mut graphic); + if graphic != before { + ui.data_mut(|d| d.insert_temp(egui::Id::unique("wm-graphic"), graphic)); if !graphic { - ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); + wm.image.clear(); } } - if opts.watermark != wm_shown { - // the preview above was drawn with the old settings - ui.ctx().request_repaint(); + if graphic { + field(ui, "Graphic", |ui| { + trailing_button_row(ui, |ui| { + if app.services.pick_files.is_some() + && crate::widgets::text_button(ui, "exportWmChoose", crate::i18n::tr("Choose…"), false).clicked() + && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) + { + wm.image = f; + } + crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())), + crate::i18n::tr("Image"), + ); + }); + }); + let mut width = wm.image_width as f64 * 100.0; + if num(ui, &WM_IMAGE_WIDTH, &mut width) { + wm.image_width = (width / 100.0) as f32; + } + } else { + choices( + ui, + app.ui.language.tr("Text direction"), + "exportWmOrientation", + &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], + &mut wm.vertical, + ); + field(ui, "Text", |ui| { + crate::widgets::named( + ui.add( + egui::TextEdit::multiline(&mut wm.text) + .desired_rows(2) + .hint_text(crate::i18n::tr("© Your Name")) + .desired_width(f32::INFINITY), + ), + crate::i18n::tr("Text"), + ) + }); + } + choices(ui, "Position", "exportWmAnchor", &WM_POSITIONS, &mut wm.anchor); + choices(ui, "Rotate", "exportWmRotation", &[(0u16, "0°"), (90, "90°"), (180, "180°"), (270, "270°")], &mut wm.rotation); + let mut size = wm.size as f64 * 100.0; + if !graphic && num(ui, &WM_SIZE, &mut size) { + wm.size = (size / 100.0) as f32; + } + let mut op = wm.opacity as f64 * 100.0; + if num(ui, &WM_OPACITY, &mut op) { + wm.opacity = (op / 100.0) as f32; + } + // like Lightroom Classic: a margin from the side edges and one from the top and bottom + // edges, each unused (greyed) when the position is centred along that axis + use lightcraft_engine::export::Anchor as P; + let across = !matches!(wm.anchor, P::Top | P::Center | P::Bottom); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_X, wm.inset_x as f64 * 100.0, across, None).value { + wm.inset_x = (v / 100.0) as f32; + } + let down = !matches!(wm.anchor, P::Left | P::Center | P::Right); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_Y, wm.inset_y as f64 * 100.0, down, None).value { + wm.inset_y = (v / 100.0) as f32; + } + if !graphic { + ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); } } - ui.add_space(4.0); + if opts.watermark != wm_shown { + // the preview above was drawn with the old settings + ui.ctx().request_repaint(); + } +} + +/// Export dialog: compression and the file name. +fn export_naming_rows(ui: &mut egui::Ui, opts: &mut ExportOptions) { + use lightcraft_engine::export::ExportFormat as F; if opts.format == F::Tiff { use lightcraft_engine::export::TiffCompression as Z; choices(ui, "Compression", "exportTiffCompression", &[(Z::None, "None"), (Z::Lzw, "LZW"), (Z::Deflate, "ZIP")], &mut opts.tiff_compression); @@ -1373,6 +1420,10 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut opts.start_number = v as u32; } } +} + +/// Export dialog: where the files go and what happens when one exists. +fn export_destination_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, opts: &mut ExportOptions, dir: &mut String) { // like Lightroom Classic's Export To: one folder, or each photo's own folder (not on // the web, where originals live in the browser's storage, not in folders) use lightcraft_engine::export::DestinationMode as D; @@ -1407,36 +1458,49 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut }); use lightcraft_engine::export::Conflict as K; choices(ui, "If file exists", "exportConflict", &[(K::Unique, "Add number"), (K::Overwrite, "Overwrite"), (K::Skip, "Skip")], &mut opts.conflict); - // After export: Apple Photos imports the files written (macOS only) - #[cfg(target_os = "macos")] - { - let r = ui.checkbox(&mut opts.add_to_photos, crate::i18n::tr("Add to Apple Photos")); - crate::widgets::register(ui, "exportAddToPhotos", r.rect); - if opts.add_to_photos { - field(ui, "Album", |ui| { - crate::widgets::named( - ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), - crate::i18n::tr("Album"), - ) - }); - ui.label( - egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos.")) - .size(11.0) - .color(t.text_dim), - ); - } - } - // nowhere to add them: a setting carried over from a Mac is dropped, not refused - #[cfg(not(target_os = "macos"))] - { - opts.add_to_photos = false; + export_apple_photos_rows(ui, t, opts); +} + +/// Export dialog, after export: Apple Photos imports the files written (macOS only). +#[cfg(target_os = "macos")] +fn export_apple_photos_rows(ui: &mut egui::Ui, t: &Tokens, opts: &mut ExportOptions) { + let r = ui.checkbox(&mut opts.add_to_photos, crate::i18n::tr("Add to Apple Photos")); + crate::widgets::register(ui, "exportAddToPhotos", r.rect); + if opts.add_to_photos { + field(ui, "Album", |ui| { + crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), + crate::i18n::tr("Album"), + ) + }); + ui.label( + egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos.")).size(11.0).color(t.text_dim), + ); } +} + +/// Nowhere to add them: a setting carried over from a Mac is dropped, not refused. +#[cfg(not(target_os = "macos"))] +fn export_apple_photos_rows(_ui: &mut egui::Ui, _t: &Tokens, opts: &mut ExportOptions) { + opts.add_to_photos = false; +} + +/// Export dialog: save the options as a preset. +fn export_save_preset_row( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + opts: &ExportOptions, + full_size: bool, + resize: &Resize, + limit_kb: u32, + preset_name: &mut String, +) { ui.add_space(4.0); field(ui, "Save preset", |ui| { trailing_button_row(ui, |ui| { let named = !preset_name.trim().is_empty(); if crate::widgets::text_button(ui, "exportSavePreset", crate::i18n::tr("Save"), false).clicked() && named { - let params = export_dialog_params(opts, *full_size, resize, *limit_kb); + let params = export_dialog_params(opts, full_size, resize, limit_kb); match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) { Ok(_) => { app.toast(ui.ctx(), crate::i18n::tr_format!("Saved export preset “{}”", preset_name.trim())); From 7b4236bc8b5e00ed081e15bfea9241c299e28723 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:40:33 +0000 Subject: [PATCH 69/91] refactor(ui-egui): pass related arguments as small structs --- .../src/dialogs/settings/appearance.rs | 31 ++++--- .../src/dialogs/settings/slide_overlays.rs | 67 ++++++++++---- crates/ui-egui/src/panels.rs | 25 +++-- crates/ui-egui/src/panels/detail.rs | 22 +++-- crates/ui-egui/src/panels/detail/masks.rs | 18 +++- crates/ui-egui/src/panels/left.rs | 17 +++- crates/ui-egui/src/panels/left/albums.rs | 12 ++- crates/ui-egui/src/panels/left/dates.rs | 4 +- crates/ui-egui/src/panels/left/folders.rs | 8 +- crates/ui-egui/src/panels/left/keywords.rs | 4 +- crates/ui-egui/src/panels/left/local.rs | 10 +- crates/ui-egui/src/panels/left/rows.rs | 92 +++++++++++-------- crates/ui-egui/src/panels/person.rs | 24 ++++- crates/ui-egui/src/tests/lifecycle_037.rs | 17 +++- 14 files changed, 233 insertions(+), 118 deletions(-) diff --git a/crates/ui-egui/src/dialogs/settings/appearance.rs b/crates/ui-egui/src/dialogs/settings/appearance.rs index 571db16fc..f714a97e6 100644 --- a/crates/ui-egui/src/dialogs/settings/appearance.rs +++ b/crates/ui-egui/src/dialogs/settings/appearance.rs @@ -17,9 +17,15 @@ pub(super) fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::Ap let mut cards = |ui: &mut egui::Ui, width: f32| { ui.spacing_mut().item_spacing.x = 12.0; let light: Vec<_> = LightTheme::ALL.iter().map(|l| (*l, l.kind())).collect(); - theme_card(ui, t, "Light Theme", light_active, &light, &mut s.light_theme, &mut s.favorite_themes, width); + theme_card( + ui, + t, + ThemeCard { title: "Light Theme", active: light_active, options: &light, width }, + &mut s.light_theme, + &mut s.favorite_themes, + ); let dark: Vec<_> = DarkTheme::ALL.iter().map(|d| (*d, d.kind())).collect(); - theme_card(ui, t, "Dark Theme", !light_active, &dark, &mut s.dark_theme, &mut s.favorite_themes, width); + theme_card(ui, t, ThemeCard { title: "Dark Theme", active: !light_active, options: &dark, width }, &mut s.dark_theme, &mut s.favorite_themes); }; if available < 440.0 { ui.vertical(|ui| cards(ui, available)); @@ -29,18 +35,19 @@ pub(super) fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::Ap ui.add_space(2.0); } +/// What a theme family's card shows. +struct ThemeCard<'a, V> { + title: &'a str, + /// the family in use + active: bool, + options: &'a [(V, crate::theme::ThemeKind)], + width: f32, +} + /// One family's card: its title (and "Active" when it is the one shown), a preview of the /// selected theme and a radio button per theme. -fn theme_card( - ui: &mut egui::Ui, - t: &Tokens, - title: &str, - active: bool, - options: &[(V, crate::theme::ThemeKind)], - value: &mut V, - favorites: &mut Vec, - width: f32, -) { +fn theme_card(ui: &mut egui::Ui, t: &Tokens, card: ThemeCard, value: &mut V, favorites: &mut Vec) { + let ThemeCard { title, active, options, width } = card; egui::Frame::new() .fill(t.inset) .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) diff --git a/crates/ui-egui/src/dialogs/settings/slide_overlays.rs b/crates/ui-egui/src/dialogs/settings/slide_overlays.rs index 3d7412b58..fe72ed6a6 100644 --- a/crates/ui-egui/src/dialogs/settings/slide_overlays.rs +++ b/crates/ui-egui/src/dialogs/settings/slide_overlays.rs @@ -30,14 +30,21 @@ fn set(app: &mut LightcraftApp, p: Value) { let _ = app.run("slideshow.overlays", p); } -/// A slider (`slider:{id}`) over `lo..=hi`; `wrap(value)` makes the `slideshow.overlays` request. -fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, lo: f64, hi: f64, unit: Unit, wrap: &dyn Fn(f64) -> Value) { - let (k, suffix) = match unit { +/// The span of a slider and how its value is shown. +struct Range { + lo: f64, + hi: f64, + unit: Unit, +} + +/// A slider (`slider:{id}`) over `range`; `wrap(value)` makes the `slideshow.overlays` request. +fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, range: Range, wrap: &dyn Fn(f64) -> Value) { + let (k, suffix) = match range.unit { Unit::Percent => (100.0, " %"), Unit::Degrees => (1.0, "°"), }; let mut v = value * k; - let r = ui.add(egui::Slider::new(&mut v, lo * k..=hi * k).suffix(suffix).max_decimals(1)); + let r = ui.add(egui::Slider::new(&mut v, range.lo * k..=range.hi * k).suffix(suffix).max_decimals(1)); register(ui, format!("slider:{id}"), r.rect); let p = wrap(v / k); if r.changed() @@ -125,10 +132,14 @@ fn placement(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, id: &str, p }); } row(ui, t, "Offset X", |ui| { - slider(app, ui, &format!("{id}OffsetX"), p.offset_x, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetX": v}))) + slider(app, ui, &format!("{id}OffsetX"), p.offset_x, Range { lo: -OFFSET_MAX / 2.0, hi: OFFSET_MAX / 2.0, unit: Unit::Percent }, &|v| { + wrap(json!({"offsetX": v})) + }) }); row(ui, t, "Offset Y", |ui| { - slider(app, ui, &format!("{id}OffsetY"), p.offset_y, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetY": v}))) + slider(app, ui, &format!("{id}OffsetY"), p.offset_y, Range { lo: -OFFSET_MAX / 2.0, hi: OFFSET_MAX / 2.0, unit: Unit::Percent }, &|v| { + wrap(json!({"offsetY": v})) + }) }); } @@ -171,8 +182,12 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, "Text", |ui| text(app, ui, "slideshowPlateText", &p.text, "Your name or studio", &|v| plate(json!({"text": v})))); row(ui, t, "Colour", |ui| colour(app, ui, "slideshowPlateColor", p.color, &|c| plate(json!({"color": c})))); } - row(ui, t, "Size", |ui| slider(app, ui, "slideshowPlateSize", p.size, 0.01, PLATE_SIZE_MAX, Unit::Percent, &|v| plate(json!({"size": v})))); - row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowPlateOpacity", p.opacity, 0.0, 1.0, Unit::Percent, &|v| plate(json!({"opacity": v})))); + row(ui, t, "Size", |ui| { + slider(app, ui, "slideshowPlateSize", p.size, Range { lo: 0.01, hi: PLATE_SIZE_MAX, unit: Unit::Percent }, &|v| plate(json!({"size": v}))) + }); + row(ui, t, "Opacity", |ui| { + slider(app, ui, "slideshowPlateOpacity", p.opacity, Range { lo: 0.0, hi: 1.0, unit: Unit::Percent }, &|v| plate(json!({"opacity": v}))) + }); placement(app, ui, t, "slideshowPlate", &p.place, true, &plate); toggle(app, ui, "slideshowPlateBehind", p.behind, "Render behind image", &|v| plate(json!({"behind": v}))); } @@ -183,7 +198,9 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let stars = |v: Value| json!({ "ratingStars": v }); if toggle(app, ui, "slideshowStars", r.enabled, "Rating stars", &|v| stars(json!({"enabled": v}))) { row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStarsColor", r.color, &|c| stars(json!({"color": c})))); - row(ui, t, "Size", |ui| slider(app, ui, "slideshowStarsSize", r.size, 0.01, STAR_SIZE_MAX, Unit::Percent, &|v| stars(json!({"size": v})))); + row(ui, t, "Size", |ui| { + slider(app, ui, "slideshowStarsSize", r.size, Range { lo: 0.01, hi: STAR_SIZE_MAX, unit: Unit::Percent }, &|v| stars(json!({"size": v}))) + }); placement(app, ui, t, "slideshowStars", &r.place, true, &stars); } @@ -205,24 +222,36 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { return; // removed: the indices moved; drawn again next frame } row(ui, t, "Size", |ui| { - slider(app, ui, &format!("{id}Size"), o.size, 0.01, TEXT_SIZE_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"size": v}))) + slider(app, ui, &format!("{id}Size"), o.size, Range { lo: 0.01, hi: TEXT_SIZE_MAX / 2.0, unit: Unit::Percent }, &|v| { + wrap(json!({"size": v})) + }) }); row(ui, t, "Colour", |ui| colour(app, ui, &format!("{id}Color"), o.color, &|c| wrap(json!({"color": c})))); - row(ui, t, "Opacity", |ui| slider(app, ui, &format!("{id}Opacity"), o.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"opacity": v})))); + row(ui, t, "Opacity", |ui| { + slider(app, ui, &format!("{id}Opacity"), o.opacity, Range { lo: 0.0, hi: 1.0, unit: Unit::Percent }, &|v| wrap(json!({"opacity": v}))) + }); placement(app, ui, t, &id, &o.place, true, &wrap); let sh = o.shadow; if toggle(app, ui, &format!("{id}Shadow"), sh.on, "Shadow", &|v| wrap(json!({"shadow": v}))) { row(ui, t, "Opacity", |ui| { - slider(app, ui, &format!("{id}ShadowOpacity"), sh.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"shadowOpacity": v}))) + slider(app, ui, &format!("{id}ShadowOpacity"), sh.opacity, Range { lo: 0.0, hi: 1.0, unit: Unit::Percent }, &|v| { + wrap(json!({"shadowOpacity": v})) + }) }); row(ui, t, "Offset", |ui| { - slider(app, ui, &format!("{id}ShadowOffset"), sh.offset, 0.0, SHADOW_OFFSET_MAX, Unit::Percent, &|v| wrap(json!({"shadowOffset": v}))) + slider(app, ui, &format!("{id}ShadowOffset"), sh.offset, Range { lo: 0.0, hi: SHADOW_OFFSET_MAX, unit: Unit::Percent }, &|v| { + wrap(json!({"shadowOffset": v})) + }) }); row(ui, t, "Radius", |ui| { - slider(app, ui, &format!("{id}ShadowRadius"), sh.radius, 0.0, SHADOW_RADIUS_MAX, Unit::Percent, &|v| wrap(json!({"shadowRadius": v}))) + slider(app, ui, &format!("{id}ShadowRadius"), sh.radius, Range { lo: 0.0, hi: SHADOW_RADIUS_MAX, unit: Unit::Percent }, &|v| { + wrap(json!({"shadowRadius": v})) + }) }); row(ui, t, "Angle", |ui| { - slider(app, ui, &format!("{id}ShadowAngle"), sh.angle, -180.0, 180.0, Unit::Degrees, &|v| wrap(json!({"shadowAngle": v}))) + slider(app, ui, &format!("{id}ShadowAngle"), sh.angle, Range { lo: -180.0, hi: 180.0, unit: Unit::Degrees }, &|v| { + wrap(json!({"shadowAngle": v})) + }) }); } } @@ -241,9 +270,13 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let wm = |v: Value| json!({ "watermark": v }); if toggle(app, ui, "slideshowWatermark", s.show_watermark, "Watermark", &|v| json!({"showWatermark": v})) { row(ui, t, "Text", |ui| text(app, ui, "slideshowWatermarkText", &w.text, "© Your Name", &|v| wm(json!({"text": v})))); - row(ui, t, "Size", |ui| slider(app, ui, "slideshowWatermarkSize", w.size as f64, 0.01, 0.15, Unit::Percent, &|v| wm(json!({"size": v})))); + row(ui, t, "Size", |ui| { + slider(app, ui, "slideshowWatermarkSize", w.size as f64, Range { lo: 0.01, hi: 0.15, unit: Unit::Percent }, &|v| wm(json!({"size": v}))) + }); row(ui, t, "Opacity", |ui| { - slider(app, ui, "slideshowWatermarkOpacity", w.opacity as f64, 0.05, 1.0, Unit::Percent, &|v| wm(json!({"opacity": v}))) + slider(app, ui, "slideshowWatermarkOpacity", w.opacity as f64, Range { lo: 0.05, hi: 1.0, unit: Unit::Percent }, &|v| { + wm(json!({"opacity": v})) + }) }); row(ui, t, "Position", |ui| { let mut a = w.anchor; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4c2bed935..ed9c45fb5 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -46,21 +46,26 @@ pub use crate::dialogs::settings::slide_templates; pub use crate::dialogs::settings::slide_titles; use crate::theme::Tokens; +/// A side panel the user resizes: which side, how it is drawn and how wide it may be. +pub struct SidePanel<'a> { + /// the left side (else the right) + pub left: bool, + pub id: &'a str, + pub frame: egui::Frame, + /// the width the user chose + pub width: f32, + pub limits: crate::state::PanelWidth, + /// room to leave for what is beside the photo + pub reserve: f32, +} + /// Show a side panel the user resizes by dragging its inner edge. `width` (in [`crate::UiState`], /// so it survives navigation and restarts) is the source of truth: egui's own remembered size is /// dropped every frame, and a drag of the edge returns the new width to store. The panel never /// takes more than `available − reserve − MIN_PHOTO_WIDTH` (its minimum permitting), so the photo /// stays usable; a narrower window shrinks it without forgetting the chosen width. -pub fn resizable_side( - ui: &mut egui::Ui, - left: bool, - id: &str, - frame: egui::Frame, - width: f32, - limits: crate::state::PanelWidth, - reserve: f32, - add: impl FnOnce(&mut egui::Ui), -) -> Option { +pub fn resizable_side(ui: &mut egui::Ui, side: SidePanel, add: impl FnOnce(&mut egui::Ui)) -> Option { + let SidePanel { left, id, frame, width, limits, reserve } = side; // `Panel::new` stores `IdSalt::new(id)`; the panel's persisted key is // `parent_ui.scope_id().with_salt(that_salt)`, which is exactly `scope_id().with(id)`. // (Passing an already-built `Id` through `Panel::left` would salt it a second time.) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index eee9d7d0c..3eb94779c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -46,7 +46,7 @@ use info::{ preview_only_pill, quick_name, }; pub(crate) use info::{draw_focus_point, failed_pill, loupe_pill, rendering_overdue}; -use masks::{RegionEdit, color_range_pick, mask_overlay, region_overlay}; +use masks::{RegionContext, RegionEdit, color_range_pick, mask_overlay, region_overlay}; use retouch::{eye_overlay, remove_overlay}; pub(crate) use window::overlay_supports_window; use window::{WindowCtx, WindowView, draw_window, request_window}; @@ -541,7 +541,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let people = if app.ui.name_edit.is_some() { app.caches.person_names(&app.session.catalog) } else { Default::default() }; // while the name box is open and recognition is not set up, the box offers the next step let setup = if app.ui.name_edit.is_some() { super::faces::setup(app, ui.ctx()) } else { super::faces::Setup::Running }; - match region_overlay(ui, &p, &map, &photo, d.orientation, hints.as_deref(), &people, &mut app.ui.name_edit, setup) { + let regions = RegionContext { photo: &photo, orient: d.orientation, hints: hints.as_deref(), people: &people, setup }; + match region_overlay(ui, &p, &map, ®ions, &mut app.ui.name_edit) { Some(RegionEdit::SetUp) => { app.ui.name_edit = None; super::faces::take_step(app, ui.ctx(), setup); @@ -596,7 +597,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(&frame, before_img), before_img, *before), _ => (map, img_rect, main_area), }; - general_interaction(app, ui, &resp, &map, img, pane, native, aspect) + general_interaction(app, ui, &resp, &map, &PhotoPlacement { img, canvas: pane, native, aspect }) } } } @@ -761,16 +762,19 @@ fn general_cursor(app: &LightcraftApp, ui: &egui::Ui, resp: &egui::Response, img ui.ctx().set_cursor_icon(cursor); } -fn general_interaction( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - resp: &egui::Response, - map: &CanvasMap, +/// Where the photo is on the canvas. +struct PhotoPlacement { + /// the photo's rectangle img: Rect, + /// the canvas it is shown on canvas: Rect, + /// the photo's size in pixels native: [usize; 2], aspect: f32, -) { +} + +fn general_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, placement: &PhotoPlacement) { + let PhotoPlacement { img, canvas, native, aspect } = *placement; if app.ui.tool == "wbPicker" { if resp.clicked() && let Some(q) = resp.interact_pointer_pos() diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index 50424f6b3..c150eac53 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -33,17 +33,25 @@ const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [ /// on release (one undo step); the × reports the region to remove. Both are catalog-only edits: /// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo; /// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes. +/// What the face/pet/focus regions of a photo are drawn from. +pub(super) struct RegionContext<'a> { + pub photo: &'a lightcraft_catalog::Photo, + /// the user's Rotate / Flip on top of the upright photo + pub orient: lightcraft_geom::Orientation, + pub hints: Option<&'a crate::panels::faces::Hints>, + /// the names to offer when a region is named + pub people: &'a [String], + pub setup: crate::panels::faces::Setup, +} + pub(super) fn region_overlay( ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, - photo: &lightcraft_catalog::Photo, - orient: lightcraft_geom::Orientation, - hints: Option<&crate::panels::faces::Hints>, - people: &[String], + regions: &RegionContext, editing: &mut Option, - setup: crate::panels::faces::Setup, ) -> Option { + let RegionContext { photo, orient, hints, people, setup } = *regions; let t = Tokens::get(p.ctx()); let clip = p.clip_rect(); let pointer = ui.input(|i| i.pointer.hover_pos()); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 522afe084..27b86aa0d 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -30,7 +30,7 @@ pub(crate) use local::{fs_cached, fs_cached_running, same_folder}; use local::{local_now, local_rows}; #[cfg(test)] pub(crate) use rows::{INDENT_STEP, MIN_INDENT_STEP}; -use rows::{LIBFOLDER_REVEALED, SCROLL_TO_ROW, row, set_tree_open}; +use rows::{LIBFOLDER_REVEALED, Row, SCROLL_TO_ROW, set_tree_open}; pub(crate) use rows::{content_width, indent_step, step_for}; pub(crate) use sections::PlacedSection; pub use sections::section_drag_feedback; @@ -90,18 +90,27 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("recentlyAdded", Icon::Clock, "Recently Added", None, LibrarySource::RecentlyAdded), ("picks", Icon::FlagPick, "Picks", Some(picks), LibrarySource::Picks), ] { - if row(app, ui, id, icon, label, count, src == s, 0.0).clicked() { + if Row::new(id, icon, label).count(count).selected(src == s).show(app, ui).clicked() { let _ = app.run("library.source", json!({"kind": id})); } } // photos whose files can't be found (checked every few seconds, not every frame) let missing = missing_count(app, ui); if (missing > 0 || src == LibrarySource::Missing) - && row(app, ui, "missing", Icon::Folder, "Missing Photos", Some(missing), src == LibrarySource::Missing, 0.0).clicked() + && Row::new("missing", Icon::Folder, "Missing Photos") + .count(Some(missing)) + .selected(src == LibrarySource::Missing) + .show(app, ui) + .clicked() { let _ = app.run("library.source", json!({"kind": "missing"})); } - if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() { + if Row::new("recentlyDeleted", Icon::Trash, "Recently Deleted") + .count(Some(deleted)) + .selected(src == LibrarySource::RecentlyDeleted) + .show(app, ui) + .clicked() + { let _ = app.run("library.source", json!({"kind": "recentlyDeleted"})); } // the album just made: the folders down to it open, once (also when the section is shut) diff --git a/crates/ui-egui/src/panels/left/albums.rs b/crates/ui-egui/src/panels/left/albums.rs index bfcc6a219..5237f69b8 100644 --- a/crates/ui-egui/src/panels/left/albums.rs +++ b/crates/ui-egui/src/panels/left/albums.rs @@ -1,5 +1,5 @@ use super::folders::{folder_menu, is_within}; -use super::rows::{INDENT_STEP, indent_x, row_sensed, set_tree_open, tree_open, tree_toggle}; +use super::rows::{INDENT_STEP, Row, indent_x, set_tree_open, tree_open, tree_toggle}; use super::{Album, AlbumId, Align2, Icon, LibrarySource, LightcraftApp, Rect, Sense, Tokens, json, pos2, register, vec2}; /// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made, @@ -34,8 +34,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare open = true; set_tree_open(ui, open_id, true); } - let resp = - row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag(), None); + let resp = Row::new(&format!("folder:{}", a.id.0), Icon::Folder, &a.name).indent(indent).sense(Sense::click_and_drag()).show(app, ui); // a folder is no source, so its row folds it too; the triangle is the same click, aimed let has_children = all.get(&Some(a.id)).is_some_and(|v| !v.is_empty()); if resp.drag_started() { @@ -74,7 +73,12 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare // a smart album whose rules no longer check (an album they test was deleted) says so let problems = app.caches.smart_album_problems(&app.session.catalog).get(&a.id).cloned().unwrap_or_default(); let label = if problems.is_empty() { label } else { format!("⚠ {label}") }; - let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag(), None); + let mut resp = Row::new(&format!("album:{}", a.id.0), icon, &label) + .count(Some(n)) + .selected(sel) + .indent(indent) + .sense(Sense::click_and_drag()) + .show(app, ui); if !problems.is_empty() { crate::widgets::register(ui, format!("albumProblem:{}", a.id.0), resp.rect); } diff --git a/crates/ui-egui/src/panels/left/dates.rs b/crates/ui-egui/src/panels/left/dates.rs index 9b822c6e5..2971cb2f4 100644 --- a/crates/ui-egui/src/panels/left/dates.rs +++ b/crates/ui-egui/src/panels/left/dates.rs @@ -1,4 +1,4 @@ -use super::rows::{INDENT_STEP, row, tree_open, tree_toggle}; +use super::rows::{INDENT_STEP, Row, tree_open, tree_toggle}; use super::{Icon, LibrarySource, LightcraftApp, json}; /// By Date: year → month → day; a click filters by that prefix, the triangle opens a level. @@ -24,7 +24,7 @@ fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, let open_id = egui::Id::unique(("date-open", key.to_string())); let mut open = tree_open(ui, open_id, false); let sel = app.session.filter.date.as_deref() == Some(key); - let resp = row(app, ui, &format!("date:{key}"), Icon::Clock, label, Some(count), sel, indent); + let resp = Row::new(&format!("date:{key}"), Icon::Clock, label).count(Some(count)).selected(sel).indent(indent).show(app, ui); if key.len() < 10 { tree_toggle(ui, &resp, indent, open_id, &mut open, format!("dateToggle:{key}")); } diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs index 1ded12f07..20c1bbce1 100644 --- a/crates/ui-egui/src/panels/left/folders.rs +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -1,7 +1,7 @@ use super::albums::pointer_over_row; use super::local::{same_folder, subfolders}; -use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, out_of_sight, row, row_sensed, set_tree_open, tree_open, tree_toggle}; -use super::{Album, FolderNode, Icon, LibrarySource, LightcraftApp, Sense, Tokens, json, register, vec2}; +use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, Row, out_of_sight, set_tree_open, tree_open, tree_toggle}; +use super::{Album, FolderNode, Icon, LibrarySource, LightcraftApp, Tokens, json, register, vec2}; /// A folder on disk with a disclosure triangle: click browses it, the triangle lists its /// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the @@ -39,7 +39,7 @@ pub(super) fn folder_tree( if !out_of_sight(app, ui, &format!("local:{path}"), name, None, indent, false) { // at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the // icon lines up with the rows above - let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path); + let resp = Row::new(&format!("local:{path}"), Icon::Folder, name).selected(sel).indent(indent).show(app, ui).on_hover_text(path); let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); if !tr.clicked() && resp.clicked() @@ -341,7 +341,7 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s None => name.to_string(), }; let mark = n.label.map(crate::theme::label_color); - let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); + let resp = Row::new(id, Icon::Folder, name).spoken(&spoken).count(Some(n.count)).selected(sel).indent(indent).mark(mark).show(app, ui); folder_drop_target(app, ui, &resp, n, open_id, open); let mut toggled = false; if !n.children.is_empty() { diff --git a/crates/ui-egui/src/panels/left/keywords.rs b/crates/ui-egui/src/panels/left/keywords.rs index 4342efcc6..d8e1720a9 100644 --- a/crates/ui-egui/src/panels/left/keywords.rs +++ b/crates/ui-egui/src/panels/left/keywords.rs @@ -1,5 +1,5 @@ use super::dates::browse_all_photos; -use super::rows::{INDENT_STEP, out_of_sight, row, tree_open, tree_toggle}; +use super::rows::{INDENT_STEP, Row, out_of_sight, tree_open, tree_toggle}; use super::{Icon, KeywordNode, LightcraftApp, json}; /// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click @@ -23,7 +23,7 @@ pub(super) fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[ /// One drawn row of [`keyword_rows`]; `open_id` and `open` are its triangle's when it has rows inside it. fn keyword_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &KeywordNode, id: &str, indent: f32, open_id: Option, open: &mut bool) { let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); - let resp = row(app, ui, id, Icon::Tag, &n.name, Some(n.count), sel, indent); + let resp = Row::new(id, Icon::Tag, &n.name).count(Some(n.count)).selected(sel).indent(indent).show(app, ui); if let Some(k) = open_id { tree_toggle(ui, &resp, indent, k, open, format!("keywordToggle:{}", n.path)); } diff --git a/crates/ui-egui/src/panels/left/local.rs b/crates/ui-egui/src/panels/left/local.rs index 6d3549570..1308059c8 100644 --- a/crates/ui-egui/src/panels/left/local.rs +++ b/crates/ui-egui/src/panels/left/local.rs @@ -1,5 +1,5 @@ use super::folders::folder_tree; -use super::rows::row; +use super::rows::Row; use super::{Icon, LibrarySource, LightcraftApp, SidebarSection, json, register}; /// Local's folders as they stand this frame, and the folder being browsed among them. @@ -61,8 +61,7 @@ pub(super) fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &Local let reveal = local.owner == Some(i); folder_tree(app, ui, path, name, path, 0.0, current, reveal, transient); } - if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked() - { + if app.services.pick_folder.is_some() && Row::new("local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…")).show(app, ui).clicked() { let picked = app.services.pick_folder.as_mut().and_then(|f| f()); if let Some(path) = picked { match app.run("library.browse", json!({"path": path})) { @@ -80,7 +79,8 @@ pub(super) fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &Local let hidden = now.hidden.len(); if hidden > 0 { let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden); - let resp = row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) + let resp = Row::new("local:restoreHidden", Icon::Folder, &label) + .show(app, ui) .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")); if let [(_, only)] = now.hidden.as_slice() { if resp.clicked() { @@ -505,7 +505,7 @@ mod tests { for frame in 0..3 { let raw = HeadlessView::raw_input(egui::vec2(300.0, 200.0), 1.0, frame as f64 / 60.0, vec![]); view.run(raw, |ui| { - super::row(&mut app, ui, "all", super::Icon::Photos, "All Photos", None, false, 0.0); + super::Row::new("all", super::Icon::Photos, "All Photos").show(&mut app, ui); super::folder_tree(&mut app, ui, path, "Pictures", path, 0.0, None, false, false); }); widgets = crate::widgets::take_registry(&view.ctx); diff --git a/crates/ui-egui/src/panels/left/rows.rs b/crates/ui-egui/src/panels/left/rows.rs index edfdff167..ac364aa36 100644 --- a/crates/ui-egui/src/panels/left/rows.rs +++ b/crates/ui-egui/src/panels/left/rows.rs @@ -77,50 +77,66 @@ pub(crate) fn step_for(panel_w: f32, deepest: f32) -> f32 { [16.0, 14.0, 12.0, 10.0].into_iter().find(|step| levels * step <= room).unwrap_or(MIN_INDENT_STEP) } -pub(super) fn row( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, +/// One row of the sidebar, as it is drawn: build it with [`Row::new`] and the setters, then [`Row::show`] it. +pub(super) struct Row<'a> { + id: &'a str, icon: Icon, - label: &str, - count: Option, - selected: bool, - indent: f32, -) -> egui::Response { - row_named(app, ui, id, icon, label, None, count, selected, indent) -} - -/// [`row`] whose spoken name is `spoken` when the painted `label` is a shortened form of it. -fn row_named( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, - icon: Icon, - label: &str, - spoken: Option<&str>, - count: Option, - selected: bool, - indent: f32, -) -> egui::Response { - row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click(), None) -} - -/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`) and a colour -/// label's dot (`mark`) before the count. -#[allow(clippy::too_many_arguments)] -pub(super) fn row_sensed( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, - icon: Icon, - label: &str, - spoken: Option<&str>, + label: &'a str, + /// the spoken name when the painted `label` is a shortened form of it + spoken: Option<&'a str>, count: Option, selected: bool, indent: f32, + /// rows that can be dragged add `drag` sense: Sense, + /// a colour label's dot, before the count mark: Option, -) -> egui::Response { +} + +impl<'a> Row<'a> { + pub(super) fn new(id: &'a str, icon: Icon, label: &'a str) -> Self { + Row { id, icon, label, spoken: None, count: None, selected: false, indent: 0.0, sense: Sense::click(), mark: None } + } + + pub(super) fn spoken(mut self, spoken: &'a str) -> Self { + self.spoken = Some(spoken); + self + } + + pub(super) fn count(mut self, count: Option) -> Self { + self.count = count; + self + } + + pub(super) fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub(super) fn indent(mut self, indent: f32) -> Self { + self.indent = indent; + self + } + + pub(super) fn sense(mut self, sense: Sense) -> Self { + self.sense = sense; + self + } + + pub(super) fn mark(mut self, mark: Option) -> Self { + self.mark = mark; + self + } + + /// Draw the row; the response is the row's. + pub(super) fn show(self, app: &mut LightcraftApp, ui: &mut egui::Ui) -> egui::Response { + draw_row(app, ui, self) + } +} + +/// Draw a [`Row`]. +fn draw_row(app: &mut LightcraftApp, ui: &mut egui::Ui, row: Row) -> egui::Response { + let Row { id, icon, label, spoken, count, selected, indent, sense, mark } = row; let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; let t = Tokens::get(ui.ctx()); let indent = drawn_indent(ui, indent); diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 98b738cf2..0b6b946aa 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -221,7 +221,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let Some(f) = page.confirmed.get(row * cols + col) else { break }; shown += 1; let min = area.min + vec2(PAD + col as f32 * (edge + super::people::TILE_GAP), PAD + row as f32 * row_h); - if let Hit::Open = tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, false, &shown_name, 0) { + if let Hit::Open = + tile(app, ui, &TileSpec { r: Rect::from_min_size(min, vec2(edge, edge)), f, ppp, more: false, name: &shown_name, above_count: 0 }) + { hit = Some((Hit::Open, f.clone())); } } @@ -254,7 +256,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { shown += 1; let min = area.min + vec2(PAD + col as f32 * (edge + super::people::TILE_GAP), grid_top + row as f32 * row_h); let above_count = more.iter().filter(|candidate| candidate.score >= f.score).count(); - match tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, true, &shown_name, above_count) { + match tile(app, ui, &TileSpec { r: Rect::from_min_size(min, vec2(edge, edge)), f, ppp, more: true, name: &shown_name, above_count }) { Hit::Confirm => hit = Some((Hit::Confirm, (*f).clone())), Hit::ConfirmAbove => hit = Some((Hit::ConfirmAbove, (*f).clone())), Hit::Dismiss => hit = Some((Hit::Dismiss, (*f).clone())), @@ -287,9 +289,25 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { } } +/// What one face tile shows. +struct TileSpec<'a> { + /// where it is drawn + r: Rect, + f: &'a Face, + /// pixels per point, for the size of the face picture + ppp: f32, + /// a "More" tile (a face that looks like the person) rather than a confirmed one + more: bool, + /// the person's name + name: &'a str, + /// how many shown faces look at least as much like the person (for a "More" tile) + above_count: usize, +} + /// One face tile. A confirmed one opens its photo when clicked; a "More" one is confirmed when clicked, and has a × that /// hides it. -fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, more: bool, name: &str, above_count: usize) -> Hit { +fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, spec: &TileSpec) -> Hit { + let TileSpec { r, f, ppp, more, name, above_count } = *spec; let t = Tokens::get(ui.ctx()); let salt = if more { "more-face" } else { "person-face" }; let resp = ui.interact(r, egui::Id::unique((salt, f.photo, f.index)), Sense::click()); diff --git a/crates/ui-egui/src/tests/lifecycle_037.rs b/crates/ui-egui/src/tests/lifecycle_037.rs index 3c4e8009b..bb6f9d860 100644 --- a/crates/ui-egui/src/tests/lifecycle_037.rs +++ b/crates/ui-egui/src/tests/lifecycle_037.rs @@ -121,9 +121,20 @@ fn resizable_side_stores_panel_state_under_the_parent_salted_key() { let ctx = egui::Context::default(); let raw = raw(egui::vec2(1000.0, 700.0)); frame(&ctx, raw, |ui| { - crate::panels::resizable_side(ui, true, "side-test", egui::Frame::NONE, 260.0, crate::state::LEFT_WIDTH, 300.0, |ui| { - ui.label("side"); - }); + crate::panels::resizable_side( + ui, + crate::panels::SidePanel { + left: true, + id: "side-test", + frame: egui::Frame::NONE, + width: 260.0, + limits: crate::state::LEFT_WIDTH, + reserve: 300.0, + }, + |ui| { + ui.label("side"); + }, + ); }); let modern = ctx.viewport_id().root_ui_id().with("side-test"); assert!( From 620826cac0b7b789a137c010d13485b75b5b7135 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:45:29 +0000 Subject: [PATCH 70/91] refactor(ui-egui): shorten functions that format localized messages --- crates/ui-egui/src/dialogs/import/task.rs | 25 +++++------ crates/ui-egui/src/export_task.rs | 55 ++++++++++++----------- crates/ui-egui/src/i18n.rs | 6 +++ crates/ui-egui/src/menus/run.rs | 21 ++++----- crates/ui-egui/src/panels/chips.rs | 47 ++++++++++--------- crates/ui-egui/src/panels/compare.rs | 19 +++++--- crates/ui-egui/src/panels/mask_effects.rs | 35 ++++++++++----- crates/ui-egui/src/panels/notices.rs | 30 ++++++++----- crates/ui-egui/src/widgets.rs | 44 ++++++++++-------- crates/ui-egui/src/widgets/text_field.rs | 25 +++++++---- 10 files changed, 182 insertions(+), 125 deletions(-) diff --git a/crates/ui-egui/src/dialogs/import/task.rs b/crates/ui-egui/src/dialogs/import/task.rs index da2c1574f..e1f506d5b 100644 --- a/crates/ui-egui/src/dialogs/import/task.rs +++ b/crates/ui-egui/src/dialogs/import/task.rs @@ -422,19 +422,14 @@ fn xmp_warnings_text(warnings: &[ImportWarning]) -> String { ) } -/// `s` for a count other than one. -fn plural(n: usize) -> &'static str { - if n == 1 { "" } else { "s" } -} - /// The name of the import's one undo step. fn undo_label(task: &ImportTask) -> String { if task.imported == 0 && task.restored > 0 { - crate::i18n::tr_format!("Restore {} Photo{}", task.restored, plural(task.restored)) + crate::i18n::tr_format!("Restore {} Photo{}", task.restored, crate::i18n::plural_s(task.restored)) } else if task.restored > 0 { - crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, plural(task.imported), task.restored) + crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, crate::i18n::plural_s(task.imported), task.restored) } else { - crate::i18n::tr_format!("Add {} Photo{}", task.imported, plural(task.imported)) + crate::i18n::tr_format!("Add {} Photo{}", task.imported, crate::i18n::plural_s(task.imported)) } } @@ -481,20 +476,20 @@ fn quiet_finish(app: &mut LightcraftApp, ctx: &egui::Context, task: &ImportTask) if !task.warnings.is_empty() { app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); } else if task.auto && task.imported > 0 { - app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, plural(task.imported))); + app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, crate::i18n::plural_s(task.imported))); } else if !task.auto && task.failed > 0 { - app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, plural(task.failed))); + app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, crate::i18n::plural_s(task.failed))); } } /// What an import did, in a sentence. fn import_summary(app: &LightcraftApp, task: &ImportTask) -> String { let mut msg = if task.cancelled { - crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) + crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, crate::i18n::plural_s(task.imported)) } else if task.imported == 0 && task.restored > 0 { - crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, plural(task.restored)) + crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, crate::i18n::plural_s(task.restored)) } else { - crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported)) + crate::i18n::tr_format!("Imported {} photo{}", task.imported, crate::i18n::plural_s(task.imported)) }; if task.restored > 0 && (task.imported > 0 || task.cancelled) { msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored)); @@ -502,11 +497,11 @@ fn import_summary(app: &LightcraftApp, task: &ImportTask) -> String { if task.params["mode"] == "move" { msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved)); if task.kept > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, plural(task.kept))); + msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, crate::i18n::plural_s(task.kept))); } } if task.duplicates > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, plural(task.duplicates))); + msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, crate::i18n::plural_s(task.duplicates))); let deleted = deleted_of(app, &task.existing).len(); if deleted > 0 { msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 1aec84820..206161102 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -232,6 +232,35 @@ pub fn poll_photos(app: &mut LightcraftApp, ctx: &egui::Context) { #[cfg(target_arch = "wasm32")] pub fn poll_photos(_: &mut LightcraftApp, _: &egui::Context) {} +/// What a finished export says: how many files were written, skipped or failed, and the Apple Photos note. +fn export_summary(files: &[Value], photos: Option<&Value>, total: usize, cancelled: bool) -> String { + let sheet = files.first().filter(|f| f["contactSheet"] == true); + let ok = files.iter().filter(|f| f.get("path").is_some()).count(); + let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect(); + let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count(); + let mut m = crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total); + if let Some(sheet) = sheet { + m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]); + } + if let Some(pdf) = files.first().filter(|f| f["print"] == true) { + m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]); + } + if skipped > 0 { + m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped); + } + if let Some(first) = failed.first() { + m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error")); + } + if cancelled { + m += " · cancelled"; + } + if let Some(photos) = photos { + m += " · "; + m += &apple_photos_note(photos); + } + m +} + /// Per frame: keep the activity row up to date; when the batch finishes, report it. pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(task) = &app.export else { return }; @@ -244,31 +273,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { let photos_failed = matches!(&r, Ok((_, Some(p), _)) if p.get("error").is_some()); let msg = match r { Ok((files, photos, _)) => { - let sheet = files.first().filter(|f| f["contactSheet"] == true); - let ok = files.iter().filter(|f| f.get("path").is_some()).count(); - let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect(); - let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count(); - let mut m = - crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total); - if let Some(sheet) = sheet { - m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]); - } - if let Some(pdf) = files.first().filter(|f| f["print"] == true) { - m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]); - } - if skipped > 0 { - m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped); - } - if let Some(first) = failed.first() { - m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error")); - } - if cancelled { - m += " · cancelled"; - } - if let Some(photos) = &photos { - m += " · "; - m += &apple_photos_note(photos); - } + let m = export_summary(&files, photos.as_ref(), total, cancelled); app.last_export_result = Some(json!({"files": files, "cancelled": cancelled, "applePhotos": photos})); m } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8be0b3e8e..a7df011c2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -262,6 +262,12 @@ fn catalog_verbatim(language: Locale) -> BTreeMap { language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect() } +/// `"s"` for a count other than one: the plural ending of English messages. Computed before a [`tr_format!`] rather +/// than inside it, since the macro repeats its arguments once per language. +pub(crate) fn plural_s(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + // `tr_format!` (one arm per message, one `format!` per language) is generated by `build.rs` from // `locales/*-formats.json`. include!(concat!(env!("OUT_DIR"), "/tr-formats.rs")); diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index ed45cbcb4..c88a50207 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -1869,11 +1869,6 @@ fn failed_files(v: &Value) -> impl Iterator + '_ { }) } -/// `s` for a count other than one. -fn plural(n: usize) -> &'static str { - if n == 1 { "" } else { "s" } -} - /// Import the `.cube` LUTs (and those inside zips / folders) as profiles; the answer to report back. fn import_luts(app: &mut LightcraftApp, lut_paths: &[&String], news: &mut ImportNews) -> Value { match app.session.execute("profile.import", &json!({"paths": lut_paths})) { @@ -1882,7 +1877,7 @@ fn import_luts(app: &mut LightcraftApp, lut_paths: &[&String], news: &mut Import if profiles > 0 { news.parts.push(crate::i18n::tr_format!( "Imported {profiles} profile{} (Profile browser ▸ their groups)", - plural(profiles), + crate::i18n::plural_s(profiles), profiles = profiles )); } @@ -1922,8 +1917,14 @@ fn presets_sentence(v: &Value, profile_news: bool) -> Option { // a zip of LUTs (or only local presets): the other half already says what happened (0, 0) if profile_news => return None, (0, 0) => "No new presets".to_string(), - (n, 0) => crate::i18n::tr_format!("Imported {n} preset{}", plural(n), n = n), - (n, f) => crate::i18n::tr_format!("Imported {n} preset{}, {f} file{} not readable", plural(n), plural(f), f = f, n = n), + (n, 0) => crate::i18n::tr_format!("Imported {n} preset{}", crate::i18n::plural_s(n), n = n), + (n, f) => crate::i18n::tr_format!( + "Imported {n} preset{}, {f} file{} not readable", + crate::i18n::plural_s(n), + crate::i18n::plural_s(f), + f = f, + n = n + ), }; Some(msg + ¬_carried_over(v)) } @@ -2131,11 +2132,11 @@ fn file_import_curve_presets_command(app: &mut LightcraftApp, ctx: &egui::Contex fn curve_import_message(v: &Value) -> String { let n = v["imported"].as_array().map_or(0, Vec::len); let failed = v["failed"].as_array().map_or(0, Vec::len); - let imported = crate::i18n::tr_format!("Imported {n} point curve preset{}", plural(n), n = n); + let imported = crate::i18n::tr_format!("Imported {n} point curve preset{}", crate::i18n::plural_s(n), n = n); if failed == 0 { return imported; } - imported + &crate::i18n::tr_format!(", {failed} file{} not readable", plural(failed), failed = failed) + imported + &crate::i18n::tr_format!(", {failed} file{} not readable", crate::i18n::plural_s(failed), failed = failed) } /// The `file.exportCurvePresets` command. diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index c18b3ad72..42640503e 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -29,6 +29,31 @@ fn chip_width(ui: &egui::Ui, label: &str, font: egui::FontId) -> f32 { ui.painter().layout_no_wrap(label.to_string(), font, egui::Color32::WHITE).size().x + 12.0 + 22.0 } +/// The colour labels a label chip names, in words ("Red or Blue"). +fn label_names(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String { + use crate::i18n::tr; + let shown = filter.labels_shown().unwrap_or_default(); + let name = |label: &lightcraft_catalog::ColorLabel| crate::i18n::color_label(catalog, *label); + let or = |labels: &[lightcraft_catalog::ColorLabel]| labels.iter().map(name).collect::>().join(&format!(" {} ", tr("or"))); + if !shown.is_empty() { + return or(&shown); + } + let plural = if filter.labels.len() > 1 { format!("({})", or(&filter.labels)) } else { or(&filter.labels) }; + format!("{} {} {plural}", filter.label.as_ref().map(name).unwrap_or_default(), tr("and")) +} + +/// The value of a `prefix: value` chip, localised. +fn prefixed_value(prefix: &str, value: &str, filter: &lightcraft_catalog::Filter) -> String { + use crate::i18n::{date_group_label, tr}; + let date = match prefix { + "Flag" | "Type" => return tr(value).to_string(), + "Date" => &filter.date, + "Imported" => &filter.imported, + _ => return value.to_string(), + }; + date.as_ref().map_or_else(|| value.to_string(), |date| date_group_label(date, false)) +} + /// Localise generated chip labels without rewriting search text or metadata values. pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String { use crate::i18n::{date_group_label, rules_label, tr}; @@ -41,16 +66,7 @@ pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filt return format!("{}: {}", tr("Rules"), rules_label(rules, catalog)); } if chip.clear.get("label").is_some() { - let shown = filter.labels_shown().unwrap_or_default(); - let name = |label: &lightcraft_catalog::ColorLabel| crate::i18n::color_label(catalog, *label); - let or = |labels: &[lightcraft_catalog::ColorLabel]| labels.iter().map(name).collect::>().join(&format!(" {} ", tr("or"))); - let names = if shown.is_empty() { - let plural = if filter.labels.len() > 1 { format!("({})", or(&filter.labels)) } else { or(&filter.labels) }; - format!("{} {} {plural}", filter.label.as_ref().map(name).unwrap_or_default(), tr("and")) - } else { - or(&shown) - }; - return format!("{}: {names}", tr("Label")); + return format!("{}: {}", tr("Label"), label_names(filter, catalog)); } if chip.clear.get("dateFrom").is_some() || chip.clear.get("dateTo").is_some() { return match (&filter.date_from, &filter.date_to) { @@ -64,16 +80,7 @@ pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filt return format!("{} {}", tr("Only"), crate::i18n::tr_format!("{n} photos", n = if filter.only_none { 0 } else { filter.only.len() })); } if let Some((prefix, value)) = chip.label.split_once(": ") { - let value = if matches!(prefix, "Flag" | "Type") { - tr(value).to_string() - } else if prefix == "Date" { - filter.date.as_ref().map_or_else(|| value.to_string(), |date| date_group_label(date, false)) - } else if prefix == "Imported" { - filter.imported.as_ref().map_or_else(|| value.to_string(), |date| date_group_label(date, false)) - } else { - value.to_string() - }; - return format!("{}: {value}", tr(prefix)); + return format!("{}: {}", tr(prefix), prefixed_value(prefix, value, filter)); } if let Some(value) = chip.label.strip_prefix("Rating ") { return format!("{} {value}", tr("Rating")); diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 829500da4..9b9a23673 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -197,6 +197,17 @@ fn area_and_filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui) -> Rect { canvas } +/// Ask for the render of a tile (about `want` pixels on its long edge) and for the thumbnail shown meanwhile. +fn request_tile_renders(app: &mut LightcraftApp, id: PhotoId, slot: Slot, want: usize, aspect: f32) { + let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; + if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) { + app.renderer.request(slot, job, 60); + } + if let Some(job) = app.session.thumb_job(id, 256) { + app.renderer.request(Slot::Thumb(id), job, 30); + } +} + /// Draw one photo fitted into `area` (rendered at its display size into `slot`), with its caption /// strip below. Returns the image rect and the click/drag response. fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slot, area: Rect, label: &str, zoom: Zoom) -> (Rect, egui::Response) { @@ -230,13 +241,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo let texture_side = super::detail::texture_side(ui.ctx()); let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).min(2560.0_f32.min(texture_side as f32)) as usize; - let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; - if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) { - app.renderer.request(slot, job, 60); - } - if let Some(job) = app.session.thumb_job(id, 256) { - app.renderer.request(Slot::Thumb(id), job, 30); - } + request_tile_renders(app, id, slot, want, aspect); // zoomed past what that render holds (1:1 to check focus): the part on screen as a window, as // in the loupe. Compare and Reference only; Survey tiles are fit views. let pane = match slot { diff --git a/crates/ui-egui/src/panels/mask_effects.rs b/crates/ui-egui/src/panels/mask_effects.rs index 86f3796b9..d1c4fe1f4 100644 --- a/crates/ui-egui/src/panels/mask_effects.rs +++ b/crates/ui-egui/src/panels/mask_effects.rs @@ -683,23 +683,38 @@ pub fn manage_body( } } +/// The distinct values, sorted. +fn distinct<'a>(values: impl Iterator) -> Vec<&'a str> { + let mut v: Vec<&str> = values.collect(); + v.sort_unstable(); + v.dedup(); + v +} + /// The toast after an import (`preset.import` / `mask.importEffects` result): how many effects, /// in how many groups, and the settings they carry that LightCraft can't apply. pub fn import_toast(v: &Value) -> String { let rows: Vec<&Value> = v["effects"].as_array().or_else(|| v["imported"].as_array()).into_iter().flatten().collect(); - let n = rows.len(); - let mut msg = crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", if n == 1 { "" } else { "s" }, e = n); - let mut groups: Vec<&str> = rows.iter().filter_map(|r| r["group"].as_str()).filter(|g| !g.is_empty()).collect(); - groups.sort_unstable(); - groups.dedup(); + let mut msg = imported_effects_message(rows.len()); + let groups = distinct(rows.iter().filter_map(|r| r["group"].as_str()).filter(|g| !g.is_empty())); if !groups.is_empty() { - msg += &crate::i18n::tr_format!(" in {g} group{}", if groups.len() == 1 { "" } else { "s" }, g = groups.len()); + msg += &in_groups_message(groups.len()); } - let mut skipped: Vec<&str> = rows.iter().flat_map(|r| r["unmapped"].as_array().into_iter().flatten()).filter_map(Value::as_str).collect(); - skipped.sort_unstable(); - skipped.dedup(); + let skipped = distinct(rows.iter().flat_map(|r| r["unmapped"].as_array().into_iter().flatten()).filter_map(Value::as_str)); if !skipped.is_empty() { - msg += &crate::i18n::tr_format!(" — kept but not applied: {}", skipped.join(", ")); + msg += ¬_applied_message(&skipped); } msg } + +fn imported_effects_message(n: usize) -> String { + crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", crate::i18n::plural_s(n), e = n) +} + +fn in_groups_message(groups: usize) -> String { + crate::i18n::tr_format!(" in {g} group{}", crate::i18n::plural_s(groups), g = groups) +} + +fn not_applied_message(settings: &[&str]) -> String { + crate::i18n::tr_format!(" — kept but not applied: {}", settings.join(", ")) +} diff --git a/crates/ui-egui/src/panels/notices.rs b/crates/ui-egui/src/panels/notices.rs index 4dbbc1fd9..96e356bb4 100644 --- a/crates/ui-egui/src/panels/notices.rs +++ b/crates/ui-egui/src/panels/notices.rs @@ -40,6 +40,22 @@ pub fn gpu_notice(reason: Option<&str>, enabled: bool) -> Option { Some(text) } +/// The quit prompt's text while `count` cancellable tasks run (`names` lists them). +fn tasks_message(count: usize, names: &str) -> String { + if count == 1 { + crate::i18n::tr_format!("1 task is still running: {names}. Quitting stops it.", names = names) + } else { + crate::i18n::tr_format!("{n} tasks are still running: {names}. Quitting stops them.", n = count, names = names) + } +} + +/// The quit prompt's text when `n` changes couldn't be written to disk, for the reason `e`. +fn unsaved_message(n: usize, e: &str) -> String { + let s = crate::i18n::plural_s(n); + let lost = if n == 1 { "it is" } else { "they are" }; + crate::i18n::tr_format!("{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.", s, lost, n = n, e = e) +} + /// The window may close now (`true`), or the quit prompt is shown (`false`). Running tasks that /// can be cancelled (an import, an export…) are asked about first; then changes that couldn't be /// written are retried — the log, then a snapshot of everything in memory. @@ -51,11 +67,7 @@ pub fn may_close(app: &mut LightcraftApp) -> bool { let running = app.session.activity.running_cancellable(); if !running.is_empty() { let names = running.iter().map(|t| crate::i18n::tr(&t.label)).collect::>().join(", "); - app.quit_prompt = Some(QuitPrompt::Tasks(if running.len() == 1 { - crate::i18n::tr_format!("1 task is still running: {names}. Quitting stops it.", names = names) - } else { - crate::i18n::tr_format!("{n} tasks are still running: {names}. Quitting stops them.", n = running.len(), names = names) - })); + app.quit_prompt = Some(QuitPrompt::Tasks(tasks_message(running.len(), &names))); return false; } if app.session.unsaved().is_none() { @@ -70,13 +82,7 @@ pub fn may_close(app: &mut LightcraftApp) -> bool { match app.session.unsaved() { None => true, Some((n, e)) => { - app.quit_prompt = Some(QuitPrompt::Unsaved(crate::i18n::tr_format!( - "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.", - if n == 1 { "" } else { "s" }, - if n == 1 { "it is" } else { "they are" }, - n = n, - e = e - ))); + app.quit_prompt = Some(QuitPrompt::Unsaved(unsaved_message(n, e))); false } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 558edfe8d..513208066 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -337,24 +337,9 @@ pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String { /// scrub rather than a click that types a value. const SCRUB_START: f32 = 3.0; -/// The radius of a slider's hollow ring thumb. -const THUMB_RING: f32 = 7.0; - -/// A Lightroom slider row (label + value above a track with a hollow ring thumb). -/// Double-click the label or thumb to reset (hovering either says so). Shift-drag = fine adjustment. -/// Scroll sideways over it to nudge it. Click the value to type one: Return or clicking away applies it, -/// Esc keeps the old one. Drag the value sideways to scrub it, as if dragging the thumb (Shift: a tenth -/// as far). -pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut { - let t = Tokens::get(ui.ctx()); - let w = ui.available_width(); - let (row, _) = ui.allocate_exact_size(vec2(w, t.slider_row_h), Sense::hover()); - let pad_l = 24.0; - let pad_r = 22.0; - let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0)); - let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0)); - let id = ui.scope_id().with(spec.id); - let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); +/// Which slider the vertical wheel edits: clicking or dragging a slider arms it (and disarms the others), leaving +/// its row or its focus disarms it. Returns whether this slider is armed, and whether the pointer is in its row. +fn update_wheel_arming(ui: &mut Ui, id: egui::Id, resp: &egui::Response, row: Rect, enabled: bool) -> (bool, bool) { // Keyboard focus survives leaving the row; vertical wheel edits need a fresh click or drag // in it (at most one slider is armed: `wheel_slider_key`). let wheel_id = id.with("wheel-rem"); @@ -394,6 +379,29 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ }); wheel_armed = false; } + (wheel_armed, pointer_in_row) +} + +/// The radius of a slider's hollow ring thumb. +const THUMB_RING: f32 = 7.0; + +/// A Lightroom slider row (label + value above a track with a hollow ring thumb). +/// Double-click the label or thumb to reset (hovering either says so). Shift-drag = fine adjustment. +/// Scroll sideways over it to nudge it. Click the value to type one: Return or clicking away applies it, +/// Esc keeps the old one. Drag the value sideways to scrub it, as if dragging the thumb (Shift: a tenth +/// as far). +pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let (row, _) = ui.allocate_exact_size(vec2(w, t.slider_row_h), Sense::hover()); + let pad_l = 24.0; + let pad_r = 22.0; + let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0)); + let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0)); + let id = ui.scope_id().with(spec.id); + let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); + let wheel_id = id.with("wheel-rem"); + let (wheel_armed, pointer_in_row) = update_wheel_arming(ui, id, &resp, row, enabled); // screen readers: a slider named after its control, with its value let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string(); resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone())); diff --git a/crates/ui-egui/src/widgets/text_field.rs b/crates/ui-egui/src/widgets/text_field.rs index cb150080b..6b5c60c55 100644 --- a/crates/ui-egui/src/widgets/text_field.rs +++ b/crates/ui-egui/src/widgets/text_field.rs @@ -162,14 +162,10 @@ impl<'a> TextField<'a> { self } - /// Draw the field and return its response, any ending this frame, and whether an edit is - /// under way. Per-frame state keeps the pre-edit text for Esc, the selection across the - /// context menu, and select-on-focus; single-line Enter ends the edit, and focus loss ends it - /// except when the focus went to the field's own menu (a choice from the menu resumes the - /// edit). The menu offers cut/copy/paste/select-all (paste is omitted on the web; egui also - /// handles their keyboard shortcuts), and a field not drawn on the previous pass starts clean. - pub fn show(self, ui: &mut Ui) -> TextFieldResponse { - let id = id(self.widget); + /// The field's remembered state at the start of a pass: a field not drawn on the previous pass starts afresh, + /// a begun edit or a choice made from the menu last frame is carried out, and a field focused by the previous + /// pass selects its text when it asks to. + fn prepare(&self, ui: &mut Ui, id: egui::Id) -> Memo { let mut memo: Memo = ui.data_mut(|d| d.get_temp(id.with("memo"))).unwrap_or_default(); // a field not drawn last frame (its dialog or panel closed) starts afresh: an edit, a menu // or a choice from it that was under way is gone with it @@ -207,6 +203,19 @@ impl<'a> TextField<'a> { memo.before = Some(self.text.clone()); select_all(ui.ctx(), id, self.text); } + memo + } + + /// Draw the field and return its response, any ending this frame, and whether an edit is + /// under way. Per-frame state keeps the pre-edit text for Esc, the selection across the + /// context menu, and select-on-focus; single-line Enter ends the edit, and focus loss ends it + /// except when the focus went to the field's own menu (a choice from the menu resumes the + /// edit). The menu offers cut/copy/paste/select-all (paste is omitted on the web; egui also + /// handles their keyboard shortcuts), and a field not drawn on the previous pass starts clean. + pub fn show(self, ui: &mut Ui) -> TextFieldResponse { + let id = id(self.widget); + let pass = ui.ctx().cumulative_pass_nr(); + let mut memo = self.prepare(ui, id); let name = self.label.clone().or_else(|| self.hint.clone()); let mut edit = if self.multiline { egui::TextEdit::multiline(self.text) } else { egui::TextEdit::singleline(self.text) }; edit = edit.id(id); From 4ed9a5c86936708dc27c594f0113a69085413c9c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 03:20:48 +0000 Subject: [PATCH 71/91] refactor(ui-egui): split the grid cell and context menu --- crates/ui-egui/src/panels/grid.rs | 170 ++++++++++++++++------- crates/ui-egui/src/panels/left/albums.rs | 46 +++--- 2 files changed, 149 insertions(+), 67 deletions(-) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 262979cc5..5064b8d40 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -770,7 +770,34 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square register(ui, format!("thumb:{}", id.0), r); let state = app.session.selection.state_of(id); let selected = state != lightcraft_engine::SelectionState::NotSelected; - // screen readers: the file, then rating / flag / label + let spoken = cell_description(app, &photo); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &spoken)); + let active = state == lightcraft_engine::SelectionState::Active; + let p = ui.painter(); + let img_rect = if square { + let base = if selected { t.cell_selected } else { t.cell }; + p.rect_filled(r, 0.0, crate::theme::label_background(base, photo.label, selected)); + Rect::from_min_max(r.min + vec2(10.0, 24.0), r.max - vec2(10.0, 10.0)) + } else { + r + }; + paint_cell_thumbnail(app, p, &t, id, img_rect, ThumbFlags { square, selected, active, onscreen, ppp }); + // labels and badges + paint_cell_caption(app, p, &t, &photo, r, square); + paint_cell_badges(app, p, &t, &photo, img_rect, BadgeFlags { square, selected, hovered: resp.hovered() }); + if let Some(name) = &photo.copy_name { + paint_copy_tag(p, &t, name, img_rect, square); + } + if let Some(why) = &photo.preview_only { + paint_preview_pill(ui, p, &t, id, why, img_rect); + } + if photo.flag == Flag::Reject { + p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110)); + } + cell_interaction(app, ui, &resp, id, &photo); +} + +fn cell_description(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> String { let mut spoken = photo.file_name.clone(); if photo.rating > 0 { spoken.push_str(&crate::i18n::tr_format!(", {} star{}", photo.rating, if photo.rating == 1 { "" } else { "s" })); @@ -783,17 +810,11 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square if let Some(l) = photo.label { spoken.push_str(&crate::i18n::tr_format!(", {} label", crate::i18n::color_label(&app.session.catalog, l))); } - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &spoken)); - let active = state == lightcraft_engine::SelectionState::Active; - let p = ui.painter(); - let img_rect = if square { - let base = if selected { t.cell_selected } else { t.cell }; - p.rect_filled(r, 0.0, crate::theme::label_background(base, photo.label, selected)); - Rect::from_min_max(r.min + vec2(10.0, 24.0), r.max - vec2(10.0, 10.0)) - } else { - r - }; - // thumbnail + spoken +} + +fn paint_cell_thumbnail(app: &mut LightcraftApp, p: &egui::Painter, t: &Tokens, id: PhotoId, img_rect: Rect, flags: ThumbFlags) { + let ThumbFlags { square, selected, active, onscreen, ppp } = flags; let size = thumb_px(img_rect.width().max(img_rect.height()), ppp); request_thumb(app, id, size, if onscreen { 10 } else { 5 }); if let Some(tex) = app.renderer.thumb(id) { @@ -822,7 +843,9 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text); } } - // labels and badges +} + +fn paint_cell_caption(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, photo: &lightcraft_catalog::Photo, r: Rect, square: bool) { if square && app.ui.show_filenames { let m = &photo.meta; let name = match app.ui.grid_info.as_str() { @@ -850,8 +873,12 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.rect_filled(br, 2.0, Color32::from_gray(26)); p.galley(br.min + vec2(4.0, 1.5), g, t.text_label); } +} + +fn paint_cell_badges(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, photo: &lightcraft_catalog::Photo, img_rect: Rect, flags: BadgeFlags) { + let BadgeFlags { square, selected, hovered } = flags; let show_badges = match app.ui.settings.grid_badges { - crate::state::GridBadges::Auto => resp.hovered() || selected || photo.rating > 0 || photo.flag != Flag::None || photo.label.is_some(), + crate::state::GridBadges::Auto => hovered || selected || photo.rating > 0 || photo.flag != Flag::None || photo.label.is_some(), crate::state::GridBadges::Always => true, crate::state::GridBadges::Never => false, }; @@ -863,7 +890,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } if show_badges { let bar = badge_bar; - if (resp.hovered() || selected) && (square || photo.label.is_none()) { + if (hovered || selected) && (square || photo.label.is_none()) { p.rect_filled(bar, 0.0, Color32::from_black_alpha(120)); } let mut x = bar.left() + 6.0; @@ -882,40 +909,41 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.circle(pos2(right - 12.0, bar.center().y), 5.0, label_color(l), Stroke::new(1.0, Color32::from_black_alpha(120))); } } - if let Some(name) = &photo.copy_name { - // virtual copy: a folded-corner tag at the top right - let g = p.layout_no_wrap(name.clone(), t.semibold(10.5), Color32::WHITE); - let tr = if square { img_rect.right_top() + vec2(-6.0, 6.0) } else { img_rect.right_top() + vec2(-5.0, 5.0) }; - let br = Rect::from_min_max(pos2(tr.x - g.size().x - 26.0, tr.y), pos2(tr.x, tr.y + 20.0)); - p.rect_filled(br, 10.0, Color32::from_black_alpha(165)); - let c = pos2(br.left() + 13.0, br.center().y); - let s = 5.0; - p.add(egui::Shape::convex_polygon( - vec![c + vec2(-s, -s), c + vec2(s * 0.3, -s), c + vec2(s, -s * 0.3), c + vec2(s, s), c + vec2(-s, s)], - Color32::TRANSPARENT, - Stroke::new(1.2, Color32::WHITE), - )); - p.line_segment([c + vec2(s * 0.3, -s), c + vec2(s * 0.3, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); - p.line_segment([c + vec2(s * 0.3, -s * 0.3), c + vec2(s, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); - p.galley(pos2(br.left() + 22.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); - } - if let Some(why) = &photo.preview_only { - // a raw shown from its embedded JPEG: a small amber "Preview" pill at the image's bottom - // left, above the badge bar (always shown: it changes what editing does) - let g = p.layout_no_wrap(crate::i18n::tr("Preview").into(), t.semibold(9.5), Color32::WHITE); - let at = pos2(img_rect.left() + 6.0, img_rect.bottom() - 30.0 - 16.0); - let br = Rect::from_min_size(at, vec2(g.size().x + 24.0, 16.0)); - p.rect_filled(br, 8.0, Color32::from_black_alpha(170)); - paint(p, Rect::from_center_size(pos2(br.left() + 9.0, br.center().y), vec2(12.0, 12.0)), Icon::Info, t.caution); - p.galley(pos2(br.left() + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); - register(ui, format!("badge:previewOnly:{}", id.0), br); - ui.interact(br, egui::Id::unique(("preview-only-badge", id.0)), Sense::hover()) - .on_hover_text(crate::i18n::tr_format!("Preview only — {}", crate::widgets::preview_only_explanation(why))); - } - if photo.flag == Flag::Reject { - p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110)); - } - // interaction +} + +fn paint_copy_tag(p: &egui::Painter, t: &Tokens, name: &str, img_rect: Rect, square: bool) { + // virtual copy: a folded-corner tag at the top right + let g = p.layout_no_wrap(name.to_string(), t.semibold(10.5), Color32::WHITE); + let tr = if square { img_rect.right_top() + vec2(-6.0, 6.0) } else { img_rect.right_top() + vec2(-5.0, 5.0) }; + let br = Rect::from_min_max(pos2(tr.x - g.size().x - 26.0, tr.y), pos2(tr.x, tr.y + 20.0)); + p.rect_filled(br, 10.0, Color32::from_black_alpha(165)); + let c = pos2(br.left() + 13.0, br.center().y); + let s = 5.0; + p.add(egui::Shape::convex_polygon( + vec![c + vec2(-s, -s), c + vec2(s * 0.3, -s), c + vec2(s, -s * 0.3), c + vec2(s, s), c + vec2(-s, s)], + Color32::TRANSPARENT, + Stroke::new(1.2, Color32::WHITE), + )); + p.line_segment([c + vec2(s * 0.3, -s), c + vec2(s * 0.3, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); + p.line_segment([c + vec2(s * 0.3, -s * 0.3), c + vec2(s, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); + p.galley(pos2(br.left() + 22.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); +} + +fn paint_preview_pill(ui: &egui::Ui, p: &egui::Painter, t: &Tokens, id: PhotoId, why: &str, img_rect: Rect) { + // a raw shown from its embedded JPEG: a small amber "Preview" pill at the image's bottom + // left, above the badge bar (always shown: it changes what editing does) + let g = p.layout_no_wrap(crate::i18n::tr("Preview").into(), t.semibold(9.5), Color32::WHITE); + let at = pos2(img_rect.left() + 6.0, img_rect.bottom() - 30.0 - 16.0); + let br = Rect::from_min_size(at, vec2(g.size().x + 24.0, 16.0)); + p.rect_filled(br, 8.0, Color32::from_black_alpha(170)); + paint(p, Rect::from_center_size(pos2(br.left() + 9.0, br.center().y), vec2(12.0, 12.0)), Icon::Info, t.caution); + p.galley(pos2(br.left() + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + register(ui, format!("badge:previewOnly:{}", id.0), br); + ui.interact(br, egui::Id::unique(("preview-only-badge", id.0)), Sense::hover()) + .on_hover_text(crate::i18n::tr_format!("Preview only — {}", crate::widgets::preview_only_explanation(why))); +} + +fn cell_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, id: PhotoId, photo: &lightcraft_catalog::Photo) { if let Some(k) = app.ui.keyword_painter.clone() { // painting: a click toggles the keyword on this photo if resp.hovered() { @@ -950,7 +978,21 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } app.ui.dragging_photos = Some(app.session.selection.ids.iter().map(|p| p.0).collect()); } - crate::widgets::context_menu(&resp, |ui| context_menu(app, ui, id)); + crate::widgets::context_menu(resp, |ui| context_menu(app, ui, id)); +} + +struct ThumbFlags { + square: bool, + selected: bool, + active: bool, + onscreen: bool, + ppp: f32, +} + +struct BadgeFlags { + square: bool, + selected: bool, + hovered: bool, } /// The header of a Local folder view: the path as a breadcrumb (each part opens that folder), @@ -1215,6 +1257,16 @@ pub fn toggle_stack(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { } pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + context_menu_open_and_edit(app, ui, id); + context_menu_rate(app, ui); + context_menu_albums(app, ui, id); + context_menu_file_actions(app, ui); + context_menu_stack_and_edits(app, ui, id); + context_menu_locate_and_export(app, ui, id); + context_menu_delete(app, ui); +} + +fn context_menu_open_and_edit(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if !app.session.selection.contains(id) { let _ = app.run("library.select", json!({"ids": [id.0]})); } @@ -1266,6 +1318,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.toast(ui.ctx(), e); } ui.separator(); +} + +fn context_menu_rate(app: &mut LightcraftApp, ui: &mut egui::Ui) { crate::widgets::menu_button(ui, crate::i18n::tr("Set Rating"), |ui| { for r in 0..=5 { if ui.button(if r == 0 { crate::i18n::tr("No Stars").to_string() } else { "★".repeat(r) }).clicked() { @@ -1281,6 +1336,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); +} + +fn context_menu_albums(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if local_only(app, &app.session.selected()) { // albums hold library photos (issue #684): nothing here to add until they join My Photos let add = ui.add_enabled(false, egui::Button::new(crate::i18n::tr("Add to Album"))).on_disabled_hover_text(albums_hint()); @@ -1323,6 +1381,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("album.setCover", json!({"id": aid.0, "photo": id.0})); } } +} + +fn context_menu_file_actions(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ui.button(crate::i18n::tr("Rename…")).clicked() { let _ = app.run("dialog.rename", json!({})); } @@ -1354,6 +1415,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Create Version")).clicked() { let _ = app.run("version.create", json!({})); } +} + +fn context_menu_stack_and_edits(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { crate::widgets::menu_button(ui, crate::i18n::tr("Stack"), |ui| { let stacked = app.session.catalog.stack_of(id).is_some(); let several = app.session.selection.ids.len() > 1; @@ -1405,6 +1469,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("photo.rotateRight", json!({})); } ui.separator(); +} + +fn context_menu_locate_and_export(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // the original moved or its drive is gone: point the photo at the file again // (a cached answer, checked off the UI thread) let missing = matches!(&app.session.catalog.photo(id).map(|p| p.source.clone()), Some(lightcraft_catalog::Source::File { path }) if app.session.media.availability.is_offline(path)); @@ -1437,6 +1504,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.separator(); +} + +fn context_menu_delete(app: &mut LightcraftApp, ui: &mut egui::Ui) { if crate::menubar::selection_deleted(app) { if ui.button(crate::i18n::tr("Restore")).clicked() { let _ = app.run("photo.restore", json!({})); diff --git a/crates/ui-egui/src/panels/left/albums.rs b/crates/ui-egui/src/panels/left/albums.rs index 5237f69b8..92498eb02 100644 --- a/crates/ui-egui/src/panels/left/albums.rs +++ b/crates/ui-egui/src/panels/left/albums.rs @@ -397,23 +397,35 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response let t = Tokens::get(ui.ctx()); ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); if released { - match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) { - Ok(r) => { - let n = r.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0); - let skipped = r.get("skipped").and_then(serde_json::Value::as_u64).unwrap_or(0); - let mut msg = crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n); - if skipped > 0 { - msg.push_str(" · "); - msg.push_str(&crate::i18n::tr_format!( - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)", - if skipped == 1 { "" } else { "s" }, - n = skipped - )); - } - app.toast(ui.ctx(), msg); - } - Err(e) => app.toast(ui.ctx(), e), - } + drop_photos_on_album(app, ui, a, ids); app.ui.dragging_photos = None; } } + +/// Adds the dragged photos to the album and toasts how many went in (and how many Local ones were skipped). +fn drop_photos_on_album(app: &mut LightcraftApp, ui: &egui::Ui, a: &Album, ids: Vec) { + match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) { + Ok(r) => app.toast(ui.ctx(), added_message(a, &r)), + Err(e) => app.toast(ui.ctx(), e), + } +} + +/// "Added N photos to “album”", plus a note when Local photos were skipped. +fn added_message(a: &Album, r: &serde_json::Value) -> String { + let n = r.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0); + let skipped = r.get("skipped").and_then(serde_json::Value::as_u64).unwrap_or(0); + let mut msg = crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n); + if skipped > 0 { + msg.push_str(" · "); + msg.push_str(&skipped_note(skipped)); + } + msg +} + +fn skipped_note(skipped: u64) -> String { + crate::i18n::tr_format!( + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)", + if skipped == 1 { "" } else { "s" }, + n = skipped + ) +} From f858578f8c014104cdf3a9f5b468fdccd20e020e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 03:20:48 +0000 Subject: [PATCH 72/91] refactor(ui-egui): split the detail view, mask overlay and denoise panel --- crates/ui-egui/src/panels/denoise.rs | 64 +- crates/ui-egui/src/panels/detail.rs | 921 ++++++++++++++-------- crates/ui-egui/src/panels/detail/masks.rs | 332 ++++---- 3 files changed, 802 insertions(+), 515 deletions(-) diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index fdb208b51..b72df10b2 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -319,6 +319,13 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V if status["enabled"] != true { return; } + speed_section(app, ui, t, list, &status); + photos_section(app, ui, t, list, &status); + cache_section(app, ui, t, list, &status); +} + +/// Where the work runs. +fn speed_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) { heading(ui, t, "Speed"); let current = list["runOn"].as_str().unwrap_or("auto").to_string(); let mut run_on = current.as_str(); @@ -342,6 +349,10 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V app.caches.denoise.epoch += 1; } } +} + +/// Whether photos are made as they are looked at, and the progress of what is being made. +fn photos_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) { heading(ui, t, "Photos"); let mut auto = list["auto"].as_bool().unwrap_or(true); if check(ui, "settings.denoiseAuto", &mut auto, "Make the denoised picture of the photos I am looking at") { @@ -350,27 +361,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V } hint(ui, t, "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing."); let (running, queued) = (app.caches.denoise.running, app.caches.denoise.queued); - match running { - Some((done, total)) => { - let name = status["running"]["photo"] - .as_u64() - .and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone())) - .unwrap_or_default(); - let text = tr_format!( - "Denoising {name}: {done} of {total} tiles{}", - if queued > 0 { tr_format!(" · {queued} waiting", queued = queued) } else { String::new() }, - name = name, - done = done, - total = total - ); - settings_bar(ui, done, total, text); - } - None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")), - None => { - let ready = status["ready"].as_u64().unwrap_or(0); - hint(ui, t, &count(ready, "photo has its denoised picture.", "photos have their denoised picture.")) - } - } + progress_line(app, ui, t, status, running, queued); ui.horizontal(|ui| { let r = ui.button(tr("Denoise all photos with an Amount")); register(ui, "denoise:queueAll", r.rect); @@ -385,6 +376,37 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V } } }); +} + +/// "Denoising {photo}: {done} of {total} tiles", and how many photos wait. +fn running_text(app: &LightcraftApp, status: &Value, done: u64, total: u64, queued: u64) -> String { + let name = + status["running"]["photo"].as_u64().and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone())).unwrap_or_default(); + tr_format!("Denoising {name}: {done} of {total} tiles{}", waiting_suffix(queued), name = name, done = done, total = total) +} + +/// " · N waiting" after the running photo, when any wait. +fn waiting_suffix(queued: u64) -> String { + if queued > 0 { tr_format!(" · {queued} waiting", queued = queued) } else { String::new() } +} + +/// What is being made now, or how many photos wait or are done. +fn progress_line(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, status: &Value, running: Option<(u64, u64)>, queued: u64) { + match running { + Some((done, total)) => { + let text = running_text(app, status, done, total, queued); + settings_bar(ui, done, total, text); + } + None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")), + None => { + let ready = status["ready"].as_u64().unwrap_or(0); + hint(ui, t, &count(ready, "photo has its denoised picture.", "photos have their denoised picture.")) + } + } +} + +/// The cache's contents and limit. +fn cache_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) { heading(ui, t, "Cache"); let (files, bytes) = (status["cache"]["files"].as_u64().unwrap_or(0), status["cache"]["bytes"].as_u64().unwrap_or(0)); hint( diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 3eb94779c..2b58595d2 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -138,11 +138,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max)); } let Some(id) = app.session.active() else { - if super::empty_library::shown(&app.session) { - super::empty_library::show(app, ui, canvas); - } else { - super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); - } + show_no_photo(app, ui, canvas); return; }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; @@ -154,47 +150,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool, photo.embedded_lens.as_ref()); let aspect = frame.aspect() as f32; let ppp = ui.ctx().pixels_per_point(); - let area = canvas.shrink(if fullscreen { - 0.0 - } else if crop_tool { - 48.0 - } else { - 24.0 - }); + let area = canvas.shrink(loupe_margin(fullscreen, crop_tool)); let native = output_px(&frame); let texture_side = texture_side(ui.ctx()); // two views (before, after): side by side or stacked let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom); let split_view = matches!(app.ui.before_after, BeforeAfter::Split | BeforeAfter::SplitTopBottom); - let areas: Vec = match app.ui.before_after { - BeforeAfter::SideBySide => { - let half = area.width() / 2.0 - 6.0; - vec![ - Rect::from_min_size(area.min, vec2(half, area.height())), - Rect::from_min_size(pos2(area.center().x + 6.0, area.top()), vec2(half, area.height())), - ] - } - BeforeAfter::TopBottom => { - let half = area.height() / 2.0 - 14.0; - vec![ - Rect::from_min_size(area.min, vec2(area.width(), half)), - Rect::from_min_size(pos2(area.left(), area.center().y + 14.0), vec2(area.width(), half)), - ] - } - _ => vec![area], - }; + let areas = view_areas(app.ui.before_after, area); let main_area = *areas.last().unwrap_or(&area); - // A previous crop/rotation can stay visible while its replacement renders. Its pixels - // must not override the current frame geometry, even when it is the same photo. - let current_texture = |t: &&crate::render::Tex| t.photo == id && t.look == Some((crate::render::geometry_look(&d), !crop_tool)); - let visible_texture_aspect = app - .renderer - .textures - .get(&Slot::Main) - .filter(current_texture) - .or_else(|| app.renderer.textures.get(&Slot::Preview).filter(current_texture)) - .or_else(|| app.renderer.thumb(id).filter(current_texture)) - .map(|tex| tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32); + let visible_texture_aspect = visible_texture_aspect(app, id, &d, crop_tool); // Follow the texture only where it really differs (an unsupported raw's embedded JPEG with // another crop): a texture's whole-pixel size is a little off the exact aspect, and the crop // tool's overlays need the frame's own geometry. @@ -204,15 +168,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let mut target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); let mut img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect); let in_pane = |pane: Rect, image: Rect| image.translate(pane.center() - main_area.center()); - let mut navigating = false; - for &view_area in &areas { + let navigating = areas.iter().any(|&view_area| { let view_img = in_pane(view_area, img_rect); - if navigate_gesture(app, ui, &resp, view_area, view_img, native) || middle_pan(app, ui, &resp, view_area, view_img) { - navigating = true; - target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); - img_rect = target_rect; - break; - } + navigate_gesture(app, ui, &resp, view_area, view_img, native) || middle_pan(app, ui, &resp, view_area, view_img) + }); + if navigating { + target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); + img_rect = target_rect; } let before_img = in_pane(areas[0], img_rect); let before_target = in_pane(areas[0], target_rect); @@ -222,14 +184,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let overlay = view_overlay(app, &d); // a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`; // `ready` first: `available` would wait for a device still being created) - let gpu = interacting - && lightcraft_engine::gpu::handles( - lightcraft_pipeline::OutputDepth::U8, - app.ui.soft_proof && lightcraft_pipeline::proof_profile(app.ui.proof.profile).is_some(), - overlay, - ) - && lightcraft_engine::gpu::ready() - && lightcraft_engine::gpu::available(); + let gpu = interacting && gpu_draws_view(app, overlay); let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str())); let now = ui.input(|i| i.time); @@ -250,10 +205,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { draft, windows: overlay_supports_window(overlay), }; - let mut plan = crate::region::plan(&app.ui.settings, sizes); - if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { - plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes }); - } + let plan = loupe_plan(app, id, look, sizes); // time the full-size drafts where they are what shows: under a window the whole-frame draft // is small anyway, and the frame's time is the window's upload if let Some((key, ms)) = shown.filter(|_| plan.window_edge.is_none()) { @@ -271,14 +223,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // copied 1:1 (no resample in the pipeline, whose prefilter and bilinear pass soften detail near // the output's limit) and shrunk once, by the Lanczos display fit below. // (zoomed in under a window render, the whole frame is hidden: it stays canvas-sized) - let want = if interacting || plan.window_edge.is_some() { - plan.main_edge - } else { - // (a user's explicit Preview size below the preview level still caps it) - let limit = if app.ui.settings.preview_limit == 0 { usize::MAX } else { app.ui.settings.preview_limit as usize }; - let floor = lightcraft_engine::SourceLevel::Preview.max_edge().min(limit).min(native_long); - plan.main_edge.max(floor).min(texture_side.max(crate::state::MIN_TEXTURE_SIDE)) - }; + let want = whole_frame_edge(app, interacting, &plan, native_long, texture_side); // The short side is rounded, not truncated: a truncated box (2560 x 1706 for a 2560 x 1707 // source) made the render a slightly anisotropic resample of its source, a 2-tap blur along // one axis only. A box that matches the source to rounding is rendered at exactly its size. @@ -287,72 +232,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // hover and before renders are stand-ins: the preview size is plenty and keeps them cheap let se = app.ui.settings.stand_in_edge(want, texture_side); let (sw, sh) = if aspect >= 1.0 { (se, short(se, aspect)) } else { (short(se, 1.0 / aspect), se) }; - if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { - let job = if interacting { job.draft() } else { job }; - // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on - // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap - // sampling (the render is up to a 64 px step larger, or the preview level) - let job = if interacting { job } else { job.with_display_fit(fit_px[0], fit_px[1]) }; - let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); - let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); - app.renderer.request(Slot::Main, job, 100); - // a drag's draft that renders in a few ms (GPU) is drawn in this frame, not the next (zoomed - // in, what changes on screen is the window's draft) - #[cfg(not(target_arch = "wasm32"))] - if interacting && plan.window_edge.is_none() { - let frame = ui.input(|i| i.predicted_dt).clamp(1.0 / 240.0, 1.0 / 30.0); - // (a frame time that is not a number waits for nothing) - let budget = std::time::Duration::try_from_secs_f32(frame * 0.4).unwrap_or_default(); - app.renderer.wait_for_draft(ui.ctx(), &mut app.session, budget); - } - } + let ask = LoupeAsk { id, with_crop: !crop_tool, interacting, fit_px, overlay }; + request_main(app, ui, &ask, [rw, rh], plan.window_edge.is_none()); // hovering a preset or profile: the photo with that look, shown instead of the loupe render // once it is ready (nothing is committed) - let hover_key = match app.hover_preview.clone() { - Some(h) if !interacting => app.session.preview_job(id, sw.max(8), sh.max(8), !crop_tool, &h.settings).map(|job| { - // (the same display fit as the After view, drawn texel for texel like it) - let job = job.with_display_fit(fit_px[0], fit_px[1]); - let key = job.key; - app.renderer.request(Slot::Hover, job, 105); - (key, h.label) - }), - _ => None, - }; - // once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and - // kept, view render cached) so stepping to them is instant - if !interacting && !app.renderer.is_pending(Slot::Main) && app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) { - let ids = app.session.visible_cloned(); - if let Some(i) = ids.iter().position(|p| *p == id) { - let next = ids.get(i + 1).copied(); - let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied(); - for (n, nid) in [next, prev].into_iter().enumerate() { - let Some(nid) = nid else { continue }; - let Some(np) = app.session.catalog.photo(nid).cloned() else { continue }; - let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref()); - let na = nf.aspect() as f32; - let nr = fit_rect(main_area, na, app.ui.zoom, output_px(&nf), ppp, app.ui.pan); - let nw = - app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1), texture_side); - let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; - if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) { - app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); - } - } - } + let hover_key = request_hover(app, &ask, [sw, sh]); + if !interacting { + prefetch_neighbours(app, id, main_area, ppp, texture_side, !crop_tool); } // until the loupe has this photo: show its cached render / embedded preview / a thumbnail - if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id) - && let Some(q) = app.session.quick_view_job(id, want.max(8), !crop_tool) - { - app.renderer.request_quick(Slot::Preview, q, 110); - } + request_stand_in(app, id, want, !crop_tool); let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash)); - if (split || show_before || split_view) - && let Some(job) = app.session.render_job(id, sw.max(8), sh.max(8), true, !crop_tool) - { - // (the same display fit as the After view, so both panes are the same texture size) - let job = if interacting { job } else { job.with_display_fit(fit_px[0], fit_px[1]) }; - app.renderer.request(Slot::Before, job, 90); + if split || show_before || split_view { + request_before(app, &ask, [sw, sh]); } // zoomed past what the whole-frame render holds: also render the part on screen, at no more // than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays and @@ -371,63 +263,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { look, holding: navigating || app.size_hold.holding(now), }; - // (slot, before side?, rect to draw in, rect it is requested for, area whose pixels show) - let mut window_views: Vec = Vec::new(); - if window_ctx.frame_edge.is_some() { - let after = WindowView { slot: Slot::Region, before: false, img: img_rect, target: target_rect, visible: main_area }; - if split { - window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: before_img, target: before_target, visible: areas[0] }); - window_views.push(after); - } else if show_before { - window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas }); - } else if split_view { - window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas }); - window_views.push(WindowView { visible: canvas, ..after }); - } else { - window_views.push(WindowView { visible: canvas, ..after }); - } - } - let mut windows: Vec<(WindowView, crate::region::RegionView)> = Vec::new(); - for v in window_views { - if let Some(view) = request_window(app, &window_ctx, &v) { - windows.push((v, view)); - } - } - for (slot, before) in [(Slot::Region, false), (Slot::RegionBefore, true)] { - let shown = windows.iter().find(|(v, _)| v.before == before).map(|(_, view)| *view); - if before { - app.region_before_view = shown; - } else { - app.region_view = shown; - } - if shown.is_none() { - app.region_tiles.retain(|(b, _), _| *b != before); - app.renderer.release(slot); - } - } - let p = ui.painter_at(canvas); - // what a view slot shows: its own render of this photo, else the stand-ins (no blank frame - // between photos, and the full render replaces them in place) - let draw = |slot: Slot, r: Rect, clip: Rect| -> &'static str { - let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id); - let (tex, what) = if let Some(t) = mine(slot) { - (t, "render") - } else if let Some(t) = mine(Slot::Preview) { - (t, t.quick.map(quick_name).unwrap_or("preview")) - } else if let Some(t) = app.renderer.thumb(id) { - (t, "thumb") - } else { - return "none"; - }; - let image_rect = snap_fit_rect(fit_texture_rect(r, tex.size), tex.size, ppp); - p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - what + let window_views = if window_ctx.frame_edge.is_some() { + let mode = ViewMode { split, show_before, split_view }; + window_views(mode, &Views { img_rect, target_rect, before_img, before_target, areas: &areas, main_area, canvas }) + } else { + Vec::new() }; + let windows = request_windows(app, &window_ctx, window_views); + let p = ui.painter_at(canvas); + let draw = |slot: Slot, r: Rect, clip: Rect| draw_slot(app, &p, id, ppp, slot, r, clip); // soft proofing: a paper-white surround and the proof's name, as Lightroom shows it if app.ui.soft_proof && !fullscreen { - p.rect_filled(canvas, 0.0, Color32::from_gray(238)); - let label = crate::i18n::tr_format!("Proof Preview · {}", app.ui.proof.space.label()); - p.text(pos2(canvas.right() - 16.0, canvas.top() + 14.0), Align2::RIGHT_CENTER, label, t.font(12.5), Color32::from_gray(60)); + proof_surround(app, &p, &t, canvas); } let shown; // the Before label's pill, painted once the window renders are down (they would cover it) @@ -441,10 +288,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let br = before_img; draw(Slot::Before, br, areas[0]); shown = draw(Slot::Main, img_rect, main_area); - // the labels under each photo, or under its pane when the photo reaches past it - let label_at = |r: Rect, pane: Rect| pos2(r.left().max(pane.left()), r.bottom().min(pane.bottom()) + 14.0); - p.text(label_at(br, areas[0]), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim); - p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); + split_labels(&p, &t, [(br, areas[0]), (img_rect, main_area)]); } else if show_before { shown = draw(Slot::Before, img_rect, canvas); // on the photo itself, so it gets the hover label's dark pill to stay readable over light @@ -454,37 +298,136 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else if let Some((_key, label)) = &hover_key && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id) { - let hover_rect = fit_texture_rect(img_rect, tex.size); - p.image(tex.tex.id(), hover_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE); - let bg = label_badge_rect(hover_rect, canvas, g.size(), badge_top_inset); - p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); - p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); + draw_hover(&p, &t, tex, label, [img_rect, canvas], badge_top_inset); shown = "hover"; } else { shown = draw(Slot::Main, img_rect, canvas); if shown == "none" { - match app.renderer.failure_of(Slot::Main, id) { - Some(e) => { - p.text( - canvas.center() - vec2(0.0, 10.0), - Align2::CENTER_CENTER, - crate::i18n::tr("This photo can't be opened"), - t.semibold(14.0), - t.text, - ); - p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim); - } - None if app.ui.settings.loupe_messages => { - p.text(canvas.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(13.0), t.text_dim); - } - None => {} - } + none_message(app, &p, &t, canvas, id); } } app.loupe_shown = Some((id, shown)); - // a wipe shows the Before whole-frame render on its side of the line… let line = crate::region::wipe_line(img_rect, canvas); + draw_wipe_before(app, &p, id, img_rect, line); + // …and the window renders go over the whole-frame ones, all of them, where they belong in the + // frame (clipped to their pane, or their side of the line) + for (v, view) in &windows { + // (holding `\` in a wipe shows the Before everywhere: it is the Before view alone then) + let mode = if show_before { BeforeAfter::Original } else { app.ui.before_after }; + let clip = crate::region::window_clip(mode, v.before, v.visible, canvas, img_rect); + draw_window(&p.with_clip_rect(clip), app, &window_ctx, v, view); + } + if let Some((bg, g)) = before_badge { + paint_badge(&p, bg, g); + register(ui, "canvas:beforeLabel", bg); + } + // the line of a wipe over both + draw_wipe_line(app.ui.before_after, &p, img_rect, line); + let map = CanvasMap::new(&frame, img_rect); + let over = OverPhoto { photo: &photo, d: &d, map: &map, canvas, img_rect, fullscreen, crop_tool, clipping: !show_before }; + photo_overlays(app, ui, &p, &over); + // Cursor selection must also run on gesture frames, when tool input is suppressed. + if !matches!(right, RightPanel::Crop | RightPanel::Masking | RightPanel::Remove | RightPanel::RedEye) { + general_cursor(app, ui, &resp, img_rect, main_area); + } + if !navigating { + let tv = ToolView { right, map: &map, frame: &frame, d: &d, id, split, native, aspect }; + tool_interaction(app, ui, &resp, &tv, &Views { img_rect, target_rect, before_img, before_target, areas: &areas, main_area, canvas }); + } + // drawn and hit-tested above the loupe and its tools: clicks on it pan + let nav = navigator(app, ui, canvas, img_rect, id); + // what is on screen isn't the render on its way (a stand-in, the last render after an edit, + // a magnified frame under its window): say so once that has lasted a moment + let coming = render_coming(app, shown, &windows, ViewMode { split, show_before, split_view }); + let keys = + [Slot::Main, Slot::Before, Slot::Region, Slot::RegionBefore].map(|s| app.renderer.textures.get(&s).filter(|t| t.photo == id).map(|t| t.key)); + let rendering = ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::unique("loupe-rendering"), None), keys, coming, now)); + // the render failed while a stand-in is up: what is on screen is all there will be (also while + // a library change retries it) + let failed = failed_render(app, id, !rendering && (!show_before || split), shown); + if rendering || failed.is_some() { + let over = if crop_tool { canvas } else { img_rect.intersect(if split { main_area } else { canvas }) }; + let over = if over.is_positive() { over } else { canvas }; + status_pill(ui, app, &photo, [canvas, over], nav, failed); + } + if !fullscreen { + photo.preview_only.as_deref().inspect(|why| preview_only_pill(ui, canvas, why)); + } + loupe_context_menu(app, &resp, id); +} + +/// The loupe's right-click menu: zoom commands and the grid's photo menu. +fn loupe_context_menu(app: &mut LightcraftApp, resp: &egui::Response, id: PhotoId) { + crate::widgets::context_menu(resp, |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Zoom"), |ui| { + for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { + if ui.button(crate::i18n::tr(label)).clicked() { + let _ = app.run(cmd, json!({})); + } + } + }); + ui.separator(); + super::grid::context_menu(app, ui, id); + }); +} + +/// A fine grid while a transform (geometry) slider is dragged, to judge verticals. +fn transform_grid(app: &LightcraftApp, p: &egui::Painter, img_rect: Rect) { + 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); + } + } +} + +/// The photo's face boxes and the name box on them. +fn face_boxes( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + map: &CanvasMap, + photo: &lightcraft_catalog::Photo, + d: &DevelopSettings, +) { + let id = photo.id; + // a name box left open on another photo is dropped + if app.ui.name_edit.as_ref().is_some_and(|e| e.photo != id.0) { + app.ui.name_edit = None; + } + let hints = super::faces::hints_for(app, id.0); + let people = if app.ui.name_edit.is_some() { app.caches.person_names(&app.session.catalog) } else { Default::default() }; + // while the name box is open and recognition is not set up, the box offers the next step + let setup = if app.ui.name_edit.is_some() { super::faces::setup(app, ui.ctx()) } else { super::faces::Setup::Running }; + let regions = RegionContext { photo, orient: d.orientation, hints: hints.as_deref(), people: &people, setup }; + match region_overlay(ui, p, map, ®ions, &mut app.ui.name_edit) { + Some(RegionEdit::SetUp) => { + app.ui.name_edit = None; + super::faces::take_step(app, ui.ctx(), setup); + } + Some(RegionEdit::Remove(index)) => { + let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index})); + } + Some(RegionEdit::Name(index, name)) => { + app.ui.name_edit = None; + let _ = app.run("faces.setName", json!({"id": id.0, "index": index, "name": name})); + } + Some(RegionEdit::Resize(index, r)) => { + let _ = app.run("photo.setRegion", json!({"id": id.0, "index": index, "rect": {"x0": r.x0, "y0": r.y0, "x1": r.x1, "y1": r.y1}})); + } + None => {} + } +} + +/// A wipe shows the Before whole-frame render on its side of the line. +fn draw_wipe_before(app: &LightcraftApp, p: &egui::Painter, id: PhotoId, img_rect: Rect, line: Pos2) { if app.ui.before_after == BeforeAfter::Split && let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { @@ -501,157 +444,437 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.top() + v * before_rect.height())); p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, v)), Color32::WHITE); } - // …and the window renders go over the whole-frame ones, all of them, where they belong in the - // frame (clipped to their pane, or their side of the line) - for (v, view) in &windows { - // (holding `\` in a wipe shows the Before everywhere: it is the Before view alone then) - let mode = if show_before { BeforeAfter::Original } else { app.ui.before_after }; - let clip = crate::region::window_clip(mode, v.before, v.visible, canvas, img_rect); - draw_window(&p.with_clip_rect(clip), app, &window_ctx, v, view); +} + +/// Once a photo is on screen, prepare its neighbours in filmstrip order (source decoded and kept, +/// view render cached) so stepping to them is instant. +fn prefetch_neighbours(app: &mut LightcraftApp, id: PhotoId, main_area: Rect, ppp: f32, texture_side: usize, with_crop: bool) { + // only once this photo is on screen + if app.renderer.is_pending(Slot::Main) || !app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) { + return; } - if let Some((bg, g)) = before_badge { - p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); - p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); - register(ui, "canvas:beforeLabel", bg); - } - // the line of a wipe over both - if app.ui.before_after == BeforeAfter::Split { - p.line_segment([pos2(line.x, img_rect.top()), pos2(line.x, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE)); - } - if app.ui.before_after == BeforeAfter::SplitTopBottom { - p.line_segment([pos2(img_rect.left(), line.y), pos2(img_rect.right(), line.y)], Stroke::new(1.5, Color32::WHITE)); - } - if app.ui.show_clipping && !show_before && !fullscreen { - clipping_overlay(app, &p, img_rect); - } - // View ▸ Loupe Overlay ▸ Layout Image, over the photo (the After view in a before/after) - super::layout_overlay::draw(app, &p, img_rect); - register(ui, "canvas:image", img_rect); - let map = CanvasMap::new(&frame, img_rect); - info_overlay(app, &p, canvas, &photo); - if !fullscreen { - filter_pill(app, ui, canvas); - } - if app.ui.face_boxes { - // a name box left open on another photo is dropped - if app.ui.name_edit.as_ref().is_some_and(|e| e.photo != id.0) { - app.ui.name_edit = None; - } - let hints = super::faces::hints_for(app, id.0); - let people = if app.ui.name_edit.is_some() { app.caches.person_names(&app.session.catalog) } else { Default::default() }; - // while the name box is open and recognition is not set up, the box offers the next step - let setup = if app.ui.name_edit.is_some() { super::faces::setup(app, ui.ctx()) } else { super::faces::Setup::Running }; - let regions = RegionContext { photo: &photo, orient: d.orientation, hints: hints.as_deref(), people: &people, setup }; - match region_overlay(ui, &p, &map, ®ions, &mut app.ui.name_edit) { - Some(RegionEdit::SetUp) => { - app.ui.name_edit = None; - super::faces::take_step(app, ui.ctx(), setup); - } - Some(RegionEdit::Remove(index)) => { - let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index})); - } - Some(RegionEdit::Name(index, name)) => { - app.ui.name_edit = None; - let _ = app.run("faces.setName", json!({"id": id.0, "index": index, "name": name})); - } - Some(RegionEdit::Resize(index, r)) => { - let _ = app.run("photo.setRegion", json!({"id": id.0, "index": index, "rect": {"x0": r.x0, "y0": r.y0, "x1": r.x1, "y1": r.y1}})); - } - None => {} - } - } - if app.ui.settings.focus_point && !crop_tool { - draw_focus_point(&p.with_clip_rect(img_rect.intersect(canvas)), img_rect, &photo, "canvas:focusPoint"); - } - // 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); - } - } - // Cursor selection must also run on gesture frames, when tool input is suppressed. - if !matches!(right, RightPanel::Crop | RightPanel::Masking | RightPanel::Remove | RightPanel::RedEye) { - general_cursor(app, ui, &resp, img_rect, main_area); - } - if !navigating { - match right { - RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id), - RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d), - RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d), - RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d), - _ => { - // side by side / stacked: Before shows the same frame, so a click in its view (white - // balance, colour samples, targeted adjustment) means the same point of the photo. - // Zoomed in, a view's image reaches past its pane: what counts is the pane clicked. - // A click-zoom there centres both views on the point clicked (#1742). - let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); - let (map, img, pane) = match (split, areas.first(), pointer) { - (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(&frame, before_img), before_img, *before), - _ => (map, img_rect, main_area), - }; - general_interaction(app, ui, &resp, &map, &PhotoPlacement { img, canvas: pane, native, aspect }) + let ids = app.session.visible_cloned(); + if let Some(i) = ids.iter().position(|p| *p == id) { + let next = ids.get(i + 1).copied(); + let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied(); + for (n, nid) in [next, prev].into_iter().enumerate() { + let Some(nid) = nid else { continue }; + let Some(np) = app.session.catalog.photo(nid).cloned() else { continue }; + let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, with_crop, np.embedded_lens.as_ref()); + let na = nf.aspect() as f32; + let nr = fit_rect(main_area, na, app.ui.zoom, output_px(&nf), ppp, app.ui.pan); + let nw = app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1), texture_side); + let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; + if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), with_crop) { + app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); } } } - // drawn and hit-tested above the loupe and its tools: clicks on it pan - let nav = navigator(app, ui, canvas, img_rect, id); - // what is on screen isn't the render on its way (a stand-in, the last render after an edit, - // a magnified frame under its window): say so once that has lasted a moment - let whole: &[Slot] = if show_before && !split { +} + +/// The rects of the two views of a before/after (side by side or stacked), else the one area. +fn view_areas(mode: BeforeAfter, area: Rect) -> Vec { + match mode { + BeforeAfter::SideBySide => { + let half = area.width() / 2.0 - 6.0; + vec![ + Rect::from_min_size(area.min, vec2(half, area.height())), + Rect::from_min_size(pos2(area.center().x + 6.0, area.top()), vec2(half, area.height())), + ] + } + BeforeAfter::TopBottom => { + let half = area.height() / 2.0 - 14.0; + vec![ + Rect::from_min_size(area.min, vec2(area.width(), half)), + Rect::from_min_size(pos2(area.left(), area.center().y + 14.0), vec2(area.width(), half)), + ] + } + _ => vec![area], + } +} + +/// Which before/after layout is up. +#[derive(Clone, Copy)] +struct ViewMode { + split: bool, + show_before: bool, + split_view: bool, +} + +/// The rects the loupe's views are drawn in. +struct Views<'a> { + img_rect: Rect, + target_rect: Rect, + before_img: Rect, + before_target: Rect, + areas: &'a [Rect], + main_area: Rect, + canvas: Rect, +} + +/// The window renders wanted for the layout: (slot, before side?, rect to draw in, rect it is +/// requested for, area whose pixels show). +fn window_views(mode: ViewMode, v: &Views) -> Vec { + let mut out: Vec = Vec::new(); + let after = WindowView { slot: Slot::Region, before: false, img: v.img_rect, target: v.target_rect, visible: v.main_area }; + if mode.split { + out.push(WindowView { + slot: Slot::RegionBefore, + before: true, + img: v.before_img, + target: v.before_target, + visible: v.areas.first().copied().unwrap_or(v.canvas), + }); + out.push(after); + } else if mode.show_before { + out.push(WindowView { slot: Slot::RegionBefore, before: true, img: v.img_rect, target: v.target_rect, visible: v.canvas }); + } else if mode.split_view { + out.push(WindowView { slot: Slot::RegionBefore, before: true, img: v.img_rect, target: v.target_rect, visible: v.canvas }); + out.push(WindowView { visible: v.canvas, ..after }); + } else { + out.push(WindowView { visible: v.canvas, ..after }); + } + out +} + +/// What the canvas says when nothing could be drawn: why the photo failed, or that it is rendering. +fn none_message(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, canvas: Rect, id: PhotoId) { + match app.renderer.failure_of(Slot::Main, id) { + Some(e) => { + p.text(canvas.center() - vec2(0.0, 10.0), Align2::CENTER_CENTER, crate::i18n::tr("This photo can't be opened"), t.semibold(14.0), t.text); + p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim); + } + None if app.ui.settings.loupe_messages => { + p.text(canvas.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(13.0), t.text_dim); + } + None => {} + } +} + +/// The render failed while a stand-in is up: what is on screen is all there will be (also while a +/// library change retries it). +fn failed_render<'a>(app: &'a LightcraftApp, id: PhotoId, settled: bool, shown: &str) -> Option<&'a str> { + (settled && !matches!(shown, "render" | "none" | "hover") && (!app.renderer.is_pending(Slot::Main) || app.renderer.retrying(Slot::Main))) + .then(|| app.renderer.failure_of(Slot::Main, id)) + .flatten() +} + +/// The pill over the photo (the After in two views; in the crop tool's wide margin, under it): the +/// failure, or View Options' "Show message when loading or rendering photos". +fn status_pill( + ui: &egui::Ui, + app: &LightcraftApp, + photo: &lightcraft_catalog::Photo, + [canvas, over]: [Rect; 2], + nav: Option, + failed: Option<&str>, +) { + match failed { + Some(e) => failed_pill(ui, app, photo, canvas, over, nav, "loupe:renderFailed", e), + None if app.ui.settings.loupe_messages => { + loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false); + } + None => {} + } +} + +/// Soft proofing: a paper-white surround and the proof's name, as Lightroom shows it. +fn proof_surround(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, canvas: Rect) { + p.rect_filled(canvas, 0.0, Color32::from_gray(238)); + let label = crate::i18n::tr_format!("Proof Preview · {}", app.ui.proof.space.label()); + p.text(pos2(canvas.right() - 16.0, canvas.top() + 14.0), Align2::RIGHT_CENTER, label, t.font(12.5), Color32::from_gray(60)); +} + +/// Request the window renders, remember which sides have one, and release the slot of a side without. +fn request_windows(app: &mut LightcraftApp, ctx: &WindowCtx, views: Vec) -> Vec<(WindowView, crate::region::RegionView)> { + let mut windows: Vec<(WindowView, crate::region::RegionView)> = Vec::new(); + for v in views { + if let Some(view) = request_window(app, ctx, &v) { + windows.push((v, view)); + } + } + for (slot, before) in [(Slot::Region, false), (Slot::RegionBefore, true)] { + let shown = windows.iter().find(|(v, _)| v.before == before).map(|(_, view)| *view); + if before { + app.region_before_view = shown; + } else { + app.region_view = shown; + } + if shown.is_none() { + app.region_tiles.retain(|(b, _), _| *b != before); + app.renderer.release(slot); + } + } + windows +} + +/// The aspect of the texture on screen for this photo and geometry, if one is. +fn visible_texture_aspect(app: &LightcraftApp, id: PhotoId, d: &DevelopSettings, crop_tool: bool) -> Option { + // A previous crop/rotation can stay visible while its replacement renders. Its pixels + // must not override the current frame geometry, even when it is the same photo. + let current_texture = |t: &&crate::render::Tex| t.photo == id && t.look == Some((crate::render::geometry_look(d), !crop_tool)); + app.renderer + .textures + .get(&Slot::Main) + .filter(current_texture) + .or_else(|| app.renderer.textures.get(&Slot::Preview).filter(current_texture)) + .or_else(|| app.renderer.thumb(id).filter(current_texture)) + .map(|tex| tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32) +} + +/// Whether the GPU draws the view (by the GPU's own rule, `handles`; `ready` first: `available` +/// would wait for a device still being created). +fn gpu_draws_view(app: &LightcraftApp, overlay: lightcraft_pipeline::Overlay) -> bool { + lightcraft_engine::gpu::handles( + lightcraft_pipeline::OutputDepth::U8, + app.ui.soft_proof && lightcraft_pipeline::proof_profile(app.ui.proof.profile).is_some(), + overlay, + ) && lightcraft_engine::gpu::ready() + && lightcraft_engine::gpu::available() +} + +/// The line of a wipe. +fn draw_wipe_line(mode: BeforeAfter, p: &egui::Painter, img_rect: Rect, line: Pos2) { + let stroke = Stroke::new(1.5, Color32::WHITE); + match mode { + BeforeAfter::Split => { + p.line_segment([pos2(line.x, img_rect.top()), pos2(line.x, img_rect.bottom())], stroke); + } + BeforeAfter::SplitTopBottom => { + p.line_segment([pos2(img_rect.left(), line.y), pos2(img_rect.right(), line.y)], stroke); + } + _ => {} + } +} + +/// Whether the render on its way is not what is on screen (a stand-in, the last render after an +/// edit, a magnified frame under its window). +fn render_coming(app: &LightcraftApp, shown: &str, windows: &[(WindowView, crate::region::RegionView)], mode: ViewMode) -> bool { + let whole: &[Slot] = if mode.show_before && !mode.split { &[Slot::Before] - } else if split || split_view { + } else if mode.split || mode.split_view { &[Slot::Main, Slot::Before] } else { &[Slot::Main] }; - let coming = !matches!(shown, "none" | "hover") - && whole.iter().chain(windows.iter().map(|(v, _)| &v.slot)).any(|s| app.renderer.is_pending(*s) && !app.renderer.retrying(*s)); - let keys = - [Slot::Main, Slot::Before, Slot::Region, Slot::RegionBefore].map(|s| app.renderer.textures.get(&s).filter(|t| t.photo == id).map(|t| t.key)); - let rendering = ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::unique("loupe-rendering"), None), keys, coming, now)); - // the render failed while a stand-in is up: what is on screen is all there will be (also while - // a library change retries it) - let failed = (!rendering - && (!show_before || split) - && !matches!(shown, "render" | "none" | "hover") - && (!app.renderer.is_pending(Slot::Main) || app.renderer.retrying(Slot::Main))) - .then(|| app.renderer.failure_of(Slot::Main, id)) - .flatten(); - if rendering || failed.is_some() { - // over the photo (the After in two views); in the crop tool's wide margin, under it - let over = if crop_tool { canvas } else { img_rect.intersect(if split { main_area } else { canvas }) }; - let over = if over.is_positive() { over } else { canvas }; - match failed { - Some(e) => failed_pill(ui, app, &photo, canvas, over, nav, "loupe:renderFailed", e), - // View Options' Show message when loading or rendering photos - None if app.ui.settings.loupe_messages => { - loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false); - } - None => {} + !matches!(shown, "none" | "hover") + && whole.iter().chain(windows.iter().map(|(v, _)| &v.slot)).any(|s| app.renderer.is_pending(*s) && !app.renderer.retrying(*s)) +} + +/// What the active tool needs to know about the loupe. +struct ToolView<'a> { + right: RightPanel, + map: &'a CanvasMap, + frame: &'a Frame, + d: &'a DevelopSettings, + id: PhotoId, + split: bool, + native: [usize; 2], + aspect: f32, +} + +/// Route the pointer to the right panel's tool on the photo, or to the general interactions. +fn tool_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, tv: &ToolView, v: &Views) { + let (map, d) = (tv.map, tv.d); + match tv.right { + RightPanel::Crop => crop_overlay(app, ui, resp, map, tv.frame, d, tv.id), + RightPanel::Masking => mask_overlay(app, ui, resp, map, d), + RightPanel::Remove => remove_overlay(app, ui, resp, map, d), + RightPanel::RedEye => eye_overlay(app, ui, resp, map, d), + _ => { + // side by side / stacked: Before shows the same frame, so a click in its view (white + // balance, colour samples, targeted adjustment) means the same point of the photo. + // Zoomed in, a view's image reaches past its pane: what counts is the pane clicked. + // A click-zoom there centres both views on the point clicked (#1742). + let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); + let (map, img, pane) = match (tv.split, v.areas.first(), pointer) { + (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(tv.frame, v.before_img), v.before_img, *before), + _ => (CanvasMap::new(tv.frame, v.img_rect), v.img_rect, v.main_area), + }; + general_interaction(app, ui, resp, &map, &PhotoPlacement { img, canvas: pane, native: tv.native, aspect: tv.aspect }) } } - if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) { - preview_only_pill(ui, canvas, why); +} + +/// The loupe's render plan, without a window for a view the GPU already refused one for. +fn loupe_plan(app: &LightcraftApp, id: PhotoId, look: u64, sizes: crate::region::ViewSizes) -> crate::region::LoupePlan { + let plan = crate::region::plan(&app.ui.settings, sizes); + if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { + return crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes }); + } + plan +} + +fn whole_frame_edge(app: &LightcraftApp, interacting: bool, plan: &crate::region::LoupePlan, native_long: usize, texture_side: usize) -> usize { + if interacting || plan.window_edge.is_some() { + plan.main_edge + } else { + // (a user's explicit Preview size below the preview level still caps it) + let limit = if app.ui.settings.preview_limit == 0 { usize::MAX } else { app.ui.settings.preview_limit as usize }; + let floor = lightcraft_engine::SourceLevel::Preview.max_edge().min(limit).min(native_long); + plan.main_edge.max(floor).min(texture_side.max(crate::state::MIN_TEXTURE_SIDE)) + } +} + +/// What a loupe render request is for. +struct LoupeAsk { + id: PhotoId, + with_crop: bool, + interacting: bool, + fit_px: [usize; 2], + overlay: lightcraft_pipeline::Overlay, +} + +/// Request the loupe's own render at `[rw, rh]` (drafts during drags). +fn request_main(app: &mut LightcraftApp, ui: &egui::Ui, a: &LoupeAsk, [rw, rh]: [usize; 2], whole_draft: bool) { + let (id, interacting, fit_px, overlay) = (a.id, a.interacting, a.fit_px, a.overlay); + if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), a.with_crop) { + let job = if interacting { job.draft() } else { job }; + // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on + // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap + // sampling (the render is up to a 64 px step larger, or the preview level) + let job = if interacting { job } else { job.with_display_fit(fit_px[0], fit_px[1]) }; + let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); + let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); + app.renderer.request(Slot::Main, job, 100); + // a drag's draft that renders in a few ms (GPU) is drawn in this frame, not the next (zoomed + // in, what changes on screen is the window's draft) + #[cfg(not(target_arch = "wasm32"))] + if interacting && whole_draft { + let frame = ui.input(|i| i.predicted_dt).clamp(1.0 / 240.0, 1.0 / 30.0); + // (a frame time that is not a number waits for nothing) + let budget = std::time::Duration::try_from_secs_f32(frame * 0.4).unwrap_or_default(); + app.renderer.wait_for_draft(ui.ctx(), &mut app.session, budget); + } + } +} + +/// Hovering a preset or profile: the photo with that look, shown instead of the loupe render once +/// it is ready (nothing is committed). The key of its render and the label to show. +fn request_hover(app: &mut LightcraftApp, a: &LoupeAsk, [sw, sh]: [usize; 2]) -> Option<(u64, String)> { + match app.hover_preview.clone() { + Some(h) if !a.interacting => app.session.preview_job(a.id, sw.max(8), sh.max(8), a.with_crop, &h.settings).map(|job| { + // (the same display fit as the After view, drawn texel for texel like it) + let job = job.with_display_fit(a.fit_px[0], a.fit_px[1]); + let key = job.key; + app.renderer.request(Slot::Hover, job, 105); + (key, h.label) + }), + _ => None, + } +} + +/// The Before render, at the stand-in size. +fn request_before(app: &mut LightcraftApp, a: &LoupeAsk, [sw, sh]: [usize; 2]) { + if let Some(job) = app.session.render_job(a.id, sw.max(8), sh.max(8), true, a.with_crop) { + // (the same display fit as the After view, so both panes are the same texture size) + let job = if a.interacting { job } else { job.with_display_fit(a.fit_px[0], a.fit_px[1]) }; + app.renderer.request(Slot::Before, job, 90); + } +} + +/// What a view slot shows: its own render of this photo, else the stand-ins (no blank frame +/// between photos, and the full render replaces them in place). Returns what it drew. +fn draw_slot(app: &LightcraftApp, p: &egui::Painter, id: PhotoId, ppp: f32, slot: Slot, r: Rect, clip: Rect) -> &'static str { + let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id); + let (tex, what) = if let Some(t) = mine(slot) { + (t, "render") + } else if let Some(t) = mine(Slot::Preview) { + (t, t.quick.map(quick_name).unwrap_or("preview")) + } else if let Some(t) = app.renderer.thumb(id) { + (t, "thumb") + } else { + return "none"; + }; + let image_rect = snap_fit_rect(fit_texture_rect(r, tex.size), tex.size, ppp); + p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + what +} + +/// The hovered look's render over the photo, with its label. +fn draw_hover(p: &egui::Painter, t: &Tokens, tex: &crate::render::Tex, label: &str, [img_rect, canvas]: [Rect; 2], badge_top_inset: f32) { + let hover_rect = fit_texture_rect(img_rect, tex.size); + p.image(tex.tex.id(), hover_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let g = p.layout_no_wrap(label.to_string(), t.font(12.0), Color32::WHITE); + let bg = label_badge_rect(hover_rect, canvas, g.size(), badge_top_inset); + paint_badge(p, bg, g); +} + +/// A label on a dark pill. +fn paint_badge(p: &egui::Painter, bg: Rect, g: std::sync::Arc) { + p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); + p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); +} + +/// No photo is active: the empty-library message if the library is empty, else "No photo selected". +fn show_no_photo(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { + if super::empty_library::shown(&app.session) { + super::empty_library::show(app, ui, canvas); + } else { + super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); + } +} + +/// The margin round the photo: none full screen, wide in the crop tool. +fn loupe_margin(fullscreen: bool, crop_tool: bool) -> f32 { + if fullscreen { + 0.0 + } else if crop_tool { + 48.0 + } else { + 24.0 + } +} + +/// The labels under each photo, or under its pane when the photo reaches past it. +fn split_labels(p: &egui::Painter, t: &Tokens, [before, after]: [(Rect, Rect); 2]) { + let label_at = |r: Rect, pane: Rect| pos2(r.left().max(pane.left()), r.bottom().min(pane.bottom()) + 14.0); + p.text(label_at(before.0, before.1), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim); + p.text(label_at(after.0, after.1), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); +} + +/// What is drawn over the photo. +struct OverPhoto<'a> { + photo: &'a lightcraft_catalog::Photo, + d: &'a DevelopSettings, + map: &'a CanvasMap, + canvas: Rect, + img_rect: Rect, + fullscreen: bool, + crop_tool: bool, + /// the clipping overlay may show (not in the Before view) + clipping: bool, +} + +/// The overlays over the photo: clipping, layout image, info, filter pill, faces, focus point and +/// the transform grid. +fn photo_overlays(app: &mut LightcraftApp, ui: &mut egui::Ui, p: &egui::Painter, o: &OverPhoto) { + let (canvas, img_rect) = (o.canvas, o.img_rect); + if app.ui.show_clipping && o.clipping && !o.fullscreen { + clipping_overlay(app, p, img_rect); + } + // View ▸ Loupe Overlay ▸ Layout Image, over the photo (the After view in a before/after) + super::layout_overlay::draw(app, p, img_rect); + register(ui, "canvas:image", img_rect); + info_overlay(app, p, canvas, o.photo); + if !o.fullscreen { + filter_pill(app, ui, canvas); + } + if app.ui.face_boxes { + face_boxes(app, ui, p, o.map, o.photo, o.d); + } + if app.ui.settings.focus_point && !o.crop_tool { + draw_focus_point(&p.with_clip_rect(img_rect.intersect(canvas)), img_rect, o.photo, "canvas:focusPoint"); + } + transform_grid(app, p, img_rect); +} + +fn request_stand_in(app: &mut LightcraftApp, id: PhotoId, edge: usize, with_crop: bool) { + if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id) + && let Some(q) = app.session.quick_view_job(id, edge.max(8), with_crop) + { + app.renderer.request_quick(Slot::Preview, q, 110); } - crate::widgets::context_menu(&resp, |ui| { - crate::widgets::menu_button(ui, crate::i18n::tr("Zoom"), |ui| { - for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { - if ui.button(crate::i18n::tr(label)).clicked() { - let _ = app.run(cmd, json!({})); - } - } - }); - ui.separator(); - super::grid::context_menu(app, ui, id); - }); } /// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes. diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index c150eac53..365ae2311 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -291,7 +291,6 @@ pub(super) fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &eg /// component (click selects its mask, drag moves the component), and brush painting. The mask /// itself shows as a rendered overlay ([`view_overlay`]). pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { - let long = (map.rect.width().max(map.rect.height())) as f64; let active = app.session.active_mask; let clip = app.canvas_rect.unwrap_or(map.rect); let p = &ui.painter_at(clip); @@ -310,60 +309,9 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg // mask so nothing else becomes draggable. The component check keeps this in step with the // painted region (`view_overlay` at detail.rs:1354 only covers masks with components). let show_outline = sel || (app.ui.mask_overlay_all && m.visible && !m.components.is_empty()); - let stroke_a: u8 = if sel { 230 } else { 170 }; for (ci, c) in m.components.iter().enumerate() { - match &c.shape { - MaskShape::Radial { center, rx, ry, angle, .. } if show_outline => { - let pts: Vec = (0..64) - .map(|i| { - let a = i as f64 / 64.0 * std::f64::consts::TAU; - let (s, co) = angle.to_radians().sin_cos(); - let (x, y) = (rx * a.cos(), ry * a.sin()); - let l = frame_long_norm(map); - map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) - }) - .collect(); - p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a)))); - - if sel { - let l = frame_long_norm(map); - for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { - let a_local = a_deg.to_radians(); - let (s, co) = angle.to_radians().sin_cos(); - let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); - let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); - p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); - grips.push((m.id, ci, i as u8 + 1, q)); - } - - let (s, co) = angle.to_radians().sin_cos(); - let rot_r = rx + 0.08; - let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); - let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); - p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); - p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); - grips.push((m.id, ci, 5, rot_q)); - } - } - MaskShape::Linear { start, end } if show_outline => { - let (a, b) = (map.screen(*start), map.screen(*end)); - let dir = (b - a).normalized(); - let perp = vec2(-dir.y, dir.x) * 3000.0; - let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) }; - for (q, alpha) in [(a, a1), (b, a2)] { - p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); - } - if sel { - for (i, q) in [a, b].iter().enumerate() { - p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); - grips.push((m.id, ci, i as u8 + 1, *q)); - } - } - } - _ => {} + if show_outline { + component_outline(ui, p, map, (m.id, ci), &c.shape, sel, &mut grips); } if app.ui.mask_pins && mode.shows(sel, live) @@ -377,8 +325,98 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg } } mask_keys(app, ui, map, d, &grips); - // Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude - // (clicks still on their way to the model included) + object_marks(app, p, map, d, active); + // Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction + // never runs while it is open), so the eyedropper is handled here too. + if app.ui.tool == "colorRange" { + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + color_range_pick(app, ui, resp, map); + return; + } + if app.ui.tool == "object" { + object_tool(app, ui, resp, map); + return; + } + if app.ui.tool == "brush" { + brush_tool(app, ui, resp, map, p, clip); + return; + } + grip_gestures(app, ui, resp, map, d, &grips); +} + +/// The outline of one mask component, and (on the selected mask) its grips, registered and +/// collected into `grips`. +fn component_outline( + ui: &egui::Ui, + p: &egui::Painter, + map: &CanvasMap, + at: (u32, usize), + shape: &MaskShape, + sel: bool, + grips: &mut Vec<(u32, usize, u8, Pos2)>, +) { + let (mask, ci) = at; + let stroke_a: u8 = if sel { 230 } else { 170 }; + match shape { + MaskShape::Radial { center, rx, ry, angle, .. } => { + let pts: Vec = (0..64) + .map(|i| { + let a = i as f64 / 64.0 * std::f64::consts::TAU; + let (s, co) = angle.to_radians().sin_cos(); + let (x, y) = (rx * a.cos(), ry * a.sin()); + let l = frame_long_norm(map); + map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) + }) + .collect(); + p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a)))); + + if sel { + let l = frame_long_norm(map); + for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { + let a_local = a_deg.to_radians(); + let (s, co) = angle.to_radians().sin_cos(); + let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); + let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); + p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:{}", mask, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); + grips.push((mask, ci, i as u8 + 1, q)); + } + + let (s, co) = angle.to_radians().sin_cos(); + let rot_r = rx + 0.08; + let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); + let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); + p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); + p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:5", mask), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); + grips.push((mask, ci, 5, rot_q)); + } + } + MaskShape::Linear { start, end } => { + let (a, b) = (map.screen(*start), map.screen(*end)); + let dir = (b - a).normalized(); + let perp = vec2(-dir.y, dir.x) * 3000.0; + let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) }; + for (q, alpha) in [(a, a1), (b, a2)] { + p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); + } + if sel { + for (i, q) in [a, b].iter().enumerate() { + p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:{i}", mask), Rect::from_center_size(*q, vec2(14.0, 14.0))); + grips.push((mask, ci, i as u8 + 1, *q)); + } + } + } + _ => {} + } +} + +/// Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude +/// (clicks still on their way to the model included). +fn object_marks(app: &LightcraftApp, p: &egui::Painter, map: &CanvasMap, d: &DevelopSettings, active: Option) { let pending = app.session.segmenter.pending_clicks().cloned(); for m in d.masks.iter().filter(|m| Some(m.id) == active) { for (k, c) in m.components.iter().enumerate() { @@ -397,100 +435,104 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg } } } - // Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction - // never runs while it is open), so the eyedropper is handled here too. - if app.ui.tool == "colorRange" { - if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - color_range_pick(app, ui, resp, map); - return; +} + +/// Object tool: a click includes what's under it, ⌥-click leaves it out. +fn object_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap) { + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } - // Object tool: a click includes what's under it, ⌥-click leaves it out - if app.ui.tool == "object" { - if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - { - let n = map.norm(q); - let exclude = ui.input(|i| i.modifiers.alt); - match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) { - Ok(_) => { - if let Some(mid) = app.session.active_mask { - app.ui.detail_due = Some((app.tick_now() + 1.0, mid)); - } - } - Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))), - } - } - return; - } - // brush tool - if app.ui.tool == "brush" { - let r = (app.ui.brush_size as f64 * long) as f32; - if let Some(h) = resp.hover_pos() { - ui.ctx().set_cursor_icon(egui::CursorIcon::None); - p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); - p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120))); - } - if let Some(q) = resp.interact_pointer_pos() - && (resp.drag_started() || resp.dragged() || resp.clicked()) - { - let n = map.norm(q); - let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; - match &mut app.gesture { - // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) - Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} - Some(Gesture::Brush { points, pressure }) => { - record_pressure(pressure, points.len(), force); - points.push(n); - } - _ => { - let mut pressure = Vec::new(); - record_pressure(&mut pressure, 0, force); - app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + let n = map.norm(q); + let exclude = ui.input(|i| i.modifiers.alt); + match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) { + Ok(_) => { + if let Some(mid) = app.session.active_mask { + app.ui.detail_due = Some((app.tick_now() + 1.0, mid)); } } + Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))), } - // the stroke being painted (the render shows it once it's committed), with the committed - // stroke's dab spacing, feather and flow (issue #517) - if let Some(Gesture::Brush { points, pressure }) = &app.gesture { - let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); - let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; - let params = crate::brush_live::LiveParams { - clip, - scale: ui.ctx().pixels_per_point(), - r, - hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)), - flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0), - density: 1.0, - color: app.ui.mask_overlay_color, - opacity: opacity.clamp(0.0, 1.0), - }; - crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); - } else { - app.brush_live = None; - } - if (resp.drag_stopped() || resp.clicked()) - && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() - { - app.brush_live = None; - let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); - // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing - let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift)); - let erase = app.ui.brush_erase != alt; - // a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom - let connect = shift && resp.clicked() && pts.len() == 1; - let _ = app.run( - "mask.brushStroke", - json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, - "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}), - ); - } - return; } +} + +/// Brush tool: the cursor, the stroke being painted and its commit. +fn brush_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, p: &egui::Painter, clip: Rect) { + let long = (map.rect.width().max(map.rect.height())) as f64; + let r = (app.ui.brush_size as f64 * long) as f32; + if let Some(h) = resp.hover_pos() { + ui.ctx().set_cursor_icon(egui::CursorIcon::None); + p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); + p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120))); + } + if let Some(q) = resp.interact_pointer_pos() + && (resp.drag_started() || resp.dragged() || resp.clicked()) + { + let n = map.norm(q); + let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; + match &mut app.gesture { + // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) + Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} + Some(Gesture::Brush { points, pressure }) => { + record_pressure(pressure, points.len(), force); + points.push(n); + } + _ => { + let mut pressure = Vec::new(); + record_pressure(&mut pressure, 0, force); + app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); + } + } + } + // the stroke being painted (the render shows it once it's committed), with the committed + // stroke's dab spacing, feather and flow (issue #517) + if let Some(Gesture::Brush { points, pressure }) = &app.gesture { + let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); + let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; + let params = crate::brush_live::LiveParams { + clip, + scale: ui.ctx().pixels_per_point(), + r, + hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)), + flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0), + density: 1.0, + color: app.ui.mask_overlay_color, + opacity: opacity.clamp(0.0, 1.0), + }; + crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); + } else { + app.brush_live = None; + } + if (resp.drag_stopped() || resp.clicked()) + && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() + { + app.brush_live = None; + let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); + // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing + let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift)); + let erase = app.ui.brush_erase != alt; + // a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom + let connect = shift && resp.clicked() && pts.len() == 1; + let _ = app.run( + "mask.brushStroke", + json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, + "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}), + ); + } +} + +/// Grabbing mask grips and component bodies: select, drag, release. +fn grip_gestures( + app: &mut LightcraftApp, + ui: &egui::Ui, + resp: &egui::Response, + map: &CanvasMap, + d: &DevelopSettings, + grips: &[(u32, usize, u8, Pos2)], +) { + let active = app.session.active_mask; // the grip under the pointer: the selected mask's first, then the nearest let hit = |q: Pos2| { grips From ce3ce07771c0c46436a93acd02fe0f8be4beae71 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 03:20:48 +0000 Subject: [PATCH 73/91] refactor(ui-egui): split the top bar and slider widget --- crates/ui-egui/src/panels/topbar.rs | 382 ++++++++++--------- crates/ui-egui/src/widgets.rs | 557 ++++++++++++++++------------ 2 files changed, 529 insertions(+), 410 deletions(-) diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 2ba807dcc..5e3fea8a1 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -51,185 +51,202 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.integrated_titlebar { window_handle(ui, bar); } - ui.horizontal_centered(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel")) - .clicked() - { - let _ = app.run("view.leftPanel", json!({})); - } - ui.add_space(8.0); - if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, crate::i18n::tr("Back")).clicked() { - let _ = app.run("view.back", json!({})); - } - icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, crate::i18n::tr("Forward")); - if !app.native_menu { - // no native menu bar (web, Windows, Linux): menus in the top bar - ui.add_space(10.0); - let room = search_left - ui.cursor().left() - 16.0; - crate::menubar::show_in_window(app, ui, room); - } - }); - // search field (centred on the window) - let id = crate::text_field::id("field:search"); - if std::mem::take(&mut app.ui.focus_search) { - ui.memory_mut(|m| m.request_focus(id)); + nav_buttons(app, ui, search_left); + search_field(app, ui, &t, sr); + filter_button(app, ui, &t, sr); + let x = right_icons(app, ui, &t, full); + appearance_button(app, ui, &t, full, x); + }); +} + +/// Sidebar toggle, back/forward and, without a native menu bar, the in-window menus. +fn nav_buttons(app: &mut LightcraftApp, ui: &mut egui::Ui, search_left: f32) { + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel")) + .clicked() + { + let _ = app.run("view.leftPanel", json!({})); + } + ui.add_space(8.0); + if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, crate::i18n::tr("Back")).clicked() { + let _ = app.run("view.back", json!({})); + } + icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, crate::i18n::tr("Forward")); + if !app.native_menu { + // no native menu bar (web, Windows, Linux): menus in the top bar + ui.add_space(10.0); + let room = search_left - ui.cursor().left() - 16.0; + crate::menubar::show_in_window(app, ui, room); + } + }); +} + +/// The centred search field with its placeholder and focus ring. +fn search_field(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, sr: Rect) { + // search field (centred on the window) + let id = crate::text_field::id("field:search"); + if std::mem::take(&mut app.ui.focus_search) { + ui.memory_mut(|m| m.request_focus(id)); + } + let focused = ui.memory(|m| m.has_focus(id)); + ui.painter().rect( + sr, + 4.0, + if focused { t.canvas } else { t.field }, + Stroke::new(1.0, if focused { t.accent } else { t.field_border }), + StrokeKind::Inside, + ); + let mut child = + ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + let empty = app.ui.search.is_empty(); + if empty && !focused { + let g = child.painter().layout_no_wrap(crate::i18n::tr("Search Photos").into(), t.font(13.5), t.text_dim); + let w = g.size().x + 24.0; + let x0 = sr.center().x - w / 2.0; + paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim); + child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim); + } + let resp = crate::text_field::TextField::singleline("field:search", &mut app.ui.search) + .label(crate::i18n::tr("Search Photos")) + .frame(false) + .width(sr.width() - 20.0) + .font(t.font(13.5)) + .text_color(t.text) + .show(&mut child) + .response; + if resp.changed() { + let q = app.ui.search.clone(); + let _ = app.run("library.filter", json!({"text": q})); + } +} + +/// The filter icon beside the search field, with the active-filter badge. +fn filter_button(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, sr: Rect) { + // filter icon right of the search field + let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0)); + let fresp = ui.interact(fr, egui::Id::unique("filter-btn"), Sense::click()); + crate::widgets::announce(&fresp, crate::i18n::tr("Filters")); + register(ui, "icon:filter", fr); + let filtering = app.session.filter != Default::default() || app.ui.filter_bar; + paint( + ui.painter(), + fr.shrink(6.0), + Icon::Filter, + if filtering { + t.accent + } else if fresp.hovered() { + t.text + } else { + t.icon + }, + ); + // badge: how many filters are on, even with the filter bar closed + let active = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).len(); + if active > 0 { + let c = fr.right_top() + vec2(-3.0, 8.0); + ui.painter().circle_filled(c, 7.0, t.accent); + ui.painter().text(c, Align2::CENTER_CENTER, active.to_string(), t.semibold(9.5), t.canvas); + } + let fresp = fresp.on_hover_text(if active > 0 { + crate::i18n::tr_format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }, active = active) + } else { + crate::i18n::tr("Filter bar").into() + }); + if fresp.clicked() { + let _ = app.run("view.filterBar", json!({})); + } +} + +/// The right-side action icons; returns the x where the next icon would go. +fn right_icons(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, full: Rect) -> f32 { + // right icons + let mut x = full.right() - 18.0; + // saving is failing: the cloud icon turns into a warning until a save succeeds + let unsaved = app.session.unsaved().map(|(n, e)| unsaved_tip(n, e)); + let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed"); + // Import Photos… beside Export, always one click away, with the key it is bound to now + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let import_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "file.addPhotos") { + Some(sc) => format!("{} ({})", crate::i18n::tr("Import Photos…"), crate::menubar::shortcut_text(sc, mac)), + None => crate::i18n::tr("Import Photos…").to_string(), + }; + for (id, icon, tip, cmd) in [ + ("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"), + ("cloud", Icon::Cloud, cloud_tip, ""), + ("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"), + ("share", Icon::Share, "Export", "dialog.export"), + ("import", Icon::Plus, import_tip.as_str(), "file.addPhotos"), + ("bell", Icon::Bell, "Activity", "panel.activity"), + ] { + let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); + let resp = ui.interact(r, egui::Id::unique(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); + crate::widgets::announce(&resp, crate::i18n::tr(tip)); + register(ui, format!("icon:{id}"), r); + let warn = id == "cloud" && unsaved.is_some(); + let colour = if warn { + t.caution + } else if resp.hovered() { + t.text + } else { + t.icon + }; + paint(ui.painter(), r.shrink(5.0), icon, colour); + if warn { + let c = r.right_top() + vec2(-5.0, 6.0); + ui.painter().circle_filled(c, 6.0, t.reject); + ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas); + register(ui, "indicator:unsaved", r); + } + // a refusal says why (Import while a Lightroom catalog is coming in) + if resp.clicked() + && !cmd.is_empty() + && let Err(e) = app.run(cmd, json!({})) + { + app.toast_error(ui.ctx(), e); + } + x -= 40.0; + } + x +} + +/// The appearance icon at `x`, with its theme-favourites menu. +fn appearance_button(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, full: Rect, x: f32) { + // Appearance Mode: a click steps Auto → Light → Dark → Auto; the icon shows the mode + let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); + let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); + let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); + let resp = ui.interact(r, egui::Id::unique(("top", "appearance")), Sense::click()).on_hover_text(tip.clone()); + crate::widgets::announce(&resp, tip.clone()); + register(ui, "icon:appearance", r); + paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); + if resp.clicked() { + let _ = app.run("view.appearance", json!({})); + } + crate::widgets::context_menu(&resp, |ui| { + let settings = ui.button(crate::i18n::tr("Appearance Settings…")); + register(ui, "menu:appearanceSettings", settings.rect); + if settings.clicked() { + let _ = app.run("app.settings", json!({"tab": "interface"})); + ui.close(); + } + let favorites = app.ui.settings.favorite_themes.clone(); + for dark in [false, true] { + let group: Vec<_> = + crate::theme::ThemeKind::ALL.iter().filter(|k| k.is_dark() == dark && favorites.iter().any(|id| id == k.id())).collect(); + if !group.is_empty() { + ui.separator(); } - let focused = ui.memory(|m| m.has_focus(id)); - ui.painter().rect( - sr, - 4.0, - if focused { t.canvas } else { t.field }, - Stroke::new(1.0, if focused { t.accent } else { t.field_border }), - StrokeKind::Inside, - ); - let mut child = - ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); - let empty = app.ui.search.is_empty(); - if empty && !focused { - let g = child.painter().layout_no_wrap(crate::i18n::tr("Search Photos").into(), t.font(13.5), t.text_dim); - let w = g.size().x + 24.0; - let x0 = sr.center().x - w / 2.0; - paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim); - child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim); - } - let resp = crate::text_field::TextField::singleline("field:search", &mut app.ui.search) - .label(crate::i18n::tr("Search Photos")) - .frame(false) - .width(sr.width() - 20.0) - .font(t.font(13.5)) - .text_color(t.text) - .show(&mut child) - .response; - if resp.changed() { - let q = app.ui.search.clone(); - let _ = app.run("library.filter", json!({"text": q})); - } - // filter icon right of the search field - let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0)); - let fresp = ui.interact(fr, egui::Id::unique("filter-btn"), Sense::click()); - crate::widgets::announce(&fresp, crate::i18n::tr("Filters")); - register(ui, "icon:filter", fr); - let filtering = app.session.filter != Default::default() || app.ui.filter_bar; - paint( - ui.painter(), - fr.shrink(6.0), - Icon::Filter, - if filtering { - t.accent - } else if fresp.hovered() { - t.text - } else { - t.icon - }, - ); - // badge: how many filters are on, even with the filter bar closed - let active = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).len(); - if active > 0 { - let c = fr.right_top() + vec2(-3.0, 8.0); - ui.painter().circle_filled(c, 7.0, t.accent); - ui.painter().text(c, Align2::CENTER_CENTER, active.to_string(), t.semibold(9.5), t.canvas); - } - let fresp = fresp.on_hover_text(if active > 0 { - crate::i18n::tr_format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }, active = active) - } else { - crate::i18n::tr("Filter bar").into() - }); - if fresp.clicked() { - let _ = app.run("view.filterBar", json!({})); - } - // right icons - let mut x = full.right() - 18.0; - // saving is failing: the cloud icon turns into a warning until a save succeeds - let unsaved = app.session.unsaved().map(|(n, e)| { - crate::i18n::tr_format!( - "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.", - if n == 1 { "" } else { "s" }, - if n == 1 { "it" } else { "them" }, - e = e, - n = n - ) - }); - let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed"); - // Import Photos… beside Export, always one click away, with the key it is bound to now - let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; - let import_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "file.addPhotos") { - Some(sc) => format!("{} ({})", crate::i18n::tr("Import Photos…"), crate::menubar::shortcut_text(sc, mac)), - None => crate::i18n::tr("Import Photos…").to_string(), - }; - for (id, icon, tip, cmd) in [ - ("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"), - ("cloud", Icon::Cloud, cloud_tip, ""), - ("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"), - ("share", Icon::Share, "Export", "dialog.export"), - ("import", Icon::Plus, import_tip.as_str(), "file.addPhotos"), - ("bell", Icon::Bell, "Activity", "panel.activity"), - ] { - let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); - let resp = ui.interact(r, egui::Id::unique(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); - crate::widgets::announce(&resp, crate::i18n::tr(tip)); - register(ui, format!("icon:{id}"), r); - let warn = id == "cloud" && unsaved.is_some(); - let colour = if warn { - t.caution - } else if resp.hovered() { - t.text - } else { - t.icon - }; - paint(ui.painter(), r.shrink(5.0), icon, colour); - if warn { - let c = r.right_top() + vec2(-5.0, 6.0); - ui.painter().circle_filled(c, 6.0, t.reject); - ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas); - register(ui, "indicator:unsaved", r); - } - // a refusal says why (Import while a Lightroom catalog is coming in) - if resp.clicked() - && !cmd.is_empty() - && let Err(e) = app.run(cmd, json!({})) - { - app.toast_error(ui.ctx(), e); - } - x -= 40.0; - } - // Appearance Mode: a click steps Auto → Light → Dark → Auto; the icon shows the mode - let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); - let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); - let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); - let resp = ui.interact(r, egui::Id::unique(("top", "appearance")), Sense::click()).on_hover_text(tip.clone()); - crate::widgets::announce(&resp, tip.clone()); - register(ui, "icon:appearance", r); - paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); - if resp.clicked() { - let _ = app.run("view.appearance", json!({})); - } - crate::widgets::context_menu(&resp, |ui| { - let settings = ui.button(crate::i18n::tr("Appearance Settings…")); - register(ui, "menu:appearanceSettings", settings.rect); - if settings.clicked() { - let _ = app.run("app.settings", json!({"tab": "interface"})); + for kind in group { + let r = ui.selectable_label(app.ui.settings.theme(app.system_theme(ui.ctx())) == *kind, crate::i18n::tr(kind.label())); + register(ui, format!("menu:themeFavorite-{}", kind.id()), r.rect); + if r.clicked() { + let _ = app.run("view.theme", json!({"theme": kind.id()})); ui.close(); } - let favorites = app.ui.settings.favorite_themes.clone(); - for dark in [false, true] { - let group: Vec<_> = - crate::theme::ThemeKind::ALL.iter().filter(|k| k.is_dark() == dark && favorites.iter().any(|id| id == k.id())).collect(); - if !group.is_empty() { - ui.separator(); - } - for kind in group { - let r = ui.selectable_label(app.ui.settings.theme(app.system_theme(ui.ctx())) == *kind, crate::i18n::tr(kind.label())); - register(ui, format!("menu:themeFavorite-{}", kind.id()), r.rect); - if r.clicked() { - let _ = app.run("view.theme", json!({"theme": kind.id()})); - ui.close(); - } - } - } - }); - let _ = Align2::CENTER_CENTER; - }); + } + } + }); } /// The appearance button's icon and the mode's name for its tooltip. @@ -241,3 +258,16 @@ pub fn appearance_icon(mode: crate::state::AppearanceMode) -> (Icon, &'static st AppearanceMode::Dark => (Icon::Moon, "Dark Mode"), } } + +/// Tooltip for the warning cloud icon: `n` changes held in memory because saving failed with `e`. +fn unsaved_tip(n: usize, e: &str) -> String { + let s = crate::i18n::plural_s(n); + let it = if n == 1 { "it" } else { "them" }; + crate::i18n::tr_format!( + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.", + s, + it, + e = e, + n = n + ) +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 513208066..89185d991 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -398,25 +398,15 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let pad_r = 22.0; let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0)); let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0)); + let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max); let id = ui.scope_id().with(spec.id); + let g = SliderLayout { spec, value, enabled, id, track_rect, value_rect }; let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); - let wheel_id = id.with("wheel-rem"); let (wheel_armed, pointer_in_row) = update_wheel_arming(ui, id, &resp, row, enabled); // screen readers: a slider named after its control, with its value let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string(); resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone())); - // and its range, step and the value as shown ("+0.50", "5600") - let shown = shown_value(spec, value); - ui.ctx().accesskit_node_builder(resp.id, |node| { - node.set_min_numeric_value(spec.min); - node.set_max_numeric_value(spec.max); - node.set_numeric_value_step(spec.step); - node.set_value(shown); - if enabled { - node.add_action(egui::accesskit::Action::Increment); - node.add_action(egui::accesskit::Action::Decrement); - } - }); + describe_slider(ui, &g, &resp); // the name: double-click it to reset, as the thumb (a disabled slider's name is only text) let label_resp = ui.interact(label_rect, id.with("label"), if enabled { Sense::click() } else { Sense::hover() }); // say how to reset (issue #1436: the double-click was hard to discover), as the curve channels do @@ -427,246 +417,41 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ register(ui, format!("slider:{}", spec.id), track_rect); // the value: click it to type an exact one, drag it sideways to scrub (over the label, so it // takes the click) - let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max); let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() }); crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value, drag to change it"))); register(ui, format!("sliderValue:{}", spec.id), value_rect); // say that an exact value can be typed here (issue #534: an exact crop angle was hard to find) let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value, drag to change it")) } else { value_resp }; - let typing_id = id.with("typing"); // Keep the short focus-acquisition guard, but let an explicit Return/Escape end the edit. - let mut typing: Option<(String, u8, bool)> = ui.data(|m| m.get_temp(typing_id)); + let mut typing: Option = ui.data(|m| m.get_temp(g.typing_id())); // A scrub: the unrounded track position (0..1) it has reached, so a slow drag adds up instead // of rounding back to the same step every frame. - let scrub_id = id.with("scrub-pos"); - let mut scrub: Option = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(scrub_id)) } else { None }; - let mut scrub_started = false; - if enabled - && scrub.is_none() - && value_resp.dragged() - && let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos())) - && (p.x - origin.x).abs() >= SCRUB_START - { - // a press on the value that moves sideways scrubs: the edit the press opened is dropped - typing = None; - ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); - ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); - // the travel so far counts, so the value doesn't lag the pointer by the start distance - let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; - scrub = Some((spec.position(value) + f64::from((p.x - origin.x) / track_rect.width()) * fine).clamp(0.0, 1.0)); - scrub_started = true; - } + let mut scrub: Option = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(g.scrub_id())) } else { None }; + let scrub_started = begin_scrub(ui, &g, &value_resp, &mut scrub, &mut typing); if scrub.is_none() { - ui.data_mut(|m| m.remove::(scrub_id)); - } - let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); - // Focus on the owned primary press before fast typing; retain the readout's click activation. - if enabled && typing.is_none() && (value_resp.clicked() || value_pressed) { - typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0, value_pressed)); - } - let value_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); - // A release over the value keeps the field the press opened, however long the button was held - // (a press held past egui's click time ends without a click). A release elsewhere cancels the - // gesture, and a press that scrubs already dropped the field above. - let released_over_value = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| value_rect.contains(p)); - if value_released && !released_over_value && typing.as_ref().is_some_and(|(_, _, pressed)| *pressed) && !value_resp.clicked() { - typing = None; - ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); - ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); + ui.data_mut(|m| m.remove::(g.scrub_id())); } + update_typing_gesture(ui, &g, &value_resp, &mut typing); let mut out = SliderOut::default(); - // value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`) - let to_x = |v: f64| track_rect.left() + spec.position(v) as f32 * track_rect.width(); - let from_x = |x: f32| spec.value_at(f64::from((x - track_rect.left()) / track_rect.width())); // the thumb: a click there doesn't move the value, and resting on it says how to reset (the rest // of the track, which is clicked and dragged, gets no tooltip) - let on_thumb = |p: egui::Pos2| (p.x - to_x(value)).abs() <= THUMB_RING + 1.0; - if enabled && !resp.dragged() && resp.hover_pos().is_some_and(on_thumb) { + if enabled && !resp.dragged() && resp.hover_pos().is_some_and(|p| g.on_thumb(p)) { resp.clone().on_hover_text(reset_hint.as_str()); } - let mut v = value; - if let Some(mut pos) = scrub { - // scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel - // over the track's width (a tenth with Shift), on the control's steps - if !scrub_started && value_resp.dragged() { - let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; - let dx = ui.input(|i| i.pointer.delta().x); - pos = (pos + f64::from(dx / track_rect.width()) * fine).clamp(0.0, 1.0); - } - let step = spec.step.max(1e-9); - let nv = ((spec.value_at(pos) / step).round() * step).clamp(spec.min, spec.max); - if (nv - value).abs() > 1e-12 { - out.value = Some(nv); - v = nv; - } - out.drag_started = scrub_started; - if value_resp.drag_stopped() { - out.drag_stopped = true; - ui.data_mut(|m| m.remove::(scrub_id)); - } else { - ui.data_mut(|m| m.insert_temp(scrub_id, pos)); - } - } else if let Some((mut text, frames, pressed)) = typing.take() { - // Keep the owned opening release focused without restarting its draft or selection. - // egui gives that release's click to the readout, so the field under it gives up the focus - // whatever frame it lands on (issue #1362): the field takes it back after drawing too. - let opening_release = pressed && value_released; - if opening_release { - ui.memory_mut(|m| m.request_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); - } - let pressed = pressed && !value_released; - // a child over the value, so the field never moves the rows around it - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(value_rect).layout(egui::Layout::right_to_left(egui::Align::Center))); - // the old value is selected when the field takes the focus, so typing replaces it - let field = crate::text_field::TextField::singleline(&format!("sliderField:{}", spec.id), &mut text) - .font(t.font(12.5)) - .horizontal_align(egui::Align::RIGHT) - .width(value_rect.width()) - .margin(egui::Margin::ZERO) - .select_on_focus(true) - .begin_edit(frames == 0) - .show(&mut child); - if (frames < 2 || opening_release) && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) { - field.response.request_focus(); - let frames = frames.saturating_add(1); - typing = Some((text.clone(), frames, pressed)); - ui.data_mut(|m| m.insert_temp(typing_id, (text, frames, pressed))); - } else if field.ending.is_some() || !field.editing || !enabled { - // Esc (or the slider turning off) keeps the old value; Return or clicking away applies - let cancelled = !enabled || field.cancelled(); - if let Some(nv) = typed_value(spec, &text).filter(|nv| !cancelled && (nv - value).abs() > 1e-12) { - out.value = Some(nv); - out.drag_started = true; - out.drag_stopped = true; - v = nv; - } - ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); - } else { - typing = Some((text.clone(), frames, pressed)); - ui.data_mut(|m| m.insert_temp(typing_id, (text, frames, pressed))); - } + if let Some(pos) = scrub { + scrub_value(ui, &g, &value_resp, pos, scrub_started, &mut out); + } else if let Some(draft) = typing.take() { + typing = edit_value(ui, &t, &g, draft, &mut out); } else if resp.double_clicked() || label_resp.double_clicked() { out.reset = true; out.value = Some(spec.default); - v = spec.default; } else { - if resp.drag_started() { - out.drag_started = true; - } - if (resp.dragged() || resp.drag_started()) - && let Some(p) = resp.interact_pointer_pos() - { - let fine = ui.input(|i| i.modifiers.shift); - let step = spec.step.max(1e-9); - let nv = if fine { - // a tenth of the pointer's movement along the track; the unrounded position is kept, so a - // slow drag adds up instead of rounding back to the same step every frame - let fine_id = id.with("fine-pos"); - let dx = ui.input(|i| i.pointer.delta().x); - let kept = ui.data(|m| m.get_temp::(fine_id)).filter(|&p| ((spec.value_at(p) / step).round() * step - value).abs() < step / 2.0); - let pos = (kept.unwrap_or_else(|| spec.position(value)) + f64::from(dx / track_rect.width()) * 0.1).clamp(0.0, 1.0); - ui.data_mut(|m| m.insert_temp(fine_id, pos)); - spec.value_at(pos) - } else { - from_x(p.x) - }; - let nv = ((nv / step).round() * step).clamp(spec.min, spec.max); - if (nv - value).abs() > 1e-12 { - out.value = Some(nv); - v = nv; - } - } else if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - // a click on the thumb leaves it there, so the first click of a double-click on it isn't a - // history step of its own before the reset - && !on_thumb(p) - { - let step = spec.step.max(1e-9); - let nv = ((from_x(p.x) / step).round() * step).clamp(spec.min, spec.max); - out.value = Some(nv); - v = nv; - out.drag_started = true; - out.drag_stopped = true; - } - if resp.drag_stopped() { - out.drag_stopped = true; - } - // a screen reader's increment / decrement (VoiceOver: Ctrl-Option-Up / Down) - let (inc, dec) = ui.input(|i| { - i.events.iter().fold((false, false), |(inc, dec), e| match e { - egui::Event::AccessKitActionRequest(r) if r.target_node == resp.id.accesskit_id() => { - (inc || r.action == egui::accesskit::Action::Increment, dec || r.action == egui::accesskit::Action::Decrement) - } - _ => (inc, dec), - }) - }); - // Arrow keys work with either a hovered or a focused slider. - if enabled && out.value.is_none() && typing.is_none() && (pointer_in_row || resp.has_focus() || inc || dec) { - let (up, down, shift) = ui.input_mut(|i| { - let shift = i.modifiers.shift; - let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; - (i.consume_key(m, egui::Key::ArrowUp), i.consume_key(m, egui::Key::ArrowDown), shift) - }); - let (up, down) = (up || inc, down || dec); - if up || down { - let nv = nudged(spec, value, if up { 1.0 } else { -1.0 } * if shift { 5.0 } else { 1.0 }); - if (nv - value).abs() > 1e-12 { - out.value = Some(nv); - out.drag_started = true; - out.drag_stopped = true; - v = nv; - } - } - } - // Sideways scrolling (a trackpad swipe, a tilting wheel, Shift+wheel) adjusts the slider under - // the pointer, selected or not: the panel only scrolls vertically, so nothing else wants it. - // Only a mostly sideways movement counts, so a slightly diagonal swipe that scrolls the panel - // leaves the sliders it passes alone. Right raises the value, as dragging the thumb right does. - let side_id = id.with("side-rem"); - let (dx, dy) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y)); - if !enabled || typing.is_some() || out.value.is_some() || !pointer_in_row || dx.abs() <= dy.abs() { - ui.data_mut(|m| m.remove::(side_id)); - } else { - // the vertical part would scroll the panel and move the slider out from under the pointer - ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); - let mut remainder = ui.data(|m| m.get_temp::(side_id)).unwrap_or(0.0); - let steps = wheel_nudges(&mut remainder, dx); - ui.data_mut(|m| m.insert_temp(side_id, remainder)); - let nv = nudged(spec, value, steps); - if steps != 0.0 && (nv - value).abs() > 1e-12 { - out.value = Some(nv); - out.drag_started = true; - out.drag_stopped = true; - v = nv; - } - } - // Wheel nudges require an explicitly selected slider, unlike regular panel scrolling. - // Consume only the vertical component so a trackpad's horizontal scroll still works. - // (`wheel_armed` is cleared above with its remainder whenever focus or the pointer leaves.) - if wheel_armed && enabled && typing.is_none() && out.value.is_none() && pointer_in_row { - let delta = ui.input_mut(|i| { - let dy = i.smooth_scroll_delta.y; - if dy != 0.0 { - i.smooth_scroll_delta.y = 0.0; - } - dy - }); - if delta != 0.0 { - let mut remainder = ui.data(|m| m.get_temp::(wheel_id)).unwrap_or(0.0); - let steps = wheel_nudges(&mut remainder, delta); - ui.data_mut(|m| m.insert_temp(wheel_id, remainder)); - if steps != 0.0 { - let nv = nudged(spec, value, steps); - if (nv - value).abs() > 1e-12 { - out.value = Some(nv); - out.drag_started = true; - out.drag_stopped = true; - v = nv; - } - } - } - } + pointer_edit(ui, &g, &resp, &mut out); + nudge_edit(ui, &g, &resp, pointer_in_row, &mut out); + side_scroll_edit(ui, &g, pointer_in_row, &mut out); + wheel_edit(ui, &g, wheel_armed, pointer_in_row, &mut out); } + let v = out.value.unwrap_or(value); // paint let hovered = resp.hovered() || resp.dragged(); let text_c = if enabled { t.text_label } else { t.text_disabled }; @@ -680,7 +465,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } } let ring = THUMB_RING; - let tx = to_x(v); + let tx = g.to_x(v); paint_track(ui, track_rect, &spec.track, &t, tx, ring); let p = ui.painter(); let ring_c = if !enabled { @@ -698,6 +483,310 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ out } +/// A value being typed into a slider: the text, the frames it has been open, and whether the press that opened it is +/// still down. +type Draft = (String, u8, bool); + +/// What a slider is, as its interaction code needs it. +struct SliderLayout<'a> { + spec: &'a ControlSpec, + value: f64, + enabled: bool, + id: egui::Id, + /// where the thumb moves + track_rect: Rect, + /// the readout, where a value is typed or scrubbed + value_rect: Rect, +} + +impl SliderLayout<'_> { + fn typing_id(&self) -> egui::Id { + self.id.with("typing") + } + + fn scrub_id(&self) -> egui::Id { + self.id.with("scrub-pos") + } + + /// The text field a value is typed in. + fn field_id(&self) -> egui::Id { + crate::text_field::id(&format!("sliderField:{}", self.spec.id)) + } + + fn step(&self) -> f64 { + self.spec.step.max(1e-9) + } + + /// `v` on the control's steps and within its range. + fn snap(&self, v: f64) -> f64 { + ((v / self.step()).round() * self.step()).clamp(self.spec.min, self.spec.max) + } + + /// Whether `nv` is a different value from the one shown. + fn differs(&self, nv: f64) -> bool { + (nv - self.value).abs() > 1e-12 + } + + /// value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`) + fn to_x(&self, v: f64) -> f32 { + self.track_rect.left() + self.spec.position(v) as f32 * self.track_rect.width() + } + + fn value_at_x(&self, x: f32) -> f64 { + self.spec.value_at(f64::from((x - self.track_rect.left()) / self.track_rect.width())) + } + + /// Whether `p` is on the thumb. + fn on_thumb(&self, p: egui::Pos2) -> bool { + (p.x - self.to_x(self.value)).abs() <= THUMB_RING + 1.0 + } + + /// A change made in one go (a click, a key, a wheel step): it starts and stops the drag at once. + fn set_whole(&self, out: &mut SliderOut, nv: f64) { + out.value = Some(nv); + out.drag_started = true; + out.drag_stopped = true; + } + + /// Move to `nv` as a whole change, when it is a different value. + fn set_whole_if_changed(&self, out: &mut SliderOut, nv: f64) { + if self.differs(nv) { + self.set_whole(out, nv); + } + } +} + +/// Tell screen readers the slider's range, step and the value as shown ("+0.50", "5600"). +fn describe_slider(ui: &Ui, g: &SliderLayout, resp: &Response) { + let shown = shown_value(g.spec, g.value); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(g.spec.min); + node.set_max_numeric_value(g.spec.max); + node.set_numeric_value_step(g.spec.step); + node.set_value(shown); + if g.enabled { + node.add_action(egui::accesskit::Action::Increment); + node.add_action(egui::accesskit::Action::Decrement); + } + }); +} + +/// A press on the readout that moves sideways starts a scrub: the edit the press opened is dropped. Returns whether +/// a scrub started this frame (`scrub` then holds its first position). +fn begin_scrub(ui: &mut Ui, g: &SliderLayout, value_resp: &Response, scrub: &mut Option, typing: &mut Option) -> bool { + if !g.enabled || scrub.is_some() || !value_resp.dragged() { + return false; + } + let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos())) else { return false }; + if (p.x - origin.x).abs() < SCRUB_START { + return false; + } + *typing = None; + ui.data_mut(|m| m.remove::(g.typing_id())); + ui.memory_mut(|m| m.surrender_focus(g.field_id())); + // the travel so far counts, so the value doesn't lag the pointer by the start distance + let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; + *scrub = Some((g.spec.position(g.value) + f64::from((p.x - origin.x) / g.track_rect.width()) * fine).clamp(0.0, 1.0)); + true +} + +/// Open the typed edit on a press or click of the readout; drop it when the press ends elsewhere. +fn update_typing_gesture(ui: &mut Ui, g: &SliderLayout, value_resp: &Response, typing: &mut Option) { + let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); + // Focus on the owned primary press before fast typing; retain the readout's click activation. + if g.enabled && typing.is_none() && (value_resp.clicked() || value_pressed) { + *typing = Some((shown_value(g.spec, g.value).trim_start_matches('+').to_string(), 0, value_pressed)); + } + let value_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); + // A release over the value keeps the field the press opened, however long the button was held + // (a press held past egui's click time ends without a click). A release elsewhere cancels the + // gesture, and a press that scrubs already dropped the field above. + let released_over_value = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| g.value_rect.contains(p)); + if value_released && !released_over_value && typing.as_ref().is_some_and(|(_, _, pressed)| *pressed) && !value_resp.clicked() { + *typing = None; + ui.data_mut(|m| m.remove::(g.typing_id())); + ui.memory_mut(|m| m.surrender_focus(g.field_id())); + } +} + +/// Scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel over the track's width (a +/// tenth with Shift), on the control's steps. `pos` is the unrounded track position reached so far. +fn scrub_value(ui: &mut Ui, g: &SliderLayout, value_resp: &Response, mut pos: f64, started: bool, out: &mut SliderOut) { + if !started && value_resp.dragged() { + let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; + let dx = ui.input(|i| i.pointer.delta().x); + pos = (pos + f64::from(dx / g.track_rect.width()) * fine).clamp(0.0, 1.0); + } + let nv = g.snap(g.spec.value_at(pos)); + if g.differs(nv) { + out.value = Some(nv); + } + out.drag_started = started; + if value_resp.drag_stopped() { + out.drag_stopped = true; + ui.data_mut(|m| m.remove::(g.scrub_id())); + } else { + ui.data_mut(|m| m.insert_temp(g.scrub_id(), pos)); + } +} + +/// The text field over the readout while a value is typed. Returns the draft to keep, if the edit goes on. +fn edit_value(ui: &mut Ui, t: &Tokens, g: &SliderLayout, draft: Draft, out: &mut SliderOut) -> Option { + let (mut text, frames, pressed) = draft; + // Keep the owned opening release focused without restarting its draft or selection. + // egui gives that release's click to the readout, so the field under it gives up the focus + // whatever frame it lands on (issue #1362): the field takes it back after drawing too. + let released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); + let opening_release = pressed && released; + if opening_release { + ui.memory_mut(|m| m.request_focus(g.field_id())); + } + let pressed = pressed && !released; + // a child over the value, so the field never moves the rows around it + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(g.value_rect).layout(egui::Layout::right_to_left(egui::Align::Center))); + // the old value is selected when the field takes the focus, so typing replaces it + let field = crate::text_field::TextField::singleline(&format!("sliderField:{}", g.spec.id), &mut text) + .font(t.font(12.5)) + .horizontal_align(egui::Align::RIGHT) + .width(g.value_rect.width()) + .margin(egui::Margin::ZERO) + .select_on_focus(true) + .begin_edit(frames == 0) + .show(&mut child); + if (frames < 2 || opening_release) && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) { + field.response.request_focus(); + let draft = (text, frames.saturating_add(1), pressed); + ui.data_mut(|m| m.insert_temp(g.typing_id(), draft.clone())); + return Some(draft); + } + if field.ending.is_some() || !field.editing || !g.enabled { + // Esc (or the slider turning off) keeps the old value; Return or clicking away applies + let cancelled = !g.enabled || field.cancelled(); + if let Some(nv) = typed_value(g.spec, &text).filter(|nv| !cancelled && g.differs(*nv)) { + g.set_whole(out, nv); + } + ui.data_mut(|m| m.remove::(g.typing_id())); + return None; + } + let draft = (text, frames, pressed); + ui.data_mut(|m| m.insert_temp(g.typing_id(), draft.clone())); + Some(draft) +} + +/// Dragging the thumb or the track, or clicking the track. +fn pointer_edit(ui: &mut Ui, g: &SliderLayout, resp: &Response, out: &mut SliderOut) { + if resp.drag_started() { + out.drag_started = true; + } + if (resp.dragged() || resp.drag_started()) + && let Some(p) = resp.interact_pointer_pos() + { + let raw = if ui.input(|i| i.modifiers.shift) { fine_drag_value(ui, g) } else { g.value_at_x(p.x) }; + let nv = g.snap(raw); + if g.differs(nv) { + out.value = Some(nv); + } + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + // a click on the thumb leaves it there, so the first click of a double-click on it isn't a + // history step of its own before the reset + && !g.on_thumb(p) + { + g.set_whole(out, g.snap(g.value_at_x(p.x))); + } + if resp.drag_stopped() { + out.drag_stopped = true; + } +} + +/// A tenth of the pointer's movement along the track; the unrounded position is kept, so a slow drag adds up instead +/// of rounding back to the same step every frame. +fn fine_drag_value(ui: &mut Ui, g: &SliderLayout) -> f64 { + let step = g.step(); + let fine_id = g.id.with("fine-pos"); + let dx = ui.input(|i| i.pointer.delta().x); + let kept = ui.data(|m| m.get_temp::(fine_id)).filter(|&p| ((g.spec.value_at(p) / step).round() * step - g.value).abs() < step / 2.0); + let pos = (kept.unwrap_or_else(|| g.spec.position(g.value)) + f64::from(dx / g.track_rect.width()) * 0.1).clamp(0.0, 1.0); + ui.data_mut(|m| m.insert_temp(fine_id, pos)); + g.spec.value_at(pos) +} + +/// A screen reader's increment / decrement (VoiceOver: Ctrl-Option-Up / Down) as `(increment, decrement)`. +fn accessibility_nudge(ui: &Ui, resp: &Response) -> (bool, bool) { + ui.input(|i| { + i.events.iter().fold((false, false), |(inc, dec), e| match e { + egui::Event::AccessKitActionRequest(r) if r.target_node == resp.id.accesskit_id() => { + (inc || r.action == egui::accesskit::Action::Increment, dec || r.action == egui::accesskit::Action::Decrement) + } + _ => (inc, dec), + }) + }) +} + +/// The arrow keys (with either a hovered or a focused slider; Shift: five steps) and a screen reader's actions. +fn nudge_edit(ui: &mut Ui, g: &SliderLayout, resp: &Response, pointer_in_row: bool, out: &mut SliderOut) { + let (inc, dec) = accessibility_nudge(ui, resp); + if !g.enabled || out.value.is_some() || !(pointer_in_row || resp.has_focus() || inc || dec) { + return; + } + let (up, down, shift) = ui.input_mut(|i| { + let shift = i.modifiers.shift; + let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; + (i.consume_key(m, egui::Key::ArrowUp), i.consume_key(m, egui::Key::ArrowDown), shift) + }); + let (up, down) = (up || inc, down || dec); + if up || down { + let nv = nudged(g.spec, g.value, if up { 1.0 } else { -1.0 } * if shift { 5.0 } else { 1.0 }); + g.set_whole_if_changed(out, nv); + } +} + +/// Sideways scrolling (a trackpad swipe, a tilting wheel, Shift+wheel) adjusts the slider under +/// the pointer, selected or not: the panel only scrolls vertically, so nothing else wants it. +/// Only a mostly sideways movement counts, so a slightly diagonal swipe that scrolls the panel +/// leaves the sliders it passes alone. Right raises the value, as dragging the thumb right does. +fn side_scroll_edit(ui: &mut Ui, g: &SliderLayout, pointer_in_row: bool, out: &mut SliderOut) { + let side_id = g.id.with("side-rem"); + let (dx, dy) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y)); + if !g.enabled || out.value.is_some() || !pointer_in_row || dx.abs() <= dy.abs() { + ui.data_mut(|m| m.remove::(side_id)); + return; + } + // the vertical part would scroll the panel and move the slider out from under the pointer + ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); + let mut remainder = ui.data(|m| m.get_temp::(side_id)).unwrap_or(0.0); + let steps = wheel_nudges(&mut remainder, dx); + ui.data_mut(|m| m.insert_temp(side_id, remainder)); + if steps != 0.0 { + g.set_whole_if_changed(out, nudged(g.spec, g.value, steps)); + } +} + +/// Wheel nudges require an explicitly selected (armed) slider, unlike regular panel scrolling. +/// Consume only the vertical component so a trackpad's horizontal scroll still works. +fn wheel_edit(ui: &mut Ui, g: &SliderLayout, armed: bool, pointer_in_row: bool, out: &mut SliderOut) { + if !armed || !g.enabled || out.value.is_some() || !pointer_in_row { + return; + } + let delta = ui.input_mut(|i| { + let dy = i.smooth_scroll_delta.y; + if dy != 0.0 { + i.smooth_scroll_delta.y = 0.0; + } + dy + }); + if delta == 0.0 { + return; + } + let wheel_id = g.id.with("wheel-rem"); + let mut remainder = ui.data(|m| m.get_temp::(wheel_id)).unwrap_or(0.0); + let steps = wheel_nudges(&mut remainder, delta); + ui.data_mut(|m| m.insert_temp(wheel_id, remainder)); + if steps != 0.0 { + g.set_whole_if_changed(out, nudged(g.spec, g.value, steps)); + } +} + /// Convert vertical wheel movement to bounded slider steps, keeping sub-notch /// trackpad movement between frames without amplifying a single large event. One mouse-wheel /// notch ([`crate::theme::WHEEL_LINE_POINTS`]) is one step. From f6489e60d8a4281793740488e303f1fa39b8209b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 03:20:48 +0000 Subject: [PATCH 74/91] refactor(lightcraft): import prefs and GPU crash names explicitly --- apps/lightcraft/src/gpu_crash.rs | 2 +- apps/lightcraft/src/prefs.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/lightcraft/src/gpu_crash.rs b/apps/lightcraft/src/gpu_crash.rs index 1656d88e3..3b7422315 100644 --- a/apps/lightcraft/src/gpu_crash.rs +++ b/apps/lightcraft/src/gpu_crash.rs @@ -1,5 +1,5 @@ use super::prefs::config_dir; -use super::*; +use super::window_gpu; /// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12 /// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136: diff --git a/apps/lightcraft/src/prefs.rs b/apps/lightcraft/src/prefs.rs index 0800e6f3e..5701bf161 100644 --- a/apps/lightcraft/src/prefs.rs +++ b/apps/lightcraft/src/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LibraryProblem, LightcraftApp, UiState}; /// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's /// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's. From 6e54232557c0ed97fcdb297b73ca4ab115af2034 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 03:20:48 +0000 Subject: [PATCH 75/91] test: factor long UI and MCP scenarios into helpers --- crates/mcp/tests/it/e2e.rs | 99 ++++++---- crates/ui-egui/src/headless/tests.rs | 189 +++++++++++-------- crates/ui-egui/src/i18n/tests.rs | 112 ++++++----- crates/ui-egui/src/render/thumbnail_tests.rs | 77 +++++--- crates/ui-egui/src/tests/open_lightroom.rs | 31 ++- crates/ui-egui/src/tests/preview_limit.rs | 120 +++++++----- crates/ui-egui/src/tests/slideshow.rs | 80 +++++--- 7 files changed, 438 insertions(+), 270 deletions(-) diff --git a/crates/mcp/tests/it/e2e.rs b/crates/mcp/tests/it/e2e.rs index 03d77c7e2..2a8db44c7 100644 --- a/crates/mcp/tests/it/e2e.rs +++ b/crates/mcp/tests/it/e2e.rs @@ -246,33 +246,13 @@ impl Backend for HoldAfterFirst { } } -/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation -/// between complete photos, unrelated output preservation, and a usable session afterwards. -/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or -/// the cancel is read, so neither can finish first on a fast machine. -#[test] -fn export_progress_and_cancel() { - let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let (input, mut send) = std::io::pipe().unwrap(); - let (output, write) = std::io::pipe().unwrap(); - let hold = Arc::new(AtomicBool::new(true)); - let backend = HoldAfterFirst { inner: file_backed_photos(&dir), hold: hold.clone() }; - let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap()); - let (tx, rx) = std::sync::mpsc::channel(); - let reader = std::thread::spawn(move || { - for line in BufReader::new(output).lines() { - let value: Value = serde_json::from_str(&line.unwrap()).unwrap(); - if tx.send(value).is_err() { - break; - } - } - }); - let next = || rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress"); - writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap(); - let query = next(); - let photos = query["result"]["structuredContent"]["photos"].as_array().unwrap(); +type Rx = std::sync::mpsc::Receiver; + +fn next_msg(rx: &Rx) -> Value { + rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress") +} + +fn assert_query_photos(photos: &[Value]) { assert_eq!(photos.len(), 8); // Real files, real metadata: eight distinct 1200×800 PNGs probed from disk, not synthesized. let names: std::collections::HashSet<&str> = photos.iter().map(|p| p["fileName"].as_str().unwrap()).collect(); @@ -281,13 +261,15 @@ fn export_progress_and_cancel() { assert_eq!((p["width"].as_u64(), p["height"].as_u64()), (Some(1200), Some(800)), "{p}"); assert_eq!(p["format"], "PNG", "{p}"); } - let ids: Vec<_> = photos.iter().map(|p| p["id"].clone()).collect(); +} + +fn run_complete_export(send: &mut impl Write, rx: &Rx, hold: &AtomicBool, ids: &[Value], dir: &std::path::Path) { let mut request = call(2, "export", json!({"ids":ids,"dir":dir.join("complete"),"longEdge":1200,"format":"png"})); request["params"]["_meta"] = json!({"progressToken":"photos","io.modelcontextprotocol/protocolVersion":"2026-07-28"}); writeln!(send, "{request}").unwrap(); let (mut last, mut notes, mut asked, mut answered) = (-1.0, 0, false, false); loop { - let value = next(); + let value = next_msg(rx); if value["method"] == "notifications/progress" { assert_eq!(value["params"]["progressToken"], "photos"); let progress = value["params"]["progress"].as_f64().unwrap(); @@ -312,17 +294,16 @@ fn export_progress_and_cancel() { } assert!(notes >= 2 && answered, "{notes} progress messages; ping answered {answered}"); assert_eq!(last, 8.0); - let out = dir.join("cancelled"); - std::fs::create_dir_all(&out).unwrap(); - let decoy = out.join("photo999.png"); - std::fs::write(&decoy, b"unrelated existing output").unwrap(); +} + +fn run_cancelled_export(send: &mut impl Write, rx: &Rx, hold: &AtomicBool, ids: &[Value], out: &std::path::Path) { // this export waits after its first photo until the cancel below is read hold.store(true, Ordering::SeqCst); let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}})); request["params"]["_meta"] = json!({"progressToken":44}); writeln!(send, "{request}").unwrap(); loop { - let value = next(); + let value = next_msg(rx); assert_ne!(value["id"], 4, "must be cancellable before completion: {value}"); assert_eq!(value["params"]["progressToken"], 44, "{value}"); if value["params"]["progress"].as_f64().unwrap() > 0.0 { @@ -332,7 +313,7 @@ fn export_progress_and_cancel() { writeln!(send, "{}", json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}})).unwrap(); writeln!(send, "{}", call(5, "doc_inspect", json!({}))).unwrap(); loop { - let value = next(); + let value = next_msg(rx); if value["method"] == "notifications/progress" { continue; } @@ -340,13 +321,19 @@ fn export_progress_and_cancel() { assert_eq!(value["result"]["isError"], false); break; } - assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output"); - let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect(); +} + +fn assert_cancelled_output(out: &std::path::Path, decoy: &std::path::Path) { + assert_eq!(std::fs::read(decoy).unwrap(), b"unrelated existing output"); + let photos: Vec<_> = std::fs::read_dir(out).unwrap().map(|e| e.unwrap().path()).filter(|p| p != decoy).collect(); assert_eq!(photos.len(), 1, "only the photo completed before the cancel remains: {photos:?}"); for path in &photos { let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file"); assert_eq!((d.width, d.height), (1200, 800), "{}", path.display()); } +} + +fn assert_complete_output(dir: &std::path::Path) { // The finished export's eight outputs are real 1200×800 PNGs, one per distinct original. let complete: Vec<_> = std::fs::read_dir(dir.join("complete")).unwrap().map(|e| e.unwrap().path()).collect(); assert_eq!(complete.len(), 8, "earlier export retained: {complete:?}"); @@ -359,6 +346,44 @@ fn export_progress_and_cancel() { exported.insert(bytes); } assert_eq!(exported.len(), 8, "eight distinct outputs, not copies of one export"); +} + +/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation +/// between complete photos, unrelated output preservation, and a usable session afterwards. +/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or +/// the cancel is read, so neither can finish first on a fast machine. +#[test] +fn export_progress_and_cancel() { + let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (input, mut send) = std::io::pipe().unwrap(); + let (output, write) = std::io::pipe().unwrap(); + let hold = Arc::new(AtomicBool::new(true)); + let backend = HoldAfterFirst { inner: file_backed_photos(&dir), hold: hold.clone() }; + let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap()); + let (tx, rx) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(output).lines() { + let value: Value = serde_json::from_str(&line.unwrap()).unwrap(); + if tx.send(value).is_err() { + break; + } + } + }); + writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap(); + let query = next_msg(&rx); + let photos = query["result"]["structuredContent"]["photos"].as_array().unwrap(); + assert_query_photos(photos); + let ids: Vec<_> = photos.iter().map(|p| p["id"].clone()).collect(); + run_complete_export(&mut send, &rx, &hold, &ids, &dir); + let out = dir.join("cancelled"); + std::fs::create_dir_all(&out).unwrap(); + let decoy = out.join("photo999.png"); + std::fs::write(&decoy, b"unrelated existing output").unwrap(); + run_cancelled_export(&mut send, &rx, &hold, &ids, &out); + assert_cancelled_output(&out, &decoy); + assert_complete_output(&dir); drop(send); // The fixture's files stay in place until the server (which owns the session) and the reader // have finished: the session reads the originals; the output is what the checks above read. diff --git a/crates/ui-egui/src/headless/tests.rs b/crates/ui-egui/src/headless/tests.rs index f38ed636a..13bd7a2ae 100644 --- a/crates/ui-egui/src/headless/tests.rs +++ b/crates/ui-egui/src/headless/tests.rs @@ -2775,6 +2775,109 @@ fn browse_a_local_folder() { let _ = std::fs::remove_dir_all(&dir); } +fn hide_rects(h: &mut Headless) -> Vec<(String, Vec)> { + let w = h.request("ui.widgets", json!({"filter": "source:local:"}), Duration::from_secs(10)); + w["result"] + .as_array() + .unwrap() + .iter() + .map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"].as_array().unwrap().iter().filter_map(|v| v.as_f64()).collect())) + .collect() +} + +fn hide_ids(h: &mut Headless) -> Vec { + hide_rects(h).into_iter().map(|(id, _)| id).collect() +} + +fn hide_menu(h: &mut Headless) -> Vec { + let w = h.request("ui.widgets", json!({"filter": "local:restoreHidden:"}), Duration::from_secs(10)); + w["result"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap().to_string()).collect() +} + +/// Hides `path` through the command and settles the frames. +fn hide_path(h: &mut Headless, path: &str) { + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), Duration::from_secs(10)); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +/// Keeps and browses `path` the way Browse Folder… does; returns whether anything was hidden before. +fn hide_browse_and_hide(h: &mut Headless, path: &str, size: [f32; 2]) -> bool { + let t = Duration::from_secs(10); + // Browse Folder… browses the picked folder and keeps it in Local within one frame; a + // request runs frames, so keep it first (no frame sees it browsed but not yet kept) + let r = h.request("engine.execute", json!({"command": "local.addRoot", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let row = format!("source:local:{path}"); + assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the browsed folder is listed: {:?}", hide_ids(h)); + // the "Show hidden locations" row appears only while something is hidden + let hidden_before = !h.app.ui.hidden_locations.is_empty(); + assert_eq!(hide_ids(h).iter().any(|i| i == "source:local:restoreHidden"), hidden_before); + // hide it while it is still being browsed + hide_path(h, path); + // give any listing a reveal would wait for time to arrive + for _ in 0..20 { + h.step(); + std::thread::sleep(Duration::from_millis(5)); + } + h.settle(SETTLE); + let after = hide_rects(h); + let after_ids: Vec<&str> = after.iter().map(|(id, _)| id.as_str()).collect(); + assert!(!after_ids.contains(&format!("source:local:{path}").as_str()), "{after_ids:?}"); + let restore = after.iter().find(|(id, _)| id == "source:local:restoreHidden").map(|(_, r)| r.clone()); + let restore = restore.unwrap_or_else(|| panic!("no restore row: {after_ids:?}")); + assert!(restore[1] >= 0.0 && restore[1] + restore[3] <= f64::from(size[1]), "the restore row is in view: {restore:?} {after_ids:?}"); + assert!(h.app.session.browse.is_some(), "hiding does not stop browsing"); + hidden_before +} + +/// A second hidden place: the row lists them, and each comes back on its own (issue #2114: +/// the row used to bring every hidden place back at once). +fn hide_second_place_restores_alone(h: &mut Headless, path: &str, path2: &str) { + let t = Duration::from_secs(10); + for (command, params) in [("local.addRoot", json!({"path": path2})), ("local.hide", json!({"path": path2}))] { + let r = h.request("engine.execute", json!({"command": command, "params": params}), t); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + assert!(hide_menu(h).is_empty(), "the list opens from the row"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + let items = hide_menu(h); + assert!(items.contains(&format!("local:restoreHidden:{path}")) && items.contains(&"local:restoreHidden:all".to_string()), "{items:?}"); + assert_eq!(h.request("ui.clickWidget", json!({"id": format!("local:restoreHidden:{path}")}), t)["ok"], true); + h.settle(SETTLE); + assert!(!h.app.ui.hidden_locations.iter().any(|x| x == path), "{:?}", h.app.ui.hidden_locations); + assert!(h.app.ui.hidden_locations.iter().any(|x| x == path2), "the other place stays hidden"); + assert!(hide_ids(h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore"); +} + +fn hide_restore_all_then_single(h: &mut Headless, path: &str, path2: &str, hidden_before: bool) { + let t = Duration::from_secs(10); + // two hidden again: Restore All brings every one back + hide_path(h, path); + assert!(h.app.ui.hidden_locations.len() >= 2, "{:?}", h.app.ui.hidden_locations); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true); + h.settle(SETTLE); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); + assert!(!hide_ids(h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore"); + // one hidden: the row brings it back itself, no list + hide_path(h, path2); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert!(hide_menu(h).is_empty(), "no list for a single hidden place"); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); + if !hidden_before { + // (with Home hidden for this test, the folder is back inside Home's tree instead) + assert!(hide_ids(h).contains(&format!("source:local:{path}"))); + } +} + /// Local: Remove from Local hides a sidebar location (nothing on disk changes) and the /// "Show hidden locations" row puts it back. The folder is browsed and kept the way Browse /// Folder… does it, in the temporary folder — on Windows beneath Home, whose tree must not @@ -2788,96 +2891,16 @@ fn local_location_can_be_hidden_and_restored() { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let path = dir.to_string_lossy().to_string(); - let rects = |h: &mut Headless| -> Vec<(String, Vec)> { - let w = h.request("ui.widgets", json!({"filter": "source:local:"}), t); - w["result"] - .as_array() - .unwrap() - .iter() - .map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"].as_array().unwrap().iter().filter_map(|v| v.as_f64()).collect())) - .collect() - }; - let ids = |h: &mut Headless| -> Vec { rects(h).into_iter().map(|(id, _)| id).collect() }; h.request("ui.set", json!({"leftPanel": true}), t); h.hide_home_above(&dir); - // Browse Folder… browses the picked folder and keeps it in Local within one frame; a - // request runs frames, so keep it first (no frame sees it browsed but not yet kept) - let r = h.request("engine.execute", json!({"command": "local.addRoot", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - let row = format!("source:local:{path}"); - assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the browsed folder is listed: {:?}", ids(&mut h)); - // the "Show hidden locations" row appears only while something is hidden - let hidden_before = !h.app.ui.hidden_locations.is_empty(); - assert_eq!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), hidden_before); - // hide it while it is still being browsed - let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - // give any listing a reveal would wait for time to arrive - for _ in 0..20 { - h.step(); - std::thread::sleep(Duration::from_millis(5)); - } - h.settle(SETTLE); - let after = rects(&mut h); - let after_ids: Vec<&str> = after.iter().map(|(id, _)| id.as_str()).collect(); - assert!(!after_ids.contains(&format!("source:local:{path}").as_str()), "{after_ids:?}"); - let restore = after.iter().find(|(id, _)| id == "source:local:restoreHidden").map(|(_, r)| r.clone()); - let restore = restore.unwrap_or_else(|| panic!("no restore row: {after_ids:?}")); - assert!(restore[1] >= 0.0 && restore[1] + restore[3] <= f64::from(size[1]), "the restore row is in view: {restore:?} {after_ids:?}"); - assert!(h.app.session.browse.is_some(), "hiding does not stop browsing"); + let hidden_before = hide_browse_and_hide(&mut h, &path, size); assert!(dir.is_dir(), "the folder itself is untouched"); - // a second hidden place: the row lists them, and each comes back on its own (issue #2114: - // the row used to bring every hidden place back at once) let dir2 = std::env::temp_dir().join(format!("lc-ui-hide-{}-b", std::process::id())); let _ = std::fs::remove_dir_all(&dir2); std::fs::create_dir_all(&dir2).unwrap(); let path2 = dir2.to_string_lossy().to_string(); - for (command, params) in [("local.addRoot", json!({"path": path2})), ("local.hide", json!({"path": path2}))] { - let r = h.request("engine.execute", json!({"command": command, "params": params}), t); - assert_eq!(r["ok"], true, "{r}"); - } - h.settle(SETTLE); - let menu = |h: &mut Headless| -> Vec { - let w = h.request("ui.widgets", json!({"filter": "local:restoreHidden:"}), t); - w["result"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap().to_string()).collect() - }; - assert!(menu(&mut h).is_empty(), "the list opens from the row"); - assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); - h.settle(SETTLE); - let items = menu(&mut h); - assert!(items.contains(&format!("local:restoreHidden:{path}")) && items.contains(&"local:restoreHidden:all".to_string()), "{items:?}"); - assert_eq!(h.request("ui.clickWidget", json!({"id": format!("local:restoreHidden:{path}")}), t)["ok"], true); - h.settle(SETTLE); - assert!(!h.app.ui.hidden_locations.iter().any(|x| x == &path), "{:?}", h.app.ui.hidden_locations); - assert!(h.app.ui.hidden_locations.iter().any(|x| x == &path2), "the other place stays hidden"); - assert!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore"); - // two hidden again: Restore All brings every one back - let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(h.app.ui.hidden_locations.len() >= 2, "{:?}", h.app.ui.hidden_locations); - assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); - h.settle(SETTLE); - assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true); - h.settle(SETTLE); - assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); - assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore"); - // one hidden: the row brings it back itself, no list - let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path2}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); - h.settle(SETTLE); - assert!(menu(&mut h).is_empty(), "no list for a single hidden place"); - assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); - if !hidden_before { - // (with Home hidden for this test, the folder is back inside Home's tree instead) - assert!(ids(&mut h).contains(&format!("source:local:{path}"))); - } + hide_second_place_restores_alone(&mut h, &path, &path2); + hide_restore_all_then_single(&mut h, &path, &path2, hidden_before); let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir2); } diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 2a44072f0..77bbc14cb 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -214,6 +214,32 @@ fn switching_language_translates_and_restores() { assert_eq!(tr("Exposure"), "Exposure"); } +fn assert_format_specs(language: &Locale) { + let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); + assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}"); + let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning"); + assert!(merging.contains("42%"), "{language:?}: {merging}"); + let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5); + assert!(delta.contains("+5"), "{language:?}: {delta}"); + let reading = tr_format!("Reading photos… {} of {}", 3, 10); + let (three, ten) = (reading.find('3'), reading.find("10")); + assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); +} + +fn assert_format_values_kept(language: &Locale) { + let name = "Color 1234.56 {title} 日本語"; + let added = tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22); + assert!(added.contains(name) && added.contains("22"), "{language:?}: {added}"); + for n in [0, 1, 2, 5, 22] { + let imported = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }); + assert!(imported.contains(&n.to_string()), "{language:?}: {imported}"); + } + // The trailing argument is queue progress, not an English plural suffix. + let queued = tr_format!(" · {queued} waiting", queued = 5); + let denoising = tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4); + assert!(denoising.contains("IMG.CR3") && denoising.contains('5'), "{language:?}: {denoising}"); +} + /// Format strings carry their values in every language, and a language without a translation /// falls back to the English format rather than dropping the values. #[test] @@ -228,26 +254,8 @@ fn formats_render_in_every_language() { // text (an album name with braces and CJK) and counts are never dropped. for language in Locale::ALL { set_language(*language); - let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); - assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}"); - let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning"); - assert!(merging.contains("42%"), "{language:?}: {merging}"); - let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5); - assert!(delta.contains("+5"), "{language:?}: {delta}"); - let reading = tr_format!("Reading photos… {} of {}", 3, 10); - let (three, ten) = (reading.find('3'), reading.find("10")); - assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); - let name = "Color 1234.56 {title} 日本語"; - let added = tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22); - assert!(added.contains(name) && added.contains("22"), "{language:?}: {added}"); - for n in [0, 1, 2, 5, 22] { - let imported = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }); - assert!(imported.contains(&n.to_string()), "{language:?}: {imported}"); - } - // The trailing argument is queue progress, not an English plural suffix. - let queued = tr_format!(" · {queued} waiting", queued = 5); - let denoising = tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4); - assert!(denoising.contains("IMG.CR3") && denoising.contains('5'), "{language:?}: {denoising}"); + assert_format_specs(language); + assert_format_values_kept(language); } set_language(Locale::En); assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); @@ -1128,24 +1136,7 @@ fn a_dropped_photos_notice_names_every_outcome_of_the_import_and_is_silent_for_d let _ = std::fs::remove_dir_all(dir); } -/// Polish: the menu and control channel select it, dates read day.month.year, and every count -/// reads right for any number. Polish has three plural forms (1 zdjęcie, 2 zdjęcia, 5 zdjęć), so -/// counts follow their noun ("Zaimportowane zdjęcia: 22") instead of a form that fits only some. -#[test] -fn polish_switches_formats_dates_and_reads_every_count() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.polish", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Pl); - assert_eq!(Locale::Pl.name(), "Polski"); - assert_eq!(crate::menubar::checked(&app, "app.language.polish"), Some(true)); - assert_eq!(tr("File"), "Plik"); - assert_eq!(tr("Exposure"), "Ekspozycja"); - assert_eq!(date_group_label("2026-09-20", false), "Niedziela, 20.09.2026"); - assert_eq!(date_group_label("2026-09-20", true), "Niedziela, 20"); - assert_eq!(date_group_label("2026-09", false), "09.2026"); - assert_eq!(date_group_label("2026", false), "2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Niedziela, 20.09.2026 16:04:05"); +fn assert_polish_counts() { for n in [0, 1, 2, 3, 4, 5, 12, 21, 22, 25, 111] { let s = if n == 1 { "" } else { "s" }; assert_eq!(tr_format!("Imported {} photo{}", n, s), format!("Zaimportowane zdjęcia: {n}")); @@ -1159,20 +1150,20 @@ fn polish_switches_formats_dates_and_reads_every_count() { assert_eq!(tr_format!("Delete {n} Photos", n = n), format!("Usuń zdjęcia ({n})")); assert_eq!(tr_format!("{n} preset{}", s, n = n), format!("Ustawienia: {n}")); } - let name = String::from("Color 1234.56 {title} 日本語"); - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Dodano do „{name}”: 22")); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Pl); - let ctx = egui::Context::default(); +} + +fn assert_polish_selected_by_tags(app: &mut crate::LightcraftApp, ctx: &egui::Context) { + use crate::control::{ControlRequest, Outcome}; for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"] { assert_eq!(Locale::parse_tag(tag), Some(Locale::Pl), "{tag}"); let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + let Outcome::Done(reply) = crate::control::handle(app, ctx, &request) else { panic!("expected reply") }; assert_eq!(reply["ok"], true); assert_eq!(language(), Locale::Pl); } - let text = painted_text(&ctx, &mut app, Locale::Pl); - assert!(text.contains("Plik") && text.contains("Ekspozycja"), "{text}"); +} + +fn assert_polish_glyphs(ctx: &egui::Context) { ctx.fonts_mut(|fonts| { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { let font = egui::FontId::new(13.0, family); @@ -1181,5 +1172,34 @@ fn polish_switches_formats_dates_and_reads_every_count() { } } }); +} + +/// Polish: the menu and control channel select it, dates read day.month.year, and every count +/// reads right for any number. Polish has three plural forms (1 zdjęcie, 2 zdjęcia, 5 zdjęć), so +/// counts follow their noun ("Zaimportowane zdjęcia: 22") instead of a form that fits only some. +#[test] +fn polish_switches_formats_dates_and_reads_every_count() { + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.polish", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Pl); + assert_eq!(Locale::Pl.name(), "Polski"); + assert_eq!(crate::menubar::checked(&app, "app.language.polish"), Some(true)); + assert_eq!(tr("File"), "Plik"); + assert_eq!(tr("Exposure"), "Ekspozycja"); + assert_eq!(date_group_label("2026-09-20", false), "Niedziela, 20.09.2026"); + assert_eq!(date_group_label("2026-09-20", true), "Niedziela, 20"); + assert_eq!(date_group_label("2026-09", false), "09.2026"); + assert_eq!(date_group_label("2026", false), "2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Niedziela, 20.09.2026 16:04:05"); + assert_polish_counts(); + let name = String::from("Color 1234.56 {title} 日本語"); + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Dodano do „{name}”: 22")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Pl); + let ctx = egui::Context::default(); + assert_polish_selected_by_tags(&mut app, &ctx); + let text = painted_text(&ctx, &mut app, Locale::Pl); + assert!(text.contains("Plik") && text.contains("Ekspozycja"), "{text}"); + assert_polish_glyphs(&ctx); set_language(Locale::En); } diff --git a/crates/ui-egui/src/render/thumbnail_tests.rs b/crates/ui-egui/src/render/thumbnail_tests.rs index eaf4e8f2f..2fcd6c5ab 100644 --- a/crates/ui-egui/src/render/thumbnail_tests.rs +++ b/crates/ui-egui/src/render/thumbnail_tests.rs @@ -774,66 +774,81 @@ fn undo_to_existing_texture_supersedes_pending_edit() { assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels, original.pixels); } -#[test] -fn cells_at_different_thumbnail_buckets_keep_one_pending_request() { - let (mut app, work, ctx) = fixture(); +/// First request: other buckets must not replace the pending job. Returns the pending key. +fn thumb_buckets_pending_request(app: &mut crate::LightcraftApp, work: &std::rc::Rc>, ctx: &egui::Context) -> u64 { let id = PhotoId(1); let slot = Slot::Thumb(id); // A print page's cells show the same photo at different sizes. Before any texture arrives, // each frame's other bucket used to replace (and restart) the pending job. - crate::panels::grid::request_thumb(&mut app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 256, 10); let built = app.renderer.thumb_jobs_built; assert_eq!(built, 1); - let (submitted, pending) = take_job(&work); + let (submitted, pending) = take_job(work); assert_eq!(submitted, slot); for _ in 0..12 { - crate::panels::grid::request_thumb(&mut app, id, 512, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 8); - crate::panels::grid::request_thumb(&mut app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 512, 10); + crate::panels::grid::request_thumb(app, id, 384, 8); + crate::panels::grid::request_thumb(app, id, 256, 10); } assert_eq!(app.renderer.thumb_jobs_built, built, "another bucket must not replace the pending thumbnail"); assert!(work.borrow().jobs.is_empty(), "the pending render is not restarted"); assert_eq!(app.renderer.pending[&slot].0, pending.key); let pending_key = pending.key; - finish(&mut app, &work, &ctx, slot, pending.run()); + finish(app, work, ctx, slot, pending.run()); assert_eq!(app.renderer.textures[&slot].key, pending_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 256, "the completed request's bucket is remembered"); assert!(!app.renderer.is_pending(slot)); + pending_key +} +/// A larger bucket still upgrades; smaller cells wait behind it. Returns the larger key. +fn thumb_buckets_larger_upgrades( + app: &mut crate::LightcraftApp, + work: &std::rc::Rc>, + ctx: &egui::Context, + pending_key: u64, +) -> u64 { + let id = PhotoId(1); + let slot = Slot::Thumb(id); // With a completed 256 thumbnail, the larger cell asks for its bucket once; the smaller // cells then wait behind it instead of replacing it back every frame. let built = app.renderer.thumb_jobs_built; - crate::panels::grid::request_thumb(&mut app, id, 512, 10); + crate::panels::grid::request_thumb(app, id, 512, 10); assert_eq!(app.renderer.thumb_jobs_built, built + 1, "a larger bucket still upgrades"); - let (submitted, larger) = take_job(&work); + let (submitted, larger) = take_job(work); assert_eq!(submitted, slot); assert_ne!(larger.key, pending_key); let larger_key = app.renderer.pending[&slot].0; assert_eq!(larger_key, larger.key); for _ in 0..12 { - crate::panels::grid::request_thumb(&mut app, id, 512, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - crate::panels::grid::request_thumb(&mut app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 512, 10); + crate::panels::grid::request_thumb(app, id, 384, 10); + crate::panels::grid::request_thumb(app, id, 256, 10); } assert_eq!(app.renderer.thumb_jobs_built, built + 1, "smaller cells keep the larger render in flight"); assert!(work.borrow().jobs.is_empty(), "the larger render is not restarted"); assert_eq!(app.renderer.pending[&slot].0, larger_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 512, "the in-flight bucket is still the larger one"); - finish(&mut app, &work, &ctx, slot, larger.run()); + finish(app, work, ctx, slot, larger.run()); assert_eq!(app.renderer.textures[&slot].key, larger_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 512); assert!(!app.renderer.is_pending(slot)); + larger_key +} +fn thumb_buckets_largest_serves_smaller(app: &mut crate::LightcraftApp, work: &std::rc::Rc>, larger_key: u64) { + let id = PhotoId(1); + let slot = Slot::Thumb(id); // Once the largest visible bucket is complete, every smaller cell reuses those pixels. // The 8x8 fixture texture cannot show its requested edge, so the remembered bucket, key // and photo identity are what prove no render was built. Repeated frames at all sizes // (the old bug alternated 256/512 forever) must settle with nothing further requested. let built = app.renderer.thumb_jobs_built; for _ in 0..24 { - crate::panels::grid::request_thumb(&mut app, id, 256, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - crate::panels::grid::request_thumb(&mut app, id, 512, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 8); + crate::panels::grid::request_thumb(app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 384, 10); + crate::panels::grid::request_thumb(app, id, 512, 10); + crate::panels::grid::request_thumb(app, id, 384, 8); } assert_eq!(app.renderer.thumb_jobs_built, built, "completed larger thumbnails serve smaller cells"); assert!(work.borrow().jobs.is_empty()); @@ -841,7 +856,16 @@ fn cells_at_different_thumbnail_buckets_keep_one_pending_request() { assert_eq!(app.renderer.textures[&slot].key, larger_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 512); assert_eq!(app.renderer.thumb_inputs[&id].2, larger_key); +} +fn thumb_buckets_edit_requests_again( + app: &mut crate::LightcraftApp, + work: &std::rc::Rc>, + ctx: &egui::Context, + larger_key: u64, +) { + let id = PhotoId(1); + let slot = Slot::Thumb(id); // The renderer remembers the photo through a `Weak` only. That is enough for // `Catalog`'s `Arc::make_mut` to copy the photo on edit (std: an existing weak pointer // forces a new allocation even with a single strong reference), so the identity below is @@ -861,18 +885,27 @@ fn cells_at_different_thumbnail_buckets_keep_one_pending_request() { assert_eq!(remembered.strong_count(), 0, "the renderer's Weak now points at the old, unowned allocation"); assert_eq!(Arc::strong_count(current), 1, "the edit left one strong owner; no extra Arc was needed"); let built = app.renderer.thumb_jobs_built; - crate::panels::grid::request_thumb(&mut app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 256, 10); assert_eq!(app.renderer.thumb_jobs_built, built + 1, "an edited photo is requested again"); - let (submitted, edited) = take_job(&work); + let (submitted, edited) = take_job(work); assert_eq!(submitted, slot); assert_ne!(edited.key, larger_key); let edited_key = edited.key; - finish(&mut app, &work, &ctx, slot, edited.run()); + finish(app, work, ctx, slot, edited.run()); assert_eq!(app.renderer.textures[&slot].key, edited_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 256); assert!(!app.renderer.is_pending(slot)); } +#[test] +fn cells_at_different_thumbnail_buckets_keep_one_pending_request() { + let (mut app, work, ctx) = fixture(); + let pending_key = thumb_buckets_pending_request(&mut app, &work, &ctx); + let larger_key = thumb_buckets_larger_upgrades(&mut app, &work, &ctx, pending_key); + thumb_buckets_largest_serves_smaller(&mut app, &work, larger_key); + thumb_buckets_edit_requests_again(&mut app, &work, &ctx, larger_key); +} + /// The remembered larger bucket is not a picture by itself: once its texture is evicted, a /// smaller cell must render again rather than trust the stale record. #[test] diff --git a/crates/ui-egui/src/tests/open_lightroom.rs b/crates/ui-egui/src/tests/open_lightroom.rs index d97f9d0e8..684883aa0 100644 --- a/crates/ui-egui/src/tests/open_lightroom.rs +++ b/crates/ui-egui/src/tests/open_lightroom.rs @@ -22,8 +22,16 @@ fn names(dir: &std::path::Path) -> Vec { v } -#[test] -fn open_library_on_a_lightroom_catalog_offers_its_import() { +struct Fx { + dir: std::path::PathBuf, + lib: std::path::PathBuf, + lr: std::path::PathBuf, + lrcat: std::path::PathBuf, + before: Vec, + photos: usize, +} + +fn setup() -> (Headless, Fx) { let dir = std::env::temp_dir().join(format!("lc-ui-openlrcat-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let lib = dir.join("LightCraft Library"); @@ -52,7 +60,12 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { }; let mut h = Headless::new(LightcraftApp::new(session, services), [1200.0, 800.0], 1.0); h.step(); + (h, Fx { dir, lib, lr, lrcat, before, photos }) +} +fn offer_and_cancel(h: &mut Headless, fx: &Fx) { + let Fx { lib, lr, lrcat, before, photos, .. } = fx; + let (photos, lib, lr, lrcat, before) = (*photos, lib.clone(), lr.clone(), lrcat.clone(), before.clone()); // File ▸ Open Library…, the Lightroom folder chosen in the folder dialog let r = h.app.run("app.openLibrary", json!({})).unwrap(); assert_eq!(r["opened"], false, "{r}"); @@ -64,7 +77,7 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { "{:?}", h.app.ui.dialog ); - assert!(has(&h, "button:dialogOk") && has(&h, "button:dialogCancel")); + assert!(has(h, "button:dialogOk") && has(h, "button:dialogCancel")); // the library open before is still open, and nothing was made in Lightroom's folder assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone())); assert_eq!(h.app.session.catalog.len(), photos); @@ -79,7 +92,10 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { h.step(); assert!(h.app.ui.dialog.is_none()); assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none()); +} +fn reoffer_import_and_merge(h: &mut Headless, fx: &Fx) { + let (lib, lr, lrcat, before) = (fx.lib.clone(), fx.lr.clone(), fx.lrcat.clone(), fx.before.clone()); // a previews folder given by path (control channel, agents): the same offer let r = h.app.run("app.openLibrary", json!({"path": lr.join("Lightroom Catalog Previews.lrdata").to_string_lossy()})).unwrap(); assert_eq!(r["lightroomCatalog"], json!(lrcat.to_string_lossy()), "{r}"); @@ -108,6 +124,13 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { assert_eq!(last["path"], json!(lrcat), "{last}"); assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone())); assert_eq!(names(&lr), before, "the catalog is only read"); +} + +#[test] +fn open_library_on_a_lightroom_catalog_offers_its_import() { + let (mut h, fx) = setup(); + offer_and_cancel(&mut h, &fx); + reoffer_import_and_merge(&mut h, &fx); drop(h); - let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&fx.dir); } diff --git a/crates/ui-egui/src/tests/preview_limit.rs b/crates/ui-egui/src/tests/preview_limit.rs index 07a30c8a5..3687199c9 100644 --- a/crates/ui-egui/src/tests/preview_limit.rs +++ b/crates/ui-egui/src/tests/preview_limit.rs @@ -840,6 +840,72 @@ mod in_the_loupe { } } + fn drag_first_window(h: &mut Headless, work: &Rc>, id: lightcraft_catalog::PhotoId) -> RenderJob { + let first = next_region_job(h, work); + let view = h.app.region_view.expect("Detail asked for a window"); + assert_eq!(view.photo, id); + assert_eq!(view.full, (WIDTH as usize, HEIGHT as usize), "at 400 % the window's frame is the photo's own pixels"); + assert!(view.window.w >= 1300 && view.window.h >= 700, "the window covers the canvas: {:?}", view.window); + assert!(view.window.x + view.window.w <= view.full.0 && view.window.y + view.window.h <= view.full.1, "inside the frame: {:?}", view.window); + assert_eq!(first.request.window, Some(view.window), "the job is the window on screen"); + assert_eq!((first.request.max_w, first.request.max_h), view.full, "the job renders the frame the view uses"); + assert_eq!(first.request.quality, lightcraft_pipeline::Quality::Draft, "a drag drafts the window"); + assert_eq!(first.level, SourceLevel::Full); + assert!(matches!(&first.source, SourceRef::File { .. }), "the first window decodes the real file"); + assert_ne!(first.request_id, 0, "the renderer gave the request its own identity"); + first + } + + /// An edit during the first decode waits; the first result is accepted with its original. + fn drag_accepts_first_original( + h: &mut Headless, + work: &Rc>, + id: lightcraft_catalog::PhotoId, + first: RenderJob, + ) -> (u64, u64) { + // An edit changes the desired window key while the first original is still held. + // Detail must defer a second decode until this first result has populated the cache. + let (first_key, first_request) = (first.key, first.request_id); + let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); + assert_eq!(reply["ok"], true); + h.step(); + assert!(work.borrow().jobs.is_empty(), "a changed window waits for the first original"); + assert_eq!(h.app.renderer.wanted(Slot::Region), Some(first_key)); + assert_eq!(h.app.session.memory_report().full_source.bytes, 0); + + deliver(work, Slot::Region, first.run()); + h.step(); + assert!(h.app.region_view.is_some()); + assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(first_key), "the first window arrived"); + assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4); + assert!(h.app.session.media.has_source(id, SourceLevel::Full), "the original was accepted before any supersession"); + (first_key, first_request) + } + + fn drag_later_windows_reuse_original(h: &mut Headless, work: &Rc>, first: (u64, u64), discarded: u64) { + let (first_key, first_request) = first; + let second = next_region_job(h, work); + let (second_key, second_request) = (second.key, second.request_id); + assert_ne!(second_key, first_key); + assert_ne!(second_request, first_request); + assert_eq!(h.app.renderer.wanted(Slot::Region), Some(second_key), "the newer window is pending"); + assert!(matches!(&second.source, SourceRef::Loaded(_)), "the newer window reuses the accepted original"); + + let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 1.0}}), T); + assert_eq!(reply["ok"], true); + let third = next_region_job(h, work); + assert_ne!(third.key, second_key); + assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key)); + + deliver(work, Slot::Region, second.run()); + h.step(); + assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(second_key), "the superseded draft still reached the screen"); + assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key), "the newest request is still pending"); + assert_eq!(h.app.renderer.discarded, discarded, "the superseded draft was accepted, not discarded"); + assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4); + assert!(matches!(&third.source, SourceRef::Loaded(_)), "the pending window still reuses the original"); + } + // Given a 400 % view of a real 24 MP photo, when the first window is decoded and accepted and // later drag ticks supersede each other, then the original stays in the cache and every later // window is cut from it. The first accepted result is the actual Detail wiring's job: the @@ -856,57 +922,9 @@ mod in_the_loupe { let discarded = h.app.renderer.discarded; h.app.ui.zoom = crate::state::Zoom::Percent(400.0); h.app.session.begin_interaction("Exposure").expect("a drag"); - - let first = next_region_job(&mut h, &work); - let (first_key, first_request) = (first.key, first.request_id); - let view = h.app.region_view.expect("Detail asked for a window"); - assert_eq!(view.photo, id); - assert_eq!(view.full, (WIDTH as usize, HEIGHT as usize), "at 400 % the window's frame is the photo's own pixels"); - assert!(view.window.w >= 1300 && view.window.h >= 700, "the window covers the canvas: {:?}", view.window); - assert!(view.window.x + view.window.w <= view.full.0 && view.window.y + view.window.h <= view.full.1, "inside the frame: {:?}", view.window); - assert_eq!(first.request.window, Some(view.window), "the job is the window on screen"); - assert_eq!((first.request.max_w, first.request.max_h), view.full, "the job renders the frame the view uses"); - assert_eq!(first.request.quality, lightcraft_pipeline::Quality::Draft, "a drag drafts the window"); - assert_eq!(first.level, SourceLevel::Full); - assert!(matches!(&first.source, SourceRef::File { .. }), "the first window decodes the real file"); - assert_ne!(first_request, 0, "the renderer gave the request its own identity"); - - // An edit changes the desired window key while the first original is still held. - // Detail must defer a second decode until this first result has populated the cache. - let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); - assert_eq!(reply["ok"], true); - h.step(); - assert!(work.borrow().jobs.is_empty(), "a changed window waits for the first original"); - assert_eq!(h.app.renderer.wanted(Slot::Region), Some(first_key)); - assert_eq!(h.app.session.memory_report().full_source.bytes, 0); - - deliver(&work, Slot::Region, first.run()); - h.step(); - assert!(h.app.region_view.is_some()); - assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(first_key), "the first window arrived"); - assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4); - assert!(h.app.session.media.has_source(id, SourceLevel::Full), "the original was accepted before any supersession"); - - let second = next_region_job(&mut h, &work); - let (second_key, second_request) = (second.key, second.request_id); - assert_ne!(second_key, first_key); - assert_ne!(second_request, first_request); - assert_eq!(h.app.renderer.wanted(Slot::Region), Some(second_key), "the newer window is pending"); - assert!(matches!(&second.source, SourceRef::Loaded(_)), "the newer window reuses the accepted original"); - - let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 1.0}}), T); - assert_eq!(reply["ok"], true); - let third = next_region_job(&mut h, &work); - assert_ne!(third.key, second_key); - assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key)); - - deliver(&work, Slot::Region, second.run()); - h.step(); - assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(second_key), "the superseded draft still reached the screen"); - assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key), "the newest request is still pending"); - assert_eq!(h.app.renderer.discarded, discarded, "the superseded draft was accepted, not discarded"); - assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4); - assert!(matches!(&third.source, SourceRef::Loaded(_)), "the pending window still reuses the original"); + let first = drag_first_window(&mut h, &work, id); + let keys = drag_accepts_first_original(&mut h, &work, id, first); + drag_later_windows_reuse_original(&mut h, &work, keys, discarded); } // The collector's accept branch on its own: a superseded window result still carries the diff --git a/crates/ui-egui/src/tests/slideshow.rs b/crates/ui-egui/src/tests/slideshow.rs index 01a468ed8..26c72c2cd 100644 --- a/crates/ui-egui/src/tests/slideshow.rs +++ b/crates/ui-egui/src/tests/slideshow.rs @@ -871,20 +871,8 @@ fn settings_shows_the_titles_and_the_preview_shows_a_title_screen() { h.settle(SETTLE); } -/// Settings ▸ Slideshow ▸ Templates: built-ins listed, hover previews on the selected photo -/// without changing anything, a click applies, and the user's own templates are saved, updated, -/// renamed and deleted from the browser. -#[test] -fn template_browser_previews_on_hover_applies_and_manages_user_templates() { +fn tpl_hover_previews_without_changes(h: &mut Headless) -> lightcraft_engine::slide_template::SlideLook { use lightcraft_engine::slide_template::SlideLook; - let mut h = grid(); - let dir = std::env::temp_dir().join(format!("lc-ui-sstpl-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - h.app.session.slide_templates_dir = Some(dir.clone()); - let first = h.app.session.visible_cloned()[0]; - run(&mut h, "library.select", json!({"ids": [first.0]})); - run(&mut h, "view.slideshowOptions", json!({})); - assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); let rect = |h: &Headless, w: &str| h.app.widgets.iter().find(|(id, _)| id == w).map(|(_, r)| *r).unwrap(); for w in [ @@ -896,7 +884,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { "field:slideshowTemplateName", "button:slideshowTemplateSave", ] { - assert!(has(&h, w), "{w}"); + assert!(has(h, w), "{w}"); } // hover: the preview shows Gallery Wall (its light grey wall in the margin), nothing changes let before = SlideLook::of(&h.app.session); @@ -915,42 +903,55 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { assert!(h.step_until(SETTLE, grey), "the hovered template on the selected photo"); assert_eq!(SlideLook::of(&h.app.session), before, "hovering changes nothing"); assert!(h.app.session.slide_templates_previous.is_none()); + before +} + +fn tpl_click_applies_and_saves_own(h: &mut Headless, dir: &std::path::Path) { + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); // a click applies it - reveal(&mut h, "template:builtin:gallery-wall"); + reveal(h, "template:builtin:gallery-wall"); let r = h.request("ui.clickWidget", json!({"id": "template:builtin:gallery-wall"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert!(h.app.session.slide_overlays.rating_stars.enabled && h.app.session.slide_layout.stroke_border); - assert!(has(&h, "template:previous"), "Previous settings appear"); + assert!(has(h, "template:previous"), "Previous settings appear"); // save as a template of our own - reveal(&mut h, "field:slideshowTemplateName"); + reveal(h, "field:slideshowTemplateName"); let r = h.request("ui.clickWidget", json!({"id": "field:slideshowTemplateName"}), T); assert_eq!(r["ok"], true, "{r}"); let r = h.request("ui.text", json!({"text": "Holiday / 2026"}), T); assert_eq!(r["ok"], true, "{r}"); - reveal(&mut h, "button:slideshowTemplateSave"); + reveal(h, "button:slideshowTemplateSave"); let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateSave"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert!(dir.join("Holiday _ 2026.json").exists(), "the separator never makes a folder"); h.step(); - assert!(has(&h, "template:user:Holiday _ 2026")); + assert!(has(h, "template:user:Holiday _ 2026")); +} + +fn tpl_update_from_menu(h: &mut Headless) { + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); // update it from its menu with changed settings - run(&mut h, "slideshow.layout", json!({"backdropColor": "#123456"})); - reveal(&mut h, "template:user:Holiday _ 2026"); + run(h, "slideshow.layout", json!({"backdropColor": "#123456"})); + reveal(h, "template:user:Holiday _ 2026"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); for w in ["button:slideshowTemplateUpdate", "button:slideshowTemplateRename", "button:slideshowTemplateExport", "button:slideshowTemplateDelete"] { - assert!(has(&h, w), "{w}"); + assert!(has(h, w), "{w}"); } let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateUpdate"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert_eq!(h.app.session.slide_template("user:Holiday _ 2026").unwrap().look.layout.backdrop_color, [0x12, 0x34, 0x56]); +} + +fn tpl_rename_in_place(h: &mut Headless) { + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); // rename it in place - reveal(&mut h, "template:user:Holiday _ 2026"); + reveal(h, "template:user:Holiday _ 2026"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -958,7 +959,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { assert_eq!(r["ok"], true, "{r}"); h.step(); h.step(); - assert!(has(&h, "field:slideshowTemplateRename")); + assert!(has(h, "field:slideshowTemplateRename")); let r = h.request("ui.key", json!({"key": "a", "cmd": true}), T); assert_eq!(r["ok"], true, "{r}"); let r = h.request("ui.text", json!({"text": "Summer"}), T); @@ -967,9 +968,13 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { assert_eq!(r["ok"], true, "{r}"); h.step(); h.step(); - assert!(has(&h, "template:user:Summer"), "{:?}", h.app.session.slide_templates().templates.iter().map(|t| t.id.clone()).collect::>()); + assert!(has(h, "template:user:Summer"), "{:?}", h.app.session.slide_templates().templates.iter().map(|t| t.id.clone()).collect::>()); +} + +fn tpl_delete_then_back_to_previous(h: &mut Headless, dir: &std::path::Path, before: &lightcraft_engine::slide_template::SlideLook) { + use lightcraft_engine::slide_template::SlideLook; // delete it (after confirming) - reveal(&mut h, "template:user:Summer"); + reveal(h, "template:user:Summer"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Summer", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -983,12 +988,33 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { h.step(); assert!(!dir.join("Summer.json").exists()); // back to the settings before Gallery Wall - reveal(&mut h, "template:previous"); + reveal(h, "template:previous"); let r = h.request("ui.clickWidget", json!({"id": "template:previous"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert_eq!(SlideLook::of(&h.app.session).overlays, before.overlays); h.settle(SETTLE); +} + +/// Settings ▸ Slideshow ▸ Templates: built-ins listed, hover previews on the selected photo +/// without changing anything, a click applies, and the user's own templates are saved, updated, +/// renamed and deleted from the browser. +#[test] +fn template_browser_previews_on_hover_applies_and_manages_user_templates() { + let mut h = grid(); + let dir = std::env::temp_dir().join(format!("lc-ui-sstpl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + h.app.session.slide_templates_dir = Some(dir.clone()); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "library.select", json!({"ids": [first.0]})); + run(&mut h, "view.slideshowOptions", json!({})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + let before = tpl_hover_previews_without_changes(&mut h); + tpl_click_applies_and_saves_own(&mut h, &dir); + tpl_update_from_menu(&mut h); + tpl_rename_in_place(&mut h); + tpl_delete_then_back_to_previous(&mut h, &dir, &before); + h.settle(SETTLE); let _ = std::fs::remove_dir_all(dir); } From 307ecb981d05ee6a6412709fa0aeeb90589fc382 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 04:06:29 +0000 Subject: [PATCH 76/91] fix(ui-egui): keep the Export dialog's original spacing before the preset row --- crates/ui-egui/src/dialogs.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bca04fe0c..646288220 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1094,7 +1094,6 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut ui.add_space(4.0); export_naming_rows(ui, opts); export_destination_rows(app, ui, t, opts, dir); - ui.add_space(4.0); export_save_preset_row(app, ui, opts, *full_size, resize, *limit_kb, preset_name); } From aaf532c5252ebb235847107002998b462fce0de6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 28 Oct 2026 12:09:18 -0400 Subject: [PATCH 77/91] macOS packaging: retry stapler when Apple's CloudKit drops the connection Co-Authored-By: Claude Opus 5.5 --- packaging/macos/package.sh | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index eab3cde22..110d9ee16 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -84,6 +84,21 @@ sign() { codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } +# `stapler staple` and `stapler validate` both fetch the ticket from Apple's CloudKit, and a dropped +# connection there (NSURLErrorDomain -1005, "Error 68") fails them although notarization worked. +# Both are idempotent, so retry with a pause; a missing or bad ticket still fails every try. +stapler_retry() { + local try + for try in 1 2 3 4; do + xcrun stapler "$1" "$2" && return 0 + [[ "$try" = 4 ]] && break + echo "stapler $1 failed (try $try of 4); retrying in $((try * 20)) s" >&2 + sleep $((try * 20)) + done + echo "error: stapler $1 $(basename "$2") failed 4 times" >&2 + exit 1 +} + notarize() { local file="$1" out id status echo "==> notarizing $(basename "$file") (this can take a few minutes)" @@ -129,8 +144,8 @@ codesign --verify --strict --deep --verbose=2 "$APP" if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/LightCraft-notarize.zip" notarize "$WORK/LightCraft-notarize.zip" - xcrun stapler staple "$APP" - xcrun stapler validate "$APP" + stapler_retry staple "$APP" + stapler_retry validate "$APP" spctl --assess --type execute -vvv "$APP" fi @@ -203,8 +218,8 @@ verify_dmg_app() { verify_dmg_app if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" + stapler_retry staple "$DMG" + stapler_retry validate "$DMG" spctl --assess --type open --context context:primary-signature -vvv "$DMG" fi From e381c1ae6b2006ce627b243fe41697ce0d9608e0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 28 Oct 2026 12:09:24 -0400 Subject: [PATCH 78/91] Release 0.15.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 66 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cff5ae6a9..461191754 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3535,7 +3535,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "craft-update", @@ -3562,7 +3562,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "crc32fast", @@ -3582,7 +3582,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.14.0" +version = "0.15.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -3598,7 +3598,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.14.0" +version = "0.15.0" dependencies = [ "avif-serialize", "bytemuck", @@ -3629,7 +3629,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-geom", "proptest", @@ -3638,7 +3638,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.14.0" +version = "0.15.0" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3655,7 +3655,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.14.0" +version = "0.15.0" dependencies = [ "dhat", "lightcraft-raster", @@ -3671,7 +3671,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3683,7 +3683,7 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.14.0" +version = "0.15.0" dependencies = [ "ab_glyph", "chrono", @@ -3727,7 +3727,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-testkit", "matrixmultiply", @@ -3742,7 +3742,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.14.0" +version = "0.15.0" dependencies = [ "log", "rustls", @@ -3753,7 +3753,7 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.14.0" +version = "0.15.0" dependencies = [ "miniz_oxide 0.9.1", "serde", @@ -3761,7 +3761,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proptest", "serde", @@ -3769,7 +3769,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.14.0" +version = "0.15.0" dependencies = [ "bytemuck", "lightcraft-color", @@ -3789,7 +3789,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.14.0" +version = "0.15.0" dependencies = [ "heic-rs", "rayon", @@ -3797,14 +3797,14 @@ dependencies = [ [[package]] name = "lightcraft-inpaint" -version = "0.14.0" +version = "0.15.0" dependencies = [ "rayon", ] [[package]] name = "lightcraft-lensdb" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-develop", "lightcraft-geom", @@ -3813,7 +3813,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3824,7 +3824,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3840,7 +3840,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "lightcraft-geom", @@ -3853,7 +3853,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.14.0" +version = "0.15.0" dependencies = [ "dhat", "image", @@ -3872,7 +3872,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.14.0" +version = "0.15.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3884,7 +3884,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.14.0" +version = "0.15.0" dependencies = [ "libc", "lightcraft-color", @@ -3896,7 +3896,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.14.0" +version = "0.15.0" dependencies = [ "dhat", "jpeg-encoder", @@ -3920,7 +3920,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.14.0" +version = "0.15.0" dependencies = [ "image", "lightcraft-color", @@ -3931,7 +3931,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.14.0" +version = "0.15.0" dependencies = [ "candle-core", "candle-nn", @@ -3945,7 +3945,7 @@ dependencies = [ [[package]] name = "lightcraft-slideshow" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-raster", "serde", @@ -3954,15 +3954,15 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.14.0" +version = "0.15.0" [[package]] name = "lightcraft-testkit" -version = "0.14.0" +version = "0.15.0" [[package]] name = "lightcraft-tiff" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proptest", "serde", @@ -3971,7 +3971,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-ui", "craft-update", @@ -4002,7 +4002,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.14.0" +version = "0.15.0" dependencies = [ "crc32fast", "eframe", @@ -7819,7 +7819,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.14.0" +version = "0.15.0" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index cd85d80f3..2056f3e49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.14.0" +version = "0.15.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 7ab25755055f970e218e2c29920a952baef9f083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:41:59 +0000 Subject: [PATCH 79/91] Reject ZIP entries with inconsistent decoded sizes Found while testing crates/geocode/src/unzip.rs:64 Co-Authored-By: GPT-6-Luna --- crates/geocode/src/unzip.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/geocode/src/unzip.rs b/crates/geocode/src/unzip.rs index f78648d83..d422297a7 100644 --- a/crates/geocode/src/unzip.rs +++ b/crates/geocode/src/unzip.rs @@ -61,7 +61,13 @@ pub fn extract(zip: &[u8], name: &str, max: usize) -> Result, GeocodeErr return match method { 0 if data.len() <= max => Ok(data.to_vec()), 0 => Err(GeocodeError::Invalid(format!("{name} is larger than {max} bytes"))), - 8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, max).map_err(|e| bad(&format!("deflate: {e:?}"))), + 8 => { + let decoded = miniz_oxide::inflate::decompress_to_vec_with_limit(data, max).map_err(|e| bad(&format!("deflate: {e:?}")))?; + if decoded.len() != usize_ { + return Err(bad("uncompressed size")); + } + Ok(decoded) + } m => Err(bad(&format!("compression method {m}"))), }; } @@ -120,4 +126,15 @@ mod tests { let _ = extract(&bad, "cities15000.txt", 100); } } + + #[test] + fn rejects_deflated_entry_with_inconsistent_directory_size() { + let mut z = make("cities15000.txt", b"hello world"); + let eocd = z.len() - 22; + let cd = u32::from_le_bytes([z[eocd + 16], z[eocd + 17], z[eocd + 18], z[eocd + 19]]) as usize; + let declared_size = cd + 24; + z[declared_size..declared_size + 4].copy_from_slice(&2u32.to_le_bytes()); + + assert!(extract(&z, "cities15000.txt", 100).is_err()); + } } From e2d18b5fb9e6177167177c194461a0b427b0a196 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 13:05:19 -0400 Subject: [PATCH 80/91] Grant network access so update checks can reach GitHub (#3269) --- .../linux/flatpak/ai.storyteller.lightcraft.bundle.yml | 8 ++++---- packaging/linux/flatpak/ai.storyteller.lightcraft.yml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml index 752e1edec..73a44f022 100644 --- a/packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml @@ -29,10 +29,10 @@ finish-args: # drag-and-drop through the document portal, so picked folders elsewhere work without broad # filesystem access. - --filesystem=xdg-pictures - # Deliberately not granted: home/host filesystem access, and network. The network is only for - # the optional SAM 3 model download (AI Object/Describe masks, docs/ai-masks.md); without it - # that download fails with an error and everything else works. Users who want it run - # `flatpak override --user --share=network ai.storyteller.lightcraft`. + # Deliberately not granted: home/host filesystem access. Network is needed for update checks + # against GitHub and the optional SAM 3 model download (AI Object/Describe masks, + # docs/ai-masks.md). + - --share=network modules: - name: lightcraft buildsystem: simple diff --git a/packaging/linux/flatpak/ai.storyteller.lightcraft.yml b/packaging/linux/flatpak/ai.storyteller.lightcraft.yml index e125475c5..83721a81c 100644 --- a/packaging/linux/flatpak/ai.storyteller.lightcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.lightcraft.yml @@ -35,10 +35,10 @@ finish-args: # drag-and-drop through the document portal, so picked folders elsewhere work without broad # filesystem access. - --filesystem=xdg-pictures - # Deliberately not granted: home/host filesystem access, and network. The network is only for - # the optional SAM 3 model download (AI Object/Describe masks, docs/ai-masks.md); without it - # that download fails with an error and everything else works. Users who want it run - # `flatpak override --user --share=network ai.storyteller.lightcraft`. + # Deliberately not granted: home/host filesystem access. Network is needed for update checks + # against GitHub and the optional SAM 3 model download (AI Object/Describe masks, + # docs/ai-masks.md). + - --share=network build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: From 953c4ee5fc308881760014b49911eb754b2709a4 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 24 Oct 2026 14:50:40 -0400 Subject: [PATCH 81/91] raw: decode lossy single-plane (grey) JPEG XL tiles in LinearRaw DNGs Lightroom Classic's HDR and panorama merges of monochrome raws store a one-plane LinearRaw image as lossy (XYB) JPEG XL tiles whose codestream declares a grey colour encoding (16-bit float with a linear curve, or 16-bit integer with the sRGB curve). Those were refused as "lossy JPEG XL tile with a single colour plane", so the files opened preview-only. A lossy grey tile is still coded as three XYB channels. It is now restored like the RGB case: jxl-oxide renders linear sRGB (never the declared encoding, which would clip), the single plane is the luminance (CIE Y, D65) of that, then the declared transfer curve is applied. One plane must be declared grey and three RGB; HDR curves, other white points and intensity targets above 255 stay unsupported. RENDER_CACHE_VERSION 70 -> 71: files that showed their embedded preview before render from raw data now, under the same content key. Refs #275 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM --- crates/engine/src/media/source.rs | 2 +- crates/raw/src/jxl.rs | 52 ++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/media/source.rs b/crates/engine/src/media/source.rs index 8f6a9c28c..8490d2ee2 100644 --- a/crates/engine/src/media/source.rs +++ b/crates/engine/src/media/source.rs @@ -1,7 +1,7 @@ use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 70; +pub const RENDER_CACHE_VERSION: u64 = 71; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 209f8f4de..77bd3245c 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -12,7 +12,9 @@ //! Lossless (non-XYB) tiles are rendered in the codestream's own colour encoding, which is the identity, //! keeping the values in the raw IFD's sample space. Lossy (XYB) tiles are decoded by jxl-oxide to linear //! sRGB primaries; the codestream's declared encoding is then restored by hand: its primaries with a -//! plain 3×3 matrix, then its transfer curve. The samples of a lossy tile mean what they would in a +//! plain 3×3 matrix, then its transfer curve. A grey (single-plane) lossy tile is still coded as three +//! XYB channels; its declared grey encoding is restored as the luminance (CIE Y, D65 white) of the +//! linear sRGB output, then its transfer curve. The samples of a lossy tile mean what they would in a //! lossless one with the same header, so a declared transfer curve is part of the stored values, not a //! display hint. Lightroom Classic's lossy LinearRaw DNGs (Convert to DNG with lossy compression) store //! perceptually shaped samples, label them with the sRGB curve and linearise them with a cubic @@ -126,7 +128,7 @@ enum Output { /// Decide how to render a chunk from its header: whether it is XYB-coded, the declared colour /// encoding and the intensity target. Lossy data is only accepted when the declared encoding is an RGB -/// one with a D65 white point and an SDR transfer curve (linear, sRGB, BT.709 or a power: what raw DNG +/// one (three planes) or a grey one (one plane) with a D65 white point and an SDR transfer curve (linear, sRGB, BT.709 or a power: what raw DNG /// writers signal) and no HDR tone mapping would apply, otherwise the samples cannot be restored /// faithfully. fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, planes: usize) -> Result { @@ -134,13 +136,15 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane return Ok(Output::AsCoded); } let unsupported = |what: &str| RawError::Unsupported(format!("lossy JPEG XL tile with {what}")); - if planes != 3 { - return Err(unsupported("a single colour plane")); - } let ColourEncoding::Enum(e) = encoding else { return Err(unsupported("an ICC profile instead of a colour encoding")); }; - if e.colour_space != ColourSpace::Rgb { + // a single plane is declared grey, three are declared RGB (the header's grey flag is the colour space) + let grey = planes == 1; + if grey && e.colour_space != ColourSpace::Grey { + return Err(unsupported("a single colour plane that is not declared grey")); + } + if !grey && e.colour_space != ColourSpace::Rgb { return Err(unsupported("a declared encoding that is not RGB")); } let curve = match e.tf { @@ -167,6 +171,13 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane if intensity_target > 255.0 { return Err(unsupported("an intensity target above 255 nits")); } + if grey { + // a grey encoding has no primaries, only the white point: its single plane is the luminance (CIE Y) + // of the decoder's linear sRGB, which is the row of sRGB → XYZ that gives Y. The rows of the + // matrix are all that row; only the first is used for one plane. + let y = rgb_to_xyz(SRGB)[1].map(|v| v as f32); + return Ok(Output::FromXyb { matrix: Some([y; 3]), curve }); + } let primaries = match e.primaries { Primaries::Srgb => return Ok(Output::FromXyb { matrix: None, curve }), Primaries::Bt2100 => BT2100, @@ -337,6 +348,25 @@ mod tests { assert_eq!(output_for(true, &linear(Primaries::Srgb), 255.0, 3).unwrap(), Output::FromXyb { matrix: None, curve: Curve::Linear }); } + #[test] + fn lossy_grey_data_is_the_luminance_in_the_declared_curve() { + // Lightroom Classic's merged monochrome LinearRaw DNGs: one plane, declared grey with a linear + // (16-bit float) or an sRGB (16-bit integer) curve + let grey = |tf| { + let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Perceptual); + e.colour_space = ColourSpace::Grey; + e.tf = tf; + output_for(true, &ColourEncoding::Enum(e), 255.0, 1).unwrap() + }; + let Output::FromXyb { matrix: Some(m), curve: Curve::Linear } = grey(TransferFunction::Linear) else { panic!("grey, linear") }; + // the luminance of linear sRGB (IEC 61966-2-1: 0.2126, 0.7152, 0.0722); white stays white + for (v, e) in m[0].iter().zip([0.2126, 0.7152, 0.0722]) { + assert!((v - e).abs() < 1e-4, "{m:?}"); + } + assert!((m[0].iter().sum::() - 1.0).abs() < 1e-5, "{m:?}"); + assert!(matches!(grey(TransferFunction::Srgb), Output::FromXyb { curve: Curve::Srgb, .. })); + } + #[test] fn lossy_data_keeps_the_declared_transfer_curve() { // Lightroom Classic's lossy LinearRaw DNGs: Rec.2100 primaries, sRGB transfer curve @@ -466,7 +496,15 @@ mod tests { #[test] fn lossy_data_that_cannot_be_restored_is_unsupported() { let bad = |xyb_planes: usize, e: ColourEncoding, it: f32| matches!(output_for(true, &e, it, xyb_planes), Err(RawError::Unsupported(_))); - assert!(bad(1, linear(Primaries::Srgb), 255.0), "grey"); + assert!(bad(1, linear(Primaries::Srgb), 255.0), "one plane declared RGB"); + let mut g = EnumColourEncoding::srgb_linear(RenderingIntent::Relative); + g.colour_space = ColourSpace::Grey; + assert!(bad(1, ColourEncoding::Enum(g.clone()), 10_000.0), "grey with an HDR intensity target"); + g.tf = TransferFunction::Pq; + assert!(bad(1, ColourEncoding::Enum(g.clone()), 255.0), "grey with PQ"); + g.tf = TransferFunction::Linear; + g.white_point = WhitePoint::Dci; + assert!(bad(1, ColourEncoding::Enum(g), 255.0), "grey with a white point other than D65"); assert!(bad(3, linear(Primaries::Srgb), 10_000.0), "HDR intensity target would tone-map"); let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Relative); for tf in [TransferFunction::Pq, TransferFunction::Hlg, TransferFunction::Unknown, TransferFunction::Gamma { g: 0, inverted: true }] { From 20c1f3c0579a9748b270239c08d0527215a85a46 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 24 Oct 2026 15:15:04 -0400 Subject: [PATCH 82/91] raw: test the grey JPEG XL restore through decode The existing synthetic lossy RGB fixture tiles are re-declared grey by a header edit (colour space RGB to Grey, dropping the 6-bit primaries field a grey encoding does not carry, per the ColourEncoding bundle). Their XYB frame data is unchanged, so they decode as one grey plane whose rendered channels differ. The test checks decode against the gradient's BT.2100 luminance in the linear and sRGB curves. Refs #275 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM --- crates/raw/src/jxl.rs | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 77bd3245c..128f4eeea 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -493,6 +493,56 @@ mod tests { } } + /// A fixture tile re-declared grey: its colour encoding's colour space goes from RGB to Grey and, + /// as a grey encoding has no primaries, the 6-bit BT.2100 primaries field is dropped (ISO/IEC 18181-1 + /// ColourEncoding; the bits after it move up and the header's zero padding to the frame grows by 6). + /// The XYB frame data is untouched, so the codestream is one grey plane coded as three XYB channels + /// whose colour is the gradient's, like Lightroom Classic's merged monochrome DNGs. + fn as_grey(tile: &[u8]) -> Vec { + // bit offset of the colour space, read LSB first, and the frame's first byte in these fixture tiles + const AT: usize = 42; + const FRAME: usize = 8; + let mut bits: Vec = tile.iter().flat_map(|b| (0..8).map(move |i| (b >> i) & 1)).collect(); + // colour space Rgb (selector 0), white point D65 (selector 1), primaries Bt2100 (selector 2 + 4 bits of 7) + assert_eq!(bits[AT..AT + 10], [0, 0, 1, 0, 0, 1, 1, 1, 1, 0], "the fixture's colour encoding"); + bits[AT] = 1; // colour space Grey (selector 1) + bits.drain(AT + 4..AT + 10); + bits.splice(FRAME * 8 - 6..FRAME * 8 - 6, [0; 6]); + bits.as_chunks::<8>().0.iter().map(|b| b.iter().enumerate().fold(0, |a, (i, v)| a | v << i)).collect() + } + + #[test] + fn lossy_grey_tiles_decode_to_the_luminance_of_their_colour() { + let srgb_eotf = |v: f32| if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let y = rgb_to_xyz(BT2100)[1].map(|v| v as f32); + for (name, tile, curve) in [("linear", LINEAR_TILE, Curve::Linear), ("sRGB", SRGB_TILE, Curve::Srgb)] { + let grey = as_grey(tile); + // the decoder still renders three unequal channels: the restore must reduce them, not pick one + let mut img = JxlImage::builder().read(std::io::Cursor::new(&grey)).unwrap(); + assert!(img.image_header().metadata.grayscale(), "{name}: declared grey"); + img.request_color_encoding(EnumColourEncoding::srgb_linear(RenderingIntent::Relative)); + let render = img.render_frame(0).unwrap(); + let fb = render.image_all_channels(); + assert_eq!(fb.channels(), 3, "{name}"); + assert!((fb.buf()[2] - fb.buf()[0]).abs() > 0.1, "{name}: the rendered channels differ"); + + let ChunkPx::U16(px) = decode(&grey, 16, 16, 16, 16, 1, 16, false).unwrap() else { panic!("{name}: integer samples") }; + assert_eq!(px.len(), 256, "{name}: one plane"); + let (mut sum, mut worst) = (0.0f32, 0.0f32); + for (i, got) in px.iter().enumerate() { + // the gradient's samples are coded in the tile's curve: back to linear for the luminance + let rgb = gradient(i % 16, i / 16).map(|v| if curve == Curve::Srgb { srgb_eotf(v / 65535.0) } else { v / 65535.0 }); + let want = curve.encode(y[0] * rgb[0] + y[1] * rgb[1] + y[2] * rgb[2]) * 65535.0; + let err = (f32::from(*got) - want).abs(); + sum += err; + worst = worst.max(err); + } + let mean = sum / px.len() as f32; + assert!(mean < 0.02 * 65535.0, "{name}: mean error {mean}"); + assert!(worst < 0.1 * 65535.0, "{name}: worst error {worst}"); + } + } + #[test] fn lossy_data_that_cannot_be_restored_is_unsupported() { let bad = |xyb_planes: usize, e: ColourEncoding, it: f32| matches!(output_for(true, &e, it, xyb_planes), Err(RawError::Unsupported(_))); From 6188590675363b8db2b3a78b10bca9fec4493951 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 24 Oct 2026 13:45:11 -0400 Subject: [PATCH 83/91] Docs: link gap 8 and roadmap priority 7 to lens profile issue #329 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM --- ROADMAP.md | 5 +++-- docs/gaps.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6802f9234..daad94027 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Print: Japanese photo and postcard paper sizes — L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺; Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the lens profile database priority links its tracking issue #329; Print: Japanese photo and postcard paper sizes — L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺; Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -108,7 +108,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ 4. ~~Full-resolution zoom~~ (done 2026-10-12) 5. AI masks: Subject / Sky / People (40–70 h + model decision) 6. Compressed ORF and HE NEF (28–55 h) -7. Lens profile database of our own (40–80 h) +7. Lens profile database of our own (40–80 h, #329) 8. Tests in CI on macOS, Windows and Linux (6–12 h) 9. Per-model raw verification (20–35 h) @@ -191,6 +191,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Priority 7 (lens profile database) links its tracking issue #329; headline numbers unchanged | | 2026-10-22 | minor | Print: Japanese photo and postcard paper sizes (L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺) in the page setup, Print dialog and PDF output; headline numbers unchanged | | 2026-10-19 | minor | Indonesian interface (`id`): every plain and formats message, plus 69 command labels, locale, menu command and tests; What's New left in English; Localization ~55% → ~64% (mean of the 12 key languages), 8 of 12 present | | 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index 42d9d437b..671f3089c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -22,7 +22,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | | 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) | | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | -| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A, full-frame ILCE-7CR distortion (Auto only) and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | +| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A, full-frame ILCE-7CR distortion (Auto only) and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`); tracked in #329 | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS or Windows**: Linux x64 runs `cargo xtask ci` on every PR and push to main (`.github/workflows/ci.yml`) and FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (ci, freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU; macOS / Windows jobs: #1148 | regressions in `cfg(target_os)` code reach macOS and Windows users | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 551 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 regression (#702); F828 SRF now discovered and imported as preview-only, sensor decoding still missing, DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | @@ -84,6 +84,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Gap 8: lens profile work is tracked in #329; hours unchanged | | 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged | | 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) | | 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged | From 77c0a6adfa7db6e4f79b80843c0c578e5f16dee1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 15:48:43 -0400 Subject: [PATCH 84/91] Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#3275) * Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds package.sh always built for the host and took the arch from uname -m, so a cross build (aarch64 in an x86_64 FreeBSD VM) would read the wrong target directory and name the archive after the VM. With CARGO_BUILD_TARGET set, pass it to cargo, read target//release, and take the arch from the target. Unset keeps the old behavior. * Reject unknown CARGO_BUILD_TARGET in FreeBSD packaging Only the two FreeBSD triples are packaged. Any other target now fails before the build instead of being labelled x86_64. * Skip the --version smoke run for cross-built FreeBSD packages A binary built for another target cannot run on the build host, so under set -e the run would fail the release after the archive is written. --- packaging/freebsd/package.sh | 39 +++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 1e4c5ed41..bae91b770 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash # Build and package LightCraft for FreeBSD: # -# $DIST/lightcraft--freebsd-x86_64.tar.gz a /usr/local-style tree: -# lightcraft--freebsd-x86_64/{bin, share/applications, share/icons, share/mime, +# $DIST/lightcraft--freebsd-.tar.gz (arch: x86_64 or aarch64) a /usr/local-style tree: +# lightcraft--freebsd-/{bin, share/applications, share/icons, share/mime, # share/metainfo, share/doc/lightcraft} # # Install by copying the tree's contents into /usr/local: -# tar -xzf lightcraft--freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local +# tar -xzf lightcraft--freebsd-.tar.gz --strip-components 1 -C /usr/local # # Usage: packaging/freebsd/package.sh [--skip-build] [--dry-run] # --dry-run stage the tree from stub binaries and list it, without building (works on any OS) @@ -29,13 +29,22 @@ while [[ $# -gt 0 ]]; do esac done +# A cross build (CI runs aarch64 in an x86_64 VM) takes its arch from the target, not the VM's uname. # FreeBSD's uname says amd64; release file names use x86_64 like the Linux and Windows ones. -MACHINE="$(uname -m)" -case "$MACHINE" in - amd64 | x86_64) ARCH=x86_64 ;; - arm64 | aarch64) ARCH=aarch64 ;; - *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; -esac +if [[ -n "${CARGO_BUILD_TARGET:-}" ]]; then + case "$CARGO_BUILD_TARGET" in + aarch64-unknown-freebsd) ARCH=aarch64 ;; + x86_64-unknown-freebsd) ARCH=x86_64 ;; + *) echo "unsupported FreeBSD target $CARGO_BUILD_TARGET" >&2; exit 2 ;; + esac +else + MACHINE="$(uname -m)" + case "$MACHINE" in + amd64 | x86_64) ARCH=x86_64 ;; + arm64 | aarch64) ARCH=aarch64 ;; + *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; + esac +fi if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 exit 2 @@ -47,14 +56,15 @@ echo "==> LightCraft $VERSION for FreeBSD $ARCH" # The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory. export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" if [[ "$SKIP_BUILD" = 0 ]]; then - (cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli --features lightcraft/heif,lightcraft-cli/heif) + (cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli --features lightcraft/heif,lightcraft-cli/heif \ + ${CARGO_BUILD_TARGET:+--target "$CARGO_BUILD_TARGET"}) fi WORK="$CARGO_TARGET_DIR/freebsd-package" STAGE="$WORK/$BASENAME" rm -rf "$WORK" mkdir -p "$WORK" -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release" OUT_DIR="$DIST" if [[ "$DRY_RUN" = 1 ]]; then # Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds. @@ -92,7 +102,12 @@ mkdir -p "$OUT_DIR" tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME" echo "wrote $OUT_DIR/$BASENAME.tar.gz" -"$STAGE/bin/lightcraft-cli" --version +# A cross-built binary (e.g. aarch64 on the x86_64 VM) cannot run on the build host. +if [[ -z "${CARGO_BUILD_TARGET:-}" ]]; then + "$STAGE/bin/lightcraft-cli" --version +else + echo "skipping --version: $CARGO_BUILD_TARGET binaries do not run on this host" +fi if [[ "$DRY_RUN" = 1 ]]; then echo "==> dry run: tarball contents" tar -tzvf "$OUT_DIR/$BASENAME.tar.gz" From 40c3a09decfed5ee66cc1ac83838ab4fbb1ba66c Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Thu, 29 Oct 2026 02:41:16 -0500 Subject: [PATCH 85/91] docs(readme): recover a Windows update that fails with 1612/1714/1603 The old version's cached installer is missing, so Windows cannot remove it. Re-caching it with msiexec /fv on the same-version MSI fixes both updating and uninstalling (reproduced and verified on Windows 11, #3254). Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 6c556e46f..ba094749c 100644 --- a/README.md +++ b/README.md @@ -570,6 +570,14 @@ option to start it. For unattended installs: `msiexec /i lightcraft--window A silent install is per-user by default. `INSTALLFOLDER` chooses the folder, not who it is installed for; add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install. +**Update or uninstall asks for an old `.msi`, or stops with error 1612 or 1714?** Windows removes the installed version +with its own cached copy of that version's installer; if the copy is gone, it asks for the original file and stops +(an update then ends with error 1603). Download the release MSI of the version and build you have installed from +[all releases](https://github.com/storytold/lightcraft/releases) and run +`msiexec /fv "" /qb+ /norestart`, approving the permission prompt if Windows shows one. That restores +the cached copy; then retry the update or uninstall. Opening the MSI, or choosing Repair or Uninstall on it, may still +fail. Error 1603 has other causes too; if this doesn't help, open an issue with the error message. + ### macOS | Build | File | Notes | From 260d797acc11531388e3f4bfc3d9ae7a644d38c9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 22:21:27 -0400 Subject: [PATCH 86/91] deps: bump tar, zip, roxmltree, rustls-webpki and symphonia to their latest versions (#3358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `rustls-webpki`: 0.102.8, 0.103.15 → 0.102.8, 0.103.16 - `zip`: 7.2.0, 9.1.0 → 7.4.0, 9.1.3 Lockfile only. --- Cargo.lock | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 461191754..fc59452fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -888,7 +888,7 @@ dependencies = [ "safetensors 0.7.0", "thiserror 2.0.21", "yoke 0.8.3", - "zip 7.2.0", + "zip 7.4.0", ] [[package]] @@ -1257,7 +1257,7 @@ dependencies = [ "verifysign", "winreg", "winsafe", - "zip 9.1.0", + "zip 9.1.3", ] [[package]] @@ -1870,7 +1870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4883,7 +4883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5842,7 +5842,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5855,7 +5855,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.15", + "rustls-webpki 0.103.16", "subtle", "zeroize", ] @@ -5895,11 +5895,11 @@ dependencies = [ "rustls", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.15", + "rustls-webpki 0.103.16", "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5951,9 +5951,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -6478,10 +6478,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7404,7 +7404,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8086,9 +8086,9 @@ dependencies = [ [[package]] name = "zip" -version = "7.2.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" dependencies = [ "crc32fast", "indexmap", @@ -8098,9 +8098,9 @@ dependencies = [ [[package]] name = "zip" -version = "9.1.0" +version = "9.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ "crc32fast", "flate2", From b2d89895adaf5705fd8616f6c6135c561510132d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 22:36:34 -0400 Subject: [PATCH 87/91] CI: workflows only call craft-common-actions at @main (#3356) AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and link or open a craft-common-actions issue (a PR only for a small fix that is safe for every app and does not increase CI time); legacy jobs aren't added to and move only as their own task, staying until a shared workflow runs them. windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on craft-common-actions main and the workflow (and the actions it calls) is identical there, so nothing changes in behaviour. --- .github/workflows/windows-arm64.yml | 4 ++-- AGENTS.md | 5 +++++ CLAUDE.md | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 8477823bf..03f57e508 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,8 +27,8 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main with: app-name: lightcraft app-dir: LightCraft diff --git a/AGENTS.md b/AGENTS.md index 31dfd70f0..4044a6563 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,3 +156,8 @@ evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the work GitHub profiles). Never hand-edit `contributors.json`. - Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/CLAUDE.md b/CLAUDE.md index 7005cac3b..9b384fc6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,3 +152,8 @@ photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. From 445dd3106ef0b9f399584505daa29cbfba636c96 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 01:27:49 -0400 Subject: [PATCH 88/91] Update prompt shows the app icon and accent color instead of a letter (#3362) UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#f2a516, its colour in ArtCraft Launcher). The updater, the self-update helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer). craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434). Clippy fixes for Rust 1.99 in untouched code (same logic): - crates/engine/src/cmd/export.rs - crates/ui-egui/src/dialogs/rules_editor/rules_move.rs - crates/ui-egui/src/panels/detail.rs --- Cargo.lock | 3 ++- Cargo.toml | 3 ++- apps/lightcraft/src/main.rs | 2 +- crates/engine/src/cmd/export.rs | 2 +- crates/ui-egui/src/dialogs/rules_editor/rules_move.rs | 2 +- crates/ui-egui/src/dialogs/updates_ui.rs | 2 +- crates/ui-egui/src/lib.rs | 9 +++++++++ crates/ui-egui/src/panels/detail.rs | 2 +- 8 files changed, 18 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc59452fb..4abbe1b89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1238,13 +1238,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", diff --git a/Cargo.toml b/Cargo.toml index 2056f3e49..87f730e31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,8 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# craft-update with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", features = ["egui"] } craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 7b572fa0e..f87f0dc72 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -136,7 +136,7 @@ const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2); fn main() -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] - if let Some(result) = craft_update::self_update::finish(&craft_update::suite::LIGHTCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(result) = craft_update::self_update::finish(&lightcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } // First, so every start-up record is kept for the log file (`logging`). diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 949c08f1b..9af0606d9 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -38,7 +38,7 @@ impl Session { } // the preset's depth is judged against the format written, known only now (`path`); a // depth the call passes itself is the caller's, and validated as such - if !p.get("bitDepth").is_some_and(|d| !d.is_null()) { + if p.get("bitDepth").is_none_or(|d| d.is_null()) { crate::export::ExportOptions::drop_unfit_depth(&mut out, fallback); } Ok(out) diff --git a/crates/ui-egui/src/dialogs/rules_editor/rules_move.rs b/crates/ui-egui/src/dialogs/rules_editor/rules_move.rs index de5ccc2ef..8506a2826 100644 --- a/crates/ui-egui/src/dialogs/rules_editor/rules_move.rs +++ b/crates/ui-egui/src/dialogs/rules_editor/rules_move.rs @@ -154,7 +154,7 @@ pub fn move_node(rs: &mut RuleSet, from: &[usize], dest: &[usize]) -> Option chain.len() && dest.starts_with(&chain)) && list_at(&next, &chain).is_some_and(|l| l.rules.is_empty()) { + while !(chain.is_empty() || dest.len() > chain.len() && dest.starts_with(&chain)) && list_at(&next, &chain).is_some_and(|l| l.rules.is_empty()) { remove_at(&mut next, &chain)?; if !shift_after_removal(&mut dest, &chain) { return None; diff --git a/crates/ui-egui/src/dialogs/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs index 5e159a0f4..bdd2a81eb 100644 --- a/crates/ui-egui/src/dialogs/updates_ui.rs +++ b/crates/ui-egui/src/dialogs/updates_ui.rs @@ -26,7 +26,7 @@ impl Default for Runtime { fn default() -> Self { Self { #[cfg(not(target_arch = "wasm32"))] - updater: Updater::new(&craft_update::suite::LIGHTCRAFT, env!("CARGO_PKG_VERSION")), + updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")), #[cfg(not(target_arch = "wasm32"))] startup_check: false, #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9db7035c1..8bf57b341 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,15 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +/// LightCraft as craft-update knows it, with its 128 px icon and accent colour: the update +/// prompt shows both, and an AppImage's menu entry gets the icon. +#[cfg(not(target_arch = "wasm32"))] +pub const UPDATE_APP: craft_update::app::App = craft_update::app::App { + icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.lightcraft.png")), + accent: Some([0xf2, 0xa5, 0x16]), + ..craft_update::suite::LIGHTCRAFT +}; + pub mod brush_live; pub mod canvas_keys; mod command_error; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 2b58595d2..6840afdf0 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -450,7 +450,7 @@ fn draw_wipe_before(app: &LightcraftApp, p: &egui::Painter, id: PhotoId, img_rec /// view render cached) so stepping to them is instant. fn prefetch_neighbours(app: &mut LightcraftApp, id: PhotoId, main_area: Rect, ppp: f32, texture_side: usize, with_crop: bool) { // only once this photo is on screen - if app.renderer.is_pending(Slot::Main) || !app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) { + if app.renderer.is_pending(Slot::Main) || app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id) { return; } let ids = app.session.visible_cloned(); From b25808686dd2a1633ab3cc929c0afdb636850e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:10:13 +0000 Subject: [PATCH 89/91] Reject mismatched XML closing tags Found while testing crates/lensdb/src/xml.rs:77 Co-Authored-By: GPT-6-Luna --- crates/lensdb/src/tests.rs | 5 +++++ crates/lensdb/src/xml.rs | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/lensdb/src/tests.rs b/crates/lensdb/src/tests.rs index 435882381..6411ffd66 100644 --- a/crates/lensdb/src/tests.rs +++ b/crates/lensdb/src/tests.rs @@ -84,6 +84,11 @@ fn parses_cameras_lenses_and_skips_invalid_entries() { assert!(!db.lenses[2].rectilinear); } +#[test] +fn rejects_mismatched_xml_closing_tags() { + assert!(LensDb::parse("XL").is_err()); +} + #[test] fn malformed_documents_are_errors_not_panics() { assert!(LensDb::parse("").is_err()); diff --git a/crates/lensdb/src/xml.rs b/crates/lensdb/src/xml.rs index dc611a647..0ff0c742c 100644 --- a/crates/lensdb/src/xml.rs +++ b/crates/lensdb/src/xml.rs @@ -74,7 +74,6 @@ fn unescape(s: &str) -> String { pub(crate) fn parse(s: &str) -> Result { let mut reader = quick_xml::Reader::from_str(s); - reader.config_mut().check_end_names = false; let mut stack = vec![Node { name: "#root".into(), ..Default::default() }]; let mut count = 0usize; loop { From 5194cc20dea06d725079ac162c6912fc1f0d3a78 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 09:02:24 -0400 Subject: [PATCH 90/91] UPD.1: honor pretend versions and track the updater icon in CI (#3365) --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- contributors/people.toml | 3 +++ xtask/src/ci_quick.rs | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4abbe1b89..17f738ea5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1238,7 +1238,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0" +source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index 87f730e31..a59de44ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,8 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -# craft-update with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", features = ["egui"] } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } diff --git a/contributors/people.toml b/contributors/people.toml index 95e58421a..7518acd38 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -74,3 +74,6 @@ sync_github_name = true [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true + +[people.dylanmtaylor] +sync_github_name = true diff --git a/xtask/src/ci_quick.rs b/xtask/src/ci_quick.rs index f187daf59..be3e5777d 100644 --- a/xtask/src/ci_quick.rs +++ b/xtask/src/ci_quick.rs @@ -33,7 +33,7 @@ const HEIF_PACKAGES: &[&str] = &["lightcraft-codecs", "lightcraft-heif"]; /// a build script's `rerun-if-changed`): a change to one counts as a change to those packages. /// An entry ending in `/` covers the directory. `embedded_files_are_mapped` keeps this complete. const EMBEDDED: &[(&str, &[&str])] = &[ - ("assets/app-icon/", &["lightcraft"]), + ("assets/app-icon/", &["lightcraft", "lightcraft-ui-egui"]), ("assets/camera-profiles/", &["lightcraft-engine"]), ("assets/camera-tone/", &["lightcraft-engine"]), ("assets/fonts/", &["lightcraft-engine", "lightcraft-ui-egui"]), From 831d724b8744580c377748091888e3e140b5f512 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 15:26:29 -0400 Subject: [PATCH 91/91] Reject .cube data lines that start with nan or inf A data line such as `nan 0 0` starts with a letter and was skipped as an unknown keyword, so a file with enough other entries still parsed. (CodeRabbit review on #2540.) Co-Authored-By: Claude Opus 5.5 --- crates/pipeline/src/lut.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/pipeline/src/lut.rs b/crates/pipeline/src/lut.rs index fccb34e8f..00f6ea185 100644 --- a/crates/pipeline/src/lut.rs +++ b/crates/pipeline/src/lut.rs @@ -45,6 +45,10 @@ impl Lut3d { } else if let Some(r) = l.strip_prefix("DOMAIN_MAX") { max = three(r).ok_or("bad DOMAIN_MAX")?; } else if l.starts_with(|c: char| c.is_ascii_alphabetic()) { + // a data line of `nan` / `inf` starts with a letter too: it is bad data, not a keyword + if l.split_whitespace().next().is_some_and(|w| w.parse::().is_ok()) { + return Err(format!("bad line `{l}`")); + } // other keywords (LUT_IN_VIDEO_RANGE…) are ignored } else { data.push(three(l).ok_or_else(|| format!("bad line `{l}`"))?); @@ -150,6 +154,8 @@ mod tests { bad_line(&format!("LUT_3D_SIZE 2\n{}", "-inf inf nan\n".repeat(8))); bad_line(&format!("LUT_3D_SIZE 2\n{}", "1e39 0 0\n".repeat(8))); bad_line(&format!("LUT_3D_SIZE 2\n-inf 0 0\n{}", "0 0 0\n".repeat(7))); + bad_line(&format!("LUT_3D_SIZE 2\nnan 0 0\n{}", "0 0 0\n".repeat(8))); + bad_line(&format!("LUT_3D_SIZE 2\ninf 0 0\n{}", "0 0 0\n".repeat(8))); let ok = cube(|c| c, 2); assert!(Lut3d::parse_cube(&ok).is_ok()); assert!(Lut3d::parse_cube(&format!("DOMAIN_MIN nan 0 0\n{ok}")).is_err());