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 <noreply@anthropic.com>
This commit is contained in:
Chad Elliott
2026-10-11 16:48:16 -05:00
co-authored by Claude Opus 5.5
parent 3f3ffa541a
commit 39c33a99d6
3 changed files with 53 additions and 6 deletions
+18
View File
@@ -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<RulesView>)>,
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<std::collections::HashMap<lightcraft_catalog::AlbumId, Vec<lightcraft_catalog::rules::Problem>>>)>,
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(
+14 -6
View File
@@ -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<String> = 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 {
+21
View File
@@ -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");
}