Merge main into feat/copyright-status

# Conflicts:
#	crates/engine/src/tests_xmp.rs
#	docs/parity.md
This commit is contained in:
Brandon Thomas 2026-10-04 15:26:53 -04:00
commit efdd8a4e10
100 changed files with 2731 additions and 637 deletions

View File

@ -17,6 +17,30 @@ branches can still break each other (e.g. a new struct field vs. a new construct
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
and often so a crash loses minutes, not hours.
## Never crash (outranks feature work)
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
of it. Full standard: `../craftrules/standards/never-crash.md`
([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)).
- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and
no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
+ `.expect("why it can't fail")`.
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:**
`lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s.
- **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.
## 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…).
- **Pure Rust** in the product. No C/C++ dependencies.

View File

@ -17,6 +17,30 @@ branches can still break each other (e.g. a new struct field vs. a new construct
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
and often so a crash loses minutes, not hours.
## Never crash (outranks feature work)
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
of it. Full standard: `../craftrules/standards/never-crash.md`
([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)).
- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and
no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
+ `.expect("why it can't fail")`.
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:**
`lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s.
- **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.
## 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…).
- **Pure Rust** in the product. No C/C++ dependencies.

View File

@ -347,6 +347,9 @@ Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. T
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit.
- **Pure Rust**, enforced crate layering, everything is a command, and `cargo xtask ci` green before every commit
(one task id per commit).
- **Never crash.** Non-test code returns errors instead of panicking: no `unwrap()`, `expect()`, `panic!` or
`unsafe`, checked indexing on anything derived from input, and a regression test with every crash fix. Details in
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work).
Questions, ideas or a bug you'd like to talk through first? Bring them to [Discord](https://discord.gg/artcraft).

View File

@ -10,6 +10,7 @@
//! lightcraft-cli commands [--json]
//! lightcraft-cli controls [--json]
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod alloc_release;
@ -283,14 +284,18 @@ fn synth_merge(args: &[String]) -> Result<(), String> {
let mut written = Vec::new();
match kind {
"hdr" => {
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).into_iter().enumerate() {
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).map_err(|e| e.to_string())?.into_iter().enumerate() {
let p = Path::new(&dir).join(format!("bracket-{k}.dng"));
std::fs::write(&p, b).map_err(|e| e.to_string())?;
written.push(p);
}
}
"panorama" | "pano" => {
for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0]).into_iter().enumerate() {
for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0])
.map_err(|e| e.to_string())?
.into_iter()
.enumerate()
{
let p = Path::new(&dir).join(format!("view-{k}.png"));
let img = v.to_srgb8();
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default())

View File

@ -14,6 +14,7 @@
//! console (see [`bench`]); `?store=idb` forces the IndexedDB backend, `?store=memory` disables
//! persistence and `?workers=0` renders on the main thread.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod bench;
pub mod files;

View File

@ -29,17 +29,18 @@ const LIBRARY_DIR: &str = "library";
/// How often view state, UI prefs and the thumbnail index are saved (ms).
const SAVE_EVERY_MS: f64 = 1000.0;
fn window() -> web_sys::Window {
web_sys::window().expect("no window")
/// The page's window (`None` only outside a browser page, e.g. in a worker).
fn window() -> Option<web_sys::Window> {
web_sys::window()
}
/// Milliseconds since navigation start.
fn perf_now() -> f64 {
window().performance().map(|p| p.now()).unwrap_or(0.0)
window().and_then(|w| w.performance()).map(|p| p.now()).unwrap_or(0.0)
}
fn set_status(text: &str) {
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_status")) {
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_status")) {
el.set_text_content(Some(text));
}
}
@ -59,7 +60,7 @@ struct Options {
impl Options {
fn from_url() -> Options {
let q = window().location().search().unwrap_or_default();
let q = window().and_then(|w| w.location().search().ok()).unwrap_or_default();
let params: Vec<(String, String)> = q
.trim_start_matches('?')
.split('&')
@ -102,7 +103,7 @@ async fn read_file(originals: Originals, backend: Option<Backend>, file: web_sys
/// Show the browser's open dialog; picked files are imported asynchronously.
fn open_picker(originals: Originals, backend: Option<Backend>, ctx: egui::Context) {
let Some(doc) = window().document() else { return };
let Some(doc) = window().and_then(|w| w.document()) else { return };
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_multiple(true);
@ -131,7 +132,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
opts.set_type(crate::store::mime_for(name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&arr, &opts).map_err(e)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(e)?;
let doc = window().document().ok_or("no document")?;
let doc = window().and_then(|w| w.document()).ok_or("no document")?;
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into();
a.set_href(&url);
a.set_download(name);
@ -140,7 +141,9 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let revoke = Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
if let Some(w) = window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
}
log::info!("exported {name} ({} bytes)", bytes.len());
Ok(())
}
@ -315,7 +318,7 @@ impl WebApp {
}
app.ui = app.ui.sanitized();
let n = opts.workers.unwrap_or_else(|| {
let cores = window().navigator().hardware_concurrency() as usize;
let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize);
cores.saturating_sub(1).clamp(1, 4)
});
let workers =
@ -516,8 +519,10 @@ pub fn start() {
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
let opts = Options::from_url();
wasm_bindgen_futures::spawn_local(async move {
let Some(canvas) =
window().document().and_then(|d| d.get_element_by_id("lightcraft_canvas")).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
let Some(canvas) = window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
else {
log::error!("missing <canvas id=\"lightcraft_canvas\">");
return;
@ -527,7 +532,7 @@ pub fn start() {
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await;
match r {
Ok(()) => {
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_loading")) {
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {
el.remove();
}
}

View File

@ -17,6 +17,7 @@
//! window (issue #7). Their `--help` / `--version` output and diagnostics then have no console to
//! go to; use `lightcraft-cli` (a console program) from a terminal, or a debug build.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod alloc_release;
mod control_server;
@ -263,6 +264,7 @@ OPTIONS:
";
fn main() -> eframe::Result {
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
alloc_release::install();
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();

4
clippy.toml Normal file
View File

@ -0,0 +1,4 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true

View File

@ -128,13 +128,15 @@ pub fn display_time(iso: &str) -> String {
/// Header text for a group key.
pub fn group_label(key: &str) -> String {
let month = |k: &str| k.get(5..7).and_then(|m| m.parse::<usize>().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]);
let month = |k: &str| k.get(5..7).and_then(|m| m.parse::<usize>().ok()).and_then(|m| MONTHS.get(m.wrapping_sub(1)));
// `get` rather than `[..]`: a non-ASCII key must not split a character
let year = key.get(..4).unwrap_or(key);
match key.len() {
10 => match (weekday(key), month(key), key[8..10].parse::<u32>().ok()) {
(Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {}", &key[..4]),
10 => match (weekday(key), month(key), key.get(8..10).and_then(|d| d.parse::<u32>().ok())) {
(Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {year}"),
_ => key.to_string(),
},
7 => month(key).map(|m| format!("{m} {}", &key[..4])).unwrap_or_else(|| key.to_string()),
7 => month(key).map(|m| format!("{m} {year}")).unwrap_or_else(|| key.to_string()),
4 => key.to_string(),
_ => "Unknown Date".into(),
}
@ -196,6 +198,9 @@ mod tests {
assert_eq!(group_label("2026-02"), "February 2026");
assert_eq!(group_label("2026"), "2026");
assert_eq!(group_label(""), "Unknown Date");
// non-ASCII keys of a date's length used to be sliced mid-character
assert_eq!(group_label("2024-01é0"), "2024-01é0");
assert_eq!(group_label("202é01"), "January 202é01");
assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00"));
assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00"));
assert_eq!(shift_iso("nope", 5), None);

View File

@ -6,6 +6,7 @@
//! - **undo/redo**: the engine keeps inverse ops;
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod dates;
pub mod journal;
@ -289,7 +290,8 @@ impl Catalog {
/// Number of photos shown for an album in the sources list (excludes deleted photos).
pub fn album_count(&self, id: AlbumId) -> usize {
match self.albums.get(&id) {
Some(Album { smart: Some(_), .. }) => self.album_photos(id).len(),
// counted in place: no id list is built just for its length
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(),
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
None => 0,
}

View File

@ -174,6 +174,11 @@ impl Filter {
if v.is_empty() { "all photos".into() } else { v.join(", ") }
}
/// Whether matches depend on the clock (only "in the last…" rules do).
pub fn depends_on_now(&self) -> bool {
self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now)
}
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
if p.deleted != self.deleted {
return false;
@ -346,6 +351,52 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
}
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
/// No file-system access: the paths compared should already be absolute.
pub fn folder_key(path: &str) -> String {
let mut s = path.replace('\\', "/");
if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) {
s = match rest.strip_prefix("UNC/") {
Some(unc) => format!("//{unc}"),
None => rest.to_string(),
};
}
// the part `..` can't climb above: `//server/share`, `c:` or `/`
let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) {
let mut it = unc.splitn(3, '/');
let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or(""));
(format!("//{server}/{share}"), rest.to_string())
} else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
(s[..2].to_ascii_lowercase(), s[2..].to_string())
} else {
(String::new(), s.clone())
};
let absolute = rest.starts_with('/') || !prefix.is_empty();
let mut parts: Vec<&str> = Vec::new();
for c in rest.split('/') {
match c {
"" | "." => {}
".." if parts.last().is_some_and(|p| *p != "..") => {
parts.pop();
}
".." if absolute => {}
c => parts.push(c),
}
}
let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") };
if cfg!(windows) { key.to_lowercase() } else { key }
}
/// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled
/// (see [`folder_key`]).
pub fn folder_within(path: &str, root: &str) -> bool {
let (p, r) = (folder_key(path), folder_key(root));
p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/')))
}
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
/// `IMG_1-Pano.dng`, `IMG_1-HDR-Pano.dng` — also the names other raw editors use):
/// `hdr`, `panorama` or `hdrPanorama`.

View File

@ -346,6 +346,15 @@ impl RuleSet {
}
}
/// Whether matches depend on the clock ("in the last…" rules, nested groups included): the
/// same photos can enter or leave the set without any catalog change.
pub fn depends_on_now(&self) -> bool {
self.rules.iter().any(|r| match r {
Rule::Group { group } => group.depends_on_now(),
Rule::Field { op, .. } => op == "inLast" || op == "notInLast",
})
}
/// Unknown fields or operators (for command validation), as readable messages.
pub fn problems(&self) -> Vec<String> {
let mut out = Vec::new();

View File

@ -277,7 +277,7 @@ mod tests {
#[test]
fn group_merge_set_top_remove_and_arrange() {
let (mut c, ids) = cat(6, &[]);
let [a, b, x, d, e, f] = ids[..] else { unreachable!() };
let [a, b, x, d, e, f] = ids[..] else { panic!("six ids") };
let op = c.group_ops(b, &[a, b, x], false).unwrap();
let inv = c.apply(op).unwrap();
let s = c.stack_of(a).unwrap().clone();

View File

@ -87,7 +87,7 @@ impl Store for FsStore {
}
self.appender = Some((name.to_string(), f));
}
let (_, f) = self.appender.as_mut().expect("appender just opened");
let Some((_, f)) = self.appender.as_mut() else { return Err(io::Error::other("appender not open")) };
f.write_all(data)?;
f.sync_data()
}

View File

@ -264,3 +264,36 @@ fn preview_only_survives_serde_and_set_content_undo() {
assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
assert_eq!(c.photo(a).unwrap().width, 6000);
}
/// Equivalent spellings of one folder (separators, trailing or doubled separators, `.`/`..`,
/// drive-letter case, verbatim and UNC prefixes) share an identity; different folders don't.
#[test]
fn folder_identity_ignores_spelling() {
use crate::query::{folder_key, folder_within};
let same = |a: &str, b: &str| assert_eq!(folder_key(a), folder_key(b), "{a} vs {b}");
let differ = |a: &str, b: &str| assert_ne!(folder_key(a), folder_key(b), "{a} vs {b}");
for (a, b) in [
("D:/Example/Photos", "D:\\Example\\Photos"),
("D:/Example/Photos", "d:\\Example\\Photos\\"),
("D:\\Example/Photos", "D:/Example//Photos/."),
("D:/Example/Photos", "D:/Example/Other/../Photos"),
("D:/Example/Photos", "\\\\?\\D:\\Example\\Photos"),
("\\\\server\\share\\Photos", "//server/share/Photos/"),
("\\\\?\\UNC\\server\\share\\Photos", "//server/share/Photos"),
("/home/me/Photos", "/home/me/Photos/"),
("/home/me/Photos", "/home//me/./Photos"),
("/", "//"),
("C:\\", "c:/"),
] {
same(a, b);
}
differ("D:/Example/Photos", "D:/Example/Photos2");
differ("D:/Example/Photos", "E:/Example/Photos");
differ("/home/me/Photos", "/home/me");
assert_eq!(folder_key("/a/../../b"), "/b", "`..` stops at the root");
assert!(folder_within("D:\\Example\\Photos\\2026", "D:/Example/Photos/"));
assert!(folder_within("D:/Example/Photos", "d:\\Example\\Photos"));
assert!(!folder_within("D:/Example/Photos2", "D:/Example/Photos"));
assert!(folder_within("/a/b", "/"));
assert!(folder_within("C:\\x", "c:\\"));
}

View File

@ -323,7 +323,9 @@ fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccIn
}
// Naive: RGB = (1 - C)(1 - K) … treated as sRGB-encoded.
let px = samples
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|c| {
let k = 1.0 - c[3];
[(1.0 - c[0]) * k, (1.0 - c[1]) * k, (1.0 - c[2]) * k].map(lightcraft_color::transfer::srgb_to_linear)

View File

@ -91,7 +91,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin
let n = img.width as usize * img.height as usize;
let (data, ct): (std::borrow::Cow<[u8]>, jpeg_encoder::ColorType) = match img.channels {
1 => (data[..n].into(), jpeg_encoder::ColorType::Luma),
2 => (data.chunks_exact(2).take(n).map(|c| c[0]).collect::<Vec<_>>().into(), jpeg_encoder::ColorType::Luma),
2 => (data.as_chunks::<2>().0.iter().take(n).map(|c| c[0]).collect::<Vec<_>>().into(), jpeg_encoder::ColorType::Luma),
3 => (data[..n * 3].into(), jpeg_encoder::ColorType::Rgb),
_ => (data[..n * 4].into(), jpeg_encoder::ColorType::Rgba),
};
@ -254,7 +254,7 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
}};
}
fn expand_ga<T: Copy>(s: &[T], n: usize) -> Vec<T> {
s.chunks_exact(2).take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
s.as_chunks::<2>().0.iter().take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
}
match img.samples {
Samples::U8(s) => match ch {

View File

@ -236,7 +236,7 @@ pub(crate) fn cms_to_linear_rec2020(icc: &[u8], samples: &[f32], channels: usize
for (s, d) in src_chunks.zip(dst_chunks) {
t.transform(s, d).ok()?;
}
Some(out.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect())
Some(out.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect())
};
std::panic::catch_unwind(run).ok().flatten()
}

View File

@ -159,7 +159,7 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
let (model, buf, depth) = match info.pixel_format {
PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8),
PixelFormat::L16 => {
let v = px.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
let v = px.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
(Model::Gray, Buf::U16(v), 16)
}
PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8),
@ -226,7 +226,7 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
let Some(e) = entries.iter().find(|e| e.tag == 0xB002) else { return vec![] };
let Some(list) = t.bytes(e) else { return vec![] };
let mut out = Vec::new();
for (i, rec) in list.chunks_exact(16).enumerate().take(16) {
for (i, rec) in list.as_chunks::<16>().0.iter().enumerate().take(16) {
if i == 0 {
continue;
}

View File

@ -274,7 +274,7 @@ pub fn encode(
{
let rows: Vec<(usize, Vec<&mut [f32]>)> = {
let mut its: Vec<_> = full.iter_mut().map(|p| p.chunks_mut(width)).collect();
(0..height).map(|y| (y, its.iter_mut().map(|it| it.next().unwrap()).collect())).collect()
(0..height).map_while(|y| Some((y, its.iter_mut().map(|it| it.next()).collect::<Option<_>>()?))).collect()
};
let conv = |(y, mut out): (usize, Vec<&mut [f32]>)| {
let src = &data[y * width * channels..(y + 1) * width * channels];

View File

@ -12,6 +12,7 @@
//!
//! No decoder panics on malformed input (property-tested).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod convert;
pub mod encode;

View File

@ -37,7 +37,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
};
buf.truncate(out.buffer_size());
let (samples, depth) = match out.bit_depth {
png::BitDepth::Sixteen => (Buf::U16(buf.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16),
png::BitDepth::Sixteen => (Buf::U16(buf.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16),
_ => (Buf::U8(buf), info.bit_depth as u8),
};
let raw = Raw { width: out.width as usize, height: out.height as usize, model, alpha, premultiplied: false, buf: samples, bit_depth: depth };

View File

@ -9,6 +9,7 @@
//!
//! The pipeline's working space is **linear Rec.2020, D65** ([`WORKING`]).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod cct;
pub mod perceptual;
@ -125,14 +126,17 @@ pub const WORKING: RgbSpace = REC2020;
impl RgbSpace {
/// Matrix taking linear RGB in this space to XYZ (white Y = 1) relative to its own white.
/// Degenerate (collinear) primaries, which readers reject before building a space from a
/// file, leave the primaries unscaled instead of panicking.
pub fn to_xyz(&self) -> Mat3 {
let p = [self.r.to_xyz(), self.g.to_xyz(), self.b.to_xyz()];
let m = Mat3([[p[0][0], p[1][0], p[2][0]], [p[0][1], p[1][1], p[2][1]], [p[0][2], p[1][2], p[2][2]]]);
let s = m.inverse().expect("primaries are independent").apply(self.white.to_xyz());
let s = m.inverse().map_or([1.0; 3], |inv| inv.apply(self.white.to_xyz()));
m.mul(&Mat3::diag(s[0], s[1], s[2]))
}
/// Inverse of [`to_xyz`](Self::to_xyz); the identity for degenerate primaries.
pub fn from_xyz(&self) -> Mat3 {
self.to_xyz().inverse().expect("invertible")
self.to_xyz().inverse().unwrap_or(Mat3::IDENTITY)
}
/// Luminance weights (the Y row of `to_xyz`).
pub fn luma(&self) -> [f64; 3] {
@ -154,7 +158,8 @@ pub fn bradford(src: Xy, dst: Xy) -> Mat3 {
}
let s = BRADFORD.apply(src.to_xyz());
let d = BRADFORD.apply(dst.to_xyz());
let inv = BRADFORD.inverse().expect("invertible");
// a constant, invertible matrix: the fallback (no adaptation) can't happen
let Some(inv) = BRADFORD.inverse() else { return Mat3::IDENTITY };
inv.mul(&Mat3::diag(d[0] / s[0], d[1] / s[1], d[2] / s[2])).mul(&BRADFORD)
}

View File

@ -28,9 +28,10 @@ fn mats() -> &'static OkMats {
let to_lms = XYZ_TO_LMS.mul(&REC2020.to_xyz());
OkMats {
to_lms: to_lms.to_f32(),
from_lms: to_lms.inverse().expect("invertible").to_f32(),
// constant, invertible matrices (round trips tested below): the identity fallback can't happen
from_lms: to_lms.inverse().unwrap_or(Mat3::IDENTITY).to_f32(),
to_lab: LMS_TO_LAB.to_f32(),
from_lab: LMS_TO_LAB.inverse().expect("invertible").to_f32(),
from_lab: LMS_TO_LAB.inverse().unwrap_or(Mat3::IDENTITY).to_f32(),
}
})
}

View File

@ -4,6 +4,7 @@
//! The pipeline evaluates it; the catalog stores it; presets, copy/paste, sync, versions and history
//! are operations on it. Numeric sliders are addressed by id through [`controls`].
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod controls;
pub mod presets;

View File

@ -250,7 +250,7 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"selected": s.selection.ids.len()}))
}),
cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| {
Ok(serde_json::to_value(crate::devices::devices()).unwrap_or_default())
Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default())
}),
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| {
let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?;
@ -745,10 +745,10 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(
"library.import",
"Add Photos…",
"Import Photos",
["File"],
Some("Cmd+Shift+I"),
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), local?: bool (browsing: the photos stay out of the library, like library.browse), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), local?: bool (browsing: the photos stay out of the library, like library.browse), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
always,
|s, p| {
let paths = strs(p, "paths");
@ -773,11 +773,19 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad("library.import", "album must be a regular album"));
}
let organize = match str_param(p, "organize") {
Some(o) => {
crate::import::Organize::parse(o).ok_or_else(|| bad("library.import", format!("unknown organize `{o}` (date|month|flat)")))?
}
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
bad(
"library.import",
format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"),
)
})?,
None => Default::default(),
};
if let crate::import::Organize::Template(t) = &organize
&& let Some(e) = crate::rename::folder_template_error(t)
{
return Err(bad("library.import", e));
}
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if let Some(n) = &metadata_preset
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))

View File

@ -61,7 +61,10 @@ fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
cmd!(query "photo.renameTokens", "Rename Template Tags", [], None, "{} → {tokens: [{tag, aliases, meaning, example}], dateDirectives: [{directive, meaning}], notes: [..], sample} — the file-name template tags shared by photo.rename, library.import (rename) and app.export (naming); examples are for a sample photo", always, |_, _| {
Ok(crate::rename::token_help_json())
}),
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
let (ids, template, start) = rename_args(s, p, "photo.renamePreview")?;
Ok(serde_json::to_value(s.plan_rename(&ids, &template, start)).unwrap_or_default())
}),
@ -70,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rename Photos",
[],
None,
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
has_selection,
|s, p| {
let (ids, template, start) = rename_args(s, p, "photo.rename")?;

View File

@ -5,24 +5,48 @@
use std::collections::HashMap;
use std::path::Path;
use lightcraft_catalog::{Op, PhotoId, Source};
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
/// The file Missing Photos checks for `p`, if it is in scope: a library photo (not in Recently
/// Deleted, not a Local-only record seen while browsing a folder) whose original is a file.
///
/// Local browse records are out of scope on purpose: browsing can leave tens of thousands of
/// them in the catalog, they never show in library views (Missing Photos included), and a
/// folder that is gone is simply not browsed again. The sidebar count, the Missing Photos view,
/// `library.missing` and Find Missing Photos all use this one rule.
pub fn checked_path(p: &Photo) -> Option<&str> {
match &p.source {
Source::File { path } if p.in_library() => Some(path),
_ => None,
}
}
/// Every in-scope photo's file ([`checked_path`]), without touching the disk.
pub fn candidates(cat: &Catalog) -> Vec<String> {
cat.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect()
}
/// [`missing`] with the existence check supplied (tests count the file-system calls).
pub fn missing_with(cat: &Catalog, mut exists: impl FnMut(&str) -> bool) -> Vec<(PhotoId, String)> {
cat.photos().filter_map(|p| checked_path(p).filter(|f| !exists(f)).map(|f| (p.id, f.to_string()))).collect()
}
/// Library photos whose original file can't be found: (id, path).
pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
if cfg!(target_arch = "wasm32") {
return Vec::new();
}
s.catalog
.photos()
.filter_map(|p| match &p.source {
Source::File { path } if !p.deleted && !Path::new(path).exists() => Some((p.id, path.clone())),
_ => None,
})
.collect()
missing_with(&s.catalog, |f| Path::new(f).exists())
}
/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only
/// the photos its query already narrowed to).
pub fn is_missing(cat: &Catalog, id: PhotoId) -> bool {
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| !Path::new(f).exists())
}
fn relink_op(id: PhotoId, path: &str) -> Op {
@ -50,7 +74,11 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
{
ops.extend(crate::cmd::convert::content_op(id, ph, info));
}
s.commit("Relink Photo", if ops.len() == 1 { ops.pop().expect("one op") } else { Op::Batch { ops } })?;
let op = match <[Op; 1]>::try_from(ops) {
Ok([op]) => op,
Err(ops) => Op::Batch { ops },
};
s.commit("Relink Photo", op)?;
s.media.forget(id);
Ok(json!({"id": id.0, "path": abs}))
}

View File

@ -1,6 +1,15 @@
//! Cameras and memory cards: mounted volumes with a `DCIM` folder (the DCF layout every camera
//! writes). Listed by `library.devices`; the app offers them under File → Add from Device, and
//! writes). Listed by `library.devices`; the app offers them under File → Import from Device, and
//! the import review copies from them into the library.
//!
//! Menus are rebuilt every frame (even with their popups closed), so [`devices`] never touches the
//! disk: it serves the last scan and, when that is older than [`MAX_AGE`], starts one background
//! rescan (coalesced; no lock is held while it runs). A changed result calls the [`on_change`]
//! hook, which the UI uses to repaint, so hot-plugged cards still appear. [`devices_now`] scans
//! synchronously for explicit requests (`library.devices`, Add from Device without a path).
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde::Serialize;
@ -14,6 +23,9 @@ pub struct Device {
pub root: String,
}
/// How long a scan is served before [`devices`] starts another one.
pub const MAX_AGE: Duration = Duration::from_secs(2);
/// Folders whose children are mounted volumes on this platform. `LIGHTCRAFT_DEVICE_ROOTS`
/// (paths joined like `PATH`) replaces them (tests, unusual mounts).
fn mount_parents() -> Vec<std::path::PathBuf> {
@ -34,21 +46,114 @@ fn mount_parents() -> Vec<std::path::PathBuf> {
out
}
/// Mounted volumes that look like a camera or a memory card. Cached for a couple of seconds:
/// menus ask often, and listing mounts can be slow (network volumes).
pub fn devices() -> Vec<Device> {
use std::sync::Mutex;
use std::time::{Duration, Instant};
static CACHE: Mutex<Option<(Instant, Vec<Device>)>> = Mutex::new(None);
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((t, d)) = c.as_ref()
&& t.elapsed() < Duration::from_secs(2)
{
return d.clone();
type Scanner = dyn Fn() -> Vec<Device> + Send + Sync;
type Notify = dyn Fn() + Send + Sync;
#[derive(Default)]
struct WatchState {
list: Vec<Device>,
/// When the served list was scanned (`None`: never). No clock (and no threads) on wasm, where
/// there are no mounted volumes either.
#[cfg(not(target_arch = "wasm32"))]
scanned: Option<std::time::Instant>,
/// A background scan is running.
refreshing: bool,
}
/// A cached device list, refreshed off the calling thread.
pub struct DeviceWatch {
scanner: Box<Scanner>,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
max_age: Duration,
state: Mutex<WatchState>,
notify: Mutex<Option<Arc<Notify>>>,
}
impl DeviceWatch {
pub fn new(scanner: impl Fn() -> Vec<Device> + Send + Sync + 'static, max_age: Duration) -> Arc<Self> {
Arc::new(DeviceWatch { scanner: Box::new(scanner), max_age, state: Mutex::default(), notify: Mutex::new(None) })
}
let d = scan();
*c = Some((Instant::now(), d.clone()));
d
fn state(&self) -> std::sync::MutexGuard<'_, WatchState> {
self.state.lock().unwrap_or_else(|e| e.into_inner())
}
/// Called (on the scanning thread) whenever a scan changes the list.
pub fn on_change(&self, f: impl Fn() + Send + Sync + 'static) {
*self.notify.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(f));
}
/// The last scan, immediately; starts a background rescan when it is stale and none is running.
pub fn get(self: &Arc<Self>) -> Vec<Device> {
let mut s = self.state();
#[cfg(not(target_arch = "wasm32"))]
if !s.refreshing && s.scanned.is_none_or(|t| t.elapsed() >= self.max_age) {
s.refreshing = true;
let me = self.clone();
// the scan runs without the state lock; `get` only ever waits for a list clone
let spawned = std::thread::Builder::new().name("lightcraft-devices".into()).spawn(move || {
let list = (me.scanner)();
me.store(list);
});
if spawned.is_err() {
s.refreshing = false;
}
}
s.list.clone()
}
/// Scan now on this thread, and serve the result to later [`DeviceWatch::get`] calls.
pub fn scan_now(&self) -> Vec<Device> {
let list = (self.scanner)();
self.store(list.clone());
list
}
fn store(&self, list: Vec<Device>) {
let changed = {
let mut s = self.state();
s.refreshing = false;
#[cfg(not(target_arch = "wasm32"))]
{
s.scanned = Some(std::time::Instant::now());
}
let changed = s.list != list;
s.list = list;
changed
};
if changed {
let f = self.notify.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(f) = f {
f();
}
}
}
/// A background scan is running.
pub fn refreshing(&self) -> bool {
self.state().refreshing
}
}
fn global() -> &'static Arc<DeviceWatch> {
static WATCH: std::sync::OnceLock<Arc<DeviceWatch>> = std::sync::OnceLock::new();
WATCH.get_or_init(|| DeviceWatch::new(scan, MAX_AGE))
}
/// Mounted volumes that look like a camera or a memory card, without blocking: the last scan,
/// refreshed in the background when older than [`MAX_AGE`] (empty until the first scan lands).
pub fn devices() -> Vec<Device> {
global().get()
}
/// Scan the mounted volumes now (blocking), for explicit requests.
pub fn devices_now() -> Vec<Device> {
global().scan_now()
}
/// Run `f` whenever a scan changes the device list (the UI requests a repaint).
pub fn on_change(f: impl Fn() + Send + Sync + 'static) {
global().on_change(f);
}
fn scan() -> Vec<Device> {
@ -73,10 +178,7 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
roots.extend(extra);
let mut out: Vec<Device> = Vec::new();
for root in roots {
let Ok(rd) = std::fs::read_dir(&root) else { continue };
let Some(dcim) = rd.flatten().map(|e| e.path()).find(|p| p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM"))) else {
continue;
};
let Some(dcim) = dcim_in(&root) else { continue };
let name = root.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| root.to_string_lossy().to_string());
if !out.iter().any(|d| d.root == root.to_string_lossy()) {
out.push(Device { name, path: dcim.to_string_lossy().to_string(), root: root.to_string_lossy().to_string() });
@ -85,9 +187,22 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
out
}
/// The volume's `DCIM` folder: the usual spellings are checked directly, so a volume's root is
/// only listed (other spellings on a case-sensitive volume) off Windows, whose drives aren't.
fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
if let Some(p) = ["DCIM", "dcim"].iter().map(|n| root.join(n)).find(|p| p.is_dir()) {
return Some(p);
}
if cfg!(windows) {
return None;
}
std::fs::read_dir(root).ok()?.flatten().map(|e| e.path()).find(|p| p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")) && p.is_dir())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn volumes_with_a_dcim_folder_are_devices() {
@ -96,10 +211,76 @@ mod tests {
std::fs::create_dir_all(base.join("EOS_DIGITAL/DCIM/100CANON")).unwrap();
std::fs::create_dir_all(base.join("Backup/Photos")).unwrap();
std::fs::create_dir_all(base.join("SD/dcim")).unwrap();
std::fs::create_dir_all(base.join("X/Dcim")).unwrap();
let d = devices_in(std::slice::from_ref(&base), Vec::new());
let names: Vec<&str> = d.iter().map(|x| x.name.as_str()).collect();
assert_eq!(names, ["EOS_DIGITAL", "SD"], "{d:?}");
assert!(d[0].path.ends_with("DCIM") && d[1].path.ends_with("dcim"));
assert_eq!(names, ["EOS_DIGITAL", "SD", "X"], "{d:?}");
// the DCIM folder itself, in a spelling that opens on that volume
for x in &d {
let p = std::path::Path::new(&x.path);
assert!(p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")), "{x:?}");
}
let _ = std::fs::remove_dir_all(&base);
}
fn card(name: &str) -> Device {
Device { name: name.into(), path: format!("/v/{name}/DCIM"), root: format!("/v/{name}") }
}
/// Wait (bounded) for `f`.
fn until(f: impl Fn() -> bool) -> bool {
let t = std::time::Instant::now();
while t.elapsed() < Duration::from_secs(20) {
if f() {
return true;
}
std::thread::sleep(Duration::from_millis(5));
}
false
}
#[test]
fn slow_scans_never_block_and_hot_plug_still_arrives() {
// a scanner that takes 1.5 s (a sleeping network drive) and counts its runs
const SLOW: Duration = Duration::from_millis(1500);
let mounted = Arc::new(Mutex::new(Vec::<Device>::new()));
let runs = Arc::new(AtomicUsize::new(0));
let (m, r) = (mounted.clone(), runs.clone());
let watch = DeviceWatch::new(
move || {
r.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(SLOW);
m.lock().unwrap().clone()
},
Duration::from_millis(20),
);
let changes = Arc::new(AtomicUsize::new(0));
let c = changes.clone();
watch.on_change(move || {
c.fetch_add(1, Ordering::SeqCst);
});
// cold: served at once (empty) while one scan runs in the background
let t = std::time::Instant::now();
for _ in 0..50 {
assert!(watch.get().is_empty());
}
assert!(t.elapsed() < SLOW / 2, "get() waited for the scan: {:?}", t.elapsed());
assert!(watch.refreshing());
assert!(until(|| !watch.refreshing()));
assert_eq!(runs.load(Ordering::SeqCst), 1, "requests while scanning are coalesced");
assert_eq!(changes.load(Ordering::SeqCst), 0, "nothing changed");
// a card is plugged in: the next stale request starts a rescan, still without waiting
mounted.lock().unwrap().push(card("EOS_DIGITAL"));
std::thread::sleep(Duration::from_millis(40));
let t = std::time::Instant::now();
assert!(watch.get().is_empty(), "the old list is served while rescanning");
assert!(t.elapsed() < SLOW / 2, "warm get() waited: {:?}", t.elapsed());
assert!(until(|| changes.load(Ordering::SeqCst) == 1), "a changed scan notifies");
assert_eq!(watch.get(), [card("EOS_DIGITAL")]);
// explicit requests scan synchronously
assert!(until(|| !watch.refreshing()));
mounted.lock().unwrap().clear();
assert!(watch.scan_now().is_empty());
assert_eq!(changes.load(Ordering::SeqCst), 2);
}
}

View File

@ -553,10 +553,9 @@ impl ExportOptions {
r(ResizeMode::ShortEdge, v)
} else if let Some(v) = f("megapixels") {
r(ResizeMode::Megapixels, v)
} else if let Some(v) = f("percent") {
r(ResizeMode::Percent, v)
} else {
return None;
let v = f("percent")?;
r(ResizeMode::Percent, v)
}
}
})
@ -659,7 +658,7 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar
let (w, h) = (img.width, img.height);
match &mut img.samples {
DeepSamples::U16(v) => {
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect();
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();
if let Some(out) = unsharp(w, h, &src, target, amount) {
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
*d = s.round().clamp(0.0, 65535.0) as u16;
@ -667,7 +666,7 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar
}
}
DeepSamples::F32(v) => {
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect();
if let Some(out) = unsharp(w, h, &src, target, amount) {
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
*d = s.max(0.0);

View File

@ -0,0 +1,55 @@
//! Last-resort panic guard (never-crash standard, `AGENTS.md`): a panic that escapes a command
//! becomes that command's error and the session (catalog, library, undo history) stays usable.
//! It's a safety net, not a way to report errors: commands return `Err` themselves.
use std::any::Any;
use std::io::Write;
use std::panic::{self, AssertUnwindSafe};
use std::path::PathBuf;
/// The text of a panic payload (`panic!("…")` gives a `&str` or a `String`).
pub fn panic_message(payload: &(dyn Any + Send)) -> String {
match (payload.downcast_ref::<&str>(), payload.downcast_ref::<String>()) {
(Some(s), _) => (*s).to_string(),
(_, Some(s)) => s.clone(),
_ => "unknown panic".into(),
}
}
/// Run `f`; a panic inside it becomes `Err("<what> failed unexpectedly: <message>")`. On wasm32
/// (built with `panic = "abort"`) nothing can be caught and this just runs `f`.
pub fn catch<T>(what: &str, f: impl FnOnce() -> T) -> Result<T, String> {
panic::catch_unwind(AssertUnwindSafe(f)).map_err(|p| {
let msg = format!("{what} failed unexpectedly: {}", panic_message(p.as_ref()));
log::error!("{msg}");
msg
})
}
/// Install a panic hook that keeps the default report (stderr) and also appends the panic's
/// location and message to `log` (best effort), so a panic in a GUI session leaves a trace.
pub fn install_hook(log: PathBuf) {
let default = panic::take_hook();
panic::set_hook(Box::new(move |info| {
default(info);
let at = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default();
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let thread = std::thread::current().name().unwrap_or("unnamed").to_string();
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log) {
let _ = writeln!(f, "{secs} [{thread}] panic at {at}: {}", panic_message(info.payload()));
}
}));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_panic_becomes_an_error() {
assert_eq!(catch("ok", || 7), Ok(7));
let e = catch("`demo.cmd`", || -> u32 { panic!("boom {}", 1) }).unwrap_err();
assert_eq!(e, "`demo.cmd` failed unexpectedly: boom 1");
assert!(catch("x", || std::panic::panic_any(5u8)).unwrap_err().ends_with("unknown panic"));
}
}

View File

@ -8,8 +8,9 @@
//! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch).
//! 5. *Add* in place (the photo points at the original file) or *copy* into the library's
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy.
//! 6. Read each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
//! metadata and develop settings are restored (see [`crate::sidecar`]).
//! 6. Apply each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
//! metadata and develop settings are restored (see [`crate::sidecar`]). Its capture time fills
//! in when the file has none (read before step 5, so it also files and names the copy).
//! 7. Optionally apply a preset and add keywords (the import dialog's options).
//! 8. Commit all new photos as one undoable op.
//!
@ -66,8 +67,9 @@ pub struct ImportOptions {
pub convert_dng: bool,
}
/// How copies are filed in the destination.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
/// How copies are filed in the destination. The date is the capture time, else the time of the
/// import.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Organize {
/// `YYYY/YYYY-MM-DD/` by capture date.
#[default]
@ -76,17 +78,36 @@ pub enum Organize {
ByMonth,
/// All in the destination itself.
Flat,
/// A folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → `2026/20260114/`: the template's own
/// `/` make the levels, each expanded with the file-name tokens ([`crate::rename::expand_folder`];
/// never outside the destination).
Template(String),
}
impl Organize {
/// `date` | `month` | `flat`, or a folder template (anything with a `{`, `/` or `\`).
pub fn parse(s: &str) -> Option<Organize> {
match s {
"date" | "day" | "byDay" => Some(Organize::ByDay),
"month" | "byMonth" => Some(Organize::ByMonth),
"flat" | "none" | "intoOneFolder" => Some(Organize::Flat),
t if t.contains(['{', '/', '\\']) => Some(Organize::Template(t.trim().to_string())),
_ => None,
}
}
/// The folders (inside the destination) a copy of `p` goes to.
pub fn folders(&self, p: &Photo) -> Vec<String> {
let date = p.date();
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
let year = day.get(..4).unwrap_or("undated");
match self {
Organize::ByDay => vec![year.into(), day.into()],
Organize::ByMonth => vec![year.into(), day.get(..7).unwrap_or("undated").into()],
Organize::Flat => Vec::new(),
Organize::Template(t) => crate::rename::expand_folder(t, p, 1),
}
}
}
/// A file found by [`scan`], for the import review.
@ -322,16 +343,10 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
.collect()
}
/// Copy `src` into `root` (filed per `organize` by `date`) as `name` (default: its own name),
/// made unique with -1, -2…; returns the new path.
fn copy_into(root: &Path, src: &str, date: &str, organize: Organize, name: Option<&str>) -> Result<String, String> {
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
let year = day.get(..4).unwrap_or("undated");
let dir = match organize {
Organize::ByDay => root.join(year).join(day),
Organize::ByMonth => root.join(year).join(day.get(..7).unwrap_or("undated")),
Organize::Flat => root.to_path_buf(),
};
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
/// returns the new path.
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>) -> Result<String, String> {
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let name = name
.map(str::to_string)
@ -553,21 +568,38 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
continue;
}
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
// and names the copy when the file itself has none
let raw = info.kind == MediaKind::Raw;
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
Ok(sc) => (sc != crate::sidecar::SidecarData::default()).then_some(sc),
Err(e) => {
log::warn!("import {path}: XMP: {e}");
None
}
});
let mut info = info;
if info.captured.is_none() {
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
}
let stored = match (mode, &copy_root) {
(ImportMode::Copy, Some(root))
if !Path::new(&path).starts_with(root) && !lib_dir.as_ref().is_some_and(|l| Path::new(&path).starts_with(l)) =>
{
// the templates see the photo as it will be catalogued (undated: the import time)
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
q.captured = info.captured.clone();
q.meta = info.meta.clone();
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
// the template sees the photo as it will be catalogued
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
q.captured = info.captured.clone();
q.meta = info.meta.clone();
let n = crate::rename::expand(t, &q, seq);
seq += 1;
n
});
match copy_into(root, &path, info.captured.as_deref().unwrap_or(&now), opts.organize, name.as_deref()) {
match copy_into(root, &path, &opts.organize.folders(&q), name.as_deref()) {
Ok(p) => p,
Err(e) => {
report.failed.push((path, e));
@ -583,7 +615,6 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
}
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
let mut stored = stored;
let mut info = info;
if opts.convert_dng && mode == ImportMode::Copy && stored != path && info.kind == MediaKind::Raw && !info.format.eq_ignore_ascii_case("DNG") {
match crate::cmd::convert::write_dng_for(s, &stored, String::new()) {
Ok(dng) => {
@ -606,19 +637,9 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
apply_import_defaults(s, &mut p);
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
if let Some(x) = packet {
match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
Ok(sc) if sc != crate::sidecar::SidecarData::default() => {
crate::sidecar::merge_into(&mut p, &sc, &now);
report.sidecars += 1;
}
Ok(_) => {}
Err(e) => log::warn!("import {path}: XMP: {e}"),
}
if let Some(sc) = &sidecar {
crate::sidecar::merge_into(&mut p, sc, &now);
report.sidecars += 1;
}
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);

View File

@ -17,6 +17,7 @@ pub mod demo;
pub mod devices;
pub mod export;
pub mod files;
pub mod guard;
pub mod import;
pub mod library;
pub mod media;
@ -237,7 +238,8 @@ impl Session {
let log_start = self.pending_log.len();
let was_active = self.active();
self.depth += 1;
let r = (spec.run)(self, params);
// last-resort guard: a panic in a command is that command's error, not a crash
let r = guard::catch(&format!("`{id}`"), || (spec.run)(self, params)).unwrap_or_else(|e| Err(EngineError::Other(e)));
self.depth -= 1;
if self.depth == 0 && was_active.is_some() && self.active() != was_active {
self.previous_active = was_active;
@ -507,8 +509,9 @@ impl Session {
});
}
if self.source == LibrarySource::Missing {
let missing: std::collections::HashSet<PhotoId> = cmd::missing::missing(self).into_iter().map(|m| m.0).collect();
self.visible.retain(|id| missing.contains(id));
// only the photos the query kept (library photos, not Local browse records) are checked
let cat = &self.catalog;
self.visible.retain(|id| cmd::missing::is_missing(cat, *id));
}
if self.source != LibrarySource::RecentlyDeleted {
self.visible = self.catalog.arrange_stacks(&self.visible);

View File

@ -228,8 +228,8 @@ impl Session {
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
}
let view_written = self.view_json();
self.library = Some(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
Ok(&self.library.as_ref().expect("just set").report)
let library = self.library.insert(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
Ok(&library.report)
}
/// Write pending ops to the log (fsynced), compact when due, and save changed presets.

View File

@ -309,14 +309,10 @@ fn output_path(first: &str, suffix: &str) -> String {
let p = std::path::Path::new(first);
let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default();
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Merge".into());
for k in 1.. {
let name = if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") };
let cand = dir.join(name);
if !cand.exists() {
return cand.to_string_lossy().to_string();
}
}
unreachable!()
let cand = |k: u64| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") });
// (`u64::MAX` taken names can't exist; the last candidate is the fallback)
let free = (1..u64::MAX).map(cand).find(|c| !c.exists()).unwrap_or_else(|| cand(u64::MAX));
free.to_string_lossy().to_string()
}
/// Parse `merge.*` command parameters into a kind and finishing options.

View File

@ -82,6 +82,148 @@ fn format_date(iso: &str, fmt: &str) -> String {
out
}
/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and
/// command description shows; a test checks that [`expand_tokens`] knows each of them.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct TokenHelp {
/// The token as typed, e.g. `{seq:3}`.
pub tag: &'static str,
/// Other spellings with the same meaning.
pub aliases: &'static [&'static str],
pub meaning: &'static str,
}
/// Every template token, in the order the help shows them.
pub const TOKENS: &[TokenHelp] = &[
TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" },
TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" },
TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" },
TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" },
TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" },
TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" },
TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" },
TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" },
TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" },
TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" },
TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" },
TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" },
TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" },
TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" },
];
/// The `%` directives of `{date:…}`.
pub const DATE_DIRECTIVES: &[(&str, &str)] = &[
("%Y", "year, 4 digits"),
("%y", "year, 2 digits"),
("%m", "month 01–12"),
("%d", "day 01–31"),
("%H", "hour 00–23"),
("%M", "minute"),
("%S", "second"),
("%%", "a literal %"),
];
/// How templates behave, one sentence each (shown under the token list).
pub const TEMPLATE_NOTES: &[&str] = &[
"The original extension is always added; {ext} only puts it inside the name as well.",
"A blank template keeps the original names.",
"{date} is the capture time; a photo without one uses the time it was imported.",
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.",
"Unknown tags stay as typed — check the preview for a {typo}.",
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….",
];
/// The tokens as one line (`{name} {num} {seq} …`), for compact hints.
pub fn token_summary() -> String {
TOKENS.iter().map(|t| t.tag).collect::<Vec<_>>().join(" ")
}
/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48).
pub fn sample_photo() -> Photo {
let mut p =
Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00");
p.captured = Some("2026-01-14T05:58:48".into());
p.rating = 4;
p.meta.camera = "Canon EOS R5".into();
p.meta.lens = "RF24-70mm F2.8".into();
p.meta.iso = Some(400);
p.meta.title = "Harbour".into();
p.meta.creator = "Ann Lee".into();
p
}
/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column.
pub fn token_example(tag: &str) -> String {
expand_tokens(tag, &sample_photo(), 1, 1)
}
/// Why a folder template (`{date:%Y}/{date:%Y%m%d}`) can't be used, if it can't: it must be
/// relative (no leading `/` or `\`, drive letter or `~`) and have no `.` / `..` levels.
pub fn folder_template_error(template: &str) -> Option<String> {
let t = template.trim();
if t.is_empty() {
return Some("the folder template is empty".into());
}
let b = t.as_bytes();
if t.starts_with(['/', '\\', '~']) || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') {
return Some("the folder template must be relative to the destination (no leading /, \\, ~ or drive)".into());
}
if t.split(['/', '\\']).any(|s| matches!(s.trim(), "." | "..")) {
return Some("the folder template may not contain . or .. folders".into());
}
None
}
/// The folders (relative to the destination) a folder template gives `p`: the template's own `/`
/// (or `\`) separate levels; each level's tokens are expanded and the result made a safe folder
/// name — a value can never add a level or climb out (`/`, `\`, `:`… become `-`, leading and
/// trailing dots are dropped). A level that comes out empty (missing metadata) is `unknown`; empty
/// levels in the template (`a//b`) are skipped. `{date}` is the capture time, else [`Photo::date`]'s
/// fallback (the import time). Check [`folder_template_error`] first; levels it rejects are skipped.
pub fn expand_folder(template: &str, p: &Photo, seq: usize) -> Vec<String> {
template
.split(['/', '\\'])
.map(str::trim)
.filter(|s| !s.is_empty() && !matches!(*s, "." | ".."))
.map(|s| {
let v = sanitize(&expand_tokens(s, p, seq, 1));
if v.is_empty() { "unknown".to_string() } else { v }
})
.collect()
}
/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning.
pub fn unknown_tokens(template: &str) -> Vec<String> {
let p = sample_photo();
let mut out = Vec::new();
let mut rest = template;
while let Some(i) = rest.find('{') {
let Some(j) = rest[i..].find('}') else { break };
let tag = &rest[i..i + j + 1];
if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) {
out.push(tag.to_string());
}
rest = &rest[i + j + 1..];
}
out
}
/// Every token with its meaning and example, the date directives and the notes, as JSON (the
/// `photo.renameTokens` command).
pub fn token_help_json() -> serde_json::Value {
let tokens: Vec<serde_json::Value> = TOKENS
.iter()
.map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)}))
.collect();
let directives: Vec<serde_json::Value> = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect();
serde_json::json!({
"tokens": tokens,
"dateDirectives": directives,
"notes": TEMPLATE_NOTES,
"sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48",
})
}
/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension.
pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
let (stem, ext) = split_ext(&p.file_name);
@ -358,6 +500,66 @@ impl Session {
mod tests {
use super::*;
#[test]
fn folder_templates_stay_inside_the_destination() {
let mut p = sample_photo();
assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]);
assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too");
// metadata can't add levels or climb out
p.meta.camera = "../../etc/x".into();
p.meta.title = "..".into();
assert_eq!(expand_folder("{camera}/{title}/{date:%Y}", &p, 1), vec!["-..-etc-x", "unknown", "2026"]);
p.meta.camera = "C:\\Windows".into();
assert_eq!(expand_folder("{camera}", &p, 1), vec!["C--Windows"]);
// missing metadata → `unknown`; missing capture time → the import time
p.meta.camera.clear();
p.captured = None;
assert_eq!(expand_folder("{camera}//{date:%Y%m%d}", &p, 1), vec!["unknown", "20260120"]);
for bad in ["", " ", "/abs/{date}", "\\\\server\\x", "C:/x", "c:x", "~/x", "../{date}", "{date}/../x", "a/./b", "{date:%Y}/.."] {
assert!(folder_template_error(bad).is_some(), "{bad:?} should be rejected");
}
for ok in ["{date:%Y}/{date:%Y%m%d}", "Trips/{camera}", "{date}", "a//b"] {
assert_eq!(folder_template_error(ok), None, "{ok:?}");
}
// even unchecked, rejected levels are skipped rather than followed
assert_eq!(expand_folder("/../{date:%Y}/./x", &p, 1), vec!["2026", "x"]);
}
/// The help lists exactly what `expand_tokens` understands: every listed tag (and alias) is
/// replaced, never left literal, and the help's examples match the documented behaviour.
#[test]
fn token_help_matches_the_implementation() {
let p = sample_photo();
for t in TOKENS {
for tag in std::iter::once(&t.tag).chain(t.aliases) {
let v = expand_tokens(tag, &p, 1, 1);
assert!(!v.contains('{'), "{tag} is not a known token (expanded to {v:?})");
assert!(!v.is_empty(), "{tag}: the sample photo should give an example");
}
}
assert_eq!(token_example("{seq:3}"), "001");
assert_eq!(token_example("{date}"), "20260114");
assert_eq!(token_example("{date:%Y%m%d_%H%M%S}"), "20260114_055848");
assert_eq!(token_example("{ext}"), "CR3");
assert_eq!(expand("{date:%Y%m%d_%H%M%S}_{seq:3}", &p, 1), "20260114_055848_001.CR3");
for (d, _) in DATE_DIRECTIVES {
let v = format_date("2026-01-14T05:58:48", d);
assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive");
}
assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]);
assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty());
let json = token_help_json();
assert_eq!(json["tokens"].as_array().unwrap().len(), TOKENS.len());
assert_eq!(json["tokens"][0]["example"], "IMG_0042");
// the command descriptions that list the tokens list all of them
for id in ["photo.rename", "photo.renamePreview", "library.import"] {
let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap();
for t in TOKENS.iter().filter(|t| !t.tag.contains(':')) {
assert!(spec.params.contains(t.tag), "{id} does not mention {}", t.tag);
}
}
}
#[test]
fn templates() {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "IMG_0042.CR2", "CR2", 1, 1, "2026-01-01T00:00:00");

View File

@ -5,7 +5,9 @@
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
//!
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata; develop
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except
//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`,
//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
@ -84,6 +86,9 @@ pub struct SidecarData {
pub alt_text: Option<String>,
pub extended_description: Option<String>,
pub keywords: Option<Vec<String>>,
/// Capture time (ISO 8601) from `exif:DateTimeOriginal`, `photoshop:DateCreated` or
/// `xmp:CreateDate` (first found). Used only when the file itself has no capture time.
pub captured: Option<String>,
pub develop: Option<DevelopPatch>,
}
@ -115,6 +120,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
alt_text: m.alt_text.clone(),
extended_description: m.extended_description.clone(),
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
captured: m.capture_time.map(|d| d.to_iso()),
..Default::default()
};
if let Some(r) = m.rating {
@ -143,8 +149,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
Ok(out)
}
/// Apply sidecar data to a photo record (the sidecar wins). Returns true if develop changed.
/// Apply sidecar data to a photo record (the sidecar wins, except that a capture time embedded in
/// the file is kept). Returns true if develop changed.
pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
if p.captured.is_none() {
p.captured = sc.captured.clone();
}
if let Some(r) = sc.rating {
p.rating = r;
}
@ -321,6 +331,9 @@ impl Session {
Op::SetLabel { id, label: q.label },
Op::SetMeta { id, meta: Box::new(q.meta.clone()) },
];
if q.captured != p.captured {
ops.push(Op::SetCaptured { id, captured: q.captured.clone() });
}
if develop_changed {
ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File"));
}

View File

@ -257,6 +257,37 @@ fn copy_with_destination_organize_rename_and_metadata_preset() {
}
}
/// Copy with a custom folder template: `{date:%Y}/{date:%Y%m%d}` files a photo under
/// `2026/20260114/` (an undated file by the import time); templates that would leave the
/// destination are refused.
#[test]
fn copy_with_a_folder_template() {
let src = temp_dir("tplsrc");
let dest = temp_dir("tpldest");
write_png(&src.join("a.png"), 1);
let mut s = Session::new().with_fs();
s.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
for bad in ["../{date}", "/tmp/{date}", "{date:%Y}/../x", "C:/x"] {
let r = s.execute(
"library.import",
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": bad}),
);
assert!(r.is_err(), "{bad} should be refused");
}
assert_eq!(s.catalog.len(), 0);
let r = s
.execute(
"library.import",
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "{date:%Y}/{date:%Y%m%d}"}),
)
.unwrap();
assert_eq!(ids(&r, "imported"), 1, "{r}");
assert!(dest.join("2026").join("20260114").join("a.png").is_file());
for d in [&src, &dest] {
let _ = std::fs::remove_dir_all(d);
}
}
/// Auto import: files in the watched folder are added once their size held between two scans,
/// into the named album; non-photos are tried once; the selection stays put.
#[test]
@ -585,3 +616,31 @@ fn smart_previews_folder_is_chosen_per_library() {
assert_eq!(r["custom"], false, "{r}");
assert_eq!(r["path"], lib.join("Smart Previews").to_string_lossy().as_ref());
}
/// Missing Photos covers library photos only: a small library next to many Local browse
/// records (and a deleted photo) checks — and lists, and counts — the library files alone.
#[test]
fn missing_photos_skip_local_browse_records() {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
let gone = std::env::temp_dir().join(format!("lc-missing-scope-{}", std::process::id()));
let mut s = Session::new();
let mut ops = Vec::new();
for i in 0..205u64 {
let path = gone.join(format!("IMG_{i:04}.jpg")).to_string_lossy().to_string();
let mut p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:04}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
p.local = i >= 5; // 5 library photos, 200 seen while browsing
p.deleted = i == 4;
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
s.commit("Add", Op::Batch { ops }).unwrap();
let mut checked = 0;
let lost = crate::cmd::missing::missing_with(&s.catalog, |_| {
checked += 1;
false
});
assert_eq!((checked, lost.len()), (4, 4), "only the non-deleted library files are checked");
assert_eq!(crate::cmd::missing::candidates(&s.catalog).len(), 4, "the sidebar count checks the same files");
assert_eq!(s.execute("library.missing", &json!({})).unwrap().as_array().map(Vec::len), Some(4));
s.execute("library.source", &json!({"kind": "missing"})).unwrap();
assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts");
}

View File

@ -21,7 +21,7 @@ fn import(s: &mut Session, paths: Vec<String>) -> Vec<u64> {
fn hdr_merge_command_creates_and_imports_a_dng() {
let dir = temp_dir("hdr");
let mut paths = Vec::new();
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).into_iter().enumerate() {
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() {
let p = dir.join(format!("IMG_{i}.dng"));
std::fs::write(&p, b).unwrap();
paths.push(p.to_string_lossy().to_string());
@ -71,7 +71,7 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
fn panorama_command_with_auto_crop() {
let dir = temp_dir("pano");
let mut paths = Vec::new();
for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).into_iter().enumerate() {
for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).unwrap().into_iter().enumerate() {
let img = v.to_srgb8();
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
let p = dir.join(format!("P_{i}.png"));

View File

@ -446,6 +446,58 @@ fn dng_export_compression_choices() {
assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}");
}
fn write_jpeg(path: &Path, capture: Option<&str>) {
let (w, h) = (32usize, 24usize);
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 7) as u8, (i / w * 9) as u8, 90, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
let exif = capture.map(|c| {
lightcraft_meta::write_exif(&lightcraft_meta::Metadata { capture_time: lightcraft_meta::DateTime::parse_iso(c), ..Default::default() })
});
let meta = lightcraft_codecs::EncodeMeta { exif: exif.as_deref(), ..Default::default() };
let bytes = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap();
std::fs::write(path, bytes).unwrap();
}
const DATED_SIDECAR: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:exif="http://ns.adobe.com/exif/1.0/"
exif:DateTimeOriginal="2026-01-14T05:58:48" />
</rdf:RDF>
</x:xmpmeta>"#;
#[test]
fn sidecar_capture_time_fills_in_when_the_file_has_none() {
let src = temp_dir("sidecar-date");
write_jpeg(&src.join("sample.jpg"), None);
std::fs::write(src.join("sample.jpg.xmp"), DATED_SIDECAR).unwrap();
// the embedded capture time wins over the sidecar's
write_jpeg(&src.join("dated.jpg"), Some("2020-05-06T07:08:09"));
std::fs::write(src.join("dated.jpg.xmp"), DATED_SIDECAR).unwrap();
let lib = temp_dir("sidecar-date-lib");
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
// copy: the sidecar's date also files the copy
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).unwrap();
assert_eq!(r["sidecars"], 2, "{r}");
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().clone();
let sample = by_name(&s, "sample.jpg");
assert_eq!(sample.captured.as_deref(), Some("2026-01-14T05:58:48"));
let lightcraft_catalog::Source::File { path } = &sample.source else { panic!("not a file") };
assert!(Path::new(path).starts_with(lib.join("Originals").join("2026").join("2026-01-14")), "{path}");
assert_eq!(by_name(&s, "dated.jpg").captured.as_deref(), Some("2020-05-06T07:08:09"));
// Read Metadata from File fills in a missing capture time too
let id = sample.id;
s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: None }).unwrap();
std::fs::copy(src.join("sample.jpg.xmp"), format!("{path}.xmp")).unwrap();
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2026-01-14T05:58:48"));
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
}
/// Copyright status, rights usage terms and copyright info URL: edited with `photo.setMeta` (one
/// undo step), copied / pasted, kept in metadata presets, written to the sidecar as XMP Rights
/// Management fields and read back on a fresh import.

View File

@ -3,6 +3,7 @@
//! Conventions: image coordinates are y-down. *Normalized* coordinates map the full (uncropped,
//! oriented) image to `0..1` on both axes, so tools and settings are resolution independent.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod crop;
mod homography;

View File

@ -86,6 +86,9 @@ pub struct Buf {
impl Buf {
pub fn raw(&self) -> &wgpu::Buffer {
// `buf` is `Some` from construction (`Gpu::buffer`/`upload`) until `Drop` takes it back
// for the pool, so a live `Buf` always has it.
#[allow(clippy::expect_used)]
&self.buf.as_ref().expect("live buffer").0
}
}
@ -442,7 +445,12 @@ impl Gpu {
/// Record kernel `name` with parameters `p` over buffers `bufs` (None = unused binding).
pub fn run(&self, enc: &mut wgpu::CommandEncoder, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) {
let k = self.kernels.get(name).unwrap_or_else(|| panic!("unknown kernel {name}"));
let Some(k) = self.kernels.get(name) else {
// a kernel name typo: fail this render over to the CPU instead of crashing
log::error!("gpu: unknown kernel {name}");
crate::mark_broken();
return;
};
assert_eq!(bufs.len(), k.nbuf, "{name}: binding count");
let params = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
@ -479,11 +487,21 @@ impl Gpu {
let _ = tx.send(r);
});
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
// A failed readback (device lost, out of memory) marks the GPU broken: the caller
// (`crate::render`) then discards this render's zeroed result and renders on the CPU.
let failed = |why: String| {
log::error!("gpu readback failed: {why}");
crate::mark_broken();
vec![<T as bytemuck::Zeroable>::zeroed(); len]
};
match rx.recv() {
Ok(Ok(())) => {}
other => panic!("gpu readback failed: {other:?}"),
other => return failed(format!("{other:?}")),
}
let data = slice.get_mapped_range().expect("mapped");
let data = match slice.get_mapped_range() {
Ok(d) => d,
Err(e) => return failed(format!("{e:?}")),
};
let out: Vec<T> = bytemuck::cast_slice(&data).to_vec();
drop(data);
staging.unmap();

View File

@ -11,6 +11,7 @@
//! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here
//! compiles to the CPU fallback on wasm32.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

View File

@ -86,15 +86,17 @@ pub fn finish_fields() -> Vec<(&'static str, usize)> {
.collect()
}
fn index(name: &str) -> usize {
/// Offset of field `name`; `None` (and a debug assertion) for a name not in [`FIELDS`].
fn index(name: &str) -> Option<usize> {
let mut i = 0;
for (n, len) in FIELDS {
if *n == name {
return i;
return Some(i);
}
i += len;
}
panic!("unknown finish field {name}")
debug_assert!(false, "unknown finish field {name}");
None
}
/// A parameter block being filled by name.
@ -105,20 +107,20 @@ impl Block {
Block(vec![0; FIELDS.iter().map(|f| f.1).sum()])
}
fn f(&mut self, name: &str, v: f32) {
let i = index(name);
self.0[i] = v.to_bits();
self.u(name, v.to_bits());
}
fn u(&mut self, name: &str, v: u32) {
let i = index(name);
self.0[i] = v;
if let Some(slot) = index(name).and_then(|i| self.0.get_mut(i)) {
*slot = v;
}
}
fn b(&mut self, name: &str, v: bool) {
self.u(name, v as u32);
}
fn fs(&mut self, name: &str, v: &[f32]) {
let i = index(name);
for (k, x) in v.iter().enumerate() {
self.0[i + k] = x.to_bits();
let Some(i) = index(name) else { return };
for (slot, x) in self.0.iter_mut().skip(i).zip(v) {
*slot = x.to_bits();
}
}
}

View File

@ -402,17 +402,19 @@ fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>
Some(d) => (Arc::new(resize(cx, &oriented, (sp.ow, sp.oh), d, 3, Filter::Mitchell)), d),
None => (oriented, (sp.ow, sp.oh)),
};
match &sp.mode {
SampleMode::Copy => base,
SampleMode::Affine(xf) => {
let out = cx.gpu.buffer(w * h * 3);
let mut p = vec![bw as u32, bh as u32, w as u32, h as u32];
p.extend(affine_bits(xf));
cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
Arc::new(out)
}
SampleMode::Warp(o2t) => {
let wp = fr.warp.as_ref().expect("warp mode has a warp");
let mut affine = |xf: &lightcraft_geom::Affine| {
let out = cx.gpu.buffer(w * h * 3);
let mut p = vec![bw as u32, bh as u32, w as u32, h as u32];
p.extend(affine_bits(xf));
cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
Arc::new(out)
};
match (&sp.mode, fr.warp.as_ref()) {
(SampleMode::Copy, _) => base,
(SampleMode::Affine(xf), _) => affine(xf),
// `sample_plan` plans a warp only when there is one; without it the same mapping is affine
(SampleMode::Warp(o2t), None) => affine(&(lightcraft_geom::Affine::scale(sp.sx, sp.sy) * *o2t)),
(SampleMode::Warp(o2t), Some(wp)) => {
let out = cx.gpu.buffer(w * h * 3);
let p = warp_params(wp, (bw, bh), (w, h), o2t, sp.sx, sp.sy);
cx.run("sample_warp", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));

View File

@ -13,6 +13,7 @@
//!
//! Entry points: [`Server::serve`] (stdio loop) and [`Server::handle_line`] (one message).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod backend;
mod base64;

View File

@ -72,6 +72,26 @@ fn tools_list_has_helpers_and_every_command() {
assert!(r["result"]["tools"].as_array().unwrap().iter().all(|t| !t["name"].as_str().unwrap().starts_with("cmd_")));
}
/// The `import` helper passes the copy options through: a folder template files the copy.
#[test]
fn import_tool_copies_with_a_folder_template() {
let base = std::env::temp_dir().join(format!("lc-mcp-import-tpl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (src, dest) = (base.join("card"), base.join("out"));
std::fs::create_dir_all(&src).unwrap();
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[20, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join("a.png"), png).unwrap();
let mut b = Headless::demo();
b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let args =
json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "{date:%Y}/{date:%Y%m%d}"});
let r = call_tool(&mut b, "import", &args);
assert!(!r.is_error, "{r:?}");
assert!(dest.join("2026").join("20260114").join("a.png").is_file());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn command_tools_run_commands() {
let mut b = Headless::demo();

View File

@ -96,7 +96,14 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"import",
"Import photos",
"Add image/raw files to the library. Folders are scanned recursively for photos; relative paths resolve against the server's working directory. The first imported photo becomes active. Returns the new photo ids.",
json!({"paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"}, "album": {"type": "integer", "description": "Also add to this album id"}}),
json!({
"paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"},
"album": {"type": "integer", "description": "Also add to this album id"},
"mode": {"type": "string", "enum": ["add", "copy"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/)"},
"destination": {"type": "string", "description": "Copy: destination folder"},
"organize": {"type": "string", "description": "Copy: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"},
"rename": {"type": "string", "description": "Copy: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"}
}),
&["paths"],
),
tool(
@ -454,8 +461,10 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
return ToolResult::error(format!("no photos found in {paths:?}"));
}
let mut p = json!({"paths": files});
if let Some(a) = args.get("album") {
p["album"] = a.clone();
for k in ["album", "mode", "destination", "organize", "rename"] {
if let Some(v) = args.get(k).filter(|v| !v.is_null()) {
p[k] = v.clone();
}
}
ToolResult::from(exec(b, "library.import", p).map(|mut r| {
if let Some(o) = r.as_object_mut() {

View File

@ -97,7 +97,8 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz
}
}
let Some(most) = fits.iter().map(|(_, f)| f.inliers.len()).max() else { return Alignment::identity() };
let (name, f) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97).expect("the best fit qualifies");
// the fit with `most` inliers always qualifies
let Some((name, f)) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97) else { return Alignment::identity() };
Alignment { h: scale_homography(&f.h, scale), model: name, inliers: f.inliers.len(), rms: f.rms / scale }
}

View File

@ -54,7 +54,7 @@ impl Features {
pub fn detect(img: &Plane, max: usize) -> Features {
let mut levels = vec![img.clone()];
while levels.len() < 4 {
let l = levels.last().expect("non-empty");
let Some(l) = levels.last() else { break };
if l.width.min(l.height) < 96 {
break;
}

View File

@ -11,6 +11,7 @@
//! Every algorithm here is our own implementation from published papers (cited in each module);
//! no code from GPL projects was consulted.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod align;
pub mod features;

View File

@ -106,14 +106,14 @@ pub fn push_pull(vals: &mut Buf, wgt: &[f32]) {
}
pm.data[i * (c + 1) + c] = a;
}
let mut levels = vec![pm];
while levels.last().is_some_and(|l| l.w > 1 || l.h > 1) {
let d = down(levels.last().expect("level"));
levels.push(d);
// the finer levels, and the coarsest (1 × 1) one
let (mut levels, mut l) = (Vec::new(), pm);
while l.w > 1 || l.h > 1 {
let d = down(&l);
levels.push(std::mem::replace(&mut l, d));
}
// normalise the coarsest, then fill each finer level's holes from the coarser one
let mut filled = {
let l = levels.last().expect("level");
let mut f = Buf::new(l.w, l.h, c);
for i in 0..l.w * l.h {
let a = l.data[i * (c + 1) + c];
@ -123,8 +123,7 @@ pub fn push_pull(vals: &mut Buf, wgt: &[f32]) {
}
f
};
for li in (0..levels.len() - 1).rev() {
let l = &levels[li];
for l in levels.iter().rev() {
let coarse = up(&filled, l.w, l.h);
let mut f = Buf::new(l.w, l.h, c);
for i in 0..l.w * l.h {
@ -235,7 +234,8 @@ impl Blender {
}
res = Some(b);
}
res.expect("level 0")
// `0..=levels` always yields level 0
res.unwrap_or_else(|| Buf::new(self.w, self.h, 3))
}
}

View File

@ -191,7 +191,7 @@ pub fn initial_cameras(ids: &[usize], sizes: &[(usize, usize)], pairs: &[PairMat
let s = d.signum() * d.abs().cbrt();
let rel = rel.map(|r| r.map(|v| v / s));
let rel = linalg::orthonormalize(&rel);
let rk = cams[known].expect("known").r;
let Some(rk) = cams[known].map(|c| c.r) else { continue };
cams[new] = Some(Camera { f: f0, r: linalg::mul3(&rel, &rk), cx: c(new).0, cy: c(new).1 });
grew = true;
}
@ -277,7 +277,9 @@ pub fn bundle_adjust(cams: &[Option<Camera>], pairs: &[PairMatch], anchor: usize
let cost = |cs: &[Option<Camera>]| -> (f64, f64) {
let (mut c, mut sq) = (0.0, 0.0);
for o in &obs {
let r = residual(cs[o.i].as_ref().expect("cam"), cs[o.j].as_ref().expect("cam"), o.a, o.b);
// every observation joins two posed cameras (see `obs` above)
let (Some(ci), Some(cj)) = (cs[o.i].as_ref(), cs[o.j].as_ref()) else { continue };
let r = residual(ci, cj, o.a, o.b);
for v in r {
let a = v.abs();
sq += v * v;
@ -298,11 +300,12 @@ pub fn bundle_adjust(cams: &[Option<Camera>], pairs: &[PairMatch], anchor: usize
.fold(
|| (vec![0.0f64; np * np], vec![0.0f64; np]),
|(mut jtj, mut jtr), o| {
let (ci, cj) = (cur[o.i].as_ref().expect("cam"), cur[o.j].as_ref().expect("cam"));
let (Some(ci), Some(cj)) = (cur[o.i].as_ref(), cur[o.j].as_ref()) else { return (jtj, jtr) };
let r = residual(ci, cj, o.a, o.b);
let mut cols: Vec<(usize, [f64; 4])> = Vec::with_capacity(8);
for (cam, which) in [(o.i, 0), (o.j, 1)] {
let base = 4 * idx[cam].expect("idx");
let Some(i) = idx[cam] else { continue };
let base = 4 * i;
for k in 0..4 {
if k < 3 && cam == anchor {
continue;

View File

@ -223,7 +223,7 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re
Projection::Auto => choose_projection(&frames, &cams),
p => p,
};
let fmed = median(used.iter().map(|&i| cams[i].expect("used").f).collect());
let fmed = median(used.iter().filter_map(|&i| cams[i].map(|c| c.f)).collect());
let Some((bx0, by0, bx1, by1)) = bounds(&frames, &cams, projection, fmed) else {
return Err(MergeError::NoOverlap(format!("the field of view ({:.0}°) is too wide for a perspective projection", fov.0)));
};

View File

@ -8,9 +8,13 @@ use lightcraft_raster::Rgb32f;
use crate::frame::FrameColor;
use crate::linalg::{self, M3};
use crate::output::{DngSamples, write_linear_dng};
use crate::{MergeError, Result};
fn scene(kind: lightcraft_scenes::Kind) -> lightcraft_scenes::Scene {
lightcraft_scenes::demo_library().into_iter().find(|s| s.kind == kind).expect("scene kind in the demo library")
fn scene(kind: lightcraft_scenes::Kind) -> Result<lightcraft_scenes::Scene> {
lightcraft_scenes::demo_library()
.into_iter()
.find(|s| s.kind == kind)
.ok_or_else(|| MergeError::Mismatch(format!("no {kind:?} scene in the demo library")))
}
fn srgb_color() -> FrameColor {
@ -19,12 +23,12 @@ fn srgb_color() -> FrameColor {
/// Exposure brackets (`evs`, e.g. `[-2, 0, 2]`) of a sunset scene as 16-bit linear DNGs, each
/// shifted by a few pixels (handheld) with EXIF exposure times; `w × h` pixels.
pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Vec<Vec<u8>> {
pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Result<Vec<Vec<u8>>> {
let m = 12usize;
let truth = scene(lightcraft_scenes::Kind::OceanSunset).render(w + 2 * m, h + 2 * m);
let truth = scene(lightcraft_scenes::Kind::OceanSunset)?.render(w + 2 * m, h + 2 * m);
let mut l: Vec<f32> = truth.data.iter().map(|p| p[1]).collect();
l.sort_by(|a, b| a.total_cmp(b));
let s = 0.18 / l[l.len() / 2].max(1e-6);
let s = 0.18 / l.get(l.len() / 2).copied().unwrap_or(0.0).max(1e-6);
let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32();
evs.iter()
.enumerate()
@ -44,20 +48,21 @@ pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Vec<Vec<u8>> {
iso: Some(100),
..Default::default()
};
write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16).expect("encode")
write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16)
})
.collect()
}
/// `yaws.len()` overlapping views (degrees of yaw, small pitch/roll wobble) of a canyon scene used
/// as a 200° × 70° spherical environment, `w × h` pixels with focal length `f`, as linear sRGB.
pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Vec<Rgb32f> {
let env = scene(lightcraft_scenes::Kind::Canyon).render(2400, 840);
pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Result<Vec<Rgb32f>> {
let env = scene(lightcraft_scenes::Kind::Canyon)?.render(2400, 840);
let mut l: Vec<f32> = env.data.iter().map(|p| p[1]).collect();
l.sort_by(|a, b| a.total_cmp(b));
let s = 0.25 / l[l.len() / 2].max(1e-6);
let s = 0.25 / l.get(l.len() / 2).copied().unwrap_or(0.0).max(1e-6);
let (theta, phi) = (200.0f64, 70.0f64);
yaws.iter()
Ok(yaws
.iter()
.enumerate()
.map(|(i, yaw)| {
let wob = (i as f64 * 1.3).sin();
@ -77,5 +82,5 @@ pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Vec<Rgb32f> {
env.sample_bilinear(px as f32, py as f32).map(|v| (v * s).min(1.0))
})
})
.collect()
.collect())
}

View File

@ -211,19 +211,22 @@ impl Frame {
Some((nw, nh)) => std::borrow::Cow::Owned(resize(o, nw, nh, Filter::Mitchell)),
None => std::borrow::Cow::Borrowed(o),
};
match plan.mode {
SampleMode::Copy => base.into_owned(),
SampleMode::Warp(o2t) => sample_warped(&base, plan.sx, plan.sy, self.warp.as_ref().expect("warp"), o2t, w, h),
SampleMode::Affine(xf) => {
let mut out = Rgb32f::new(w, h);
par_rows(&mut out.data, w, |y, row| {
for (x, px) in row.iter_mut().enumerate() {
let p = xf.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
*px = base.sample_bilinear(p.x as f32, p.y as f32);
}
});
out
}
let affine = |xf: Affine| {
let mut out = Rgb32f::new(w, h);
par_rows(&mut out.data, w, |y, row| {
for (x, px) in row.iter_mut().enumerate() {
let p = xf.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
*px = base.sample_bilinear(p.x as f32, p.y as f32);
}
});
out
};
match (plan.mode, self.warp.as_ref()) {
(SampleMode::Copy, _) => base.into_owned(),
(SampleMode::Warp(o2t), Some(warp)) => sample_warped(&base, plan.sx, plan.sy, warp, o2t, w, h),
// `sample_plan` plans a warp only when there is one; without it the same mapping is affine
(SampleMode::Warp(o2t), None) => affine(Affine::scale(plan.sx, plan.sy) * o2t),
(SampleMode::Affine(xf), _) => affine(xf),
}
}

View File

@ -21,6 +21,7 @@
//! [`render_cached`] each stage's output is reused while its inputs are unchanged ([`StageCache`]):
//! dragging a tone, colour or exposure slider re-runs only the per-pixel stage.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod auto;
pub mod colorops;

View File

@ -217,12 +217,12 @@ impl DeepImage {
let trc = self.space.trc();
match &self.samples {
DeepSamples::U16(v) => {
for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) {
for (p, c) in out.data.iter_mut().zip(v.as_chunks::<3>().0) {
*p = [c[0], c[1], c[2]].map(|x| ((x as u32 * 255 + 32767) / 65535) as u8).into_rgba();
}
}
DeepSamples::F32(v) => {
for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) {
for (p, c) in out.data.iter_mut().zip(v.as_chunks::<3>().0) {
*p = [c[0], c[1], c[2]].map(|x| (trc.encode(x) * 255.0 + 0.5) as u8).into_rgba();
}
}

View File

@ -173,6 +173,6 @@ pub fn decode_jpeg(bytes: &[u8]) -> Option<Rgba8> {
if px.len() < w * h * 3 {
return None;
}
let data = px.chunks_exact(3).take(w * h).map(|c| [c[0], c[1], c[2], 255]).collect();
let data = px.as_chunks::<3>().0.iter().take(w * h).map(|c| [c[0], c[1], c[2], 255]).collect();
Some(Rgba8 { width: w, height: h, data })
}

View File

@ -10,6 +10,7 @@
//!
//! No UI dependencies (L3): the egui frontend, the CLI and the MCP server share it.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod disk;
pub mod hash;

View File

@ -6,6 +6,7 @@
//! - Blur ([`blur`]): separable Gaussian via repeated box filters (O(1) per pixel in the radius).
//! - [`Histogram`] of display-encoded RGB + luminance.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod blur;
pub mod histogram;
@ -243,7 +244,7 @@ impl Rgba8 {
if bytes.len() != width * height * 4 {
return None;
}
Some(Self { width, height, data: bytes.chunks_exact(4).map(|c| [c[0], c[1], c[2], c[3]]).collect() })
Some(Self { width, height, data: bytes.as_chunks::<4>().0.iter().map(|c| [c[0], c[1], c[2], c[3]]).collect() })
}
/// Linear Rec.709/sRGB primaries float image from 8-bit sRGB.
pub fn to_linear(&self) -> Rgb32f {

View File

@ -70,7 +70,7 @@ pub fn demosaic(n: &Normalized, method: Method) -> Rgb32f {
return Rgb32f::new(w, h);
}
match (&n.cfa, n.cpp) {
(_, 3) => Rgb32f { width: w, height: h, data: n.data.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect() },
(_, 3) => Rgb32f { width: w, height: h, data: n.data.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect() },
(_, cpp) if cpp != 1 => Rgb32f { width: w, height: h, data: n.data.chunks_exact(cpp).map(|c| [c[0], c[1], c[2]]).collect() },
(None, _) => Rgb32f { width: w, height: h, data: n.data.iter().map(|&v| [v; 3]).collect() },
(Some(cfa), _) => {

View File

@ -185,9 +185,8 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
}
}
match &raw.data {
RawData::F32(v) if matches!(opts.compression, DngCompression::Deflate { .. }) => {
let DngCompression::Deflate { tile, half } = opts.compression else { unreachable!() };
match (&raw.data, opts.compression) {
(RawData::F32(v), DngCompression::Deflate { tile, half }) => {
let tile = (tile.max(16) & !15) as usize;
let bp = if half { 2 } else { 4 };
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![(bp * 8) as u16; cpp]));
@ -222,7 +221,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
.collect();
ifd.set_image(ImageData::Tiles { tile_width: tile as u32, tile_height: tile as u32, tiles });
}
RawData::F32(v) => {
(RawData::F32(v), _) => {
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![32; cpp]));
ifd.set(t::SAMPLE_FORMAT, Value::Short(vec![3; cpp]));
ifd.set(t::COMPRESSION, Value::Short(vec![compression::NONE]));
@ -234,7 +233,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
}
ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![bytes] });
}
RawData::U16(v) => {
(RawData::U16(v), _) => {
let maxv = v.iter().copied().max().unwrap_or(0);
let bits = (16 - maxv.leading_zeros()).max(raw.bits.min(16)).max(2) as u8;
ifd.set(

View File

@ -18,6 +18,7 @@
//! (public specifications, tag-name documentation, black-box analysis of CC0 samples) and gaps. Non-DNG files carry no
//! colour matrix: [`color`] falls back to a documented neutral model. The decoders never panic on malformed input.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod binned;
pub mod color;
@ -266,6 +267,11 @@ impl Cfa {
.collect::<Option<_>>()?;
(p.len() == 4).then_some(Cfa { width: 2, height: 2, pattern: p })
}
/// A 2×2 Bayer layout named in code (`"RGGB"`, `"BGGR"`, `"GRBG"`, `"GBRG"`). Only for literal
/// names (tested below); anything else falls back to RGGB instead of failing.
pub(crate) fn bayer_static(s: &'static str) -> Cfa {
Cfa::bayer(s).unwrap_or(Cfa { width: 2, height: 2, pattern: vec![0, 1, 1, 2] })
}
/// The Fujifilm X-Trans 6×6 layout (as commonly documented), anchored at (0, 0).
pub fn xtrans() -> Cfa {
let rows = ["GGRGGB", "GGBGGR", "BRGRBG", "GGBGGR", "GGRGGB", "RBGBRG"];
@ -575,6 +581,9 @@ mod tests {
assert_eq!(c.shifted(1, 0).name(), "GRBG");
assert_eq!(c.shifted(1, 1).name(), "BGGR");
assert!(Cfa::bayer("RGGX").is_none());
for name in ["RGGB", "BGGR", "GRBG", "GBRG"] {
assert_eq!(Cfa::bayer_static(name).name(), name);
}
let x = Cfa::xtrans();
assert!(!x.is_bayer());
let greens = x.pattern.iter().filter(|&&p| p == 1).count();

View File

@ -359,7 +359,11 @@ pub fn decode(d: &[u8], max_samples: usize) -> Result<Frame, RawError> {
if (entropy.len() as u64 + 64) * 8 < total as u64 {
return Err(err("entropy data too short for frame"));
}
let tables: Vec<&Huffman> = h.table_for.iter().map(|&t| h.tables[t].as_ref().expect("checked")).collect();
let tables: Vec<&Huffman> = h
.table_for
.iter()
.map(|&t| h.tables.get(t).and_then(Option::as_ref).ok_or_else(|| err("scan uses an undefined Huffman table")))
.collect::<Result<_, RawError>>()?;
let mut out = vec![0u16; total];
let mut br = BitReader::new(entropy);
let init = 1i32 << (h.precision - h.pt - 1);

View File

@ -511,7 +511,7 @@ pub fn apply_list3(list: &[Opcode], img: &mut Rgb32f) {
other => {
let mut flat: Vec<f32> = img.data.iter().flat_map(|p| *p).collect();
apply_list(std::slice::from_ref(other), &mut flat, w, h, 3, None, 1.0);
for (d, c) in img.data.iter_mut().zip(flat.chunks_exact(3)) {
for (d, c) in img.data.iter_mut().zip(flat.as_chunks::<3>().0) {
*d = [c[0], c[1], c[2]];
}
}

View File

@ -41,7 +41,7 @@ pub fn unpack_lsb(src: &[u8], bits: u32, out: &mut [u16]) {
}
pub fn read_u16s(src: &[u8], order: ByteOrder, out: &mut [u16]) {
for (o, c) in out.iter_mut().zip(src.chunks_exact(2)) {
for (o, c) in out.iter_mut().zip(src.as_chunks::<2>().0) {
*o = order.u16([c[0], c[1]]);
}
}

View File

@ -60,7 +60,8 @@ pub(crate) fn decode_row(row: &[u8], out: &mut [u16]) {
while x0 + 32 <= w && x0 + 32 <= row.len() {
for half in 0..2 {
let off = x0 + half * 16;
let block = u128::from_le_bytes(row[off..off + 16].try_into().expect("16 bytes"));
let Some(bytes) = row.get(off..off + 16).and_then(|b| <[u8; 16]>::try_from(b).ok()) else { return };
let block = u128::from_le_bytes(bytes);
let max = (block & 0x7ff) as u16;
let min = ((block >> 11) & 0x7ff) as u16;
let imax = ((block >> 22) & 0xf) as usize;
@ -217,7 +218,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) {
(Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() },
_ => Cfa::bayer("RGGB").expect("static"),
_ => Cfa::bayer_static("RGGB"),
};
let default_black = if out_bits >= 14 { 512.0 } else { 128.0 };
let black = match raw.f64s(BLACK_LEVEL).as_deref() {

View File

@ -117,7 +117,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
}
}
let wb = mn.as_ref().and_then(|m| m.ifd.u64s(COLOR_BALANCE)).and_then(|v| wb_from_color_balance(&v));
let cfa = Cfa::bayer("RGGB").expect("static");
let cfa = Cfa::bayer_static("RGGB");
let black = if active.x >= 8 {
black_from_columns(&data, width, 2..active.x - 2, active.y..active.y + active.height, active)
} else {

View File

@ -84,7 +84,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
.map(|v| [v[0] as f32, 1.0, v[1] as f32]);
let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) {
(Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() },
_ => Cfa::bayer("RGGB").expect("static"),
_ => Cfa::bayer_static("RGGB"),
};
let white = white_from_data(samples, bits);
let active_w = trailing_masked_columns(samples, w, h, white);

View File

@ -79,7 +79,7 @@ pub(crate) fn cfa_from_data(d: &[u16], w: usize, a: Rect) -> Cfa {
anti += (p(1, 0) - p(0, 1)).unsigned_abs();
}
}
Cfa::bayer(if main < anti { "GRBG" } else { "RGGB" }).expect("static")
Cfa::bayer_static(if main < anti { "GRBG" } else { "RGGB" })
}
pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {

View File

@ -232,7 +232,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
};
let cfa = match (ifd0.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), ifd0.bytes(t::CFA_PATTERN_EP)) {
(Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() },
_ => Cfa::bayer("BGGR").expect("static"),
_ => Cfa::bayer_static("BGGR"),
};
let black = match mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_POINT)).as_deref() {
Some(v @ [_, _, _, _]) => {

View File

@ -91,14 +91,14 @@ fn raw_ifd(raw: &[u8]) -> Result<Ifd> {
fn cfa(h: &Header) -> Cfa {
match record(h, XTRANS_LAYOUT) {
Some(l) if l.len() == 36 && l.iter().all(|&c| c <= 2) => Cfa { width: 6, height: 6, pattern: l.iter().rev().copied().collect() },
_ => Cfa::bayer("RGGB").expect("static"),
_ => Cfa::bayer_static("RGGB"),
}
}
/// Unpack one row of `bits`-bit samples in Fujifilm's packing.
pub(crate) fn unpack_row(src: &[u8], bits: u32, out: &mut [u16]) {
match bits {
16 => out.iter_mut().zip(src.chunks_exact(2)).for_each(|(o, c)| *o = u16::from_le_bytes([c[0], c[1]])),
16 => out.iter_mut().zip(src.as_chunks::<2>().0).for_each(|(o, c)| *o = u16::from_le_bytes([c[0], c[1]])),
12 => unpack_lsb(src, 12, out),
_ => {
let swapped: Vec<u8> = src

View File

@ -74,11 +74,11 @@ pub(crate) fn unpack(stream: &[u8], bits: u32, out: &mut [u16]) -> Result<()> {
let first_block = ci * 1024;
for (bi, px) in o.chunks_mut(per).enumerate() {
let at = (first_block + bi) * 16;
let Some(b) = stream.get(at..at + 16) else {
let Some(b) = stream.get(at..at + 16).and_then(|b| <[u8; 16]>::try_from(b).ok()) else {
px.fill(0);
continue;
};
let word = u128::from_le_bytes(b.try_into().expect("16 bytes"));
let word = u128::from_le_bytes(b);
for (i, p) in px.iter_mut().enumerate() {
*p = ((word >> (start + i as u32 * bits)) & mask) as u16;
}
@ -94,7 +94,7 @@ fn cfa(code: u16) -> Cfa {
4 => "BGGR",
_ => "RGGB",
};
Cfa::bayer(s).expect("static")
Cfa::bayer_static(s)
}
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {

View File

@ -0,0 +1,220 @@
//! Malformed vendor raw files (NEF, ARW, PEF, ORF, RW2, CR2, RAF) must decode to an error, never panic.
use lightcraft_tiff::tags as t;
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
use proptest::prelude::*;
const W: u32 = 32;
const H: u32 = 8;
/// A CFA TIFF the way most vendors lay it out: IFD0 (make) + a SubIFD holding the raw strip.
fn cfa_tiff(make: &str, compression: u16, bits: u16, order: ByteOrder) -> Vec<u8> {
let px = (W * H) as usize;
// Sony's ARW2 (32767) packs a sample per byte
let len = if compression == 32767 { px } else { px * 2 };
let strip: Vec<u8> = (0..len).map(|i| (i * 37 % 251) as u8).collect();
let mut raw = IfdBuilder::new();
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![W]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![H]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits]));
raw.set(t::COMPRESSION, Value::Short(vec![compression]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
raw.set_image(ImageData::Strips { rows_per_strip: H, strips: vec![strip] });
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii(make.into()));
ifd0.set(t::MODEL, Value::Ascii("TEST".into()));
ifd0.add_sub_ifd(raw);
TiffWriter::new(order, false).write(&[ifd0]).unwrap()
}
/// An Olympus ORF (TIFF with an `IIRO` magic) or Panasonic RW2 (`IIU\0`), raw image in IFD0.
fn magic_tiff(magic: &[u8; 4], make: &str) -> Vec<u8> {
let px = (W * H) as usize;
let mut ifd = IfdBuilder::new();
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![W]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![H]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![1]));
ifd.set(t::MAKE, Value::Ascii(make.into()));
ifd.set_image(ImageData::Strips { rows_per_strip: H, strips: vec![(0..px * 2).map(|i| (i * 13) as u8).collect()] });
let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap();
b[..4].copy_from_slice(magic);
b
}
/// A Panasonic RW2: sensor size, bit depth and raw format in IFD0, 12-bit packed blocks.
fn rw2() -> Vec<u8> {
let mut ifd = IfdBuilder::new();
ifd.set(0x0002, Value::Short(vec![W as u16]));
ifd.set(0x0003, Value::Short(vec![H as u16]));
ifd.set(0x0004, Value::Short(vec![1]));
ifd.set(0x0005, Value::Short(vec![2]));
ifd.set(0x0006, Value::Short(vec![H as u16 - 1]));
ifd.set(0x0007, Value::Short(vec![W as u16 - 2]));
ifd.set(0x000a, Value::Short(vec![12]));
ifd.set(0x002d, Value::Short(vec![5]));
ifd.set(0x001c, Value::Short(vec![64]));
ifd.set(0x001d, Value::Short(vec![64]));
ifd.set(0x001e, Value::Short(vec![64]));
ifd.set(t::MAKE, Value::Ascii("Panasonic".into()));
let need = (W * H).div_ceil(10) as usize * 16;
ifd.set_image(ImageData::Strips { rows_per_strip: H, strips: vec![(0..need).map(|i| (i * 29) as u8).collect()] });
let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap();
b[..4].copy_from_slice(b"IIU\0");
b
}
/// A Pentax PEF: the raw image in IFD0, wide enough for the dark-border trim.
fn pef(compression: u16) -> Vec<u8> {
let (w, h) = (264u32, 4u32);
let mut ifd = IfdBuilder::new();
ifd.set(t::MAKE, Value::Ascii("PENTAX".into()));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
ifd.set(t::COMPRESSION, Value::Short(vec![compression]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
let strip: Vec<u8> = (0..(w * h * 2) as usize).map(|i| if i % 528 < 8 { 0 } else { (i * 7 % 200) as u8 }).collect();
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] });
TiffWriter::new(ByteOrder::Big, false).write(&[ifd]).unwrap()
}
/// A Canon CR2: three placeholder IFDs and IFD3 with a 2-component lossless JPEG frame.
fn cr2() -> Vec<u8> {
let img: Vec<u16> = (0..(W * H) as usize).map(|i| 1024 + (i * 7919 % 12000) as u16).collect();
let enc = lightcraft_raw::ljpeg::encode(&img, W as usize / 2, H as usize, 2, 14, 1, 0);
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii("Canon".into()));
let mut ifd3 = IfdBuilder::new();
ifd3.set(t::COMPRESSION, Value::Short(vec![6]));
ifd3.set(0xc640, Value::Short(vec![1, 16, 16]));
ifd3.set_image(ImageData::Strips { rows_per_strip: H, strips: vec![enc] });
let blank = || IfdBuilder::new().with(1, Value::Short(vec![0]));
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0, blank(), blank(), ifd3]).unwrap()
}
/// A Fujifilm RAF: header, records, raw block (LE TIFF whose IFD0 points to a 0xf000 raw IFD).
fn raf_file(w: u32, h: u32, bits: u32, strip: Vec<u8>, layout: Option<[u8; 36]>, jpeg: &[u8]) -> Vec<u8> {
let mut block = b"II*\0\x08\0\0\0".to_vec();
let sub_off: u32 = 8 + 2 + 12 + 4;
block.extend_from_slice(&1u16.to_le_bytes());
block.extend_from_slice(&[0x00, 0xf0, 13, 0, 1, 0, 0, 0]);
block.extend_from_slice(&sub_off.to_le_bytes());
block.extend_from_slice(&0u32.to_le_bytes());
let n = 7u32;
let wb_off = sub_off + 2 + 12 * n + 4;
let strip_off = wb_off + 12;
let entries: [(u16, u32, u32); 7] = [
(0xf001, 1, w),
(0xf002, 1, h),
(0xf003, 1, bits),
(0xf007, 1, strip_off),
(0xf008, 1, strip.len() as u32),
(0xf00a, 1, 64),
(0xf00e, 3, wb_off),
];
block.extend_from_slice(&(n as u16).to_le_bytes());
for (tag, count, v) in entries {
block.extend_from_slice(&tag.to_le_bytes());
block.extend_from_slice(&4u16.to_le_bytes());
block.extend_from_slice(&count.to_le_bytes());
block.extend_from_slice(&v.to_le_bytes());
}
block.extend_from_slice(&0u32.to_le_bytes());
for v in [300u32, 600, 450] {
block.extend_from_slice(&v.to_le_bytes());
}
block.extend_from_slice(&strip);
let mut recs: Vec<u8> = Vec::new();
let mut nrec = 0u32;
let mut add = |tag: u16, d: &[u8]| {
recs.extend_from_slice(&tag.to_be_bytes());
recs.extend_from_slice(&(d.len() as u16).to_be_bytes());
recs.extend_from_slice(d);
nrec += 1;
};
add(0x0100, &[(h >> 8) as u8, h as u8, (w >> 8) as u8, w as u8]);
add(0x0110, &[0, 2, 0, 4]);
add(0x0111, &[0, (h - 2) as u8, 0, (w - 4) as u8]);
if let Some(l) = layout {
add(0x0131, &l);
}
let mut dir = nrec.to_be_bytes().to_vec();
dir.extend_from_slice(&recs);
let mut out = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec();
out.resize(108, 0);
let jpeg_off = 160u32;
let dir_off = jpeg_off + jpeg.len() as u32;
let raw_off = dir_off + dir.len() as u32;
for (i, v) in [jpeg_off, jpeg.len() as u32, dir_off, dir.len() as u32, raw_off, block.len() as u32].iter().enumerate() {
out[84 + i * 4..88 + i * 4].copy_from_slice(&v.to_be_bytes());
}
out.resize(160, 0);
out.extend_from_slice(jpeg);
out.extend_from_slice(&dir);
out.extend_from_slice(&block);
out
}
fn raf() -> Vec<u8> {
let strip: Vec<u8> = (0..(W * H * 2) as usize).map(|i| (i * 11) as u8).collect();
let layout: [u8; 36] = std::array::from_fn(|i| (i % 3) as u8);
raf_file(W, H, 16, strip, Some(layout), b"\xff\xd8\xff\xd9")
}
fn samples() -> Vec<Vec<u8>> {
vec![
cfa_tiff("NIKON CORPORATION", 1, 12, ByteOrder::Big),
cfa_tiff("NIKON CORPORATION", 34713, 14, ByteOrder::Big),
cfa_tiff("SONY", 32767, 12, ByteOrder::Little),
cfa_tiff("SONY", 1, 14, ByteOrder::Little),
pef(1),
pef(65535),
cfa_tiff("SAMSUNG", 1, 12, ByteOrder::Little),
cfa_tiff("ACME", 1, 16, ByteOrder::Little),
magic_tiff(b"IIRO", "OLYMPUS IMAGING CORP."),
rw2(),
cr2(),
raf(),
]
}
fn exercise(bytes: &[u8]) {
let _ = lightcraft_raw::probe(bytes);
let _ = lightcraft_raw::probe_info(bytes);
let _ = lightcraft_raw::embedded_preview(bytes);
if let Ok(img) = lightcraft_raw::decode(bytes)
&& img.width * img.height <= 1 << 16
{
let _ = img.develop(lightcraft_raw::Method::Ahd);
}
}
#[test]
fn vendor_samples_truncated_at_every_length() {
for s in samples() {
exercise(&s);
for n in (0..s.len()).step_by(3) {
exercise(&s[..n]);
}
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 2000, .. ProptestConfig::default() })]
#[test]
fn mutated_vendor_files_never_panic(kind in 0usize..12, flips in proptest::collection::vec((any::<usize>(), any::<u8>()), 1..16), cut in any::<usize>()) {
let mut data = samples().swap_remove(kind);
let n = data.len();
for (i, v) in flips {
data[i % n] = v;
}
let keep = if cut % 3 == 0 { cut % n } else { n };
exercise(&data[..keep]);
}
}

View File

@ -5,6 +5,7 @@
//! develop pipeline's highlight recovery, tone mapping and colour tools have something real to do.
//! Every scene is deterministic for its seed. No external assets: zero licensing or privacy risk.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod noise;
mod paint;

View File

@ -17,6 +17,7 @@
//! Sources: TIFF 6.0 specification (Adobe, 1992), TIFF Technical Note / supplements, the BigTIFF
//! design notes (AWare Systems), Adobe DNG specification 1.7 (tag numbers), CIPA DC-008 (Exif 2.32).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod image;
pub mod makernote;

View File

@ -220,17 +220,19 @@ pub(crate) fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value {
Value::Ascii(String::from_utf8_lossy(&b[..end]).into_owned())
}
FieldType::SByte => Value::SByte(b.iter().map(|&x| x as i8).collect()),
FieldType::Short => Value::Short(b.chunks_exact(2).map(|c| o.u16(arr(c))).collect()),
FieldType::SShort => Value::SShort(b.chunks_exact(2).map(|c| o.u16(arr(c)) as i16).collect()),
FieldType::Long => Value::Long(b.chunks_exact(4).map(|c| o.u32(arr(c))).collect()),
FieldType::Ifd => Value::Ifd(b.chunks_exact(4).map(|c| o.u32(arr(c))).collect()),
FieldType::SLong => Value::SLong(b.chunks_exact(4).map(|c| o.u32(arr(c)) as i32).collect()),
FieldType::Float => Value::Float(b.chunks_exact(4).map(|c| f32::from_bits(o.u32(arr(c)))).collect()),
FieldType::Rational => Value::Rational(b.chunks_exact(8).map(|c| (o.u32(arr(&c[..4])), o.u32(arr(&c[4..])))).collect()),
FieldType::SRational => Value::SRational(b.chunks_exact(8).map(|c| (o.u32(arr(&c[..4])) as i32, o.u32(arr(&c[4..])) as i32)).collect()),
FieldType::Double => Value::Double(b.chunks_exact(8).map(|c| f64::from_bits(o.u64(arr(c)))).collect()),
FieldType::Long8 => Value::Long8(b.chunks_exact(8).map(|c| o.u64(arr(c))).collect()),
FieldType::Ifd8 => Value::Ifd8(b.chunks_exact(8).map(|c| o.u64(arr(c))).collect()),
FieldType::SLong8 => Value::SLong8(b.chunks_exact(8).map(|c| o.u64(arr(c)) as i64).collect()),
FieldType::Short => Value::Short(b.as_chunks::<2>().0.iter().map(|c| o.u16(arr(c))).collect()),
FieldType::SShort => Value::SShort(b.as_chunks::<2>().0.iter().map(|c| o.u16(arr(c)) as i16).collect()),
FieldType::Long => Value::Long(b.as_chunks::<4>().0.iter().map(|c| o.u32(arr(c))).collect()),
FieldType::Ifd => Value::Ifd(b.as_chunks::<4>().0.iter().map(|c| o.u32(arr(c))).collect()),
FieldType::SLong => Value::SLong(b.as_chunks::<4>().0.iter().map(|c| o.u32(arr(c)) as i32).collect()),
FieldType::Float => Value::Float(b.as_chunks::<4>().0.iter().map(|c| f32::from_bits(o.u32(arr(c)))).collect()),
FieldType::Rational => Value::Rational(b.as_chunks::<8>().0.iter().map(|c| (o.u32(arr(&c[..4])), o.u32(arr(&c[4..])))).collect()),
FieldType::SRational => {
Value::SRational(b.as_chunks::<8>().0.iter().map(|c| (o.u32(arr(&c[..4])) as i32, o.u32(arr(&c[4..])) as i32)).collect())
}
FieldType::Double => Value::Double(b.as_chunks::<8>().0.iter().map(|c| f64::from_bits(o.u64(arr(c)))).collect()),
FieldType::Long8 => Value::Long8(b.as_chunks::<8>().0.iter().map(|c| o.u64(arr(c))).collect()),
FieldType::Ifd8 => Value::Ifd8(b.as_chunks::<8>().0.iter().map(|c| o.u64(arr(c))).collect()),
FieldType::SLong8 => Value::SLong8(b.as_chunks::<8>().0.iter().map(|c| o.u64(arr(c)) as i64).collect()),
}
}

View File

@ -268,7 +268,14 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
}
}
"ui.dialog.confirm" => match app.ui.dialog.take() {
Some(d) => wrap(crate::panels::dialogs::confirm_dialog(app, &d)),
Some(d) => {
let r = crate::panels::dialogs::confirm_dialog(app, &d);
// the import review stays open on an error, as with its button
if r.is_err() && matches!(d, crate::state::Dialog::Import { .. }) {
app.ui.dialog = Some(d);
}
wrap(r)
}
None => err("no dialog open"),
},
"ui.dialog.cancel" => {

View File

@ -831,6 +831,54 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Local: a kept folder stays a root while its subfolders (and other locations) are
/// browsed — the subfolder is highlighted inside its tree, its siblings stay listed — and
/// the kept folders survive a save/load of the UI state (a restart).
#[test]
fn kept_local_root_stays_while_browsing_below_and_elsewhere() {
let mut h = demo([1300.0, 1400.0]);
let t = Duration::from_secs(10);
let base = std::env::temp_dir().join(format!("lc-ui-roots-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["Photos/2026/20260101", "Photos/2026/20260114", "Other"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let s = |p: std::path::PathBuf| p.to_string_lossy().to_string();
let (photos, day1, day2, other) =
(s(base.join("Photos")), s(base.join("Photos/2026/20260101")), s(base.join("Photos/2026/20260114")), s(base.join("Other")));
let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t);
let rects = |h: &mut Headless| -> std::collections::HashMap<String, f64> {
let w = h.request("ui.widgets", json!({"filter": "lc-ui-roots-"}), t);
w["result"].as_array().unwrap().iter().map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"][0].as_f64().unwrap_or(0.0))).collect()
};
h.request("ui.set", json!({"leftPanel": true}), t);
assert_eq!(exec(&mut h, "local.addRoot", json!({"path": photos}))["ok"], true);
assert_eq!(exec(&mut h, "local.addRoot", json!({"path": format!("{photos}/")}))["result"]["roots"].as_array().map(Vec::len), Some(1));
exec(&mut h, "library.browse", json!({"path": day1}));
h.settle(SETTLE);
h.step();
let r = rects(&mut h);
assert!(r.contains_key(&format!("source:local:{photos}")), "the kept root stays: {r:?}");
assert!(r.contains_key(&format!("source:local:{day2}")), "the sibling stays reachable: {r:?}");
let (root_x, child_x) = (r[&format!("folderToggle:{photos}")], r[&format!("folderToggle:{day1}")]);
assert!(child_x > root_x, "the browsed folder is inside the root's tree, not a root of its own ({child_x} vs {root_x})");
assert_eq!(h.request("ui.clickWidget", json!({"id": format!("source:local:{day2}")}), t)["ok"], true);
h.settle(SETTLE);
assert_eq!(h.app.session.browse.as_ref().map(|b| b.path.clone()), Some(day2.clone()), "the sibling is browsed");
// another location: the kept root stays listed
exec(&mut h, "library.browse", json!({"path": other}));
h.settle(SETTLE);
h.step();
let r = rects(&mut h);
assert!(r.contains_key(&format!("source:local:{photos}")) && r.contains_key(&format!("source:local:{other}")), "{r:?}");
// restart: the kept roots come back with the saved UI state
let saved = serde_json::to_string(&h.app.ui).unwrap();
let back: crate::state::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(back.local_roots, vec![photos.clone()]);
let _ = std::fs::remove_dir_all(&base);
h.settle(SETTLE);
}
/// Versions panel: resting on a version previews it in the loupe without changing the photo;
/// a click restores it.
#[test]
@ -898,7 +946,7 @@ mod tests {
assert!(h.quit_requested());
}
/// File → Add Folder… opens the import review for a folder (searched recursively).
/// File → Import from Folder… opens the import review for a folder (searched recursively).
#[test]
fn add_folder_opens_the_import_review() {
let mut h = demo([1200.0, 900.0]);
@ -920,6 +968,13 @@ mod tests {
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review: {:?}", h.app.ui.dialog) };
assert_eq!(opts.candidates.len(), 2, "both files, the subfolder's too");
assert!(!opts.copy, "a folder is added in place by default");
// the review names its source; scanning doesn't save a Local location
assert_eq!(opts.sources, vec![dir.to_string_lossy().to_string()]);
let name = dir.file_name().unwrap().to_string_lossy().to_string();
assert_eq!(crate::import::source_summary(&opts.sources), format!("Folder “{name}” (and its subfolders)"));
let r = h.request("ui.widgets", json!({}), t);
assert!(r.to_string().contains("label:importSource"), "source shown");
assert!(h.app.ui.local_roots.is_empty(), "no Local shortcut saved");
// a camera / card folder: copied into the library by default
h.app.ui.dialog = None;
let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": sub.to_string_lossy()}}), t);
@ -1113,7 +1168,7 @@ mod tests {
h.settle(SETTLE);
}
/// File → Add Photos… opens the import review: candidates with thumbnails, the duplicate is
/// File → Import Photos… opens the import review: candidates with thumbnails, the duplicate is
/// unchecked; unchecking a cell and confirming imports the rest in batches, into a new album
/// with keywords, as one undo step.
#[test]
@ -1215,6 +1270,105 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
/// Import review, Copy, Folders → Custom template…: the default `{date:%Y}/{date:%Y%m%d}`
/// files copies under `2026/20260114/`, the example line shows the full destination, and a
/// template that would climb out of the destination is refused.
#[test]
fn import_copy_into_a_custom_folder_template() {
let base = std::env::temp_dir().join(format!("lc-ui-import-tpl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (src, dest) = (base.join("card"), base.join("out"));
std::fs::create_dir_all(&src).unwrap();
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join("IMG_01.png"), png).unwrap();
let dest_s = dest.to_string_lossy().to_string();
let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() };
let mut session = lightcraft_engine::Session::with_demo().with_fs();
// undated files are filed by the import time
session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let mut app = LightcraftApp::new(session, services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1300.0, 1000.0], 1.0);
let t = Duration::from_secs(10);
let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
h.settle(SETTLE);
for id in ["button:importCopy", "button:importDest"] {
let r = h.request("ui.clickWidget", json!({"id": id}), t);
assert_eq!(r["ok"], true, "{id}: {r}");
}
if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog {
opts.organize = "custom".into();
opts.folder_template = "../{date:%Y}".into();
}
h.step();
// refused: the dialog stays open, nothing is imported
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_ne!(r["ok"], true, "{r}");
assert!(h.app.import.is_none());
let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("dialog closed") };
opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into();
let opts = opts.clone();
let sep = std::path::MAIN_SEPARATOR;
let example = crate::import::example_destination(&h.app, &opts).expect("example");
assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}IMG_01.png", dest.to_string_lossy()));
h.step();
let r = h.request("ui.widgets", json!({}), t);
assert!(r.to_string().contains("label:importExample"), "example shown");
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(dest.join("2026").join("20260114").join("IMG_01.png").is_file());
let _ = std::fs::remove_dir_all(&base);
}
/// Import review, Copy: Tags beside the Rename field lists the template tags; clicking one
/// inserts it at the text cursor (not appended, not replacing the template).
#[test]
fn import_rename_tags_insert_at_the_cursor() {
let dir = std::env::temp_dir().join(format!("lc-ui-import-tags-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(dir.join("IMG_0007.png"), png).unwrap();
let lib = dir.join("lib");
let mut session = lightcraft_engine::Session::with_demo().with_fs();
session.open_library(&lib, false).unwrap();
let mut app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() });
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1300.0, 1000.0], 1.0);
let t = Duration::from_secs(10);
let r =
h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.join("IMG_0007.png").to_string_lossy()]}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
h.settle(SETTLE);
let r = h.request("ui.clickWidget", json!({"id": "button:importCopy"}), t);
assert_eq!(r["ok"], true, "{r}");
if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog {
opts.rename = "Trip-_x".into();
}
// the cursor sits after "Trip-"
let id = egui::Id::new("import-rename");
let mut st = egui::text_edit::TextEditState::default();
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(5))));
st.store(&h.view.ctx, id);
let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTags"}), t);
assert_eq!(r["ok"], true, "{r}");
let seq3 = lightcraft_engine::rename::TOKENS.iter().position(|x| x.tag == "{seq:3}").unwrap();
let r = h.request("ui.clickWidget", json!({"id": format!("button:importRenameTag-{seq3}")}), t);
assert_eq!(r["ok"], true, "{r}");
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") };
assert_eq!(opts.rename, "Trip-{seq:3}_x", "inserted at the cursor");
// a second tag goes after the first (the cursor moved past it)
let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTag-0"}), t);
assert_eq!(r["ok"], true, "{r}");
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") };
assert_eq!(opts.rename, "Trip-{seq:3}{name}_x");
let _ = std::fs::remove_dir_all(&dir);
}
/// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through
/// the engine; the delete confirmation guards ⌫.
#[test]

View File

@ -1,6 +1,8 @@
//! The import review dialog (File → Add Photos…): the files found under the chosen files and
//! folders as a grid of thumbnails with checkboxes (duplicates marked and unchecked), the
//! destination (add in place / copy into the library's dated `Originals/` folders), an album
//! The import review dialog (File → Import Photos… / Import from Folder… / Import from Device):
//! the source it scanned (a scanned folder is *not* added to Local), the files found under it as
//! a grid of thumbnails with checkboxes (duplicates marked and unchecked), the destination (add in
//! place / copy into the library's `Originals/` or a chosen folder, filed by day, by month, into
//! one folder or by a custom folder template, optionally renamed), an album
//! (existing or new), a preset and keywords to apply. Importing runs in small batches, one per
//! frame, with a progress window; the whole import is one undo step.
@ -21,6 +23,8 @@ pub(crate) const BATCH: usize = 8;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ImportDialog {
/// The files / folders that were scanned (shown as the source).
pub sources: Vec<String>,
pub candidates: Vec<ImportCandidate>,
pub checked: Vec<bool>,
/// Copy into the library (else add in place).
@ -35,8 +39,10 @@ pub struct ImportDialog {
pub keywords: String,
/// Copy: destination folder ("" = the library's Originals/).
pub destination: String,
/// Copy: `date` (YYYY/YYYY-MM-DD), `month` or `flat`.
/// Copy: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or `custom` ([`ImportDialog::folder_template`]).
pub organize: String,
/// Copy, `custom`: the folder template, e.g. `{date:%Y}/{date:%Y%m%d}`.
pub folder_template: String,
/// Copy: file-name template for the copies ("" = keep the names).
pub rename: String,
/// Metadata preset name ("" = none).
@ -61,6 +67,22 @@ impl ImportDialog {
.map(|(c, _)| c.path.clone())
.collect()
}
/// The `organize` param of `library.import` (`None` = the default, by day); an unusable
/// custom folder template is an error.
pub fn organize_param(&self) -> Result<Option<String>, String> {
match self.organize.as_str() {
"" => Ok(None),
"custom" => {
let t = self.folder_template.trim();
if let Some(e) = lightcraft_engine::rename::folder_template_error(t) {
return Err(e);
}
// a plain folder name ("Imports") is a one-level template too
Ok(Some(if t.contains(['{', '/', '\\']) { t.to_string() } else { format!("{t}/") }))
}
o => Ok(Some(o.to_string())),
}
}
fn keywords(&self) -> Vec<String> {
self.keywords.split(',').map(str::trim).filter(|k| !k.is_empty()).map(str::to_string).collect()
}
@ -92,6 +114,8 @@ pub struct ScanTask {
pub copy: bool,
/// Browsing a folder (Local): the photos are read in place instead of opening the review.
browse: bool,
/// What is being scanned (the review's source).
sources: Vec<String>,
}
/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]).
@ -99,6 +123,7 @@ pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String
if app.scan.is_some() {
return Err("a scan is already running".into());
}
let sources = paths.clone();
let (input, paths) = ScanInput::new(&mut app.session, &paths);
let progress = std::sync::Arc::new(ScanProgress::default());
let (tx, rx) = std::sync::mpsc::channel();
@ -110,7 +135,7 @@ pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
job();
app.scan = Some(ScanTask { progress, rx, copy: false, browse: false });
app.scan = Some(ScanTask { progress, rx, copy: false, browse: false, sources });
Ok(json!({"scanning": true}))
}
@ -171,7 +196,7 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) ->
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
job();
app.scan = Some(ScanTask { progress, rx, copy: false, browse: true });
app.scan = Some(ScanTask { progress, rx, copy: false, browse: true, sources: Vec::new() });
app.renderer.forget_imports();
Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true}))
}
@ -189,7 +214,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
return;
}
};
let task = app.scan.take().expect("scan task");
let Some(task) = app.scan.take() else { return };
if task.progress.cancel.load(Ordering::Relaxed) {
return;
}
@ -212,6 +237,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
app.renderer.forget_imports();
let mut d = ImportDialog::new(out.candidates);
d.copy = task.copy;
d.sources = task.sources;
app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) });
}
@ -277,8 +303,8 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>
if !d.destination.trim().is_empty() {
params["destination"] = json!(d.destination.trim());
}
if !d.organize.is_empty() {
params["organize"] = json!(d.organize);
if let Some(o) = d.organize_param()? {
params["organize"] = json!(o);
}
if !d.rename.trim().is_empty() {
params["rename"] = json!(d.rename.trim());
@ -307,7 +333,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
p["renameStart"] = json!(1 + task.imported);
}
let r = app.session.execute("library.import", &p);
let task = app.import.as_mut().expect("import task");
let Some(task) = app.import.as_mut() else { return };
task.done += n;
match r {
Ok(v) => {
@ -328,7 +354,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
if !task.queue.is_empty() {
return;
}
let task = app.import.take().expect("import task");
let Some(task) = app.import.take() else { return };
let steps = app.session.undo.len().saturating_sub(task.undo0);
let label = format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" });
app.session.merge_undo(steps, &label);
@ -341,7 +367,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
if let Some(f) = task.first {
let _ = app.run("library.select", json!({"ids": [f]}));
}
let mut msg = format!("Added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" });
let mut msg = format!("Imported {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" });
if task.duplicates > 0 {
msg.push_str(&format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" }));
}
@ -407,7 +433,18 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
});
ui.add_space(4.0);
// options
field(ui, "Destination", |ui| {
if !d.sources.is_empty() {
field(ui, "Source", |ui| {
let r = ui.label(egui::RichText::new(source_summary(&d.sources)).color(t.text_label)).on_hover_text(d.sources.join("\n"));
register(ui.ctx(), "label:importSource", r.rect);
});
ui.label(
egui::RichText::new("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).")
.color(t.text_dim)
.small(),
);
}
field(ui, "Transfer", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if crate::widgets::text_button(ui, "importAdd", "Add in place", !d.copy).on_hover_text("Reference the files where they are").clicked() {
d.copy = false;
@ -418,6 +455,15 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
d.copy = true;
}
});
ui.label(
egui::RichText::new(if d.copy {
"Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are."
} else {
"Add in place: the library references the files where they are; nothing is copied or moved."
})
.color(t.text_dim)
.small(),
);
if d.copy {
field(ui, "Copy to", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
@ -434,40 +480,76 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
}
});
field(ui, "Folders", |ui| {
let opts = [("date", "By day (YYYY/YYYY-MM-DD)"), ("month", "By month (YYYY/YYYY-MM)"), ("flat", "Into one folder")];
let opts = [
("date", "By day (YYYY/YYYY-MM-DD)"),
("month", "By month (YYYY/YYYY-MM)"),
("flat", "Into one folder"),
("custom", "Custom template…"),
];
let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() };
let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1);
egui::ComboBox::from_id_salt("import-organize").selected_text(cur).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("import-organize").selected_text(cur).show_ui(ui, |ui| {
for (k, label) in opts {
if ui.selectable_label(key == k, label).clicked() {
let r = ui.selectable_label(key == k, label);
register(ui.ctx(), format!("button:importOrganize-{k}"), r.rect);
if r.clicked() {
d.organize = k.to_string();
if k == "custom" && d.folder_template.trim().is_empty() {
d.folder_template = DEFAULT_FOLDER_TEMPLATE.into();
}
}
}
});
register(ui.ctx(), "combo:importOrganize", combo.response.rect);
});
if d.organize == "custom" {
let folders_id = egui::Id::new("import-folder-template");
let tags_open = field(ui, "Template", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
let r = ui.add(egui::TextEdit::singleline(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w));
register(ui.ctx(), "field:importFolderTemplate", r.rect);
tag_toggle(ui, "importFolders")
});
if tags_open {
tag_help(ui, "importFolders", &mut d.folder_template, folders_id);
}
let t = Tokens::get(ui.ctx());
match lightcraft_engine::rename::folder_template_error(&d.folder_template) {
Some(e) => {
ui.label(egui::RichText::new(e).color(t.caution));
}
None => unknown_tags_warning(ui, &d.folder_template),
}
ui.label(
egui::RichText::new("Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").")
.color(t.text_dim)
.small(),
);
}
field(ui, "Raw files", |ui| {
let r = ui.checkbox(&mut d.dng, "Copy as DNG");
register(ui.ctx(), "check:importDng", r.rect);
});
field(ui, "Rename", |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut d.rename).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(f32::INFINITY));
let rename_id = egui::Id::new("import-rename");
let tags_open = field(ui, "Rename", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
let r = ui.add(egui::TextEdit::singleline(&mut d.rename).id(rename_id).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(w));
register(ui.ctx(), "field:importRename", r.rect);
tag_toggle(ui, "importRename")
});
if !d.rename.trim().is_empty()
&& let Some(c) = d.candidates.iter().zip(&d.checked).find(|(c, on)| **on && c.duplicate.is_none()).map(|(c, _)| c)
{
let mut q = lightcraft_catalog::Photo::new(
lightcraft_catalog::PhotoId(0),
lightcraft_catalog::Source::Demo { scene: 0 },
&c.name,
&c.format,
0,
0,
"",
if tags_open {
tag_help(ui, "importRename", &mut d.rename, rename_id);
}
unknown_tags_warning(ui, &d.rename);
if let Some(example) = example_destination(app, d) {
let t = Tokens::get(ui.ctx());
let r = ui.label(egui::RichText::new(format!("Example: {example}")).color(t.text_dim));
register(ui.ctx(), "label:importExample", r.rect);
ui.label(
egui::RichText::new("Folders and {date} use the capture time; a photo without one uses today's date.").color(t.text_dim).small(),
);
q.captured = c.captured.clone();
let example = lightcraft_engine::rename::expand(d.rename.trim(), &q, 1);
ui.label(egui::RichText::new(format!("{} → {example}", c.name)).color(Tokens::get(ui.ctx()).text_dim));
}
}
let albums: Vec<(u64, String)> = {
@ -602,6 +684,141 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
}
}
/// The "Tags" toggle beside a template field (`button:<key>Tags`); returns whether the tag help
/// ([`tag_help`]) is open.
pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool {
let id = egui::Id::new((key, "tags-open"));
let mut open = ui.ctx().data(|d| d.get_temp::<bool>(id)).unwrap_or(false);
let r = crate::widgets::text_button(ui, &format!("{key}Tags"), "Tags", open).on_hover_text("Show the template tags; click one to insert it");
if r.clicked() {
open = !open;
ui.ctx().data_mut(|d| d.insert_temp(id, open));
}
open
}
/// "Unknown tag {x} stays as typed" under a template field, when it has one.
pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) {
let unknown = lightcraft_engine::rename::unknown_tokens(template);
if !unknown.is_empty() {
let s = if unknown.len() == 1 { "" } else { "s" };
let text = format!("Unknown tag{s} {} stay{} as typed (see Tags)", unknown.join(" "), if unknown.len() == 1 { "s" } else { "" });
ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution));
}
}
/// Insert `tag` into `text` at the text cursor of the field `edit_id` (replacing its selection);
/// appended when the field never had a cursor. The cursor ends up after the tag.
pub(crate) fn insert_at_cursor(ctx: &egui::Context, edit_id: egui::Id, text: &mut String, tag: &str) {
use egui::text::{CCursor, CCursorRange};
let mut state = egui::text_edit::TextEditState::load(ctx, edit_id).unwrap_or_default();
let n = text.chars().count();
let (a, b) = state.cursor.char_range().map_or((n, n), |r| {
let (x, y) = (r.primary.index.0.min(n), r.secondary.index.0.min(n));
(x.min(y), x.max(y))
});
let byte = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i);
let (ba, bb) = (byte(a), byte(b));
text.replace_range(ba..bb, tag);
state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(a + tag.chars().count()))));
state.store(ctx, edit_id);
ctx.memory_mut(|m| m.request_focus(edit_id));
}
/// The template tag help: every tag (from the engine's one list, `rename::TOKENS`) with its
/// meaning and an example, the `{date:…}` directives and how templates behave. Clicking a tag
/// (`button:<key>Tag-<i>`) inserts it at the cursor of the field `edit_id`.
pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: egui::Id) {
use lightcraft_engine::rename::{DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, token_example};
let t = Tokens::get(ui.ctx());
egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| {
ui.label(
egui::RichText::new("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.").color(t.text_dim).small(),
);
egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| {
egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| {
for (i, tok) in TOKENS.iter().enumerate() {
let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text(
if tok.aliases.is_empty() {
"Insert at the cursor".to_string()
} else {
format!("Insert at the cursor (also written {})", tok.aliases.join(", "))
},
);
register(ui.ctx(), format!("button:{key}Tag-{i}"), r.rect);
if r.clicked() {
insert_at_cursor(ui.ctx(), edit_id, text, tok.tag);
}
ui.label(egui::RichText::new(tok.meaning).color(t.text_label));
ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim));
ui.end_row();
}
});
let directives: Vec<String> = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {m}")).collect();
ui.label(egui::RichText::new(format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small());
for n in TEMPLATE_NOTES {
ui.label(egui::RichText::new(format!("• {n}")).color(t.text_dim).small());
}
});
});
}
/// The folder template the Custom choice starts with (`2026/20260114/`).
pub const DEFAULT_FOLDER_TEMPLATE: &str = "{date:%Y}/{date:%Y%m%d}";
/// Where the first selected photo would be copied to (destination, folders, name), for the
/// dialog's example line; `None` without a photo or with an unusable folder template.
pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option<String> {
let c = d.candidates.iter().zip(&d.checked).find(|(c, on)| **on && c.duplicate.is_none() && c.error.is_none()).map(|(c, _)| c)?;
let organize = match d.organize_param().ok()? {
Some(o) => lightcraft_engine::import::Organize::parse(&o)?,
None => Default::default(),
};
let mut q = lightcraft_catalog::Photo::new(
lightcraft_catalog::PhotoId(0),
lightcraft_catalog::Source::File { path: c.path.clone() },
&c.name,
&c.format,
0,
0,
// a photo without a capture time is dated by the import (now)
&(app.session.clock)(),
);
q.captured = c.captured.clone();
// the probe's metadata, so {camera}, {title}… preview as they will import
if let Some(info) = app.session.import_probes.get(&c.path) {
q.meta = info.meta.clone();
}
let name = if d.rename.trim().is_empty() { c.name.clone() } else { lightcraft_engine::rename::expand(d.rename.trim(), &q, 1) };
let root = if d.destination.trim().is_empty() {
app.session.library.as_ref().map_or_else(|| "Originals".to_string(), |l| l.dir.join("Originals").to_string_lossy().to_string())
} else {
d.destination.trim().trim_end_matches(['/', '\\']).to_string()
};
let sep = std::path::MAIN_SEPARATOR_STR;
let mut parts = vec![root];
parts.extend(organize.folders(&q));
parts.push(name);
Some(parts.join(sep))
}
/// The import source in a few words: a folder's name, a file's name, or "N files and folders".
pub fn source_summary(sources: &[String]) -> String {
let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |n| n.to_string_lossy().to_string());
match sources {
[one] if std::path::Path::new(one).is_dir() => format!("Folder “{}” (and its subfolders)", name(one)),
[one] => name(one),
many => {
let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count();
match folders {
0 => format!("{} files", many.len()),
f if f == many.len() => format!("{f} folders (and their subfolders)"),
f => format!("{} files and {f} folder{}", many.len() - f, if f == 1 { "" } else { "s" }),
}
}
}
}
/// A labelled row (fixed label column).
fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let t = Tokens::get(ui.ctx());

View File

@ -4,6 +4,7 @@
//! (views, panels, zoom — see [`menus::UI_COMMANDS`]) and forwards everything else to the engine.
//! The same entry point serves menus, shortcuts, buttons and the control channel ([`control`]).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod control;
pub mod export_task;
@ -27,6 +28,8 @@ mod tests_curve;
#[cfg(test)]
mod tests_masking;
#[cfg(test)]
mod tests_panels;
#[cfg(test)]
mod tests_scroll;
use std::sync::mpsc::{Receiver, Sender};
@ -404,6 +407,9 @@ impl LightcraftApp {
if !self.styled {
theme::install_fonts(ctx);
theme::apply(ctx);
// File → Add from Device lists cards scanned in the background: show hot-plugs
let repaint = ctx.clone();
lightcraft_engine::devices::on_change(move || repaint.request_repaint());
self.styled = true;
} else {
self.fonts_ready = true;
@ -658,6 +664,9 @@ pub struct Caches {
counts: Option<(u64, LibraryCounts)>,
date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>,
filter_values: Option<(u64, std::sync::Arc<FilterValues>)>,
album_counts: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>>)>,
/// How often the album counts were recomputed (tests check that unchanged frames don't).
pub album_count_scans: usize,
/// The grid's layout (by photos, shapes, width, thumbnail size, grouping).
pub grid_layout: Option<(u64, std::sync::Arc<panels::grid::GridLayout>)>,
}
@ -779,4 +788,95 @@ impl Caches {
}
}
}
/// Every album's photo count for the sidebar (a smart album evaluates its rules, which
/// scans the catalog). Recomputed when the catalog changes (photos, metadata, album rules
/// all bump its revision) and — only while some smart album has an "in the last…" rule —
/// when `now` (the session clock, ISO) enters a new minute, so such counts follow the clock
/// within a minute without rescanning every frame.
pub fn album_counts(
&mut self,
cat: &lightcraft_catalog::Catalog,
now: &str,
) -> std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>> {
let relative = cat.albums().any(|a| a.smart.as_ref().is_some_and(|f| f.depends_on_now()));
let minute = if relative { now.get(..16).unwrap_or(now) } else { "" };
let k = key_of((cat.revision, minute));
match &self.album_counts {
Some((h, v)) if *h == k => v.clone(),
_ => {
if relative {
// "in the last N days" counts back from the session clock, as in the grid
lightcraft_catalog::rules::set_now(Some(now.to_string()));
}
let v: std::sync::Arc<std::collections::HashMap<_, _>> =
std::sync::Arc::new(cat.albums().map(|a| (a.id, cat.album_count(a.id))).collect());
self.album_count_scans += 1;
self.album_counts = Some((k, v.clone()));
v
}
}
}
}
#[cfg(test)]
mod cache_tests {
use lightcraft_catalog::{Album, AlbumId, Catalog, Filter, Op, Photo, PhotoId, RuleSet, Source};
fn photo(id: u64, captured: &str, rating: u8) -> Op {
let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, &format!("p{id}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
p.captured = Some(captured.into());
p.rating = rating;
Op::AddPhoto { photo: Box::new(p) }
}
fn smart(id: u64, rules: serde_json::Value) -> Op {
let mut a = Album::new(AlbumId(id), format!("smart {id}"));
let rules: RuleSet = serde_json::from_value(rules).unwrap();
a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() }));
Op::AddAlbum { album: a }
}
/// Smart-album counts: unchanged frames reuse them; metadata and rule changes and (for
/// "in the last…" rules) the clock crossing into a new minute recompute them.
#[test]
fn smart_album_counts_are_cached_until_something_changes() {
let mut cat = Catalog::default();
let mut c = super::Caches::default();
for (i, d) in ["2026-09-30T11:59:30", "2026-09-29T08:00:00", "2026-01-01T08:00:00"].iter().enumerate() {
cat.apply(photo(i as u64 + 1, d, if i == 0 { 5 } else { 1 })).unwrap();
}
cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap();
cat.apply(smart(11, serde_json::json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "hours"}}]}))).unwrap();
let now = "2026-09-30T12:00:00";
let n = c.album_counts(&cat, now);
assert_eq!((n[&AlbumId(10)], n[&AlbumId(11)]), (1, 1));
assert_eq!(c.album_count_scans, 1);
for _ in 0..10 {
c.album_counts(&cat, "2026-09-30T12:00:40");
}
assert_eq!(c.album_count_scans, 1, "unchanged frames in the same minute don't rescan");
// metadata change
cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap();
assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 2);
// rule change
let rs: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 5}]})).unwrap();
cat.apply(Op::SetAlbumRules { id: AlbumId(10), rules: Box::new(Filter { rule_set: Some(rs), ..Default::default() }) }).unwrap();
assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 1);
// an hour later the 11:59:30 photo has left "in the last hour", with no catalog change
assert_eq!(c.album_counts(&cat, "2026-09-30T13:00:10")[&AlbumId(11)], 0);
assert_eq!(c.album_count_scans, 4);
lightcraft_catalog::rules::set_now(None);
}
/// Without "in the last…" rules the clock never causes a rescan.
#[test]
fn absolute_rules_ignore_the_clock() {
let mut cat = Catalog::default();
let mut c = super::Caches::default();
cat.apply(photo(1, "2026-09-30T11:59:30", 5)).unwrap();
cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap();
c.album_counts(&cat, "2026-09-30T12:00:00");
c.album_counts(&cat, "2027-01-01T00:00:00");
assert_eq!(c.album_count_scans, 1);
}
}

View File

@ -47,14 +47,15 @@ impl MenuNode {
pub const MENUS: &[&str] = &["File", "Edit", "View", "Photo", "Window", "Help"];
/// Order and grouping per menu: command ids, `---` separators and `@Submenu` placeholders.
/// Entries with a menu path that aren't listed are appended at the end of their menu.
/// Entries with a menu path that aren't listed are appended at the end of their menu, before any
/// [`LAST`] item.
const LAYOUT: &[(&str, &[&str])] = &[
(
"File",
&[
"file.addPhotos",
"file.addFolder",
"@Add from Device",
"@Import from Device",
"---",
"app.openLibrary",
"---",
@ -219,6 +220,10 @@ const LAYOUT: &[(&str, &[&str])] = &[
),
];
/// Items that always end their menu in their own separator group, after the entries appended
/// because the layout doesn't list them (Quit is the last item of the in-window File menu).
const LAST: &[&str] = &["app.quit"];
/// Registry entries that are reached another way (parameterized commands are expanded into
/// submenus below; others duplicate a dialog command).
const HIDDEN: &[&str] = &[
@ -428,7 +433,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
v.extend(groups.into_iter().map(|(label, g, k)| item("library.sort", json!({"group": k}), label, None, true, Some(cur.group == g))));
v
}
"Add from Device" => {
"Import from Device" => {
let devices = lightcraft_engine::devices::devices();
if devices.is_empty() {
vec![item("file.addFromDevice", Value::Null, "No Camera or Card Found", None, false, None)]
@ -535,6 +540,7 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec<MenuNode>)> {
for title in MENUS {
let layout = LAYOUT.iter().find(|(t, _)| t == title).map(|(_, l)| *l).unwrap_or(&[]);
let mut items = Vec::new();
let mut last = Vec::new();
let sub = |name: &str, used: &mut [bool]| -> Vec<MenuNode> {
let mut children = expanded(app, name).unwrap_or_default();
let mut dialogs = Vec::new();
@ -559,7 +565,7 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec<MenuNode>)> {
items.push(MenuNode::Submenu { label: name.to_string(), children });
} else if let Some(i) = entries.iter().position(|e| e.id == *slot && e.menu.first().map(String::as_str) == Some(title)) {
used[i] = true;
items.push(node(app, &entries[i]));
if LAST.contains(slot) { last.push(node(app, &entries[i])) } else { items.push(node(app, &entries[i])) }
}
}
// everything else that names this menu
@ -580,6 +586,10 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec<MenuNode>)> {
let children = sub(&name, &mut used);
items.push(MenuNode::Submenu { label: name, children });
}
if !last.is_empty() {
items.push(MenuNode::Separator);
items.extend(last);
}
bar.push((title.to_string(), tidy(items)));
}
bar
@ -723,6 +733,28 @@ mod tests {
})
}
/// File opens with the import entry points, worded as importing (not as adding a sidebar
/// folder): Import Photos… (⇧⌘I), Import from Folder…, Import from Device ▸.
#[test]
fn file_menu_starts_with_the_import_entry_points() {
let bar = menu_bar(&app());
let file = &bar.iter().find(|(t, _)| t == "File").expect("File menu").1;
let labels: Vec<String> = file
.iter()
.take(3)
.map(|n| match n {
MenuNode::Item { label, shortcut, .. } => format!("{label}{}", shortcut.as_deref().map(|s| format!(" [{s}]")).unwrap_or_default()),
MenuNode::Submenu { label, .. } => format!("{label} ▸"),
MenuNode::Separator => "---".into(),
})
.collect();
assert_eq!(labels[0], "Import Photos… [Cmd+Shift+I]");
assert_eq!(labels[1..], ["Import from Folder…".to_string(), "Import from Device ▸".to_string()]);
let all: Vec<MenuNode> = bar.iter().flat_map(|(_, v)| v.clone()).collect();
let text = serde_json::to_string(&all).unwrap();
assert!(!text.contains("Add Folder") && !text.contains("\"Add Photos"), "no add-folder wording left in the menus");
}
#[test]
fn bar_follows_the_registry_and_state() {
let mut app = app();
@ -773,6 +805,28 @@ mod tests {
assert_eq!(*checked, Some(app.ui.filmstrip));
}
#[test]
fn quit_ends_the_file_menu_after_unlisted_entries() {
let app = app();
// the File menu has registry entries the layout doesn't list (appended as fallbacks)
let listed = LAYOUT.iter().find(|(t, _)| *t == "File").unwrap().1;
let unlisted: Vec<String> = crate::menus::menu_entries(&app)
.into_iter()
.filter(|e| e.menu.first().map(String::as_str) == Some("File") && !listed.contains(&e.id.as_str()) && !HIDDEN.contains(&e.id.as_str()))
.map(|e| e.id)
.collect();
assert!(!unlisted.is_empty(), "no fallback File entries to order");
let bar = menu_bar(&app);
let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1;
// Quit is the last item, alone in its separator group
assert!(matches!(file.last(), Some(MenuNode::Item { id, .. }) if id == "app.quit"), "{file:?}");
assert_eq!(file.get(file.len() - 2), Some(&MenuNode::Separator));
// every fallback entry is still reachable, before Quit
for id in &unlisted {
assert!(find(&file[..file.len() - 1], id).is_some(), "{id} missing before Quit");
}
}
#[test]
fn export_with_preset_submenu() {
let mut app = app();

View File

@ -53,6 +53,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.back", "Back to Grid", Some("Escape"), ""),
("tool.done", "Done", Some("Enter"), ""),
("view.filterBar", "Filter Bar", Some("Shift+F"), "View"),
("local.addRoot", "Add Folder to Local", None, ""),
("local.hide", "Remove from Local", None, ""),
("local.restoreHidden", "Show Hidden Local Locations", None, ""),
("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"),
@ -109,9 +110,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("merge.hdrLast", "HDR with Last Settings", Some("Ctrl+Shift+H"), "Photo>Photo Merge"),
("merge.panoramaLast", "Panorama with Last Settings", Some("Ctrl+Shift+M"), "Photo>Photo Merge"),
("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"),
("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"),
("file.addFolder", "Add Folder…", None, "File"),
("file.addFromDevice", "Add from Device", None, ""),
("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"),
("file.addFolder", "Import from Folder…", None, "File"),
("file.addFromDevice", "Import from Device", None, ""),
("file.findMissing", "Find Missing Photos…", None, "File"),
("photo.locate", "Locate Missing File…", None, ""),
("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""),
@ -555,9 +556,24 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.crop_overlay_orient = (app.ui.crop_overlay_orient + 1) % 4;
Ok(json!({"orientation": app.ui.crop_overlay_orient}))
}
"local.addRoot" => {
// a folder kept in Local's sidebar (saved with the UI state); nothing on disk changes
let Some(path) = p.get("path").and_then(Value::as_str) else { return Some(Err("local.addRoot needs a path".into())) };
let abs = std::path::absolute(path).map(|a| a.to_string_lossy().trim_end_matches(['/', '\\']).to_string()).unwrap_or(path.into());
let abs = if abs.is_empty() { path.to_string() } else { abs };
if !std::path::Path::new(&abs).is_dir() {
return Some(Err(format!("{path}: not a folder")));
}
use crate::panels::left::same_folder;
if !app.ui.local_roots.iter().any(|r| same_folder(r, &abs)) {
app.ui.local_roots.push(abs.clone());
}
app.ui.hidden_locations.retain(|h| !same_folder(h, &abs));
Ok(json!({"roots": app.ui.local_roots}))
}
"local.hide" => match p.get("path").and_then(Value::as_str) {
Some(path) => {
if !app.ui.hidden_locations.iter().any(|h| h == path) {
if !app.ui.hidden_locations.iter().any(|h| crate::panels::left::same_folder(h, path)) {
app.ui.hidden_locations.push(path.to_string());
}
Ok(json!({"hidden": app.ui.hidden_locations}))
@ -567,7 +583,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
"local.restoreHidden" => {
// one path, or (no path) every hidden location
match p.get("path").and_then(Value::as_str) {
Some(path) => app.ui.hidden_locations.retain(|h| h != path),
Some(path) => app.ui.hidden_locations.retain(|h| !crate::panels::left::same_folder(h, path)),
None => app.ui.hidden_locations.clear(),
}
Ok(json!({"hidden": app.ui.hidden_locations}))
@ -915,7 +931,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
// a camera / card: review its DCIM folder, copying into the library by default
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => x.to_string(),
None => match lightcraft_engine::devices::devices().into_iter().next() {
None => match lightcraft_engine::devices::devices_now().into_iter().next() {
Some(d) => d.path,
None => return Some(Err("no camera or memory card found".into())),
},

View File

@ -124,12 +124,16 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b
let (tx, rx) = channel();
let (j, p, c) = (job.clone(), progress.clone(), cancel.clone());
let work = move || {
let r = j.run(&|f, stage| {
if let Ok(mut g) = p.lock() {
*g = (f, stage.to_string());
}
!c.load(Ordering::Relaxed)
});
let run = || {
j.run(&|f, stage| {
if let Ok(mut g) = p.lock() {
*g = (f, stage.to_string());
}
!c.load(Ordering::Relaxed)
})
};
// a panicking merge reports an error instead of leaving "Merging…" up forever
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(run)).unwrap_or_else(|_| Err("the merge failed unexpectedly".into()));
let _ = tx.send(r);
};
#[cfg(not(target_arch = "wasm32"))]
@ -220,7 +224,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
if let Some(t) = &app.merge.preview_task {
match t.rx.try_recv() {
Ok(Ok(out)) => {
let t = app.merge.preview_task.take().expect("task");
let Some(t) = app.merge.preview_task.take() else { return };
if let Some(img) = out.preview {
let color = Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes()));
let tex = ctx.load_texture("merge-preview", color.clone(), egui::TextureOptions::LINEAR);
@ -229,7 +233,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
Ok(Err(e)) => {
let t = app.merge.preview_task.take().expect("task");
let Some(t) = app.merge.preview_task.take() else { return };
if e != "cancelled" {
app.merge.error = Some(e);
app.merge.failed_options = Some(t.options);
@ -242,7 +246,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
if let Some(t) = &app.merge.final_task {
match t.rx.try_recv() {
Ok(r) => {
let t = app.merge.final_task.take().expect("task");
let Some(t) = app.merge.final_task.take() else { return };
let what = if t.options.command == "merge.panorama" {
"Panorama"
} else if t.options.command == "merge.hdrPanorama" {

View File

@ -17,179 +17,199 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 }))
.show(ui, |ui| {
let full = ui.max_rect();
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (id, icon, mode, tip) in [
("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"),
("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (G toggles)"),
("detail", Icon::Single, ViewMode::Detail, "Detail (D)"),
("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"),
("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"),
] {
if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() {
let _ = app.run(&format!("view.{id}"), json!({}));
}
}
ui.add_space(10.0);
let (sep, _) = ui.allocate_exact_size(vec2(1.0, 22.0), Sense::hover());
ui.painter().rect_filled(sep, 0.0, t.button_border);
ui.add_space(10.0);
let sort = icon_button(ui, "sort", Icon::Sort, vec2(30.0, 30.0), false, true, "Sort");
egui::Popup::menu(&sort).show(|ui| sort_menu(app, ui));
});
// centre: rating/flag pill + copy/paste settings
let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned());
let pill = Rect::from_center_size(pos2(full.center().x - 60.0, full.center().y), vec2(196.0, 30.0));
ui.painter().rect_filled(pill, 15.0, t.canvas);
let mut pill_ui =
ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
pill_ui.spacing_mut().item_spacing.x = 3.0;
let rating = active.as_ref().map(|p| p.rating).unwrap_or(0);
if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) {
let mut params = json!({"rating": r});
crate::panels::compare::target_active(app, &mut params);
let _ = app.run("photo.rate", params);
}
pill_ui.add_space(6.0);
let flag = active.as_ref().map(|p| p.flag).unwrap_or_default();
for (id, icon, f) in
[("unflag", Icon::Circle, Flag::None), ("pick", Icon::FlagPick, Flag::Pick), ("reject", Icon::FlagReject, Flag::Reject)]
{
let (r, resp) = pill_ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
register(pill_ui.ctx(), format!("flag:{id}"), r);
let on = flag == f && f != Flag::None;
let c = if on {
if f == Flag::Reject { t.reject } else { t.pick }
} else if resp.hovered() {
t.text
} else {
t.icon
};
paint(pill_ui.painter(), r.shrink(3.0), icon, c);
if resp.clicked() {
let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }});
crate::panels::compare::target_active(app, &mut params);
let _ = app.run("photo.flag", params);
}
}
// copy / paste settings
let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(136.0, 30.0));
let has_clip = app.session.clipboard.is_some();
let label = if has_clip { "Paste Edit Settings" } else { "Copy Edit Settings" };
let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click());
register(ui.ctx(), "button:copySettings", copy_r);
ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas });
ui.painter().text(
copy_r.center(),
Align2::CENTER_CENTER,
label,
t.font(13.0),
if active.is_some() { t.text_label } else { t.text_disabled },
);
if cresp.clicked() && active.is_some() {
let _ = if has_clip { app.run("develop.paste", json!({})) } else { app.run("develop.copy", json!({})) };
let msg = if has_clip { "Settings pasted" } else { "Edit settings copied" };
app.toast(ui.ctx(), msg);
}
let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0));
let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click());
register(ui.ctx(), "icon:copyGear", gear_r);
ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas });
paint(ui.painter(), gear_r.shrink(7.0), Icon::Gear, t.icon);
if gresp.clicked() {
let _ = app.run("dialog.copySettings", json!({}));
}
// right side
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(full.right() - 360.0, full.top()), full.right_bottom()))
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
child.spacing_mut().item_spacing.x = 6.0;
if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey)
&& icon_button(
&mut child,
"autoAdvance",
Icon::ChevronRight,
vec2(30.0, 30.0),
app.ui.auto_advance,
true,
"Auto Advance after rating or flagging",
)
.clicked()
{
let _ = app.run("view.autoAdvance", json!({}));
}
if app.ui.view == ViewMode::Survey {
// no zoom or thumbnail size in Survey
} else if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) {
if icon_button(
&mut child,
"beforeAfter",
Icon::BeforeAfter,
vec2(30.0, 30.0),
app.ui.before_after != BeforeAfter::Off,
true,
"Before/After (Y)",
)
.clicked()
{
let _ = app.run("view.beforeAfter", json!({}));
}
if icon_button(&mut child, "filmstrip", Icon::Filmstrip, vec2(30.0, 30.0), app.ui.filmstrip, true, "Filmstrip (/)").clicked() {
let _ = app.run("view.filmstrip", json!({}));
}
child.add_space(10.0);
let zoom_label = match app.ui.zoom {
Zoom::Fit => "Fit".to_string(),
Zoom::Fill => "Fill".to_string(),
Zoom::Percent(p) => format!("{p}%"),
};
let zr = crate::widgets::dropdown(&mut child, "zoom", &zoom_label, t.font(13.0), t.text_label);
egui::Popup::menu(&zr).show(|ui| {
for (label, z) in [
("Fit", Zoom::Fit),
("Fill", Zoom::Fill),
("50%", Zoom::Percent(50)),
("100%", Zoom::Percent(100)),
("200%", Zoom::Percent(200)),
("400%", Zoom::Percent(400)),
let left_end = ui
.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (id, icon, mode, tip) in [
("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"),
("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (G toggles)"),
("detail", Icon::Single, ViewMode::Detail, "Detail (D)"),
("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"),
("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"),
] {
if ui.selectable_label(app.ui.zoom == z, label).clicked() {
app.ui.zoom = z;
app.ui.pan = (0.5, 0.5);
if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() {
let _ = app.run(&format!("view.{id}"), json!({}));
}
}
});
child.add_space(6.0);
let cz =
crate::widgets::dropdown(&mut child, "clickZoom", &format!("Click {}:1", app.ui.click_zoom / 100), t.font(13.0), t.text_label);
egui::Popup::menu(&cz).show(|ui| {
for pct in crate::state::CLICK_ZOOMS {
if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() {
let _ = app.run("view.clickZoom", json!({"ratio": pct / 100}));
}
}
});
} else {
// thumbnail size slider
let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag());
register(child.ctx(), "slider:thumbSize", r);
let p = child.painter();
let y = r.center().y;
p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], egui::Stroke::new(2.0, t.track));
let f = (app.ui.thumb_size - 90.0) / (480.0 - 90.0);
let x = r.left() + f * r.width();
p.rect_filled(Rect::from_center_size(pos2(x, y), vec2(3.0, 14.0)), 1.0, t.thumb_hover);
if let Some(pp) = resp.interact_pointer_pos()
&& (resp.dragged() || resp.clicked())
{
app.ui.thumb_size = 90.0 + ((pp.x - r.left()) / r.width()).clamp(0.0, 1.0) * (480.0 - 90.0);
ui.add_space(10.0);
let (sep, _) = ui.allocate_exact_size(vec2(1.0, 22.0), Sense::hover());
ui.painter().rect_filled(sep, 0.0, t.button_border);
ui.add_space(10.0);
let sort = icon_button(ui, "sort", Icon::Sort, vec2(30.0, 30.0), false, true, "Sort");
egui::Popup::menu(&sort).show(|ui| sort_menu(app, ui));
})
.response
.rect
.right();
let right_start = right_side(app, ui, full);
centre(app, ui, full, left_end + 12.0, right_start - 12.0);
});
}
/// Width of the centre group: the rating/flag pill, then Copy/Paste Edit Settings and its gear.
const PILL_W: f32 = 196.0;
const CENTRE_W: f32 = PILL_W + 10.0 + 136.0 + 4.0 + 30.0;
/// The rating/flag pill and copy/paste settings, between `from` and `to` (the side groups): the
/// copy buttons go first when there is no room (they are in the Edit menu too), then the pill.
fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: f32) {
let t = Tokens::get(ui.ctx());
let room = to - from;
if room < PILL_W {
return;
}
let with_copy = room >= CENTRE_W;
let w = if with_copy { CENTRE_W } else { PILL_W };
// where it sits with room to spare (slightly left of centre), kept between the side groups
let left = (full.center().x - 60.0 - PILL_W / 2.0).clamp(from, to - w);
let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned());
let pill = Rect::from_min_size(pos2(left, full.center().y - 15.0), vec2(PILL_W, 30.0));
ui.painter().rect_filled(pill, 15.0, t.canvas);
let mut pill_ui =
ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
pill_ui.spacing_mut().item_spacing.x = 3.0;
let rating = active.as_ref().map(|p| p.rating).unwrap_or(0);
if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) {
let mut params = json!({"rating": r});
crate::panels::compare::target_active(app, &mut params);
let _ = app.run("photo.rate", params);
}
pill_ui.add_space(6.0);
let flag = active.as_ref().map(|p| p.flag).unwrap_or_default();
for (id, icon, f) in [("unflag", Icon::Circle, Flag::None), ("pick", Icon::FlagPick, Flag::Pick), ("reject", Icon::FlagReject, Flag::Reject)] {
let (r, resp) = pill_ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
register(pill_ui.ctx(), format!("flag:{id}"), r);
let on = flag == f && f != Flag::None;
let c = if on {
if f == Flag::Reject { t.reject } else { t.pick }
} else if resp.hovered() {
t.text
} else {
t.icon
};
paint(pill_ui.painter(), r.shrink(3.0), icon, c);
if resp.clicked() {
let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }});
crate::panels::compare::target_active(app, &mut params);
let _ = app.run("photo.flag", params);
}
}
// copy / paste settings
if !with_copy {
return;
}
let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(136.0, 30.0));
let has_clip = app.session.clipboard.is_some();
let label = if has_clip { "Paste Edit Settings" } else { "Copy Edit Settings" };
let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click());
register(ui.ctx(), "button:copySettings", copy_r);
ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas });
ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled });
if cresp.clicked() && active.is_some() {
let _ = if has_clip { app.run("develop.paste", json!({})) } else { app.run("develop.copy", json!({})) };
let msg = if has_clip { "Settings pasted" } else { "Edit settings copied" };
app.toast(ui.ctx(), msg);
}
let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0));
let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click());
register(ui.ctx(), "icon:copyGear", gear_r);
ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas });
paint(ui.painter(), gear_r.shrink(7.0), Icon::Gear, t.icon);
if gresp.clicked() {
let _ = app.run("dialog.copySettings", json!({}));
}
}
/// The right-hand group (zoom, view toggles or thumbnail size); returns its left edge.
fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 {
let t = Tokens::get(ui.ctx());
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(full.right() - 360.0, full.top()), full.right_bottom()))
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
child.spacing_mut().item_spacing.x = 6.0;
if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey)
&& icon_button(
&mut child,
"autoAdvance",
Icon::ChevronRight,
vec2(30.0, 30.0),
app.ui.auto_advance,
true,
"Auto Advance after rating or flagging",
)
.clicked()
{
let _ = app.run("view.autoAdvance", json!({}));
}
if app.ui.view == ViewMode::Survey {
// no zoom or thumbnail size in Survey
} else if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) {
if icon_button(
&mut child,
"beforeAfter",
Icon::BeforeAfter,
vec2(30.0, 30.0),
app.ui.before_after != BeforeAfter::Off,
true,
"Before/After (Y)",
)
.clicked()
{
let _ = app.run("view.beforeAfter", json!({}));
}
if icon_button(&mut child, "filmstrip", Icon::Filmstrip, vec2(30.0, 30.0), app.ui.filmstrip, true, "Filmstrip (/)").clicked() {
let _ = app.run("view.filmstrip", json!({}));
}
child.add_space(10.0);
let zoom_label = match app.ui.zoom {
Zoom::Fit => "Fit".to_string(),
Zoom::Fill => "Fill".to_string(),
Zoom::Percent(p) => format!("{p}%"),
};
let zr = crate::widgets::dropdown(&mut child, "zoom", &zoom_label, t.font(13.0), t.text_label);
egui::Popup::menu(&zr).show(|ui| {
for (label, z) in [
("Fit", Zoom::Fit),
("Fill", Zoom::Fill),
("50%", Zoom::Percent(50)),
("100%", Zoom::Percent(100)),
("200%", Zoom::Percent(200)),
("400%", Zoom::Percent(400)),
] {
if ui.selectable_label(app.ui.zoom == z, label).clicked() {
app.ui.zoom = z;
app.ui.pan = (0.5, 0.5);
}
}
});
child.add_space(6.0);
let cz = crate::widgets::dropdown(&mut child, "clickZoom", &format!("Click {}:1", app.ui.click_zoom / 100), t.font(13.0), t.text_label);
egui::Popup::menu(&cz).show(|ui| {
for pct in crate::state::CLICK_ZOOMS {
if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() {
let _ = app.run("view.clickZoom", json!({"ratio": pct / 100}));
}
}
});
} else {
// thumbnail size slider
let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag());
register(child.ctx(), "slider:thumbSize", r);
let p = child.painter();
let y = r.center().y;
p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], egui::Stroke::new(2.0, t.track));
let f = (app.ui.thumb_size - 90.0) / (480.0 - 90.0);
let x = r.left() + f * r.width();
p.rect_filled(Rect::from_center_size(pos2(x, y), vec2(3.0, 14.0)), 1.0, t.thumb_hover);
if let Some(pp) = resp.interact_pointer_pos()
&& (resp.dragged() || resp.clicked())
{
app.ui.thumb_size = 90.0 + ((pp.x - r.left()) / r.width()).clamp(0.0, 1.0) * (480.0 - 90.0);
}
}
if child.min_rect().width() > 0.0 { child.min_rect().left() } else { full.right() }
}
fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {

View File

@ -27,7 +27,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::NewAlbum { .. } => "Create Album",
Dialog::RenameAlbum { .. } => "Rename Album",
Dialog::Rename { .. } => "Rename Photos",
Dialog::Import { .. } => "Add Photos",
Dialog::Import { .. } => "Import Photos",
Dialog::LabelNames { .. } => "Edit Color Label Names",
Dialog::CaptureTime { .. } => "Edit Capture Time",
Dialog::RenameKeyword { .. } => "Rename Keyword",
@ -289,10 +289,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Rename { template, start } => {
let n = app.session.targets(&json!({})).len();
ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim));
field(ui, "Template", |ui| {
let r = ui.add(egui::TextEdit::singleline(template).hint_text("{name}").desired_width(f32::INFINITY));
let template_id = egui::Id::new("rename-template");
let tags_open = field(ui, "Template", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
let r = ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w));
crate::widgets::register(ui.ctx(), "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
@ -305,7 +313,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999)));
ui.label(
egui::RichText::new("Tokens: {name} {num} {seq} {seq:3} {date} {date:%Y-%m-%d} {folder} {camera} {lens} {iso} {rating} {title} {creator}. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…")
egui::RichText::new("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 = app.session.execute("photo.renamePreview", &json!({"template": template, "start": start})).unwrap_or_default();
@ -576,9 +584,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
use lightcraft_engine::export::DngCompression as Z;
choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None")], &mut opts.dng_compression);
}
field(ui, "File name", |ui| {
ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(f32::INFINITY))
let naming_id = egui::Id::new("export-naming");
let tags_open = field(ui, "File name", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w));
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) {
@ -704,7 +720,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let ok = match &dlg {
Dialog::Import { opts } => {
let n = opts.selected_paths().len();
add_label = format!("Add {n} Photo{}", if n == 1 { "" } else { "s" });
add_label = format!("Import {n} Photo{}", if n == 1 { "" } else { "s" });
add_label.as_str()
}
Dialog::Merge { .. } => "Merge",
@ -729,8 +745,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
close = true;
}
if confirm {
let _ = confirm_dialog(app, &dlg);
close = true;
match confirm_dialog(app, &dlg) {
// the import review stays open on an error (e.g. an unusable folder template)
Err(e) if matches!(dlg, Dialog::Import { .. }) => app.toast(ctx, e),
_ => close = true,
}
}
app.ui.dialog = if close { None } else { Some(dlg) };
}

View File

@ -66,7 +66,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
} else if app.session.source == lightcraft_engine::LibrarySource::Folder {
super::empty_message(ui, ui.max_rect(), "No photos in this folder", "Turn on Include subfolders, or pick another folder under Local");
} else {
super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here");
super::empty_message(ui, ui.max_rect(), "No photos", "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here");
}
return;
}

View File

@ -56,99 +56,99 @@ fn row(
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::left("left_panel")
.exact_size(t.left_w)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "My Photos", t.semibold(15.0), t.text);
let counts = app.caches.counts(&app.session.catalog);
let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted);
egui::ScrollArea::vertical().id_salt("left-scroll").auto_shrink([false, false]).show(ui, |ui| {
let src = app.session.source;
for (id, icon, label, count, s) in [
("all", Icon::Photos, "All Photos", Some(total), LibrarySource::All),
("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() {
let _ = app.run("library.source", json!({"kind": id}));
}
let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider));
let width = app.ui.left_width;
let resized = super::resizable_side(ui, true, "left_panel", frame, width, crate::state::LEFT_WIDTH, 0.0, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "My Photos", t.semibold(15.0), t.text);
let counts = app.caches.counts(&app.session.catalog);
let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted);
egui::ScrollArea::vertical().id_salt("left-scroll").auto_shrink([false, false]).show(ui, |ui| {
let src = app.session.source;
for (id, icon, label, count, s) in [
("all", Icon::Photos, "All Photos", Some(total), LibrarySource::All),
("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() {
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()
{
let _ = app.run("library.source", json!({"kind": "missing"}));
}
// 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()
{
let _ = app.run("library.source", json!({"kind": "missing"}));
}
ui.add_space(10.0);
// Albums header
let (ar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, "Albums", t.semibold(13.5), t.text_label);
let mut hdr = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom()))
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album");
egui::Popup::menu(&plus).show(|ui| {
if ui.button("Create Album…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false });
}
ui.add_space(10.0);
// Albums header
let (ar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, "Albums", t.semibold(13.5), t.text_label);
let mut hdr = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom()))
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album");
egui::Popup::menu(&plus).show(|ui| {
if ui.button("Create Album…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false });
}
if ui.button("Create Smart Album…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::SmartRules {
id: None,
name: String::new(),
rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() },
});
}
if ui.button("Create Smart Album from Filter…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new() });
}
if ui.button("Create Folder…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true });
}
});
let albums: Vec<Album> = app.session.catalog.albums().cloned().collect();
albums_tree(app, ui, &albums, None, 0.0);
ui.add_space(10.0);
local_section(app, ui);
// By date
let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, "By Date", t.semibold(13.5), t.text_label);
for g in app.caches.date_groups(&app.session.catalog).iter() {
// year → month → day; a click filters by that prefix, the triangle opens a level
if date_row(app, ui, &g.year, &g.year, g.count, 0.0) {
for (m, n) in &g.months {
let label = lightcraft_catalog::dates::group_label(m).split(' ').next().unwrap_or(m).to_string();
if date_row(app, ui, m, &label, *n, 16.0) {
for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) {
let label = lightcraft_catalog::dates::group_label(d);
// "Sunday, 20 September 2026" → "Sunday, 20"
let label = label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string();
date_row(app, ui, d, &label, *n, 32.0);
}
if ui.button("Create Smart Album…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::SmartRules {
id: None,
name: String::new(),
rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() },
});
}
if ui.button("Create Smart Album from Filter…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new() });
}
if ui.button("Create Folder…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true });
}
});
let albums: Vec<Album> = app.session.catalog.albums().cloned().collect();
albums_tree(app, ui, &albums, None, 0.0);
ui.add_space(10.0);
local_section(app, ui);
// By date
let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, "By Date", t.semibold(13.5), t.text_label);
for g in app.caches.date_groups(&app.session.catalog).iter() {
// year → month → day; a click filters by that prefix, the triangle opens a level
if date_row(app, ui, &g.year, &g.year, g.count, 0.0) {
for (m, n) in &g.months {
let label = lightcraft_catalog::dates::group_label(m).split(' ').next().unwrap_or(m).to_string();
if date_row(app, ui, m, &label, *n, 16.0) {
for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) {
let label = lightcraft_catalog::dates::group_label(d);
// "Sunday, 20 September 2026" → "Sunday, 20"
let label = label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string();
date_row(app, ui, d, &label, *n, 32.0);
}
}
}
}
keywords_section(app, ui);
ui.add_space(10.0);
if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0)
.clicked()
{
let _ = app.run("library.source", json!({"kind": "recentlyDeleted"}));
}
});
}
keywords_section(app, ui);
ui.add_space(10.0);
if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() {
let _ = app.run("library.source", json!({"kind": "recentlyDeleted"}));
}
});
});
if let Some(w) = resized {
app.ui.left_width = w;
}
}
/// How many library photos have no file. Checking stats every file, which on a network share
/// takes seconds, so it runs on a worker thread: the count shown is the last finished one,
/// refreshed at most every 5 s (or when the catalog changed).
/// How many library photos have no file (Local browse records are not checked; see
/// `cmd::missing::checked_path`). Checking stats every file, which on a network share takes
/// seconds, so it runs on a worker thread: the count shown is the last finished one, refreshed
/// at most every 5 s, and at once (after the running check) when the catalog changed.
fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed};
#[derive(Clone, Default)]
@ -164,16 +164,8 @@ fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize {
let mut job: Job = ui.data(|d| d.get_temp(id)).unwrap_or(Job { rev: u64::MAX, at: f64::MIN, ..Default::default() });
let fresh = now - job.at < 5.0 && job.rev == rev;
if !fresh && !job.running.load(Relaxed) {
let paths: Vec<String> = app
.session
.catalog
.photos()
.filter(|p| !p.deleted)
.filter_map(|p| match &p.source {
lightcraft_catalog::Source::File { path } => Some(path.clone()),
_ => None,
})
.collect();
// the same scope as the Missing Photos view: library photos only, never Local browse records
let paths = lightcraft_engine::cmd::missing::candidates(&app.session.catalog);
job.running.store(true, Relaxed);
job.at = now;
job.rev = rev;
@ -203,41 +195,35 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let (lr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(lr.left() + 18.0, lr.center().y), Align2::LEFT_CENTER, "Local", t.semibold(13.5), t.text_label);
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default();
let mut places: Vec<(String, String)> = Vec::new();
let mut builtin: Vec<(String, String)> = Vec::new();
if !home.is_empty() {
for (name, sub) in [("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")] {
let p = if sub.is_empty() { home.clone() } else { format!("{home}/{sub}") };
// joined with the platform's separator, like the paths browsing gives back
let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() };
if std::path::Path::new(&p).is_dir() {
places.push((name.to_string(), p));
builtin.push((name.to_string(), p));
}
}
}
let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder);
// picked folders stay listed; the folder being browsed is listed even when it wasn't picked
let roots = app.ui.local_roots.iter().cloned().chain(browsing.iter().map(|b| b.path.clone()));
for path in roots {
if !places.iter().any(|(_, p)| *p == path) {
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
places.push((name, path));
}
}
let current = browsing.as_ref().map(|b| b.path.clone());
// hidden locations stay reachable: breadcrumbs, Browse Folder…, and the row below
places.retain(|(_, p)| !app.ui.hidden_locations.contains(p));
for (name, path) in places {
folder_tree(app, ui, &name, &path, 0.0, current.as_deref());
let local = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations);
if current.is_some() {
app.ui.local_browse_root = local.browse_root.clone();
}
for (i, (name, path)) in local.places.iter().enumerate() {
let transient = local.browse_root.as_deref() == Some(path.as_str());
let reveal = local.owner == Some(i);
folder_tree(app, ui, name, path, 0.0, current.as_deref(), reveal, transient);
}
if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, "Browse Folder…", None, false, 0.0).clicked() {
let picked = app.services.pick_folder.as_mut().and_then(|f| f());
if let Some(path) = picked {
// choosing a folder again puts it back in the list
let _ = app.run("local.restoreHidden", json!({"path": path}));
match app.run("library.browse", json!({"path": path})) {
// the picked folder stays in Local (and comes back if it was hidden)
Ok(r) => {
let dir = r["path"].as_str().unwrap_or(&path).to_string();
if !app.ui.local_roots.contains(&dir) {
app.ui.local_roots.push(dir);
}
let _ = app.run("local.addRoot", json!({"path": dir}));
}
Err(e) => app.toast(ui.ctx(), e),
}
@ -256,6 +242,69 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.add_space(10.0);
}
/// Whether two paths name the same folder, however they are spelled (separators, trailing
/// slash, `.`/`..`, drive-letter case; see `lightcraft_catalog::query::folder_key`).
pub(crate) fn same_folder(a: &str, b: &str) -> bool {
a == b || lightcraft_catalog::query::folder_key(a) == lightcraft_catalog::query::folder_key(b)
}
/// Local's top-level folders and how the folder being browsed sits among them.
#[derive(Debug, Default, PartialEq)]
pub(crate) struct LocalPlaces {
/// (label, path) of each top-level folder, in order.
pub places: Vec<(String, String)>,
/// The top-level folder the browsed folder lies in (the innermost one): its tree opens on
/// the way down to it.
pub owner: Option<usize>,
/// A folder listed only for this session because the browsed folder is in no saved
/// location (browsed from a breadcrumb, the CLI…); it stays while browsing below it.
pub browse_root: Option<String>,
}
fn folder_label(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
/// Local's top-level folders, in order: the built-in places, the folders kept with Browse
/// Folder… / Keep in Local (`saved`), then — only when the browsed folder lies in none of those —
/// a session root for it: the previous one (`browse_root`) while browsing stays below it, else
/// the browsed folder itself. A folder inside a listed one is shown inside that one's tree (the
/// root stays; its siblings stay reachable), never as a root of its own. Each folder is listed
/// once however its path is spelled (the first spelling wins), and hidden ones are left out —
/// they stay reachable through breadcrumbs and Browse Folder….
pub(crate) fn local_places(
builtin: Vec<(String, String)>,
saved: &[String],
browsing: Option<&str>,
browse_root: Option<&str>,
hidden: &[String],
) -> LocalPlaces {
use lightcraft_catalog::query::{folder_key, folder_within};
let is_hidden = |p: &str| hidden.iter().any(|h| same_folder(h, p));
let mut places = builtin;
for path in saved {
if !places.iter().any(|(_, p)| same_folder(p, path)) {
places.push((folder_label(path), path.clone()));
}
}
places.retain(|(_, p)| !is_hidden(p));
let mut out = LocalPlaces::default();
if let Some(c) = browsing
&& !places.iter().any(|(_, p)| folder_within(c, p))
{
let root = browse_root.filter(|r| folder_within(c, r)).unwrap_or(c);
if !is_hidden(root) {
places.push((folder_label(root), root.to_string()));
out.browse_root = Some(root.to_string());
}
}
if let Some(c) = browsing {
out.owner = places.iter().enumerate().filter(|(_, (_, p))| folder_within(c, p)).max_by_key(|(_, (_, p))| folder_key(p).len()).map(|(i, _)| i);
}
out.places = places;
out
}
/// The subfolders of `path` (not hidden ones), sorted; listed at most every 2 s per folder.
fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> {
let id = egui::Id::new(("subfolders", path.to_string()));
@ -282,13 +331,37 @@ fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> {
}
/// A folder on disk with a disclosure triangle: click browses it, the triangle lists its
/// subfolders (expanded on the way to the folder being browsed).
fn folder_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str, path: &str, indent: f32, current: Option<&str>) {
/// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the
/// way down to it open whenever the browsed folder changes, so it shows highlighted in place.
/// `transient`: a top-level row listed for this session only (it offers Keep in Local).
#[allow(clippy::too_many_arguments)]
fn folder_tree(
app: &mut LightcraftApp,
ui: &mut egui::Ui,
name: &str,
path: &str,
indent: f32,
current: Option<&str>,
reveal: bool,
transient: bool,
) {
let t = Tokens::get(ui.ctx());
let open_id = egui::Id::new(("folder-open", path.to_string()));
let on_the_way = current.is_some_and(|c| c != path && std::path::Path::new(c).starts_with(path));
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(on_the_way);
let sel = current == Some(path);
let sel = current.is_some_and(|c| same_folder(c, path));
let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path));
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false);
if reveal && on_the_way {
// opened once per browsed folder: collapsing it again afterwards sticks
let revealed_id = egui::Id::new(("folder-revealed", path.to_string()));
let target = current.map(str::to_string);
if ui.data(|d| d.get_temp::<Option<String>>(revealed_id)) != Some(target.clone()) {
open = true;
ui.data_mut(|d| {
d.insert_temp(open_id, true);
d.insert_temp(revealed_id, target);
});
}
}
let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent + 12.0).on_hover_text(path);
let c = pos2(resp.rect.left() + 10.0 + indent, resp.rect.center().y);
let tri = Rect::from_center_size(c, vec2(14.0, 14.0));
@ -310,6 +383,12 @@ fn folder_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str, path: &st
app.toast(ui.ctx(), e);
}
resp.context_menu(|ui| {
if (transient || indent > 0.0)
&& ui.button(if transient { "Keep in Local" } else { "Add to Local" }).on_hover_text("List this folder in Local from now on").clicked()
{
let _ = app.run("local.addRoot", json!({"path": path}));
ui.close();
}
if indent == 0.0
&& ui.button("Remove from Local").on_hover_text("Hides this shortcut only; the folder and its photos stay as they are").clicked()
{
@ -344,7 +423,7 @@ fn folder_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str, path: &st
});
if open && indent < 12.0 * 8.0 {
for (n, p) in subfolders(ui, path) {
folder_tree(app, ui, &n, &p, indent + 12.0, current);
folder_tree(app, ui, &n, &p, indent + 12.0, current, reveal, false);
}
}
}
@ -398,7 +477,9 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent
} else {
let sel = app.session.source == LibrarySource::Album(a.id);
let icon = if a.is_smart() { Icon::SmartAlbum } else { Icon::Album };
let n = app.session.catalog.album_count(a.id);
// cached: a smart album's count scans the catalog
let now = (app.session.clock)();
let n = app.caches.album_counts(&app.session.catalog, &now).get(&a.id).copied().unwrap_or(0);
// the album B adds to is marked "+"
let target =
app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection());
@ -597,3 +678,76 @@ fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode
}
}
}
#[cfg(test)]
mod tests {
use super::local_places;
fn names(v: &[(String, String)]) -> Vec<&str> {
v.iter().map(|(n, _)| n.as_str()).collect()
}
/// A built-in location browsed (or picked) under another spelling of its path is the same
/// row, not a second root — for every built-in, not just named ones.
#[test]
fn equivalent_paths_are_one_local_location() {
for (home, sep) in [("D:\\Example", '\\'), ("D:/Example", '/'), ("/home/example", '/')] {
let builtin: Vec<(String, String)> =
["Pictures", "Desktop", "Downloads"].iter().map(|s| (s.to_string(), format!("{home}{sep}{s}"))).collect();
for (_, p) in builtin.clone() {
// the browsed spelling: other separators, a trailing one, a different drive-letter case
let flipped = p.replace(['/', '\\'], if sep == '/' { "\\" } else { "/" });
for browsing in [flipped.clone(), format!("{p}{sep}"), p.replacen("D:", "d:", 1)] {
let l = local_places(builtin.clone(), std::slice::from_ref(&browsing), Some(&browsing), None, &[]);
assert_eq!(names(&l.places), ["Pictures", "Desktop", "Downloads"], "{p} browsed as {browsing}");
assert_eq!(l.places[l.owner.unwrap()].1, p, "the built-in row is the one highlighted");
}
// hiding under one spelling hides the other
let l = local_places(builtin.clone(), &[], None, None, std::slice::from_ref(&flipped));
assert_eq!(l.places.len(), 2, "hidden {flipped}");
}
}
// a different folder is still added
let l = local_places(vec![("Pictures".into(), "/home/example/Pictures".into())], &[], Some("/home/example/Pictures2"), None, &[]);
assert_eq!(names(&l.places), ["Pictures", "Pictures2"]);
}
/// A folder inside a kept root is shown inside that root's tree: the root stays (its other
/// subfolders stay reachable) and no second root appears; other kept roots stay too.
#[test]
fn browsing_below_a_kept_root_keeps_the_root() {
let photos = "/data/Photos".to_string();
let other = "/data/Scans".to_string();
let saved = [photos.clone(), other.clone()];
let builtin = || vec![("Home".to_string(), "/home/example".to_string())];
for browsing in ["/data/Photos/2026/20260101", "/data/Photos/2026", "/data/Photos", "D:\\x"] {
let l = local_places(builtin(), &saved, Some(browsing), None, &[]);
let roots: Vec<&str> = l.places.iter().map(|(_, p)| p.as_str()).collect();
if browsing.starts_with("/data") {
assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans"], "{browsing}");
assert_eq!(l.owner, Some(1), "the Photos tree opens down to {browsing}");
assert_eq!(l.browse_root, None);
} else {
assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans", "D:\\x"], "a folder outside them gets a row");
}
}
// the innermost containing root is the one that opens
let l = local_places(builtin(), &["/home/example/Pictures".into()], Some("/home/example/Pictures/Trip"), None, &[]);
assert_eq!(l.owner, Some(1));
}
/// A browsed folder outside every kept root gets a session row, which stays while browsing
/// below it and gives way when browsing moves elsewhere.
#[test]
fn session_root_stays_while_browsing_below_it() {
let l = local_places(Vec::new(), &[], Some("/t/base"), None, &[]);
assert_eq!(l.browse_root.as_deref(), Some("/t/base"));
let l = local_places(Vec::new(), &[], Some("/t/base/Trip/Day 1"), l.browse_root.as_deref(), &[]);
assert_eq!((l.places.len(), l.browse_root.as_deref(), l.owner), (1, Some("/t/base"), Some(0)), "{l:?}");
let l = local_places(Vec::new(), &[], Some("/elsewhere"), l.browse_root.as_deref(), &[]);
assert_eq!(l.browse_root.as_deref(), Some("/elsewhere"));
// hidden: no row at all
let l = local_places(Vec::new(), &[], Some("/t/base"), None, &["/t/base/".into()]);
assert!(l.places.is_empty() && l.browse_root.is_none());
}
}

View File

@ -58,6 +58,16 @@ fn kind_label(s: &MaskShape) -> (&'static str, Icon) {
}
}
const TILE: f32 = 52.0;
const TILE_GAP: f32 = 6.0;
/// Columns and tile width of the Create New Mask grid in `width`: four tiles of up to 52 pt (at
/// least 48, so the labels fit), else three (narrowed if even those don't fit).
fn tile_layout(width: f32) -> (usize, f32) {
let tile = |n: f32| ((width - (n - 1.0) * TILE_GAP) / n).floor().min(TILE);
if tile(4.0) >= 48.0 { (4, tile(4.0)) } else { (3, tile(3.0).max(24.0)) }
}
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let t = Tokens::get(ui.ctx());
let d = app.session.develop_of(id).unwrap_or_default();
@ -75,9 +85,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
("luminanceRange", "Luminance", Icon::Sliders),
("colorRange", "Color", Icon::Picker),
];
egui::Grid::new("mask-tiles").spacing(vec2(6.0, 6.0)).show(ui, |ui| {
// four 52 pt tiles a row when they fit, else as many as fit (at least three, shrunk)
let (cols, tile) = tile_layout(ui.available_width());
egui::Grid::new("mask-tiles").spacing(vec2(TILE_GAP, TILE_GAP)).show(ui, |ui| {
for (i, (kind, label, icon)) in tiles.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(52.0, 52.0), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(tile, 52.0), Sense::click());
register(ui.ctx(), format!("maskNew:{kind}"), r);
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.inset });
paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 7.0), vec2(20.0, 20.0)), *icon, t.text_label);
@ -101,7 +113,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
}
}
if i % 4 == 3 {
if i % cols == cols - 1 {
ui.end_row();
}
}
@ -228,7 +240,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
return;
}
let resp = ui.add(egui::Label::new(format!("{op}{label}{}", if c.invert { " (inverted)" } else { "" })).sense(Sense::click()));
// a long name is cut short (with …) before the options button, not past the panel
let text = format!("{op}{label}{}", if c.invert { " (inverted)" } else { "" });
let room = (ui.available_width() - 30.0).max(0.0);
let resp = ui.scope(|ui| {
ui.set_max_width(room);
ui.add(egui::Label::new(text).truncate().sense(Sense::click()))
});
let resp = resp.inner;
register(ui.ctx(), format!("component:{i}"), resp.rect);
if resp.double_clicked() {
app.ui.renaming_component = Some((m.id, i, label.clone()));
@ -243,7 +262,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
range_controls(app, ui, i, &c.shape);
}
ui.add_space(6.0);
ui.horizontal(|ui| {
// wraps: the five actions are wider than a narrow panel
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.y = 6.0;
let add = text_button(ui, "maskAddComp", "Add", false);
egui::Popup::menu(&add).show(|ui| component_menu(app, ui, "add"));
let sub = text_button(ui, "maskSubComp", "Subtract", false);

View File

@ -25,6 +25,38 @@ use egui::{Align2, Rect, pos2, vec2};
use crate::LightcraftApp;
use crate::theme::Tokens;
/// 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<f32> {
let pid = egui::Id::new(id);
ui.ctx().data_mut(|d| d.remove::<egui::containers::panel::PanelState>(pid));
let avail = ui.available_rect_before_wrap();
let max = (avail.width() - reserve - crate::state::MIN_PHOTO_WIDTH).clamp(limits.min, limits.max);
let panel = if left { egui::Panel::left(pid) } else { egui::Panel::right(pid) };
let r = panel.frame(frame).resizable(true).size_range(limits.min..=max).default_size(limits.clamp(width).min(max)).show(ui, add);
crate::widgets::register(ui.ctx(), format!("panel:{id}"), r.response.rect);
// egui names the edge's drag widget `<panel id>.with("__resize")`; the width is measured from
// the panel's fixed edge to the pointer (contents wider than the panel don't count)
if !ui.ctx().is_being_dragged(pid.with("__resize")) {
return None;
}
let x = ui.ctx().pointer_interact_pos()?.x;
let w = if left { x - avail.left() } else { avail.right() - x };
Some(w.round().clamp(limits.min, max))
}
/// The HUD toast at the bottom centre of the canvas.
pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);

View File

@ -13,33 +13,36 @@ use crate::widgets::{divider, register, slider, text_button};
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::right("right_panel")
.exact_size(t.panel_w)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)))
.show(ui, |ui| {
let Some(id) = app.session.active() else {
let r = ui.max_rect();
super::empty_message(ui, r, "No photo selected", "Select a photo to edit");
return;
};
egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
match app.ui.right {
RightPanel::Edit => super::edit::show(app, ui, id),
RightPanel::Profiles => super::profiles::show(app, ui, id),
RightPanel::Crop => crop(app, ui, id),
RightPanel::Remove => remove(app, ui, id),
RightPanel::Masking => super::masking::show(app, ui, id),
RightPanel::RedEye => red_eye(app, ui, id),
RightPanel::Info => info(app, ui, id),
RightPanel::Keywords => keywords(app, ui, id),
RightPanel::Versions => versions(app, ui, id),
RightPanel::Activity => activity(app, ui, id),
RightPanel::None => {}
}
});
let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider));
// the presets column and the left sidebar are laid out after this panel: leave them their room
let reserve = if app.ui.presets { t.panel_w } else { 0.0 } + if app.ui.left_panel { crate::state::LEFT_WIDTH.min } else { 0.0 };
let width = app.ui.right_width;
let resized = super::resizable_side(ui, false, "right_panel", frame, width, crate::state::RIGHT_WIDTH, reserve, |ui| {
let Some(id) = app.session.active() else {
let r = ui.max_rect();
super::empty_message(ui, r, "No photo selected", "Select a photo to edit");
return;
};
egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
match app.ui.right {
RightPanel::Edit => super::edit::show(app, ui, id),
RightPanel::Profiles => super::profiles::show(app, ui, id),
RightPanel::Crop => crop(app, ui, id),
RightPanel::Remove => remove(app, ui, id),
RightPanel::Masking => super::masking::show(app, ui, id),
RightPanel::RedEye => red_eye(app, ui, id),
RightPanel::Info => info(app, ui, id),
RightPanel::Keywords => keywords(app, ui, id),
RightPanel::Versions => versions(app, ui, id),
RightPanel::Activity => activity(app, ui, id),
RightPanel::None => {}
}
});
});
if let Some(w) = resized {
app.ui.right_width = w;
}
}
pub fn header(ui: &mut egui::Ui, title: &str) {

View File

@ -216,7 +216,7 @@ fn paint_band(prepared: &[Prepared<'_>], band: &mut [Color32], y0: usize, w: usi
continue;
}
let clip = [p.clip[0], cy0, p.clip[2], cy1];
for tri in p.indices.chunks_exact(3) {
for tri in p.indices.as_chunks::<3>().0 {
let (Some(a), Some(b), Some(c)) = (p.verts.get(tri[0] as usize), p.verts.get(tri[1] as usize), p.verts.get(tri[2] as usize)) else {
continue;
};

View File

@ -175,6 +175,28 @@ pub const PREVIEW_EDGES: [u32; 4] = [1600, 2560, 3840, 5120];
/// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1.
pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800];
/// Width limits of a side panel the user resizes (points).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PanelWidth {
pub min: f32,
pub default: f32,
pub max: f32,
}
impl PanelWidth {
/// `w` within the limits (the default when it isn't a number).
pub fn clamp(&self, w: f32) -> f32 {
if w.is_finite() { w.clamp(self.min, self.max) } else { self.default }
}
}
/// The left sidebar (sources, albums, folders).
pub const LEFT_WIDTH: PanelWidth = PanelWidth { min: 200.0, default: 268.0, max: 480.0 };
/// The right panel (Edit, Masking, Info, …).
pub const RIGHT_WIDTH: PanelWidth = PanelWidth { min: 250.0, default: 270.0, max: 520.0 };
/// The photo area the side panels always leave free (as far as their minimum widths allow).
pub const MIN_PHOTO_WIDTH: f32 = 360.0;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct UiState {
@ -184,6 +206,10 @@ pub struct UiState {
pub view: ViewMode,
pub left_panel: bool,
pub right: RightPanel,
/// Widths of the left sidebar and of the right panel in points (dragging their inner edge
/// resizes them; within [`LEFT_WIDTH`] / [`RIGHT_WIDTH`]).
pub left_width: f32,
pub right_width: f32,
/// Presets column open (opens to the left of the Edit panel).
pub presets: bool,
/// Presets column: show a live thumbnail of the photo with each preset.
@ -289,8 +315,13 @@ pub struct UiState {
pub spots_threshold: f32,
/// The library filter bar above the grid.
pub filter_bar: bool,
/// Folders picked with Local → Browse Folder…, kept in the sidebar after browsing elsewhere.
/// Folders kept in Local (Browse Folder…, Keep in Local / Add to Local; `local.addRoot`):
/// listed after the built-in locations, across restarts, whatever is browsed.
pub local_roots: Vec<String>,
/// The top-level Local row listed for this session only, for a browsed folder outside every
/// kept location; it stays while browsing below it (see `panels::left::local_places`).
#[serde(skip)]
pub local_browse_root: Option<String>,
/// Culling: after a rating, flag or colour-label key, move to the next photo.
pub auto_advance: bool,
/// Full-screen preview (F): the photo alone on black, no chrome.
@ -372,7 +403,7 @@ pub enum Dialog {
minutes: i32,
zone: f32,
},
/// The import review (File → Add Photos…).
/// The import review (File → Import Photos…).
Import {
opts: Box<crate::import::ImportDialog>,
},
@ -477,6 +508,8 @@ impl Default for UiState {
luminance_map_restore: None,
view: ViewMode::Detail,
left_panel: false,
left_width: LEFT_WIDTH.default,
right_width: RIGHT_WIDTH.default,
right: RightPanel::Edit,
presets: false,
preset_thumbs: false,
@ -544,6 +577,7 @@ impl Default for UiState {
window_fullscreen: None,
filter_bar: false,
local_roots: Vec::new(),
local_browse_root: None,
compare: None,
reference: None,
toast: None,
@ -580,6 +614,8 @@ impl UiState {
/// Clamp values restored from disk.
pub fn sanitized(mut self) -> Self {
self.thumb_size = self.thumb_size.clamp(90.0, 480.0);
self.left_width = LEFT_WIDTH.clamp(self.left_width);
self.right_width = RIGHT_WIDTH.clamp(self.right_width);
self.brush_size = self.brush_size.clamp(0.002, 0.5);
self.dialog = None;
self.fullscreen = false;

View File

@ -0,0 +1,150 @@
//! Headless tests of the side panels: dragging their inner edge resizes them within limits, and
//! the chosen widths survive switching panels and a save/load of the UI state (issue #20).
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::state::{LEFT_WIDTH, MIN_PHOTO_WIDTH, RIGHT_WIDTH};
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
fn demo(size: [f32; 2], ui: serde_json::Value) -> Headless {
let services = Services { png: None, ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, size, 1.0);
let r = h.request("ui.set", ui, T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
h
}
fn widget(h: &Headless, id: &str) -> egui::Rect {
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))
}
/// Drag horizontally from `x` by `dx` (within the 1400 pt window) at mid height.
fn drag(h: &mut Headless, x: f32, dx: f32) {
let to = (x + dx).clamp(1.0, 1399.0);
let r = h.request("ui.drag", json!({"x": x, "y": 500.0, "toX": to, "toY": 500.0, "steps": 12}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
}
#[test]
fn side_panels_resize_by_their_inner_edge_and_remember_it() {
let mut h = demo([1400.0, 900.0], json!({"view": "detail", "right": "edit", "leftPanel": true}));
let right = widget(&h, "panel:right_panel");
let left = widget(&h, "panel:left_panel");
assert_eq!((left.width(), right.width()), (LEFT_WIDTH.default, RIGHT_WIDTH.default));
// the right panel's left edge: 120 pt wider
drag(&mut h, right.left() + 2.0, -120.0);
assert!((h.app.ui.right_width - (RIGHT_WIDTH.default + 120.0)).abs() <= 2.0, "{}", h.app.ui.right_width);
assert!((widget(&h, "panel:right_panel").width() - h.app.ui.right_width).abs() <= 1.0);
// the left sidebar's right edge: 70 pt wider
drag(&mut h, left.right() - 2.0, 70.0);
assert!((h.app.ui.left_width - (LEFT_WIDTH.default + 70.0)).abs() <= 2.0, "{}", h.app.ui.left_width);
// kept across panel switches and view changes
let (lw, rw) = (h.app.ui.left_width, h.app.ui.right_width);
for set in [json!({"right": "info"}), json!({"view": "photoGrid"}), json!({"right": "masking", "view": "detail"})] {
h.request("ui.set", set, T);
h.step();
assert_eq!((widget(&h, "panel:left_panel").width(), widget(&h, "panel:right_panel").width()), (lw, rw));
}
// and across a save/load of the UI state (ui.json)
let saved = serde_json::to_string(&h.app.ui).unwrap();
let back = serde_json::from_str::<crate::UiState>(&saved).unwrap().sanitized();
assert_eq!((back.left_width, back.right_width), (lw, rw));
// limits: the photo area stays usable however far the edges are dragged…
let right = widget(&h, "panel:right_panel");
drag(&mut h, right.left() + 2.0, -1400.0);
let left = widget(&h, "panel:left_panel");
drag(&mut h, left.right() - 2.0, 1400.0);
h.step();
let (left, right) = (widget(&h, "panel:left_panel"), widget(&h, "panel:right_panel"));
assert!(right.width() <= RIGHT_WIDTH.max && left.width() <= LEFT_WIDTH.max, "{left:?} {right:?}");
assert!(right.left() - left.right() >= MIN_PHOTO_WIDTH - 1.0, "photo area {} wide", right.left() - left.right());
// …and neither panel gets narrower than its minimum
drag(&mut h, right.left() + 2.0, 1400.0);
let left = widget(&h, "panel:left_panel");
drag(&mut h, left.right() - 2.0, -1400.0);
assert_eq!((h.app.ui.left_width, h.app.ui.right_width), (LEFT_WIDTH.min, RIGHT_WIDTH.min));
// out-of-range saved widths are clamped on load
let mut u = crate::UiState { left_width: 5.0, right_width: f32::NAN, ..Default::default() }.sanitized();
assert_eq!((u.left_width, u.right_width), (LEFT_WIDTH.min, RIGHT_WIDTH.default));
u.right_width = 9000.0;
assert_eq!(u.sanitized().right_width, RIGHT_WIDTH.max);
}
/// Every widget drawn over the right panel lies within it (issue #47: selecting a mask added a
/// row wider than the panel, which pushed the panel's contents left, clipping them).
fn assert_inside_right_panel(h: &Headless, what: &str) {
let panel = widget(h, "panel:right_panel");
let mut n = 0;
for (id, r) in &h.app.widgets {
// widgets of the photo area (pins, filmstrip cells clipped at its edge) and of the tool
// strip are outside on purpose
if ["panel:", "film:", "maskPin"].iter().any(|p| id.starts_with(p))
|| r.bottom() <= panel.top()
|| r.right() <= panel.left() + 1.5
|| r.left() >= panel.right() - 1.5
{
continue;
}
n += 1;
assert!(r.left() >= panel.left() - 0.5 && r.right() <= panel.right() + 0.5, "{what}: {id} {r:?} sticks out of the panel {panel:?}");
}
assert!(n > 10, "{what}: only {n} widgets in the panel");
}
#[test]
fn masking_contents_fit_the_right_panel_at_any_width() {
let mut h = demo([1400.0, 900.0], json!({"view": "detail", "right": "masking"}));
for (kind, op) in [("subject", None), ("linear", None), ("luminanceRange", Some("subtract")), ("radial", Some("intersect"))] {
let (command, params) = match op {
None => ("mask.add", json!({"kind": kind})),
Some(op) => ("mask.addComponent", json!({"kind": kind, "op": op})),
};
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
assert_eq!(r["ok"], true, "{r}");
}
let r = h.request(
"engine.execute",
json!({"command": "mask.component", "params": {"component": 0, "action": "rename", "name": "A rather long component name for this mask"}}),
T,
);
assert_eq!(r["ok"], true, "{r}");
for width in [RIGHT_WIDTH.min, RIGHT_WIDTH.default, 330.0, RIGHT_WIDTH.max] {
for (selected, tool) in [(false, ""), (true, ""), (true, "brush")] {
let mask = if selected { json!(2) } else { serde_json::Value::Null };
h.request("engine.execute", json!({"command": "mask.select", "params": {"id": mask}}), T);
h.request("ui.set", json!({"rightWidth": width, "tool": tool}), T);
h.step();
h.step();
let what = format!("width {width}, mask selected {selected}, tool {tool:?}");
assert_eq!(widget(&h, "panel:right_panel").width(), width, "{what}");
if selected {
assert!(h.app.widgets.iter().any(|(id, _)| id == "button:maskInvert"), "{what}: no mask actions");
}
assert_inside_right_panel(&h, &what);
}
}
}
#[test]
fn a_narrow_window_shrinks_the_panels_without_forgetting_their_width() {
let mut h = demo([1400.0, 900.0], json!({"view": "detail", "right": "edit", "leftPanel": true, "rightWidth": 480.0, "leftWidth": 400.0}));
assert_eq!(widget(&h, "panel:right_panel").width(), 480.0);
h.request("ui.resize", json!({"width": 1000.0, "height": 800.0}), T);
h.settle(SETTLE);
let (left, right) = (widget(&h, "panel:left_panel"), widget(&h, "panel:right_panel"));
assert!(right.left() - left.right() >= MIN_PHOTO_WIDTH - 1.0, "{left:?} {right:?}");
// the chosen widths come back when the window does
assert_eq!((h.app.ui.left_width, h.app.ui.right_width), (400.0, 480.0));
h.request("ui.resize", json!({"width": 1400.0, "height": 900.0}), T);
h.settle(SETTLE);
assert_eq!(widget(&h, "panel:right_panel").width(), 480.0);
}

View File

@ -47,7 +47,6 @@ pub struct Tokens {
pub bottom_bar_h: f32,
pub panel_w: f32,
pub strip_w: f32,
pub left_w: f32,
pub slider_row_h: f32,
pub section_h: f32,
pub film_h: f32,
@ -88,7 +87,6 @@ impl Default for Tokens {
bottom_bar_h: 48.0,
panel_w: 270.0,
strip_w: 48.0,
left_w: 268.0,
slider_row_h: 45.0,
section_h: 57.0,
film_h: 142.0,

View File

@ -76,7 +76,7 @@ During development you can also point the client at `cargo run --release -p ligh
|---|---|
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, parameter doc, enabled now |
| `run_command {command, params?}` | Run any command by id |
| `import {paths, album?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active |
| `import {paths, album?, mode?, destination?, organize?, rename?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active. `mode: "copy"` copies into `destination` (default: the library's Originals/), filed by `organize`: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or a folder template such as `{date:%Y}/{date:%Y%m%d}` (→ `2026/20260114`; capture date, else the import date; always inside the destination), and named by the `rename` template (`run_command photo.renameTokens` lists the tags) |
| `query_photos {filter?, sort?, offset?, limit?}` | Photos in the current view (or matching a catalog `Filter`) |
| `select_photos {ids, active?, mode?}` | Set the selection / active photo |
| `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value |

View File

@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 14 | 1 | 2 | 1 | 8/8 (100%) | 5/5 (100%) |
| X. Cross-cutting behaviours (BEHAV) | 15 | 1 | 2 | 1 | 8/8 (100%) | 6/6 (100%) |
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
| Lightroom Classic extras | 29 | 9 | 41 | 9 | — | 20/21 (95%) |
| **Total** | 383 | 25 | 92 | 37 | 194/198 (98%) | 136/144 (94%) |
| **Total** | 384 | 25 | 92 | 37 | 194/198 (98%) | 137/145 (94%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.1%** of 500 in-scope rows — P0 99.0% of 198 · P1 96.2% of 144 · P2 38.6% of 158.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.1%** of 501 in-scope rows — P0 99.0% of 198 · P1 96.2% of 145 · P2 38.6% of 158.
<!-- /parity:summary -->
## Top gaps
@ -99,16 +99,16 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog: candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked), destination, album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); batched import with a progress window; one undo step. Drag-and-drop still imports directly |
| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked), transfer mode (add in place / copy, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); batched import with a progress window; one undo step. Drag-and-drop still imports directly |
| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported |
| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | |
| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Add from Device → the import review, copying into the library by default; no PTP/MTP (cameras that don't mount as a disk) |
| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); no PTP/MTP (cameras that don't mount as a disk) |
| LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s |
| LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry |
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |
| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | |
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
@ -123,7 +123,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters |
| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort |
| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) |
| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs` | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`) |
| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`); sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists |
| LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service |
| LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library |
| LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS |
@ -137,7 +137,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" |
| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | |
| LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) |
| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming); preview; renames files on disk with their XMP sidecars, never overwriting (-1, -2… suffixes), rolls back on failure; undo/redo move the files; virtual copies follow |
| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars, never overwriting (-1, -2… suffixes), rolls back on failure; undo/redo move the files; virtual copies follow |
| LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time” |
| LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ✅ | `cmd:app.showInFinder` | ⌘R (see MENU-FILE-SHOWFINDER) |
| LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | |
@ -194,7 +194,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own |
| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`) | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; no embedded map, no geocoding |
| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset |
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | |
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none |
| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets |
## F. Edit panel — global adjustments (EDIT)
@ -309,7 +309,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-MASK-PANEL | Masks panel | P0 | ✅ | `cmd:panel.masking`, `cmd:mask.add`, `cmd:mask.select`, `cmd:mask.rename`, `cmd:mask.duplicate` (`invert`), `cmd:mask.move`, `cmd:mask.visible`, `cmd:mask.delete`, `crates/ui-egui/src/panels/masking.rs` | list with per-mask show/hide eye, double-click rename, right-click menu (duplicate, duplicate and invert, invert, hide, move up/down, rename, delete); no drag-to-reorder |
| LR-MASK-PANEL | Masks panel | P0 | ✅ | `cmd:panel.masking`, `cmd:mask.add`, `cmd:mask.select`, `cmd:mask.rename`, `cmd:mask.duplicate` (`invert`), `cmd:mask.move`, `cmd:mask.visible`, `cmd:mask.delete`, `crates/ui-egui/src/panels/masking.rs` | list with per-mask show/hide eye, double-click rename, right-click menu (duplicate, duplicate and invert, invert, hide, move up/down, rename, delete); fits any panel width (create tiles go to three columns, mask actions wrap, long component names are cut short); no drag-to-reorder |
| LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model |
| LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`) | heuristic |
| LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject heuristic |
@ -482,6 +482,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback |
| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging) |
| LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | |
| LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed |
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
@ -504,9 +505,9 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-APP-SYNC | Sync status / pause | OOS | 🚫 | | |
| MENU-APP-SIGNOUT | Sign out | OOS | 🚫 | | |
| MENU-APP-HIDE | Hide / hide others / show all | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's own items in the app menu (⌘H, ⌥⌘H) |
| MENU-APP-QUIT | Quit | P0 | ✅ | `cmd:app.quit`, `apps/lightcraft/src/native_menu.rs` | macOS: app menu (native); elsewhere: File → Quit LightCraft |
| MENU-FILE-ADDPHOTOS | Add Photos… | P0 | ✅ | `cmd:file.addPhotos` | |
| MENU-FILE-ADDFOLDER | Add Folder… | P0 | ✅ | `cmd:file.addFolder`, `cmd:library.importPreview` | folder picker (desktop) → the import review, subfolders included; `path` param for agents |
| MENU-APP-QUIT | Quit | P0 | ✅ | `cmd:app.quit`, `apps/lightcraft/src/native_menu.rs` | macOS: app menu (native); elsewhere: File → Quit LightCraft, always the last item in its own group (after commands the File layout does not list) |
| MENU-FILE-ADDPHOTOS | Import Photos… (was Add Photos…) | P0 | ✅ | `cmd:file.addPhotos`, `crates/ui-egui/src/menus.rs` | first item of File, ⇧⌘I; file picker → the import review |
| MENU-FILE-ADDFOLDER | Import from Folder… (was Add Folder…) | P0 | ✅ | `cmd:file.addFolder`, `cmd:library.importPreview` | folder picker (desktop) → the import review, subfolders included; `path` param for agents. Worded as importing: it doesn't save a Local location (that is Local → Browse Folder…) |
| MENU-FILE-MIGRATE | Migrate photos | OOS | 🚫 | | |
| MENU-FILE-NEWALBUM | New Album… | P0 | ✅ | `cmd:dialog.newAlbum` | |
| MENU-FILE-NEWFOLDER | New Folder… | P0 | ✅ | `cmd:dialog.newFolder` | ⇧⌘N |
@ -638,7 +639,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
| 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-DELETE | Delete photo — ⌫ | P0 | ✅ | `cmd:photo.delete` | |
| KEY-ADDPHOTOS | Add photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | |
| KEY-ADDPHOTOS | Import photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | |
| KEY-VERSIONS | Versions panel — ⇧V | P1 | ✅ | `cmd:panel.versions` | |
| KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | ✅ | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | ⌘1–⌘5 (Light, Color, Effects, Detail, Optics) as observed; our Edit panel has no Lens Blur / Geometry section for ⌘6 / ⌘7; Zoom 100% moved to ⌥⌘0 |
| KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | |
@ -686,7 +687,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder; rename template with live example, numbered across the import); develop preset, metadata preset, keywords, album on import |
| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder / custom folder template such as `{date:%Y}/{date:%Y%m%d}` → 2026/20260114, kept inside the destination; example destination shown; rename template with live example, Tags picker (insert at cursor) and unknown-tag warning, numbered across the import); develop preset, metadata preset, keywords, album on import |
| LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO |
| LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | |
| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info); no people view |
@ -708,7 +709,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-PEOPLE | People view | P2 | ⬜ | | |
| LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | |
| LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) |
| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate` | File → Find Missing Photos… (same name and size anywhere in a folder, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. No missing-photos collection |
| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`) | File → Find Missing Photos… (same name and size anywhere in a folder, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike |
| LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable |
| LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (progress toasts, stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) |
| LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW |

View File

@ -90,7 +90,7 @@ same-origin or send CORP/CORS headers (the bundle has no third-party resources).
(`navigator.storage.persist()`); without it the browser may evict the data under storage
pressure. URL options: `?store=idb` forces IndexedDB, `?store=memory` keeps nothing, `?reset`
deletes the stored library first.
- **Importing photos with no filesystem.** *File ▸ Add Photos…* (<kbd>⌘⇧I</kbd>) opens the
- **Importing photos with no filesystem.** *File ▸ Import Photos…* (<kbd>⌘⇧I</kbd>) opens the
browser's file picker. You can also drop files anywhere on the page. The bytes are written to
storage, then imported and decoded by the same engine code as the desktop app
(`lightcraft_engine::files::{probe_bytes, load_bytes}`): JPEG/PNG/TIFF/WebP and the supported

View File

@ -31,7 +31,10 @@ What we write (standard namespaces, so other tools can read the metadata):
`lc:` is `http://ns.lightcraft.app/lc/1.0/`.
Reading merges into the catalog with the **sidecar winning** for every field it states; fields it doesn't state are
kept. `xmp:Rating="-1"` (the XMP convention for rejected) sets the reject flag. Develop settings come from
kept. The one exception is the **capture time**: a time embedded in the file (EXIF/IPTC) always wins; when the file
has none, the sidecar's `exif:DateTimeOriginal`, else `photoshop:DateCreated`, else `xmp:CreateDate` (ISO 8601) is
used — on import (it then also files a copied photo in its date folder) and by `photo.readMetadataFromFile` (undoable).
`xmp:Rating="-1"` (the XMP convention for rejected) sets the reject flag. Develop settings come from
`lc:settings` when present (exact); otherwise from the `crs:` fields below (approximate).
## Reading `crs:` develop fields