mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-19 02:10:25 -05:00
Merge PR #1660
This commit is contained in:
commit
31f1f559dd
12 files changed
+957
-79
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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, ¶ms).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));
|
||||
}
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user