Merge main into fix/issue-30-import-entry-point
# Conflicts: # crates/ui-egui/src/import.rs # docs/parity.md
This commit is contained in:
commit
b56e40e8d5
|
|
@ -289,7 +289,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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -341,6 +341,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();
|
||||
|
|
|
|||
|
|
@ -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:\\"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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, creator, location and keywords of the active photo", has_active, |s, _| {
|
||||
let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?;
|
||||
|
|
@ -735,7 +735,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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");
|
||||
|
|
@ -760,11 +760,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))
|
||||
|
|
|
|||
|
|
@ -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")?;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 → 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, ©_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);
|
||||
|
|
|
|||
|
|
@ -507,8 +507,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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -80,6 +82,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>,
|
||||
}
|
||||
|
||||
|
|
@ -108,6 +113,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 {
|
||||
|
|
@ -136,8 +142,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;
|
||||
}
|
||||
|
|
@ -306,6 +316,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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -445,3 +445,55 @@ 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -243,7 +243,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 {
|
||||
|
|
|
|||
|
|
@ -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), _) => {
|
||||
|
|
|
|||
|
|
@ -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]];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ fn cfa(h: &Header) -> Cfa {
|
|||
/// 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
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -827,6 +827,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]
|
||||
|
|
@ -1218,6 +1266,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]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
//! 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 dated `Originals/` folders), an album
|
||||
//! 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.
|
||||
|
||||
|
|
@ -37,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).
|
||||
|
|
@ -63,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()
|
||||
}
|
||||
|
|
@ -283,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());
|
||||
|
|
@ -460,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)> = {
|
||||
|
|
@ -628,6 +684,124 @@ 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());
|
||||
|
|
|
|||
|
|
@ -27,6 +27,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 +406,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 +663,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 +787,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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ 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",
|
||||
|
|
@ -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] = &[
|
||||
|
|
@ -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
|
||||
|
|
@ -795,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();
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
@ -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())),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -102,13 +102,13 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| 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 → Import 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, 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,7 +505,7 @@ 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-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 | 🚫 | | |
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -29,7 +29,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
|
||||
|
|
|
|||
Loading…
Reference in New Issue