LR-IMP-LOCAL: browse folders on disk without adding them (Local), failed renders aren't retried
- library.browse {path, subfolders}: a folder's photos are read in place as "local" photos
(catalog flag, SetLocal op) that only folder views list — not All Photos, albums, date
groups, keyword counts or stats. photo.addToLibrary promotes them; importing the folder for
real promotes instead of reporting duplicates. The browsed folder persists with the view.
- UI: Local section (Pictures, Desktop, Downloads, Home, the browsed folder, Browse Folder…);
folder views get a breadcrumb header (each part opens that folder), Include subfolders and
"Add N to My Photos".
- Renderer: a job that fails (unreadable / missing file) is remembered by slot + key and not
requested again every frame — the grid re-queued it forever (a render loop per broken
file). The loupe says "This photo can't be opened" with the reason; grid cells show "!".
ui.inspect lists pending render slots.
- Headless app.export (MCP, lightcraft-cli run) exports the selection like the desktop app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
096aefe698
commit
71dc30b951
|
|
@ -75,7 +75,7 @@ impl Catalog {
|
|||
}
|
||||
let mut roots = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for p in self.photos().filter(|p| !p.deleted) {
|
||||
for p in self.photos().filter(|p| p.in_library()) {
|
||||
for k in &p.meta.keywords {
|
||||
let parts: Vec<&str> = k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect();
|
||||
insert(&mut roots, &parts, "", p.id.0, &mut seen);
|
||||
|
|
@ -162,7 +162,7 @@ impl Catalog {
|
|||
} else {
|
||||
let mut co: std::collections::HashMap<String, i64> = Default::default();
|
||||
if !current.is_empty() {
|
||||
for p in self.photos().filter(|p| !p.deleted) {
|
||||
for p in self.photos().filter(|p| p.in_library()) {
|
||||
if p.meta.keywords.iter().any(|k| has(k)) {
|
||||
for k in p.meta.keywords.iter().filter(|k| !has(k)) {
|
||||
*co.entry(k.clone()).or_default() += 1;
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ pub enum Op {
|
|||
id: PhotoId,
|
||||
deleted: bool,
|
||||
},
|
||||
/// Browsed-only (`true`) or part of the library.
|
||||
SetLocal {
|
||||
id: PhotoId,
|
||||
local: bool,
|
||||
},
|
||||
SetVersions {
|
||||
id: PhotoId,
|
||||
versions: Vec<Version>,
|
||||
|
|
@ -339,6 +344,10 @@ impl Catalog {
|
|||
let p = self.photo_mut(id)?;
|
||||
Op::SetDeleted { id, deleted: std::mem::replace(&mut p.deleted, deleted) }
|
||||
}
|
||||
Op::SetLocal { id, local } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetLocal { id, local: std::mem::replace(&mut p.local, local) }
|
||||
}
|
||||
Op::SetVersions { id, versions } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetVersions { id, versions: std::mem::replace(&mut p.versions, versions) }
|
||||
|
|
|
|||
|
|
@ -168,6 +168,10 @@ pub struct Photo {
|
|||
/// In "Recently Deleted".
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
/// Seen while browsing a folder on disk (Local), not added to the library: only folder views
|
||||
/// show it until it is added.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub local: bool,
|
||||
/// Video duration in seconds.
|
||||
#[serde(default)]
|
||||
pub duration: Option<f64>,
|
||||
|
|
@ -217,6 +221,7 @@ impl Photo {
|
|||
versions: Vec::new(),
|
||||
history: Vec::new(),
|
||||
deleted: false,
|
||||
local: false,
|
||||
duration: None,
|
||||
as_shot_wb: None,
|
||||
content_hash: None,
|
||||
|
|
@ -246,6 +251,10 @@ impl Photo {
|
|||
}
|
||||
d
|
||||
}
|
||||
/// In the library: not deleted and not only browsed (Local).
|
||||
pub fn in_library(&self) -> bool {
|
||||
!self.deleted && !self.local
|
||||
}
|
||||
/// Edited by the user: settings differ from what import gave the photo.
|
||||
pub fn is_edited(&self) -> bool {
|
||||
*self.develop != self.import_defaults() && !self.develop.is_unedited()
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ pub struct Filter {
|
|||
pub date_to: Option<String>,
|
||||
/// Import date prefix (Recently Added).
|
||||
pub imported: Option<String>,
|
||||
/// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below.
|
||||
pub folder: Option<String>,
|
||||
pub subfolders: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -156,6 +159,17 @@ impl Filter {
|
|||
if p.deleted != self.deleted {
|
||||
return false;
|
||||
}
|
||||
match &self.folder {
|
||||
// browsed photos only show in folder views
|
||||
None if p.local => return false,
|
||||
None => {}
|
||||
Some(dir) => {
|
||||
let crate::Source::File { path } = &p.source else { return false };
|
||||
if !in_folder(path, dir, self.subfolders) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.rating > 0 {
|
||||
let ok = match self.rating_op {
|
||||
RatingOp::AtLeast => p.rating >= self.rating,
|
||||
|
|
@ -239,7 +253,7 @@ impl Catalog {
|
|||
pub fn date_groups(&self) -> Vec<DateGroup> {
|
||||
use std::collections::BTreeMap;
|
||||
let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new();
|
||||
for p in self.photos().filter(|p| !p.deleted) {
|
||||
for p in self.photos().filter(|p| p.in_library()) {
|
||||
let d = p.date();
|
||||
if d.len() >= 10 {
|
||||
let (months, days) = years.entry(&d[..4]).or_default();
|
||||
|
|
@ -263,7 +277,7 @@ impl Catalog {
|
|||
/// All keywords with usage counts, sorted by name.
|
||||
pub fn keywords(&self) -> Vec<(String, usize)> {
|
||||
let mut m: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
for p in self.photos().filter(|p| !p.deleted) {
|
||||
for p in self.photos().filter(|p| p.in_library()) {
|
||||
for k in &p.meta.keywords {
|
||||
*m.entry(k.clone()).or_default() += 1;
|
||||
}
|
||||
|
|
@ -281,3 +295,12 @@ pub struct DateGroup {
|
|||
/// `YYYY-MM-DD` and counts, newest first.
|
||||
pub days: Vec<(String, usize)>,
|
||||
}
|
||||
|
||||
/// Whether file `path` is directly in `dir` (or anywhere below it with `deep`). Both `/` and `\\`
|
||||
/// separate.
|
||||
pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
|
||||
let dir = dir.trim_end_matches(['/', '\\']);
|
||||
let Some(rest) = path.strip_prefix(dir) else { return false };
|
||||
let Some(rest) = rest.strip_prefix(['/', '\\']) else { return false };
|
||||
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,3 +205,14 @@ fn capture_times_display_like_the_info_panel() {
|
|||
assert_eq!(display_time("2022-07-04"), "July 4, 2022");
|
||||
assert_eq!(display_time("someday"), "someday");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_paths() {
|
||||
use crate::query::in_folder;
|
||||
assert!(in_folder("/a/b/c.jpg", "/a/b", false));
|
||||
assert!(in_folder("/a/b/c.jpg", "/a/b/", false));
|
||||
assert!(!in_folder("/a/b/x/c.jpg", "/a/b", false));
|
||||
assert!(in_folder("/a/b/x/c.jpg", "/a/b", true));
|
||||
assert!(!in_folder("/a/bc/d.jpg", "/a/b", true), "a sibling with the same prefix");
|
||||
assert!(in_folder("C:\\Pics\\a.jpg", "C:\\Pics", false));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
//! Local: browse a folder on disk without adding it to the library (Lightroom's Local tab).
|
||||
//! The folder's photos are probed in place as `local` photos — only folder views list them —
|
||||
//! and edits go to the files' XMP sidecars like any photo added in place. "Add to My Photos"
|
||||
//! makes them ordinary library photos.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
||||
use crate::import::{ImportMode, ImportOptions, import_with, is_supported};
|
||||
use crate::{Browse, LibrarySource, Result, Session};
|
||||
|
||||
fn browse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.browse";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let dir = std::path::absolute(Path::new(path)).map_err(|e| bad(C, e.to_string()))?;
|
||||
if !dir.is_dir() {
|
||||
return Err(bad(C, format!("{path}: not a folder")));
|
||||
}
|
||||
let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string();
|
||||
let subfolders = bool_or(p, "subfolders", s.browse.as_ref().is_some_and(|b| b.subfolders));
|
||||
let files: Vec<String> = if subfolders {
|
||||
crate::import::expand(std::slice::from_ref(&dir_s), None)
|
||||
} else {
|
||||
let mut v: Vec<String> = std::fs::read_dir(&dir)
|
||||
.map_err(|e| bad(C, format!("{path}: {e}")))?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|f| f.is_file() && is_supported(f) && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')))
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?;
|
||||
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
|
||||
s.source = LibrarySource::Folder;
|
||||
let shown = s.visible().len();
|
||||
Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()}))
|
||||
}
|
||||
|
||||
/// The browsed photos among `ids` (default: the selection) become library photos.
|
||||
fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({"added": 0}));
|
||||
}
|
||||
let n = ids.len();
|
||||
let ops = ids.into_iter().map(|id| Op::SetLocal { id, local: false }).collect();
|
||||
s.commit("Add to My Photos", Op::Batch { ops })?;
|
||||
Ok(json!({"added": n}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"library.browse",
|
||||
"Browse Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, subfolders?: bool} — show a folder's photos without adding them to the library (they're read in place; edits go to XMP sidecars) → {path, photos, new}",
|
||||
always,
|
||||
browse
|
||||
),
|
||||
cmd!("photo.addToLibrary", "Add to My Photos", ["Photo"], None, "{ids?} — browsed (Local) photos join the library", always, add_to_library),
|
||||
]
|
||||
}
|
||||
|
|
@ -568,7 +568,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
{
|
||||
return Err(bad("library.import", "album must be a regular album"));
|
||||
}
|
||||
let opts = crate::import::ImportOptions { mode, preset, keywords: strs(p, "keywords") };
|
||||
let opts = crate::import::ImportOptions { mode, preset, keywords: strs(p, "keywords"), ..Default::default() };
|
||||
let undo0 = s.undo.len();
|
||||
let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default();
|
||||
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
//! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…).
|
||||
|
||||
mod before;
|
||||
mod browse;
|
||||
mod color;
|
||||
mod develop;
|
||||
mod edit;
|
||||
|
|
@ -121,6 +122,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(prefs::specs());
|
||||
v.extend(export::specs());
|
||||
v.extend(before::specs());
|
||||
v.extend(browse::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
Ok(json!({"total": ids.len(), "photos": items}))
|
||||
}),
|
||||
cmd!(query "catalog.stats", "Catalog Statistics", [], None, "{}", always, |s, _| {
|
||||
let all: Vec<_> = s.catalog.photos().filter(|p| !p.deleted).collect();
|
||||
let all: Vec<_> = s.catalog.photos().filter(|p| p.in_library()).collect();
|
||||
Ok(json!({
|
||||
"photos": all.len(),
|
||||
"edited": all.iter().filter(|p| p.is_edited()).count(),
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ pub struct ImportOptions {
|
|||
pub preset: Option<lightcraft_develop::Preset>,
|
||||
/// Added to every imported photo.
|
||||
pub keywords: Vec<String>,
|
||||
/// Browsing a folder: photos come in as `local` (not in the library), and a file with the
|
||||
/// same content as one already known is still listed.
|
||||
pub local: bool,
|
||||
}
|
||||
|
||||
/// A file found by [`scan`], for the import review.
|
||||
|
|
@ -372,8 +375,14 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
|||
}
|
||||
}
|
||||
let mut todo = Vec::new();
|
||||
// a file that was only browsed (Local) joins the library when it is imported for real
|
||||
let mut promote = Vec::new();
|
||||
for f in files {
|
||||
match by_path.get(f.as_str()) {
|
||||
Some(id) if !opts.local && s.catalog.photo(*id).is_some_and(|p| p.local) => {
|
||||
promote.push(Op::SetLocal { id: *id, local: false });
|
||||
report.imported.push(id.0);
|
||||
}
|
||||
Some(id) => report.duplicates.push(Duplicate { path: f, existing: Some(id.0), reason: "path" }),
|
||||
None => todo.push(f),
|
||||
}
|
||||
|
|
@ -382,7 +391,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
|||
let probed = probe_all(s, &todo);
|
||||
crate::memory::release();
|
||||
let now = (s.clock)();
|
||||
let mut ops = Vec::new();
|
||||
let mut ops = promote;
|
||||
for (path, info) in todo.into_iter().zip(probed) {
|
||||
let info = match info {
|
||||
Ok(i) => i,
|
||||
|
|
@ -393,6 +402,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
|||
}
|
||||
};
|
||||
if let Some(h) = &info.content_hash
|
||||
&& !opts.local
|
||||
&& let Some(id) = by_hash.get(h)
|
||||
{
|
||||
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
|
||||
|
|
@ -452,6 +462,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
|||
p.history.push(lightcraft_catalog::HistoryStep { label, settings: d });
|
||||
}
|
||||
report.imported.push(id.0);
|
||||
p.local = opts.local;
|
||||
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
|
|||
use lightcraft_develop::DevelopSettings;
|
||||
pub use media::{RenderJob, SourceLevel};
|
||||
use serde_json::Value;
|
||||
pub use view::{LibrarySource, Selection};
|
||||
pub use view::{Browse, LibrarySource, Selection};
|
||||
pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_gpu as gpu, lightcraft_pipeline as pipeline};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -81,6 +81,8 @@ pub struct Session {
|
|||
pub interaction: Option<Interaction>,
|
||||
/// Copied develop settings (partial JSON) for Paste.
|
||||
pub clipboard: Option<Value>,
|
||||
/// The folder on disk the [`LibrarySource::Folder`] view browses.
|
||||
pub browse: Option<Browse>,
|
||||
/// Copied metadata (`photo.copyMetadata`): photo.setMeta params.
|
||||
pub meta_clipboard: Option<Value>,
|
||||
/// The photo that was active before the current one (Paste Settings from Previous).
|
||||
|
|
@ -144,6 +146,7 @@ impl Session {
|
|||
interaction: None,
|
||||
clipboard: None,
|
||||
meta_clipboard: None,
|
||||
browse: None,
|
||||
previous_active: None,
|
||||
copy_groups: lightcraft_develop::SettingsGroup::default_copy(),
|
||||
presets: presets::builtin(),
|
||||
|
|
@ -356,9 +359,15 @@ impl Session {
|
|||
|
||||
/// Photos shown in the grid/filmstrip for the current source, filter and sort.
|
||||
pub fn visible(&mut self) -> &[PhotoId] {
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.filter, self.sort));
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
|
||||
if self.visible_key.as_ref() != Some(&key) {
|
||||
let f = self.source.to_filter(&self.filter, &self.catalog);
|
||||
let mut f = self.source.to_filter(&self.filter, &self.catalog);
|
||||
if self.source == LibrarySource::Folder {
|
||||
// no folder chosen: nothing (an empty path matches nothing)
|
||||
let b = self.browse.clone().unwrap_or_default();
|
||||
f.folder = Some(b.path);
|
||||
f.subfolders = b.subfolders;
|
||||
}
|
||||
self.visible = self.catalog.query(&f, &self.sort);
|
||||
if matches!(self.source, LibrarySource::Album(_))
|
||||
&& self.sort.key == lightcraft_catalog::SortKey::CaptureDate
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ struct PresetsFile {
|
|||
#[serde(default)]
|
||||
struct ViewFile {
|
||||
source: LibrarySource,
|
||||
browse: Option<crate::Browse>,
|
||||
filter: lightcraft_catalog::Filter,
|
||||
sort: lightcraft_catalog::Sort,
|
||||
selection: Selection,
|
||||
|
|
@ -178,6 +179,7 @@ impl Session {
|
|||
// view state
|
||||
if let Some(v) = read_json::<ViewFile>(files.as_mut(), "view.json") {
|
||||
self.source = v.source;
|
||||
self.browse = v.browse;
|
||||
self.filter = v.filter;
|
||||
self.sort = v.sort;
|
||||
self.selection = v.selection;
|
||||
|
|
@ -251,7 +253,13 @@ impl Session {
|
|||
}
|
||||
|
||||
fn view_json(&self) -> Vec<u8> {
|
||||
let view = ViewFile { source: self.source, filter: self.filter.clone(), sort: self.sort, selection: self.selection.clone() };
|
||||
let view = ViewFile {
|
||||
source: self.source,
|
||||
browse: self.browse.clone(),
|
||||
filter: self.filter.clone(),
|
||||
sort: self.sort,
|
||||
selection: self.selection.clone(),
|
||||
};
|
||||
serde_json::to_vec_pretty(&view).unwrap_or_default()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,3 +101,45 @@ fn civil_dates() {
|
|||
assert_eq!(crate::import::civil(951_782_400), "2000-02-29T00:00:00");
|
||||
assert_eq!(crate::import::civil(1_790_000_000), "2026-09-21T14:13:20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browsing_a_folder_lists_its_photos_without_adding_them() {
|
||||
use crate::LibrarySource;
|
||||
let dir = std::env::temp_dir().join(format!("lc-browse-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
let png = |p: &std::path::Path, seed: u8| {
|
||||
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
|
||||
let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() })
|
||||
.unwrap();
|
||||
std::fs::write(p, b).unwrap();
|
||||
};
|
||||
png(&dir.join("a.png"), 1);
|
||||
png(&dir.join("b.png"), 2);
|
||||
png(&dir.join("sub/c.png"), 3);
|
||||
std::fs::write(dir.join("notes.txt"), "x").unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.browse", &serde_json::json!({"path": dir.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["photos"], 2, "{r}");
|
||||
assert_eq!(s.source, LibrarySource::Folder);
|
||||
assert_eq!(s.visible_cloned().len(), 2);
|
||||
// not in the library
|
||||
s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap();
|
||||
assert!(s.visible_cloned().is_empty(), "browsed photos stay out of All Photos");
|
||||
assert_eq!(s.execute("catalog.stats", &serde_json::json!({})).unwrap()["photos"], 0, "nor in the counts");
|
||||
assert!(s.catalog.date_groups().is_empty());
|
||||
// subfolders; browsing again reuses the photos
|
||||
let r = s.execute("library.browse", &serde_json::json!({"path": dir.to_string_lossy(), "subfolders": true})).unwrap();
|
||||
assert_eq!((r["photos"].as_u64(), r["new"].as_u64()), (Some(3), Some(1)));
|
||||
// add one to the library
|
||||
let first = s.visible_cloned()[0];
|
||||
s.execute("photo.addToLibrary", &serde_json::json!({"ids": [first.0]})).unwrap();
|
||||
// importing the folder for real brings in the rest (no duplicates of the browsed ones)
|
||||
let r = s.execute("library.import", &serde_json::json!({"paths": [dir.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(r["imported"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap();
|
||||
assert_eq!(s.visible_cloned().len(), 3);
|
||||
assert_eq!(s.catalog.photos().count(), 3);
|
||||
assert!(s.execute("library.browse", &serde_json::json!({"path": dir.join("a.png").to_string_lossy()})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@ pub enum LibrarySource {
|
|||
RecentlyDeleted,
|
||||
/// Photos with picks.
|
||||
Picks,
|
||||
/// A folder on disk ([`crate::Session::browse`]): its files, added to the library or not.
|
||||
Folder,
|
||||
}
|
||||
|
||||
/// The folder a [`LibrarySource::Folder`] view shows.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Browse {
|
||||
pub path: String,
|
||||
pub subfolders: bool,
|
||||
}
|
||||
|
||||
impl LibrarySource {
|
||||
|
|
@ -29,6 +39,8 @@ impl LibrarySource {
|
|||
LibrarySource::Album(a) => f.album = Some(*a),
|
||||
LibrarySource::RecentlyDeleted => f.deleted = true,
|
||||
LibrarySource::Picks => f.flag = Some(lightcraft_catalog::Flag::Pick),
|
||||
// the folder itself is filled in by the session (it holds the path)
|
||||
LibrarySource::Folder => {}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
|
@ -40,6 +52,7 @@ impl LibrarySource {
|
|||
LibrarySource::Album(a) => cat.album(*a).map(|a| a.name.clone()).unwrap_or_else(|| "Album".into()),
|
||||
LibrarySource::RecentlyDeleted => "Recently Deleted".into(),
|
||||
LibrarySource::Picks => "Picks".into(),
|
||||
LibrarySource::Folder => "Folder".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,11 @@ impl Headless {
|
|||
}
|
||||
let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(),
|
||||
// like the desktop app: the selection (in grid order), else the given / active photo
|
||||
None if p.get("id").is_none() && self.session.selection.ids.len() > 1 => {
|
||||
let sel: std::collections::HashSet<_> = self.session.selection.ids.iter().copied().collect();
|
||||
self.session.visible_cloned().into_iter().filter(|id| sel.contains(id)).collect()
|
||||
}
|
||||
None => vec![self.photo_or_active(p)?],
|
||||
};
|
||||
let dir = p.get("dir").and_then(Value::as_str).unwrap_or("");
|
||||
|
|
@ -180,3 +185,29 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String>
|
|||
let bytes = encode_image(&ext, img, quality)?;
|
||||
std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `app.export` without `ids` exports the selection (as the desktop app does), else the active photo.
|
||||
#[test]
|
||||
fn export_follows_the_selection() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-mcp-export-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut h = Headless::demo();
|
||||
let ids: Vec<u64> = h.session.visible_cloned().iter().take(3).map(|p| p.0).collect();
|
||||
h.call("engine.execute", json!({"command": "library.select", "params": {"ids": ids}})).unwrap();
|
||||
let r = h
|
||||
.call("engine.execute", json!({"command": "app.export", "params": {"dir": dir.to_string_lossy(), "longEdge": 64, "format": "png"}}))
|
||||
.unwrap();
|
||||
assert_eq!(r["files"].as_array().map(Vec::len), Some(3), "{r}");
|
||||
// one photo selected: that one
|
||||
h.call("engine.execute", json!({"command": "library.select", "params": {"ids": [ids[0]]}})).unwrap();
|
||||
let r = h
|
||||
.call("engine.execute", json!({"command": "app.export", "params": {"dir": dir.to_string_lossy(), "longEdge": 64, "format": "png"}}))
|
||||
.unwrap();
|
||||
assert_eq!(r["files"].as_array().map(Vec::len), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
|
|||
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
|
||||
"activeMask": app.session.active_mask,
|
||||
"widgetCount": app.widgets.len(),
|
||||
"perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()},
|
||||
"perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()},
|
||||
"loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})),
|
||||
"hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()),
|
||||
"status": app.ui.status,
|
||||
|
|
|
|||
|
|
@ -667,6 +667,45 @@ mod tests {
|
|||
assert_eq!((m.alt_text.as_str(), m.city.as_str()), ("A lake at dawn", "Zermatt"));
|
||||
}
|
||||
|
||||
/// Local: a folder's photos show without joining the library; the breadcrumb, Include
|
||||
/// subfolders and Add to My Photos work from the grid header.
|
||||
#[test]
|
||||
fn browse_a_local_folder() {
|
||||
let mut h = demo([1300.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-browse-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("inner")).unwrap();
|
||||
let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]);
|
||||
let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() };
|
||||
for (i, p) in [dir.join("a.png"), dir.join("inner/b.png")].iter().enumerate() {
|
||||
let mut img = img.clone();
|
||||
img.data[0][0] = i as u8; // different bytes per file
|
||||
std::fs::write(p, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap();
|
||||
}
|
||||
let library_before = h.app.session.catalog.photos().filter(|p| !p.local).count();
|
||||
let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t);
|
||||
assert_eq!(r["result"]["photos"], 1, "{r}");
|
||||
assert!(h.settle(SETTLE), "a thumbnail that can't load must not keep the renderer busy");
|
||||
// this test session has no file hooks: the thumbnail fails once, is remembered, and isn't retried
|
||||
let id = h.app.session.visible_cloned()[0];
|
||||
assert!(h.app.renderer.failure(crate::render::Slot::Thumb(id)).is_some());
|
||||
let done = h.app.renderer.completed;
|
||||
for _ in 0..30 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.app.renderer.completed, done, "no retry loop");
|
||||
let w = h.request("ui.widgets", json!({"filter": "crumb:"}), t);
|
||||
assert!(w["result"].as_array().is_some_and(|a| !a.is_empty()), "breadcrumb: {w}");
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "check:includeSubfolders"}), t)["ok"], true);
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(h.app.session.visible_cloned().len(), 2);
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:addToLibrary"}), t)["ok"], true);
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(h.app.session.catalog.photos().filter(|p| !p.local).count(), library_before + 2);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// ⌘Q (File → Quit LightCraft) closes the window.
|
||||
#[test]
|
||||
fn cmd_q_quits() {
|
||||
|
|
|
|||
|
|
@ -271,7 +271,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
} else {
|
||||
shown = draw(Slot::Main, img_rect);
|
||||
if shown == "none" {
|
||||
p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim);
|
||||
match app.renderer.failure(Slot::Main) {
|
||||
Some(e) => {
|
||||
p.text(canvas.center() - vec2(0.0, 10.0), Align2::CENTER_CENTER, "This photo can't be opened", t.semibold(14.0), t.text);
|
||||
p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim);
|
||||
}
|
||||
None => {
|
||||
p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app.loupe_shown = Some((id, shown));
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
|
||||
// metadata pickers
|
||||
let ui: &mut egui::Ui = if two_rows { &mut row2 } else { &mut row1 };
|
||||
let photos: Vec<_> = app.session.catalog.photos().filter(|p| !p.deleted).cloned().collect();
|
||||
let photos: Vec<_> = app.session.catalog.photos().filter(|p| p.in_library()).cloned().collect();
|
||||
let cameras = distinct(photos.iter().map(|p| p.meta.camera.clone()).collect());
|
||||
let lenses = distinct(photos.iter().map(|p| p.meta.lens.clone()).collect());
|
||||
let keywords: Vec<String> = app.session.catalog.keywords().into_iter().map(|(k, _)| k).collect();
|
||||
|
|
|
|||
|
|
@ -30,18 +30,25 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
let ids = app.session.visible_cloned();
|
||||
// header: source title + count
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
|
||||
let title = app.session.source.label(&app.session.catalog);
|
||||
ui.painter().rect_filled(hr, 0.0, t.canvas);
|
||||
ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text);
|
||||
let sel_n = app.session.selection.ids.len();
|
||||
let cnt = if sel_n > 1 { format!("{sel_n} of {} photos", ids.len()) } else { format!("{} photos", ids.len()) };
|
||||
ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim);
|
||||
match app.session.browse.clone().filter(|_| app.session.source == lightcraft_engine::LibrarySource::Folder) {
|
||||
Some(b) => folder_header(app, ui, hr, &b, &ids, &cnt),
|
||||
None => {
|
||||
let title = app.session.source.label(&app.session.catalog);
|
||||
ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text);
|
||||
ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim);
|
||||
}
|
||||
}
|
||||
if app.ui.filter_bar {
|
||||
super::filterbar::show(app, ui);
|
||||
}
|
||||
app.canvas_rect = Some(ui.max_rect());
|
||||
if ids.is_empty() {
|
||||
if app.session.filter != Default::default() {
|
||||
if app.session.source == lightcraft_engine::LibrarySource::Folder {
|
||||
super::empty_message(ui, ui.max_rect(), "No photos in this folder", "Turn on Include subfolders, or pick another folder under Local");
|
||||
} else if app.session.filter != Default::default() {
|
||||
super::empty_message(ui, ui.max_rect(), "No matching photos", "Change the filter, or clear it (View → Clear Filters)");
|
||||
} else {
|
||||
super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here");
|
||||
|
|
@ -300,7 +307,12 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
|
|||
p.rect_stroke(fit, 0.0, Stroke::new(2.0, Color32::from_gray(170)), StrokeKind::Outside);
|
||||
}
|
||||
} else {
|
||||
p.rect_filled(img_rect.shrink(if square { 20.0 } else { 0.0 }), 0.0, Color32::from_gray(38));
|
||||
let ph = img_rect.shrink(if square { 20.0 } else { 0.0 });
|
||||
p.rect_filled(ph, 0.0, Color32::from_gray(38));
|
||||
if app.renderer.failure(Slot::Thumb(id)).is_some() {
|
||||
// unreadable / missing file
|
||||
p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text_dim);
|
||||
}
|
||||
}
|
||||
// labels and badges
|
||||
if square && app.ui.show_filenames {
|
||||
|
|
@ -387,6 +399,58 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
|
|||
resp.context_menu(|ui| context_menu(app, ui, id));
|
||||
}
|
||||
|
||||
/// The header of a Local folder view: the path as a breadcrumb (each part opens that folder),
|
||||
/// Include subfolders, Add to My Photos.
|
||||
fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, hr: Rect, b: &lightcraft_engine::Browse, ids: &[PhotoId], cnt: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut child =
|
||||
ui.new_child(egui::UiBuilder::new().max_rect(hr.shrink2(vec2(16.0, 6.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
child.spacing_mut().item_spacing.x = 4.0;
|
||||
let parts: Vec<&str> = b.path.split(['/', '\\']).filter(|p| !p.is_empty()).collect();
|
||||
// the last few parts (the root side is elided)
|
||||
let skip = parts.len().saturating_sub(4);
|
||||
if skip > 0 {
|
||||
child.label(egui::RichText::new("… ›").color(t.text_dim));
|
||||
}
|
||||
for (i, part) in parts.iter().enumerate().skip(skip) {
|
||||
let last = i + 1 == parts.len();
|
||||
let text = egui::RichText::new(*part).size(13.0).color(if last { t.text } else { t.text_label });
|
||||
let r = child.add(egui::Label::new(if last { text.strong() } else { text }).sense(Sense::click()));
|
||||
register(child.ctx(), format!("crumb:{i}"), r.rect);
|
||||
if !last {
|
||||
if r.hovered() {
|
||||
child.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
if r.clicked() {
|
||||
let prefix = if b.path.starts_with('/') { format!("/{}", parts[..=i].join("/")) } else { parts[..=i].join("/") };
|
||||
let _ = app.run("library.browse", json!({"path": prefix}));
|
||||
}
|
||||
child.label(egui::RichText::new("›").color(t.text_dim));
|
||||
}
|
||||
}
|
||||
child.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(cnt).size(12.5).color(t.text_dim));
|
||||
ui.add_space(10.0);
|
||||
let local: Vec<u64> = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect();
|
||||
if !local.is_empty() {
|
||||
let label = format!("Add {} to My Photos", local.len());
|
||||
if crate::widgets::text_button(ui, "addToLibrary", &label, false).clicked() {
|
||||
let n = local.len();
|
||||
match app.run("photo.addToLibrary", json!({"ids": local})) {
|
||||
Ok(_) => app.toast(ui.ctx(), format!("Added {n} photo{} to My Photos", if n == 1 { "" } else { "s" })),
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut sub = b.subfolders;
|
||||
let c = ui.checkbox(&mut sub, "Include subfolders");
|
||||
register(ui.ctx(), "check:includeSubfolders", c.rect);
|
||||
if c.changed() {
|
||||
let _ = app.run("library.browse", json!({"path": b.path, "subfolders": sub}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// While photos are dragged: a badge at the pointer; the drag ends when the button is up
|
||||
/// (drop targets act on the release frame, before this runs).
|
||||
pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
|
|
|
|||
|
|
@ -59,10 +59,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::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 stats: Vec<_> = app.session.catalog.photos().filter(|p| !p.deleted).map(|p| p.flag).collect();
|
||||
let stats: Vec<_> = app.session.catalog.photos().filter(|p| p.in_library()).map(|p| p.flag).collect();
|
||||
let total = stats.len();
|
||||
let picks = stats.iter().filter(|f| **f == lightcraft_catalog::Flag::Pick).count();
|
||||
let deleted = app.session.catalog.photos().filter(|p| p.deleted).count();
|
||||
let deleted = app.session.catalog.photos().filter(|p| p.deleted && !p.local).count();
|
||||
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 [
|
||||
|
|
@ -98,6 +98,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
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);
|
||||
|
|
@ -128,6 +129,51 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop,
|
||||
/// Downloads, the home folder, the folder being browsed, and Browse Folder….
|
||||
fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
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();
|
||||
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}") };
|
||||
if std::path::Path::new(&p).is_dir() {
|
||||
places.push((name.to_string(), p));
|
||||
}
|
||||
}
|
||||
}
|
||||
let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder);
|
||||
if let Some(b) = &browsing
|
||||
&& !places.iter().any(|(_, p)| *p == b.path)
|
||||
{
|
||||
let name = std::path::Path::new(&b.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| b.path.clone());
|
||||
places.push((name, b.path.clone()));
|
||||
}
|
||||
for (name, path) in places {
|
||||
let sel = browsing.as_ref().is_some_and(|b| b.path == path);
|
||||
if row(app, ui, &format!("local:{path}"), Icon::Folder, &name, None, sel, 0.0).on_hover_text(&path).clicked()
|
||||
&& let Err(e) = app.run("library.browse", json!({"path": path}))
|
||||
{
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
}
|
||||
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
|
||||
&& let Err(e) = app.run("library.browse", json!({"path": path}))
|
||||
{
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
|
||||
/// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open.
|
||||
fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ pub struct Renderer {
|
|||
stages: HashMap<Slot, Arc<StageCache>>,
|
||||
/// Slot → key of the last quick job requested for it (each is tried once).
|
||||
quick_tried: HashMap<Slot, u64>,
|
||||
/// Jobs that failed (unreadable or missing file…), by slot and job key, with the error: not
|
||||
/// requested again until the key changes (an edit, another size) — the grid asks every frame.
|
||||
failed: HashMap<Slot, (u64, String)>,
|
||||
/// Prefetch slot → key of the last job submitted (each runs once).
|
||||
prefetched: HashMap<Slot, u64>,
|
||||
/// Variant key → frame it was last asked for (LRU of [`Slot::Variant`] textures).
|
||||
|
|
@ -131,6 +134,7 @@ impl Default for Renderer {
|
|||
keep_pixels: false,
|
||||
stages: HashMap::new(),
|
||||
quick_tried: HashMap::new(),
|
||||
failed: HashMap::new(),
|
||||
prefetched: HashMap::new(),
|
||||
variant_used: HashMap::new(),
|
||||
frame: 0,
|
||||
|
|
@ -157,7 +161,7 @@ impl Renderer {
|
|||
|
||||
/// Request a render for `slot` (no-op if already current or pending at the same priority).
|
||||
pub fn request(&mut self, slot: Slot, job: RenderJob, priority: u32) {
|
||||
if self.textures.get(&slot).is_some_and(|t| t.key == job.key) {
|
||||
if self.textures.get(&slot).is_some_and(|t| t.key == job.key) || self.failed.get(&slot).is_some_and(|f| f.0 == job.key) {
|
||||
return;
|
||||
}
|
||||
if let Some(&(key, prio)) = self.pending.get(&slot)
|
||||
|
|
@ -216,6 +220,7 @@ impl Renderer {
|
|||
self.textures.clear();
|
||||
self.stages.clear();
|
||||
self.quick_tried.clear();
|
||||
self.failed.clear();
|
||||
self.prefetched.clear();
|
||||
self.pending.clear();
|
||||
self.queue.clear();
|
||||
|
|
@ -251,6 +256,16 @@ impl Renderer {
|
|||
self.pending.len()
|
||||
}
|
||||
|
||||
/// Why the last render for `slot` failed (until a render with another key succeeds).
|
||||
pub fn failure(&self, slot: Slot) -> Option<&str> {
|
||||
self.failed.get(&slot).map(|f| f.1.as_str())
|
||||
}
|
||||
|
||||
/// The slots with a render pending (diagnostics: `ui.inspect`).
|
||||
pub fn pending_slots(&self) -> Vec<String> {
|
||||
self.pending.keys().map(|s| format!("{s:?}")).collect()
|
||||
}
|
||||
|
||||
/// Thumbnail textures currently loaded.
|
||||
/// A variant thumbnail ([`Slot::Variant`]): its texture once rendered; until then the job is
|
||||
/// queued at low priority (below on-screen grid thumbnails). Call it every frame the variant
|
||||
|
|
@ -343,8 +358,18 @@ impl Renderer {
|
|||
if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) {
|
||||
self.pending.remove(&slot);
|
||||
}
|
||||
let Ok(rendered) = r.rendered else {
|
||||
continue;
|
||||
let rendered = match r.rendered {
|
||||
Ok(x) => {
|
||||
self.failed.remove(&slot);
|
||||
x
|
||||
}
|
||||
Err(e) => {
|
||||
if !matches!(slot, Slot::Prefetch(_)) {
|
||||
log::warn!("render {slot:?}: {e}");
|
||||
self.failed.insert(slot, (r.key, e));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if matches!(slot, Slot::Prefetch(_)) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
<!-- parity:summary -->
|
||||
| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| A. Import (IMP) | 6 | 3 | 4 | 1 | 2/4 (50%) | 3/4 (75%) |
|
||||
| A. Import (IMP) | 7 | 2 | 4 | 1 | 3/4 (75%) | 3/4 (75%) |
|
||||
| B. Library management (LIB) | 18 | 1 | 6 | 2 | 9/9 (100%) | 8/9 (89%) |
|
||||
| C. Views & navigation (VIEW) | 14 | 0 | 3 | 0 | 9/9 (100%) | 4/4 (100%) |
|
||||
| D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) |
|
||||
|
|
@ -45,12 +45,12 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
|
||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 12 | 1 | 4 | 1 | 8/8 (100%) | 4/5 (80%) |
|
||||
| Y. Menus | 68 | 9 | 8 | 8 | 46/47 (98%) | 17/23 (74%) |
|
||||
| Y. Menus | 69 | 8 | 8 | 8 | 46/47 (98%) | 18/23 (78%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 63 | 12 | 5 | 1 | 49/52 (94%) | 12/23 (52%) |
|
||||
| Lightroom Classic extras | 7 | 19 | 53 | 9 | — | 6/21 (29%) |
|
||||
| **Total** | 306 | 64 | 130 | 37 | 192/198 (97%) | 95/144 (66%) |
|
||||
| Lightroom Classic extras | 7 | 20 | 52 | 9 | — | 6/21 (29%) |
|
||||
| **Total** | 308 | 63 | 129 | 37 | 193/198 (97%) | 96/144 (67%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **67.6%** of 500 in-scope rows — P0 98.5% of 198 · P1 79.9% of 144 · P2 17.7% of 158.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **67.9%** of 500 in-scope rows — P0 98.7% of 198 · P1 80.6% of 144 · P2 17.7% of 158.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
|
|
@ -107,7 +107,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| 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` | presets (.lcpreset, XMP `crs:`) only; no profile import; Adobe profile formats are deliberately unsupported |
|
||||
| LR-IMP-LOCAL | Work on files in place | P0 | 🟡 | `cmd:library.import` (mode add), `cmd:library.importPreview`, `crates/engine/src/sidecar.rs`, `cmd:library.toggleAutoWriteXmp` | files referenced in place with XMP sidecars; the import review shows a folder's photos before adding (“Add in place” or “Copy into library”); no browse-a-folder-without-adding view in the main grid |
|
||||
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `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 |
|
||||
| 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, CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode |
|
||||
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | ⬜ | | |
|
||||
|
|
@ -547,7 +547,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
|
|||
| MENU-VIEW-ZOOM | Zoom in / out / toggle / fit / 1:1 | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.zoomFit`, `cmd:view.zoom100` | |
|
||||
| MENU-VIEW-CLIPPING | Show Clipping | P0 | ✅ | `cmd:view.clipping` | |
|
||||
| MENU-VIEW-MASKOVERLAY | Mask overlay / cycle colour | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor` | colour cycles through the panel's swatches (no params) or takes `color` / `opacity` |
|
||||
| MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | 🟡 | `cmd:library.import` | folder import is always recursive |
|
||||
| MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | ✅ | `cmd:library.browse` (`subfolders`) | toggle in the folder header; imports of folders are recursive |
|
||||
| MENU-VIEW-SORT | Sort submenu | P0 | ✅ | `cmd:library.sort` | no colour-label key |
|
||||
| MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ✅ | `cmd:stack.expandAll`, `cmd:stack.collapseAll` | |
|
||||
| MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | 🟡 | `crates/ui-egui/src/panels/left.rs` | counts always shown; no toggle |
|
||||
|
|
@ -693,7 +693,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
|||
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
|
||||
| LRC-LIB-REFVIEW | Reference view | P2 | ⬜ | | |
|
||||
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | 🟡 | `cmd:library.source` | all, recently added, picks, recently deleted; no missing/problem sets |
|
||||
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ⬜ | | |
|
||||
| LRC-LIB-FOLDERS | Disk folder tree | P1 | 🟡 | `cmd:library.browse`, `crates/ui-egui/src/panels/left.rs` (`local_section`) | standard places + the browsed folder + Browse Folder…, breadcrumb navigation upwards; no expandable folder tree, no folder rename/move |
|
||||
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | 🟡 | `cmd:album.create` | albums + folders; no smart / quick / target collections |
|
||||
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | 🟡 | `cmd:album.createSmart`, `crates/catalog/src/query.rs` | the filter fields + date range; no rule editor, any/none groups or operators beyond ≥/=/≤ |
|
||||
| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue