This commit is contained in:
Brandon Thomas committed 2026-10-14 22:32:54 -04:00
commit 31f1f559dd
12 files changed
+957 -79

No files matched your search

+155
View File
@@ -285,6 +285,71 @@ pub struct Catalog {
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
/// Which photos the latest ops touched ([`Catalog::touched_since`]).
#[serde(skip)]
changes: Changes,
}
/// What of a photo an op changed: enough to tell which tallies of the library an edit leaves as they were (a
/// rating changes no folder's count; a slider changes none at all).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Aspects(u16);
impl Aspects {
pub const NONE: Aspects = Aspects(0);
pub const RATING: Aspects = Aspects(1);
pub const FLAG: Aspects = Aspects(1 << 1);
pub const LABEL: Aspects = Aspects(1 << 2);
/// Develop settings, versions and history.
pub const DEVELOP: Aspects = Aspects(1 << 3);
/// Title, caption, keywords, people, place and the rest of the metadata.
pub const META: Aspects = Aspects(1 << 4);
pub const CAPTURED: Aspects = Aspects(1 << 5);
/// Where its file is.
pub const FILE: Aspects = Aspects(1 << 6);
pub const ANALYSIS: Aspects = Aspects(1 << 7);
/// Whether it is in the library at all: added, removed, deleted, restored, taken in from Local.
pub const PRESENCE: Aspects = Aspects(1 << 8);
/// Everything: the photo may count differently for any tally.
pub const ALL: Aspects = Aspects(u16::MAX);
pub const fn with(self, other: Aspects) -> Aspects {
Aspects(self.0 | other.0)
}
/// Whether any of `other` is among these.
pub const fn any_of(self, other: Aspects) -> bool {
self.0 & other.0 != 0
}
}
/// The most photos remembered as touched; older changes are then answered with "anything may have changed".
const MAX_TOUCHED: usize = 8192;
/// A short memory of what the latest ops changed, so that what is derived from the catalog (the grid's list) can
/// follow a small edit without being worked out again from every photo.
#[derive(Clone, Debug, Default)]
struct Changes {
/// The photos ops changed, each with the revision the op made and what of the photo it changed. Oldest first.
touched: std::collections::VecDeque<(u64, PhotoId, Aspects)>,
/// What the op being applied changes of its photo.
now: Aspects,
/// The latest revision made by an op that changed more than photos of its own (an album, a stack, a keyword of
/// the list…): what it changed for which photos is not known.
broad: u64,
/// Changes up to this revision are no longer all in `touched`.
floor: u64,
/// The revision the last op recorded here made. When the catalog's revision is another, it was set from
/// outside (a library just loaded takes a range of its own, so that nothing kept from another library is
/// taken for this one's): nothing is known of how the catalog got to where it is.
seen: u64,
}
impl PartialEq for Changes {
/// Two catalogs are the same catalog whatever each remembers of how it got there.
fn eq(&self, _: &Self) -> bool {
true
}
}
impl Catalog {
@@ -483,20 +548,110 @@ impl Catalog {
// ---- writes
fn photo_mut(&mut self, id: PhotoId) -> Result<&mut Photo> {
self.touch(id);
self.photos.get_mut(&id).map(Arc::make_mut).ok_or(CatalogError::NoPhoto(id))
}
/// The op being applied changes photo `id` (or adds or removes it).
fn touch(&mut self, id: PhotoId) {
// (the revision the op will make: `apply` counts up once it is done)
self.changes.touched.push_back((self.revision.saturating_add(1), id, self.changes.now));
while self.changes.touched.len() > MAX_TOUCHED {
if let Some((revision, _, _)) = self.changes.touched.pop_front() {
self.changes.floor = revision;
}
}
}
/// What of its photos the ops since `revision` changed, taken together ([`Aspects::NONE`] when there were
/// none). `None` when that is not known ([`Catalog::touched_since`]). A tally of the library that reads only
/// other things of a photo is still right.
pub fn aspects_since(&self, revision: u64) -> Option<Aspects> {
if !self.remembers(revision) {
return None;
}
Some(self.changes.touched.iter().filter(|(r, _, _)| *r > revision).fold(Aspects::NONE, |all, (_, _, a)| all.with(*a)))
}
/// Whether every change since the catalog was at `revision` is remembered photo by photo.
fn remembers(&self, revision: u64) -> bool {
self.changes.seen == self.revision && revision <= self.revision && revision >= self.changes.floor && self.changes.broad <= revision
}
/// The photos changed, added or removed since the catalog was at `revision`, each once. `None` when that is not
/// known: an op since then changed more than photos of its own, or it was too long ago. Then anything may
/// have changed.
pub fn touched_since(&self, revision: u64) -> Option<Vec<PhotoId>> {
if !self.remembers(revision) {
return None;
}
let mut ids: Vec<PhotoId> = self.changes.touched.iter().filter(|(r, _, _)| *r > revision).map(|(_, id, _)| *id).collect();
ids.sort_unstable();
ids.dedup();
Some(ids)
}
fn album_mut(&mut self, id: AlbumId) -> Result<&mut Album> {
self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))
}
/// Apply an op; returns its inverse. On error nothing changes.
pub fn apply(&mut self, op: Op) -> Result<Op> {
if self.changes.seen != self.revision {
// the revision was set from outside: what is remembered is of another past
self.changes = Changes { floor: self.revision, seen: self.revision, ..Default::default() };
}
let inv = self.apply_inner(op)?;
self.revision += 1;
self.changes.seen = self.revision;
Ok(inv)
}
fn apply_inner(&mut self, op: Op) -> Result<Op> {
// An op that changes one photo says which (`photo_mut`, and adding or removing it below). Every other kind
// can change what any photo matches or where it sorts: an album's photos or rules, a stack, a keyword's
// name. Listed kind by kind, so a new one has to be put on one side.
match &op {
// (a photo that comes, goes, or changes what it is to the library counts for every tally; so do the
// rare kinds whose reach nobody has to think about)
Op::AddPhoto { photo } => {
self.changes.now = Aspects::ALL;
self.touch(photo.id);
}
Op::RemovePhoto { id } => {
self.changes.now = Aspects::ALL;
self.touch(*id);
}
Op::SetDeleted { .. } | Op::SetLocal { .. } | Op::SetContent { .. } | Op::SetEmbeddedLens { .. } | Op::SetAsShotWb { .. } => {
self.changes.now = Aspects::ALL
}
Op::SetRating { .. } => self.changes.now = Aspects::RATING,
Op::SetFlag { .. } => self.changes.now = Aspects::FLAG,
Op::SetLabel { .. } => self.changes.now = Aspects::LABEL,
Op::SetMeta { .. } => self.changes.now = Aspects::META,
Op::SetCaptured { .. } => self.changes.now = Aspects::CAPTURED,
Op::SetFile { .. } | Op::Relink { .. } => self.changes.now = Aspects::FILE,
Op::SetAnalysis { .. } => self.changes.now = Aspects::ANALYSIS,
Op::SetDevelop { .. } | Op::SetVersions { .. } | Op::SetHistory { .. } | Op::PushHistory { .. } | Op::SetHistoryCursor { .. } => {
self.changes.now = Aspects::DEVELOP
}
// (its ops say for themselves, one by one)
Op::Batch { .. } => {}
Op::AddAlbum { .. }
| Op::RemoveAlbum { .. }
| Op::RenameAlbum { .. }
| Op::MoveAlbum { .. }
| Op::SetAlbumOrder { .. }
| Op::SetAlbumPhotos { .. }
| Op::SetAlbumCover { .. }
| Op::SetAlbumRules { .. }
| Op::AddStack { .. }
| Op::RemoveStack { .. }
| Op::SetStack { .. }
| Op::SetLabelName { .. }
| Op::SetBrowsed { .. }
| Op::SetKeyword { .. }
| Op::SetFolderRecord { .. } => self.changes.broad = self.revision.saturating_add(1),
}
Ok(match op {
Op::AddPhoto { photo } => {
if self.photos.contains_key(&photo.id) {
+49 -17
View File
@@ -94,6 +94,27 @@ pub struct Sort {
pub seed: u64,
}
impl Sort {
/// Which of two photos comes first in this order (never `Equal` for two photos: the id decides last).
pub fn order(&self, a: &Photo, b: &Photo) -> std::cmp::Ordering {
let o = match self.key {
SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
SortKey::ImportDate => a.imported.cmp(&b.imported),
SortKey::EditDate => a.edited.cmp(&b.edited),
SortKey::FileName => cmp_name(&a.file_name, &b.file_name),
SortKey::Rating => a.rating.cmp(&b.rating),
SortKey::FileSize => a.file_size.cmp(&b.file_size),
SortKey::Random => shuffle_rank(self.seed, a.id).cmp(&shuffle_rank(self.seed, b.id)),
}
// Ties (one import batch, a burst within a second, equal ratings) sort by file name, the
// order cameras number frames, so the order doesn't depend on how ids were allocated;
// the id only keeps it stable.
.then_with(|| if self.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) })
.then(a.id.cmp(&b.id));
if self.ascending { o } else { o.reverse() }
}
}
impl Default for Sort {
fn default() -> Self {
Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto, seed: 0 }
@@ -384,26 +405,37 @@ impl Catalog {
.map(|p| p.as_ref())
.filter(|p| filter.matches_in(p, self, root.as_deref()) || (keep.contains(&p.id) && within.matches_in(p, self, within_root.as_deref())))
.collect();
v.sort_by(|a, b| {
let o = match sort.key {
SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
SortKey::ImportDate => a.imported.cmp(&b.imported),
SortKey::EditDate => a.edited.cmp(&b.edited),
SortKey::FileName => cmp_name(&a.file_name, &b.file_name),
SortKey::Rating => a.rating.cmp(&b.rating),
SortKey::FileSize => a.file_size.cmp(&b.file_size),
SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)),
}
// Ties (one import batch, a burst within a second, equal ratings) sort by file name, the
// order cameras number frames, so the order doesn't depend on how ids were allocated;
// the id only keeps it stable.
.then_with(|| if sort.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) })
.then(a.id.cmp(&b.id));
if sort.ascending { o } else { o.reverse() }
});
v.sort_by(|a, b| sort.order(a, b));
v.into_iter().map(|p| p.id).collect()
}
/// The list `was` (a [`Catalog::query`] of `filter` in `sort` order, or a [`Catalog::query_keeping`]), after ops
/// that touched only the photos `touched` (sorted by id, each once: [`Catalog::touched_since`]): those photos
/// taken out, and the ones that match `filter` now put back at their place in the order. With `within`, a
/// touched photo that was in the list and still matches `within` stays, as `query_keeping` would keep it. What
/// the query would give, without asking every photo again.
pub fn follow(&self, was: &[PhotoId], touched: &[PhotoId], filter: &Filter, sort: &Sort, within: Option<&Filter>) -> Vec<PhotoId> {
let listed: std::collections::HashSet<PhotoId> = was.iter().copied().filter(|id| touched.binary_search(id).is_ok()).collect();
let mut ids: Vec<PhotoId> = was.iter().copied().filter(|id| !listed.contains(id)).collect();
let (root, within_root) = (filter.library_root(), within.and_then(Filter::library_root));
for id in touched {
let Some(p) = self.photo(*id) else { continue };
let kept = within.is_some_and(|w| listed.contains(id) && w.matches_in(p, self, within_root.as_deref()));
if filter.matches_in(p, self, root.as_deref()) || kept {
// (the list is in order without the touched photos, and stays so as each is put back)
let at = ids.partition_point(|other| self.photo(*other).is_some_and(|o| sort.order(o, p).is_lt()));
ids.insert(at, *id);
}
}
ids
}
/// How many photos match `filter`: [`Catalog::query`]'s length without its sorting.
pub fn count(&self, filter: &Filter) -> usize {
let root = filter.library_root();
self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())).count()
}
/// Year → month → day counts for the "By Date" section (newest first).
pub fn date_groups(&self) -> Vec<DateGroup> {
use std::collections::BTreeMap;
+78
View File
@@ -771,3 +771,81 @@ fn cmp_name_ignores_case_without_allocating() {
assert_eq!(cmp_name("DSC_0001", "DSC_0002"), Less);
assert_eq!(cmp_name("a", "ab"), Less);
}
/// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be
/// said photo by photo.
#[test]
fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() {
let mut c = Catalog::default();
let photo = |c: &mut Catalog, name: &str| {
let id = c.alloc_photo_id();
c.apply(Op::AddPhoto {
photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")),
})
.unwrap();
id
};
let (a, b) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg"));
let start = c.revision;
assert_eq!(c.touched_since(start), Some(vec![]));
assert_eq!(c.aspects_since(start), Some(Aspects::NONE));
// a rating, then a flag on another photo, as one batch
c.apply(Op::SetRating { id: a, rating: 3 }).unwrap();
assert_eq!(c.touched_since(start), Some(vec![a]));
assert_eq!(c.aspects_since(start), Some(Aspects::RATING));
let after_rating = c.revision;
c.apply(Op::Batch { ops: vec![Op::SetFlag { id: b, flag: Flag::Pick }, Op::SetRating { id: a, rating: 4 }] }).unwrap();
assert_eq!(c.touched_since(start), Some(vec![a, b]), "each once");
assert_eq!(c.touched_since(after_rating), Some(vec![a, b]));
assert_eq!(c.aspects_since(after_rating), Some(Aspects::FLAG.with(Aspects::RATING)));
assert_eq!(c.touched_since(c.revision), Some(vec![]));
// a photo that goes counts for everything
let before = c.revision;
c.apply(Op::RemovePhoto { id: b }).unwrap();
assert_eq!(c.touched_since(before), Some(vec![b]));
assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::PRESENCE) && x.any_of(Aspects::FILE)));
// an op that changes more than photos of its own: nothing is said of what came before it
let before = c.revision;
let album = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap();
assert_eq!(c.touched_since(before), None);
assert_eq!(c.aspects_since(start), None);
assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again");
// a revision that is not this catalog's past
assert_eq!(c.touched_since(c.revision + 5), None);
// a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after
// the next op nothing of before it
let old = c.revision;
c.revision = 7 << 32;
assert_eq!(c.touched_since(old), None);
assert_eq!(c.aspects_since(c.revision), None);
let loaded = c.revision;
c.apply(Op::SetRating { id: a, rating: 1 }).unwrap();
assert_eq!(c.touched_since(loaded), Some(vec![a]));
assert_eq!(c.touched_since(old), None);
// an op that fails changes nothing, and what is remembered never says less than what happened
let before = c.revision;
assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err());
assert_eq!(c.revision, before);
c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap();
assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a)));
assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG)));
// a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch
// with an op that changes more than photos is not
let before = c.revision;
assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err());
assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1)));
c.apply(Op::Batch { ops: vec![Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }] }] }).unwrap();
// (the photo the failed batch asked for and never found may be named too: more is said, never less)
assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a)));
let before = c.revision;
c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 3 }, Op::RenameAlbum { id: album, name: "Journey".into() }] }).unwrap();
assert_eq!(c.touched_since(before), None);
// more ops than are remembered: the oldest are answered with "anything"
let long_ago = c.revision;
for i in 0..9000u32 {
c.apply(Op::SetRating { id: a, rating: (i % 5) as u8 }).unwrap();
}
assert_eq!(c.touched_since(long_ago), None);
assert_eq!(c.touched_since(c.revision - 10), Some(vec![a]));
}
+92 -24
View File
@@ -180,6 +180,9 @@ pub struct Interaction {
pub original: Arc<DevelopSettings>,
}
/// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole.
const MAX_FOLLOWED: usize = 256;
/// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one).
static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
static LIBRARY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -201,10 +204,18 @@ pub struct Session {
/// A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop
/// matching a smart album's rules, Picks or the filter bar leaves it where it is. Off: the photo goes at once.
pub hold_view: bool,
/// The view that is held and the photos in it, before stacks are arranged ([`Session::hold_view`]). Another
/// source, filter or sort is another view, and starts from what matches; so does a smart album whose rules
/// were changed. `None` for a view that nothing can stop matching (a source with no filter over it).
held: Option<(String, Vec<PhotoId>)>,
/// The view last worked out, the catalog's revision then, and the photos in it before stacks are arranged.
/// A small edit is followed from it: only the photos the edit touched are asked about again and put at their
/// place in the order ([`Session::visible`]). It is also what a held view holds ([`Session::hold_view`]).
/// Another source, filter or sort is another view, and so is a smart album whose rules were changed.
base: Option<(String, u64, Vec<PhotoId>)>,
/// How many times the view followed an edit from the list it had, and how many times it was worked out from
/// every photo (`ui.inspect` → `perf`): what tells a slow library from a slow edit.
pub view_followed: u64,
pub view_worked_out: u64,
/// Tests: work the view out whole every time (what following has to equal).
#[cfg(test)]
never_follow: bool,
/// Identifies the current `visible` list: a new value (unique in the process) every time it
/// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids.
visible_gen: u64,
@@ -390,7 +401,11 @@ impl Session {
visible: Vec::new().into(),
visible_key: None,
hold_view: false,
held: None,
base: None,
view_followed: 0,
view_worked_out: 0,
#[cfg(test)]
never_follow: false,
visible_gen: 0,
total: None,
undo: Vec::new(),
@@ -954,25 +969,50 @@ impl Session {
// (only a view that asks something can lose a photo to an edit: the others are queried as ever, at no
// cost for the hold)
let holds = self.hold_view && (asks || self.filter != Filter::default());
let held = self.held.as_ref().filter(|(held, _)| holds && *held == view).map(|(_, ids)| ids);
let mut visible = match held {
Some(ids) => {
let keep: std::collections::HashSet<PhotoId> = ids.iter().copied().collect();
let within = if asks {
self.view_filter(LibrarySource::All, &Filter::default())
} else if self.source == LibrarySource::RecentlyAdded {
// (how far back it reaches was worked out for `f`: not a second pass over the library)
Filter { imported_from: f.imported_from.clone(), ..Default::default() }
} else {
self.view_filter(self.source, &Filter::default())
};
self.catalog.query_keeping(&f, &self.sort, &keep, &within)
let within = holds.then(|| {
if asks {
self.view_filter(LibrarySource::All, &Filter::default())
} else if self.source == LibrarySource::RecentlyAdded {
// (how far back it reaches was worked out for `f`: not a second pass over the library)
Filter { imported_from: f.imported_from.clone(), ..Default::default() }
} else {
self.view_filter(self.source, &Filter::default())
}
None => self.catalog.query(&f, &self.sort),
});
// Views whose order or photos are not the query's alone are worked out whole each time: an album in
// its own order, Recently Added (how far back it reaches moves with every import), Missing Photos.
let own_order = matches!(self.source, LibrarySource::Album(a) if self.sort.key == lightcraft_catalog::SortKey::CaptureDate
&& self.filter == Filter::default()
&& self.catalog.album(a).is_some_and(|al| !al.is_smart()));
let whole = own_order || matches!(self.source, LibrarySource::RecentlyAdded | LibrarySource::Missing);
// A view that asks about "the last N days" (itself, or through a smart album it looks into) changes
// with the clock as well as with edits: a photo nobody touched can stop matching. It is asked whole.
let clocked = f.depends_on_now()
|| (!f.albums_tested().is_empty() && self.catalog.albums().any(|al| al.smart.as_deref().is_some_and(Filter::depends_on_now)));
let base = self.base.take().filter(|(of, _, _)| *of == view);
// A small edit to the same view: the photos it touched are taken out, asked about again, and put back
// at their place in the order. The others neither stopped matching nor moved.
#[cfg(test)]
let clocked = clocked || self.never_follow;
let followed = base.as_ref().filter(|_| !whole && !clocked).and_then(|(_, revision, ids)| {
let touched = self.catalog.touched_since(*revision).filter(|touched| touched.len() <= MAX_FOLLOWED)?;
Some(self.catalog.follow(ids, &touched, &f, &self.sort, within.as_ref()))
});
match followed {
Some(_) => self.view_followed += 1,
None => self.view_worked_out += 1,
}
let mut visible = match (followed, &base, &within) {
(Some(ids), _, _) => ids,
(None, Some((_, _, held)), Some(within)) => {
let keep: std::collections::HashSet<PhotoId> = held.iter().copied().collect();
self.catalog.query_keeping(&f, &self.sort, &keep, within)
}
_ => self.catalog.query(&f, &self.sort),
};
// what the view shows now is what it holds until it is refreshed (taken before stacks are arranged, so
// a photo folded into a stack is held with it)
self.held = holds.then(|| (view, visible.clone()));
// what the view shows now is what the next edit is followed from, and what it holds until it is
// refreshed (taken before stacks are arranged, so a photo folded into a stack is held with it)
self.base = (!whole || holds).then(|| (view, self.catalog.revision, visible.clone()));
if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() {
// the shown folder lost its last photo (deleted, moved, removed): everything, not
// an empty grid under the name of a folder that is gone from the sidebar
@@ -1040,10 +1080,25 @@ impl Session {
/// Let go of what a held view kept: the view shows what matches now ([`Session::hold_view`]).
pub fn refresh_view(&mut self) {
self.held = None;
self.base = None;
self.visible_key = None;
}
/// [`Session::visible`] worked out from every photo, whatever was followed before: what following an edit
/// has to equal.
/// Nothing the session keeps is changed by asking: the next edit is followed from where the last one left the
/// view, so a list followed many times over is compared, not only one followed once.
#[cfg(test)]
pub(crate) fn visible_in_full(&mut self) -> Vec<PhotoId> {
let kept = (self.base.clone(), self.visible_key.clone(), self.visible.clone(), self.visible_gen, self.view_followed, self.view_worked_out);
self.never_follow = true;
self.visible_key = None;
let whole = self.visible().to_vec();
self.never_follow = false;
(self.base, self.visible_key, self.visible, self.visible_gen, self.view_followed, self.view_worked_out) = kept;
whole
}
/// Whether the library still holds a photo imported from the folder a `LibraryFolder` source
/// shows.
fn folder_holds_photos(&self) -> bool {
@@ -1057,6 +1112,16 @@ impl Session {
if self.source == LibrarySource::Missing {
return None;
}
if self.filter == Filter::default() && !self.hold_view {
// nothing narrows the source: what the view lists is all of it, and is counted already (not a view
// that holds photos that stopped matching: those are not of the source any more)
let _ = self.visible();
if let Some((_, revision, ids)) = &self.base
&& *revision == self.catalog.revision
{
return Some(ids.len());
}
}
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder));
if self.total.as_ref().map(|t| &t.0) != Some(&key) {
let mut f = self.source.to_filter(&Filter::default(), &self.catalog);
@@ -1068,7 +1133,8 @@ impl Session {
if self.source == LibrarySource::LibraryFolder {
f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into()));
}
let n = self.catalog.query(&f, &self.sort).len();
// (how many, not which: nothing to put in order)
let n = self.catalog.count(&f);
self.total = Some((key, n));
}
self.total.as_ref().map(|t| t.1)
@@ -1189,4 +1255,6 @@ mod tests_spots;
#[cfg(test)]
mod tests_sync;
#[cfg(test)]
mod tests_view_follow;
#[cfg(test)]
mod tests_xmp;
+2
View File
@@ -468,6 +468,8 @@ impl Session {
static LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32;
self.catalog = catalog;
// (nothing of the list the last library showed is this one's)
self.refresh_view();
self.library_generation = crate::LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {
self.pending_sidecar_repairs.append(&mut entry.retry_sidecars);
+4 -4
View File
@@ -181,10 +181,10 @@ fn a_source_with_no_filter_over_it_holds_nothing() {
let all = s.visible_cloned();
keyword(&mut s, all[0]);
assert_eq!(s.visible_cloned(), all);
assert!(s.held.is_none());
s.execute("library.filter", &json!({"rating": 1})).unwrap();
let _ = s.visible_cloned();
assert!(s.held.is_some(), "a filter is a question: its view is held");
// a photo deleted from All Photos leaves it, held or not
s.execute("library.select", &json!({"ids": [all[0].0]})).unwrap();
s.execute("photo.delete", &json!({})).unwrap();
assert!(!s.visible_cloned().contains(&all[0]));
}
#[test]
+221
View File
@@ -0,0 +1,221 @@
//! The view follows a small edit from the list it had ([`Session::visible`]): whatever the edit, the result has to
//! be the list worked out from every photo.
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde_json::{Value, json};
use crate::Session;
/// A small generator of numbers that is the same on every run.
struct Dice(u64);
impl Dice {
fn roll(&mut self, sides: usize) -> usize {
self.0 = lightcraft_catalog::mix64(self.0);
(self.0 % sides.max(1) as u64) as usize
}
}
/// A library of `n` photos in three folders, with ratings, flags, keywords and dates spread over them, an album
/// holding every third photo, a smart album of the photos without keywords, and a stack.
fn library(n: usize) -> (Session, Vec<PhotoId>, u64, u64) {
let mut s = Session::new();
let mut ids = Vec::new();
for i in 0..n {
let id = s.catalog.alloc_photo_id();
let name = format!("IMG_{:04}.jpg", (i * 37) % n);
let mut p = Photo::new(
id,
Source::File { path: format!("/pics/{}/{name}", i % 3) },
&name,
"JPEG",
60,
40,
&format!("2026-01-{:02}T10:00:00", 1 + i % 9),
);
p.captured = Some(format!("2025-{:02}-{:02}T10:00:{:02}", 1 + i % 12, 1 + i % 28, i % 60));
s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
let pick = |s: &mut Session, ids: Vec<u64>, command: &str, params: Value| {
s.execute("library.select", &json!({"ids": ids})).unwrap();
s.execute(command, &params).unwrap();
};
let every = |k: usize, of: usize| ids.iter().enumerate().filter(|(i, _)| i % of == k).map(|(_, id)| id.0).collect::<Vec<u64>>();
pick(&mut s, every(0, 4), "photo.rate", json!({"rating": 3}));
pick(&mut s, every(1, 4), "photo.rate", json!({"rating": 5}));
pick(&mut s, every(0, 5), "photo.flag", json!({"flag": "pick"}));
pick(&mut s, every(0, 2), "photo.setMeta", json!({"addKeywords": ["travel"]}));
pick(&mut s, every(0, 7), "photo.setMeta", json!({"addKeywords": ["weddings"]}));
let album = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap();
s.execute("album.addPhotos", &json!({"id": album, "ids": every(0, 3)})).unwrap();
let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "is", "value": 0}]}});
let smart = s.execute("album.createSmart", &json!({"name": "Unkeyworded", "rules": rules})).unwrap()["id"].as_u64().unwrap();
pick(&mut s, vec![ids[10].0, ids[11].0, ids[12].0], "stack.group", json!({}));
(s, ids, album, smart)
}
/// One edit at random: most touch a photo or a few (what it is rated, flagged, keyworded, developed; when it was
/// taken; where its file is; whether it is in the library at all), some change an album, a stack or the library's
/// past, and now and then a photo is added or removed for good.
fn edit(s: &mut Session, dice: &mut Dice, ids: &mut Vec<PhotoId>, album: u64) {
let chosen: Vec<u64> = (0..1 + dice.roll(3)).map(|_| ids[dice.roll(ids.len())].0).collect();
let one = PhotoId(chosen[0]);
let n = dice.roll(1000);
// (ops a command would refuse here, or on a photo that is gone, are edits that were not made: also a case)
match dice.roll(9) {
0 => {
let _ =
s.commit("Capture time", Op::SetCaptured { id: one, captured: Some(format!("2025-{:02}-{:02}T08:00:00", 1 + n % 12, 1 + n % 28)) });
return;
}
1 => {
let name = format!("REN_{n:04}.jpg");
let source = Source::File { path: format!("/pics/{}/{name}", n % 3) };
let _ = s.commit("Locate", Op::Relink { id: one, file_name: name, source, format: None });
return;
}
2 if n.is_multiple_of(4) => {
let id = s.catalog.alloc_photo_id();
let name = format!("NEW_{n:04}.jpg");
let mut p = Photo::new(id, Source::File { path: format!("/pics/{}/{name}", n % 3) }, &name, "JPEG", 60, 40, "2026-02-01T10:00:00");
p.captured = Some(format!("2025-{:02}-11T09:00:00", 1 + n % 12));
if s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).is_ok() {
ids.push(id);
}
return;
}
2 if n % 4 == 1 => {
let _ = s.commit("Remove", Op::RemovePhoto { id: one });
return;
}
2 => {
let _ = s.commit("Local", Op::SetLocal { id: one, local: n.is_multiple_of(2) });
return;
}
_ => {}
}
// (a photo in Recently Deleted can't be chosen: the edit is then not made, which is an edit too)
let _ = s.execute("library.select", &json!({"ids": chosen}));
let flag = ["pick", "reject", "none"][dice.roll(3)];
let keyword = ["travel", "weddings", "pets"][dice.roll(3)];
let _ = match dice.roll(12) {
0 | 1 => s.execute("photo.rate", &json!({"rating": dice.roll(6)})),
2 => s.execute("photo.flag", &json!({"flag": flag})),
3 | 4 => s.execute("photo.setMeta", &json!({"addKeywords": [keyword]})),
5 | 6 => s.execute("photo.setMeta", &json!({"keywords": []})),
7 if n.is_multiple_of(2) => s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.1 * (n % 9) as f64})),
7 => s.execute("stack.group", &json!({})),
8 => s.execute("photo.delete", &json!({})),
9 => s.execute("album.addPhotos", &json!({"id": album, "ids": chosen})),
10 => s.execute("album.removePhotos", &json!({"id": album, "ids": chosen})),
_ => s.undo_step().map(|_| Value::Null),
};
}
#[test]
fn following_an_edit_gives_the_list_worked_out_from_every_photo() {
let (mut s, mut ids, album, smart) = library(240);
let mut dice = Dice(7);
let sources = [
json!({"kind": "all"}),
json!({"kind": "picks"}),
json!({"kind": "album", "id": smart}),
json!({"kind": "album", "id": album}),
json!({"kind": "libraryFolder", "path": "/pics/1"}),
json!({"kind": "recentlyDeleted"}),
];
let sorts = [
json!({"key": "random", "seed": 11}),
json!({"key": "captureDate", "ascending": false}),
json!({"key": "rating", "ascending": true}),
json!({"key": "fileName", "ascending": true}),
json!({"key": "editDate", "ascending": false}),
json!({"key": "importDate", "ascending": true}),
];
let filters = [json!({"rating": 0, "text": ""}), json!({"rating": 3, "text": ""}), json!({"rating": 0, "text": "travel"})];
let mut checked = 0;
for (i, source) in sources.iter().enumerate() {
for (j, sort) in sorts.iter().enumerate() {
let filter = &filters[(i + j) % filters.len()];
s.execute("library.source", source).unwrap();
s.execute("library.sort", sort).unwrap();
s.execute("library.filter", filter).unwrap();
let _ = s.visible_cloned();
// (every other view holds its photos until refreshed: what stays then has to be what asking every photo
// would keep)
s.execute("library.holdView", &json!({"on": (i + j) % 2 == 1})).unwrap();
let _ = s.visible_cloned();
for step in 0..40 {
edit(&mut s, &mut dice, &mut ids, album);
let followed = s.visible_cloned();
let whole = s.visible_in_full();
assert_eq!(followed, whole, "source {source}, sort {sort}, filter {filter}, hold {}, edit {step}", s.hold_view);
checked += 1;
}
}
}
assert_eq!(checked, 1440);
// and it was following that was checked: most edits touch a photo or two, and the view kept up with those
// without asking every photo again
assert!(s.view_followed > 400, "followed {} times, worked out {} times", s.view_followed, s.view_worked_out);
}
#[test]
fn a_view_that_asks_about_the_last_days_follows_the_clock_too() {
let (mut s, ids, _, _) = library(60);
// Given a smart album of the photos taken in the last 30 days, a day after the latest of them
let now = std::sync::Arc::new(std::sync::Mutex::new("2026-01-01T12:00:00".to_string()));
let clock = now.clone();
s.clock = Box::new(move || clock.lock().unwrap().clone());
let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "captureDate", "op": "inLast", "value": 30}]}});
let recent = s.execute("album.createSmart", &json!({"name": "Recent", "rules": rules})).unwrap()["id"].as_u64().unwrap();
s.execute("library.source", &json!({"kind": "album", "id": recent})).unwrap();
s.execute("library.sort", &json!({"key": "fileName", "ascending": true})).unwrap();
let shown = s.visible_cloned();
assert!(!shown.is_empty() && shown.len() < ids.len(), "{} of {}", shown.len(), ids.len());
// When a photo is rated two months later
*now.lock().unwrap() = "2026-03-05T12:00:00".to_string();
let followed = s.view_followed;
s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
// Then the photos nobody touched have left with the days: the view was asked whole, not followed
assert!(s.visible_cloned().is_empty(), "{:?}", s.visible_cloned());
assert_eq!(s.visible_cloned(), s.visible_in_full());
assert_eq!(s.view_followed, followed);
// And the same through a view that only looks into that album
let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "album", "op": "is", "value": recent}]}});
if let Ok(r) = s.execute("album.createSmart", &json!({"name": "In Recent", "rules": rules})) {
s.execute("library.source", &json!({"kind": "album", "id": r["id"]})).unwrap();
let _ = s.visible_cloned();
s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
assert_eq!(s.visible_cloned(), s.visible_in_full());
assert_eq!(s.view_followed, followed, "a view that looks into a clocked album is asked whole too");
}
}
#[test]
fn an_edit_to_many_photos_or_to_more_than_photos_is_worked_out_whole() {
let (mut s, ids, album, _) = library(600);
s.execute("library.sort", &json!({"key": "random", "seed": 3})).unwrap();
let _ = s.visible_cloned();
let (followed, whole) = (s.view_followed, s.view_worked_out);
// one photo rated: followed
s.execute("library.select", &json!({"ids": [ids[5].0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 1})).unwrap();
let _ = s.visible_cloned();
assert_eq!((s.view_followed, s.view_worked_out), (followed + 1, whole));
// five hundred photos rated at once: worked out from every photo
s.execute("library.select", &json!({"ids": ids.iter().take(500).map(|id| id.0).collect::<Vec<_>>()})).unwrap();
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
let _ = s.visible_cloned();
assert_eq!((s.view_followed, s.view_worked_out), (followed + 1, whole + 1));
// an album's photos changed: what that does to which view is not asked photo by photo
s.execute("album.addPhotos", &json!({"id": album, "ids": [ids[1].0]})).unwrap();
let _ = s.visible_cloned();
assert_eq!((s.view_followed, s.view_worked_out), (followed + 1, whole + 2));
// nothing changed: neither
let _ = s.visible_cloned();
assert_eq!((s.view_followed, s.view_worked_out), (followed + 1, whole + 2));
}
+1 -1
View File
@@ -113,7 +113,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, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_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(), "gpuReason": lightcraft_engine::gpu::unavailable_reason(), "gpuFallback": lightcraft_engine::gpu::last_fallback()},
"perf": {"viewFollowed": app.session.view_followed, "viewWorkedOut": app.session.view_worked_out, "frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_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(), "gpuReason": lightcraft_engine::gpu::unavailable_reason(), "gpuFallback": lightcraft_engine::gpu::last_fallback()},
"loupe": app.loupe_shown.map(|(p, src)| json!({
"photo": p.0,
"source": src,
+37 -33
View File
@@ -138,6 +138,8 @@ mod tests_switch_library;
#[cfg(test)]
mod tests_sync;
#[cfg(test)]
mod tests_tallies;
#[cfg(test)]
mod tests_temp_slider;
#[cfg(test)]
mod tests_titlebar;
@@ -146,6 +148,8 @@ mod tests_tool_overlay;
#[cfg(test)]
mod tests_unsaved;
#[cfg(test)]
mod tests_view_cost;
#[cfg(test)]
mod tests_wb_selector;
#[cfg(test)]
mod tests_zoom;
@@ -1661,28 +1665,35 @@ pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 {
BuildHasherDefault::<DefaultHasher>::default().hash_one(parts)
}
/// Whether a tally of the library made when the catalog was at `revision` is still right, given that it reads only
/// `reads` of a photo: nothing changed since, or only other things of some photos did (a rating changes no folder's
/// count, a slider changes none at all). Each of these tallies asks every photo, so one kept is a pass over the
/// library saved on every edit.
fn still_right(cat: &lightcraft_catalog::Catalog, revision: u64, reads: lightcraft_catalog::Aspects) -> bool {
revision == cat.revision || cat.aspects_since(revision).is_some_and(|changed| !changed.any_of(reads))
}
/// What the tallies below read of a photo, besides whether it is in the library.
const READS_FILE: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FILE);
const READS_META: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::META);
const READS_PEOPLE: lightcraft_catalog::Aspects = READS_META.with(lightcraft_catalog::Aspects::ANALYSIS);
const READS_CAPTURED: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::CAPTURED);
const READS_FLAG: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FLAG);
impl Caches {
/// The folders the library's photos were imported from.
pub fn folder_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::FolderNode>> {
match &self.folder_tree {
Some((r, t)) if *r == cat.revision => t.clone(),
_ => {
let t = std::sync::Arc::new(cat.folder_tree());
self.folder_tree = Some((cat.revision, t.clone()));
t
}
}
let kept = self.folder_tree.take().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t);
let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.folder_tree()));
self.folder_tree = Some((cat.revision, t.clone()));
t
}
/// The library's keyword tree.
pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>> {
match &self.keyword_tree {
Some((r, t)) if *r == cat.revision => t.clone(),
_ => {
let t = std::sync::Arc::new(cat.keyword_tree());
self.keyword_tree = Some((cat.revision, t.clone()));
t
}
}
let kept = self.keyword_tree.take().filter(|(r, _)| still_right(cat, *r, READS_META)).map(|(_, t)| t);
let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.keyword_tree()));
self.keyword_tree = Some((cat.revision, t.clone()));
t
}
/// The Keywording box's read-only names for the selection: what exported files carry
/// (`export`), or the keywords with those containing them.
@@ -1788,8 +1799,9 @@ impl Caches {
/// All Photos / Picks / Recently Deleted counts.
pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts {
if let Some((r, c)) = self.counts
&& r == cat.revision
&& still_right(cat, r, READS_FLAG)
{
self.counts = Some((cat.revision, c));
return c;
}
let mut c = LibraryCounts::default();
@@ -1808,25 +1820,17 @@ impl Caches {
}
/// Everyone named on a face in the library (for completing a name as it is typed).
pub fn person_names(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<String>> {
match &self.person_names {
Some((r, v)) if *r == cat.revision => v.clone(),
_ => {
let v = std::sync::Arc::new(cat.people().into_iter().map(|p| p.name).collect::<Vec<_>>());
self.person_names = Some((cat.revision, v.clone()));
v
}
}
let kept = self.person_names.take().filter(|(r, _)| still_right(cat, *r, READS_PEOPLE)).map(|(_, v)| v);
let v = kept.unwrap_or_else(|| std::sync::Arc::new(cat.people().into_iter().map(|p| p.name).collect::<Vec<_>>()));
self.person_names = Some((cat.revision, v.clone()));
v
}
/// The By Date tree.
pub fn date_groups(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::DateGroup>> {
match &self.date_groups {
Some((r, g)) if *r == cat.revision => g.clone(),
_ => {
let g = std::sync::Arc::new(cat.date_groups());
self.date_groups = Some((cat.revision, g.clone()));
g
}
}
let kept = self.date_groups.take().filter(|(r, _)| still_right(cat, *r, READS_CAPTURED)).map(|(_, g)| g);
let g = kept.unwrap_or_else(|| std::sync::Arc::new(cat.date_groups()));
self.date_groups = Some((cat.revision, g.clone()));
g
}
/// Cameras, lenses and keywords in the library (filter bar pickers).
pub fn filter_values(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<FilterValues> {
+149
View File
@@ -0,0 +1,149 @@
//! The side panels' tallies of the library (folders, keywords, dates, people, counts) are kept through an edit that
//! cannot have changed them, and are never kept through one that did.
use std::sync::Arc;
use serde_json::json;
use crate::{LightcraftApp, Services};
/// A library of photos that are files in folders, taken on different days (the demo's photos are in no folder),
/// some of them keyworded.
fn app() -> LightcraftApp {
use lightcraft_catalog::{Op, Photo, Source};
let mut s = lightcraft_engine::Session::new();
let mut ids = Vec::new();
for i in 0..60usize {
let id = s.catalog.alloc_photo_id();
let name = format!("IMG_{i:03}.jpg");
let mut p = Photo::new(id, Source::File { path: format!("/pics/{}/{name}", i % 4) }, &name, "JPEG", 60, 40, "2026-01-01T10:00:00");
p.captured = Some(format!("2025-{:02}-{:02}T10:00:00", 1 + i % 12, 1 + i % 28));
s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id.0);
}
s.execute("library.select", &json!({"ids": ids.iter().step_by(2).collect::<Vec<_>>()})).unwrap();
s.execute("photo.setMeta", &json!({"addKeywords": ["travel"]})).unwrap();
LightcraftApp::new(s, Services { png: None, ..Default::default() })
}
/// The tallies as the panels would show them now.
fn shown(a: &mut LightcraftApp) -> String {
let cat = &a.session.catalog;
format!(
"{:?}\n{:?}\n{:?}\n{:?}\n{:?}",
a.caches.folder_tree(cat),
a.caches.keyword_tree(cat),
a.caches.date_groups(cat),
a.caches.person_names(cat),
a.caches.counts(cat)
)
}
/// The same tallies made from every photo.
fn fresh(a: &LightcraftApp) -> String {
let cat = &a.session.catalog;
format!(
"{:?}\n{:?}\n{:?}\n{:?}\n{:?}",
cat.folder_tree(),
cat.keyword_tree(),
cat.date_groups(),
cat.people().into_iter().map(|p| p.name).collect::<Vec<_>>(),
crate::Caches::default().counts(cat)
)
}
#[test]
fn a_tally_kept_through_an_edit_is_the_tally_made_from_every_photo() {
let mut a = app();
let ids: Vec<u64> = a.session.visible_cloned().iter().map(|p| p.0).collect();
let mut dice = 5u64;
let mut roll = |sides: usize| {
dice = lightcraft_catalog::mix64(dice);
(dice % sides as u64) as usize
};
assert_eq!(shown(&mut a), fresh(&a));
assert!(!a.caches.folder_tree(&a.session.catalog).is_empty() && !a.caches.date_groups(&a.session.catalog).is_empty());
for step in 0..600 {
let id = ids[roll(ids.len())];
// (a photo in Recently Deleted can't be chosen: the edit is then not made, which is an edit too)
let _ = a.session.execute("library.select", &json!({"ids": [id]}));
let keyword = ["travel", "weddings", "pets|dogs"][roll(3)];
let flag = ["pick", "reject", "none"][roll(3)];
// where a file is, when a photo was taken, a photo that comes or goes for good
{
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
let n = roll(1000);
let moved = match roll(8) {
0 => {
let name = format!("MOVED_{n:03}.jpg");
let source = Source::File { path: format!("/elsewhere/{}/{name}", n % 3) };
Some(a.session.commit("Locate", Op::Relink { id: PhotoId(id), file_name: name, source, format: None }))
}
1 => Some(
a.session
.commit("Capture time", Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2024-{:02}-03T10:00:00", 1 + n % 12)) }),
),
2 if n.is_multiple_of(3) => Some(a.session.commit("Remove", Op::RemovePhoto { id: PhotoId(id) })),
2 if n % 3 == 1 => {
let new = a.session.catalog.alloc_photo_id();
let name = format!("NEW_{n:03}.jpg");
let p = Photo::new(new, Source::File { path: format!("/new/{name}") }, &name, "JPEG", 60, 40, "2026-02-01T10:00:00");
Some(a.session.commit("Add", Op::AddPhoto { photo: Box::new(p) }))
}
_ => None,
};
if moved.is_some() {
if roll(3) > 0 {
assert_eq!(shown(&mut a), fresh(&a), "after edit {step}");
}
continue;
}
}
let _ = match roll(11) {
0 | 1 => a.session.execute("photo.rate", &json!({"rating": roll(6)})),
2 => a.session.execute("photo.flag", &json!({"flag": flag})),
3 | 4 => a.session.execute("photo.setMeta", &json!({"addKeywords": [keyword]})),
5 => a.session.execute("photo.setMeta", &json!({"keywords": []})),
6 => a.session.execute("photo.setMeta", &json!({"title": format!("Title {}", roll(4))})),
7 => a.session.execute("develop.set", &json!({"control": "light.exposure", "value": 0.1 * roll(9) as f64})),
8 => a.session.execute("photo.delete", &json!({})),
9 => a.session.execute("photo.label", &json!({"label": "red"})),
_ => a.session.undo_step().map(|_| serde_json::Value::Null),
};
// (not every frame asks for every tally: sometimes several edits pass between two looks)
if roll(3) > 0 {
assert_eq!(shown(&mut a), fresh(&a), "after edit {step}");
}
}
}
#[test]
fn a_rating_or_a_slider_keeps_every_tally_and_a_keyword_keeps_the_folders_and_dates() {
let mut a = app();
let id = a.session.visible_cloned()[0].0;
a.session.execute("library.select", &json!({"ids": [id]})).unwrap();
let look = |a: &mut LightcraftApp| {
let cat = &a.session.catalog;
(a.caches.folder_tree(cat), a.caches.keyword_tree(cat), a.caches.date_groups(cat), a.caches.person_names(cat))
};
let was = look(&mut a);
// a rating, then a slider: nothing is tallied again
a.session.execute("photo.rate", &json!({"rating": 2})).unwrap();
a.session.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap();
let now = look(&mut a);
assert!(Arc::ptr_eq(&was.0, &now.0) && Arc::ptr_eq(&was.1, &now.1) && Arc::ptr_eq(&was.2, &now.2) && Arc::ptr_eq(&was.3, &now.3));
// a keyword: the keyword tree is made again, the folders and the dates are not
a.session.execute("photo.setMeta", &json!({"addKeywords": ["travel"]})).unwrap();
let now = look(&mut a);
assert!(Arc::ptr_eq(&was.0, &now.0) && Arc::ptr_eq(&was.2, &now.2), "folders and dates kept");
assert!(!Arc::ptr_eq(&was.1, &now.1), "keywords tallied again");
// a photo deleted: every tally is made again
a.session.execute("photo.delete", &json!({})).unwrap();
let gone = look(&mut a);
assert!(!Arc::ptr_eq(&now.0, &gone.0) && !Arc::ptr_eq(&now.1, &gone.1) && !Arc::ptr_eq(&now.2, &gone.2));
// an album made (more than a photo's own change): nothing is assumed
let before = look(&mut a);
a.session.execute("album.create", &json!({"name": "Trip"})).unwrap();
let after = look(&mut a);
assert!(!Arc::ptr_eq(&before.0, &after.0));
}
+155
View File
@@ -0,0 +1,155 @@
//! What one edit costs in a large library: a measurement, not a test (run with `--ignored --nocapture`).
use std::time::{Duration, Instant};
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
fn ms(d: Duration) -> f64 {
d.as_secs_f64() * 1000.0
}
/// A library of `n` photos, about a third of them keyworded from a pool of 500 keywords, showing a smart album of
/// the photos without keywords at random.
fn library(n: usize) -> lightcraft_engine::Session {
let mut s = lightcraft_engine::Session::new();
let mut keyworded: Vec<Vec<u64>> = vec![Vec::new(); 500];
for i in 0..n {
let id = s.catalog.alloc_photo_id();
let name = format!("IMG_{i:06}.jpg");
let day = 1 + (i / 400) % 28;
let month = 1 + (i / 11_200) % 12;
let mut p = Photo::new(id, Source::File { path: format!("/pics/{}/{name}", i / 1000) }, &name, "JPEG", 6000, 4000, "2026-01-01T10:00:00");
p.captured = Some(format!("2025-{month:02}-{day:02}T10:{:02}:{:02}", (i / 60) % 60, i % 60));
s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).unwrap();
if i % 3 == 0 {
keyworded[(i / 3) % 500].push(id.0);
}
}
for (k, ids) in keyworded.iter().enumerate() {
s.execute("library.select", &json!({"ids": ids})).unwrap();
s.execute("photo.setMeta", &json!({"addKeywords": [format!("keyword {k:03}")]})).unwrap();
}
let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "is", "value": 0}]}});
let album = s.execute("album.createSmart", &json!({"name": "Unkeyworded", "rules": rules})).unwrap()["id"].as_u64().unwrap();
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
s.execute("library.sort", &json!({"key": "random"})).unwrap();
s
}
#[test]
#[ignore = "a measurement: run with --ignored --nocapture"]
fn what_one_keyword_costs_in_a_library_of_67_000_photos() {
let t = Instant::now();
let mut s = library(67_000);
eprintln!("built the library in {:.0} ms", ms(t.elapsed()));
// the engine alone
let t = Instant::now();
let shown = s.visible().len();
eprintln!("engine: first view of {shown} photos: {:.1} ms", ms(t.elapsed()));
for round in 0..3 {
let id: PhotoId = s.visible()[100 + round];
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
let t = Instant::now();
s.execute("photo.setMeta", &json!({"addKeywords": ["keyword 001"]})).unwrap();
let command = t.elapsed();
let t = Instant::now();
let n = s.visible().len();
let view = t.elapsed();
let t = Instant::now();
let _ = s.source_total();
let total = t.elapsed();
eprintln!("engine: keyword {round}: command {:.1} ms, view of {n} again {:.1} ms, source total {:.1} ms", ms(command), ms(view), ms(total));
}
// its parts
let ids = s.visible_cloned();
let t = Instant::now();
let f = s.source.to_filter(&s.filter, &s.catalog);
let q = s.catalog.query(&f, &s.sort);
let query = t.elapsed();
let t = Instant::now();
let _ = s.catalog.arrange_stacks(&q);
let stacks = t.elapsed();
let t = Instant::now();
let _ = s.catalog.stack_index();
let index = t.elapsed();
eprintln!(
"engine parts: query and sort {:.1} ms, arrange stacks {:.1} ms, stack index {:.1} ms ({} ids)",
ms(query),
ms(stacks),
ms(index),
ids.len()
);
// what the side panels tally from every photo after each change
let time = |what: &str, f: &dyn Fn()| {
let t = Instant::now();
f();
eprintln!("tally: {what} {:.1} ms", ms(t.elapsed()));
};
time("folder tree", &|| drop(s.catalog.folder_tree()));
time("keyword tree", &|| drop(s.catalog.keyword_tree()));
time("date groups", &|| drop(s.catalog.date_groups()));
time("people", &|| drop(s.catalog.people()));
time("count of a smart album", &|| {
let _ = s.catalog.count(&f);
});
time("count of all photos", &|| {
let _ = s.catalog.count(&lightcraft_catalog::Filter::default());
});
// the app: a frame after an edit against a frame with nothing changed
let app = LightcraftApp::new(s, Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1600.0, 1000.0], 1.0);
h.app.ui.left_panel = true;
h.request("engine.execute", json!({"command": "view.squareGrid", "params": {}}), Duration::from_secs(60));
h.request("engine.execute", json!({"command": "panel.keywords", "params": {}}), Duration::from_secs(60));
h.settle(Duration::from_secs(120));
let frame = |h: &mut Headless| {
let t = Instant::now();
h.step();
t.elapsed()
};
let idle: Vec<f64> = (0..5).map(|_| ms(frame(&mut h))).collect();
eprintln!("app: frames with nothing changed: {idle:.1?} ms");
for round in 0..3 {
let id: PhotoId = h.app.session.visible()[300 + round];
h.app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap();
h.step();
h.step();
let t = Instant::now();
h.app.run("photo.setMeta", json!({"addKeywords": ["keyword 002"]})).unwrap();
let command = t.elapsed();
let first = frame(&mut h);
let second = frame(&mut h);
eprintln!("app: keyword {round}: command {:.1} ms, the frame after it {:.1} ms, the next {:.1} ms", ms(command), ms(first), ms(second));
}
// which part of the window the frame after an edit pays for
for (what, left, right) in [("grid alone", false, false), ("grid and left panel", true, false), ("grid and Keywords panel", false, true)] {
h.app.ui.left_panel = left;
h.request("engine.execute", json!({"command": if right { "panel.keywords" } else { "panel.close" }, "params": {}}), Duration::from_secs(60));
if right && h.app.ui.right != crate::state::RightPanel::Keywords {
h.request("engine.execute", json!({"command": "panel.keywords", "params": {}}), Duration::from_secs(60));
}
h.settle(Duration::from_secs(120));
let id: PhotoId = h.app.session.visible()[500];
h.app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap();
h.step();
h.step();
h.app.run("photo.setMeta", json!({"addKeywords": ["keyword 004"]})).unwrap();
let first = frame(&mut h);
eprintln!("app: {what} (right panel {:?}): the frame after a keyword {:.1} ms", h.app.ui.right, ms(first));
}
h.app.ui.left_panel = true;
let view = "view.photoGrid";
h.request("engine.execute", json!({"command": view, "params": {}}), Duration::from_secs(60));
h.settle(Duration::from_secs(120));
let id: PhotoId = h.app.session.visible()[400];
h.app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap();
h.step();
h.step();
h.app.run("photo.setMeta", json!({"addKeywords": ["keyword 003"]})).unwrap();
let first = frame(&mut h);
eprintln!("app: {view}: the frame after a keyword {:.1} ms", ms(first));
}
+14
View File
@@ -92,6 +92,20 @@
## 2026-10-13
### Edits keep up in a large library
- A small edit (a keyword, a rating, a flag, a slider) no longer has the grid's list worked out again from every
photo: only the photos the edit touched are asked about again and put at their place in the order. In a library of
67,000 photos that step went from 37 ms to under 1 ms. Still worked out whole: an edit to more than 256 photos, a
change to an album, a stack or the keyword list, an album shown in its own order, Recently Added, Missing Photos,
and a view that asks about "the last N days".
- With no filter set, the count of the source is taken from the list instead of from a second pass over the library.
- The side panels keep a tally an edit cannot have changed: a rating or a slider changes none of the folders,
keywords, dates, people and counts; a keyword leaves the folders, the dates and the counts as they are. With both
side panels open, the frame after a keyword went from about 156 ms to about 54 ms in that library (2 ms with the
panels closed, from 71 ms).
- Still tallied from every photo on each edit: the count beside each album and smart album in the left panel (about
25 ms for a smart album in that library), and the filter bar's lists of cameras, lenses and keywords.
### A view can hold its photos until you refresh it
- View ▸ Hold View Until Refreshed (off unless you turn it on; kept with the library). With it on, an edit that makes
a photo stop matching the view keeps it in the view: a keyword given in a smart album of photos without keywords,