From 39c33a99d6b7105fea2f783238c1a74ff2c8faa8 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 16:48:16 -0500 Subject: [PATCH] perf(ui): #1136 dialogs count once per library change, not every frame Create Smart Album ran a full query for its count and Rename Keyword scanned every photo on each frame the dialog was open; Merge Keywords recomputed suggestions. They use Caches::dialog_count (keyed by catalog revision and what is counted, and the minute for relative-date rules) and the cached suggestions. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 18 ++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 20 ++++++++++++++------ crates/ui-egui/src/tests_keywording.rs | 21 +++++++++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..35801ba9f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1477,6 +1477,9 @@ pub struct Caches { /// What the open smart-album rule dialog shows besides the rules ([`Caches::rules_view`]). rules_view: Option<(u64, std::sync::Arc)>, pub rules_view_scans: usize, + /// A count an open dialog shows ([`Caches::dialog_count`]), by what it counts. + dialog_count: Option<(u64, usize)>, + pub dialog_count_scans: usize, /// The problems of every saved smart album that has some ([`Caches::smart_album_problems`]). smart_problems: Option<(u64, std::sync::Arc>>)>, pub smart_problem_scans: usize, @@ -1749,6 +1752,21 @@ impl Caches { v } + /// A count an open dialog shows (photos a filter matches, photos with a keyword…): `count` runs + /// again only when the catalog or `what` (what is counted) changes, not every frame. + pub(crate) fn dialog_count(&mut self, cat: &lightcraft_catalog::Catalog, what: impl std::hash::Hash, count: impl FnOnce() -> usize) -> usize { + let k = key_of((cat.revision, what)); + if let Some((key, n)) = self.dialog_count + && key == k + { + return n; + } + let n = count(); + self.dialog_count_scans += 1; + self.dialog_count = Some((k, n)); + n + } + /// The problems of every saved smart album that has some (`Catalog::smart_album_problems`), /// for the sidebar's ⚠ marks: worked out again only when the catalog changes. pub fn smart_album_problems( diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..60ccdb6e3 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -430,7 +430,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } let rules = app.session.view_rules(); - let n = app.session.catalog.query(&rules, &Default::default()).len(); + // counted when the library or the rules change (or, for "in the last…" rules, the minute), not every frame + let now = (app.session.clock)(); + let minute = if rules.depends_on_now() { now.get(..16).unwrap_or(&now) } else { "" }; + let n = app.caches.dialog_count(&app.session.catalog, ("smartAlbum", format!("{rules:?}"), minute), || { + lightcraft_catalog::rules::set_now(Some(now.clone())); + app.session.catalog.query(&rules, &Default::default()).len() + }); ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label)); ui.label( egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) @@ -573,7 +579,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::RenameKeyword { from, to } => { - let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count(); + let n = app.caches.dialog_count(&app.session.catalog, ("renameKeyword", from.as_str()), || { + app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count() + }); if dialog_field(ui, "field:keywordName", to, "New name") { confirm = true; } @@ -589,11 +597,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } let options: Vec = app - .session - .catalog - .keyword_suggestions(from, into, 12) - .into_iter() + .caches + .suggestions(&app.session.catalog, from, into, 12) + .iter() .filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f))) + .cloned() .collect(); ui.horizontal_wrapped(|ui| { for k in options { diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index a130b86b2..17ddc6508 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -227,3 +227,24 @@ fn containing_keywords_view_lists_parents_too() { } assert!(!has(&h, "keywordChip:beach"), "the chips give way"); } + +/// Rename Keyword's "used by n photos" and Create Smart Album's live count scan the library once +/// per change to it, not every frame the dialog is open. +#[test] +fn open_dialogs_count_once_per_library_change() { + let (mut h, ids) = two_selected(); + h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: "beach".into(), to: "beach".into() }); + let before = h.app.caches.dialog_count_scans; + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.caches.dialog_count_scans, before + 1, "unchanged frames reuse the count"); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[1]], "keywords": ["sea"]}})); + h.step(); + assert_eq!(h.app.caches.dialog_count_scans, before + 2, "a library change counts again"); + h.app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: "Beach".into(), parent: None }); + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.caches.dialog_count_scans, before + 3, "another dialog counts its own photos, once"); +}