diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 04cc5135..9631b7d8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -222,7 +222,8 @@ pub struct Session { pub previous_active: Option, /// Groups last used for Copy (Lightroom remembers them). pub copy_groups: Vec, - pub presets: Vec, + /// Built-in then user presets; its generation tells savers and caches when it changed. + pub presets: presets::PresetList, /// Favourite profile ids (persisted with the library, like preset favourites). pub profile_favorites: Vec, /// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]). @@ -355,7 +356,7 @@ impl Session { library_folder: None, previous_active: None, copy_groups: lightcraft_develop::SettingsGroup::default_copy(), - presets: presets::builtin(), + presets: presets::PresetList::new(presets::builtin()), profile_favorites: Vec::new(), profile_recent: Vec::new(), journal: Vec::new(), @@ -1028,6 +1029,8 @@ mod tests_persist; #[cfg(test)] mod tests_prefs; #[cfg(test)] +mod tests_presets_scale; +#[cfg(test)] mod tests_process; #[cfg(test)] mod tests_segment; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 2bb5511c..0305623f 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -88,7 +88,8 @@ pub struct Library { retry_at: Option, /// What forgetting untouched Local records did when the library opened. pub forgot_local: Option, - presets_written: String, + /// What presets.json holds (see [`PresetsSaved`]). + presets_saved: PresetsSaved, view_written: Vec, /// Settings files that were unreadable or damaged when the library opened (`library.info` → /// `settingsWarnings`; shown by the UI once, see [`Session::take_library_warnings`]). @@ -123,6 +124,53 @@ struct PresetsFile { profile_recent: Vec, } +/// The state presets.json was last written (or read) from. Saving checks the preset list's +/// generation and the small profile lists before serializing anything: with a 10 000-preset +/// collection presets.json is ~40 MB, and serializing it after every command made each slider +/// tick take seconds. +#[derive(Default)] +struct PresetsSaved { + generation: u64, + profile_favorites: Vec, + profile_recent: Vec, + /// Hash of the bytes written: a list borrowed mutably but left as it was isn't rewritten. + hash: u64, +} + +impl PresetsSaved { + fn state_of(s: &Session) -> PresetsSaved { + PresetsSaved { + generation: s.presets.generation(), + profile_favorites: s.profile_favorites.clone(), + profile_recent: s.profile_recent.clone(), + hash: 0, + } + } + + /// `s` changed its presets or profile lists since this state. + fn differs(&self, s: &Session) -> bool { + self.generation != s.presets.generation() || self.profile_favorites != s.profile_favorites || self.profile_recent != s.profile_recent + } +} + +fn bytes_hash(b: &[u8]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + b.hash(&mut h); + h.finish() +} + +#[cfg(test)] +thread_local! { + static PRESETS_SERIALIZED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times this thread serialized the preset list (tests). +#[cfg(test)] +pub(crate) fn presets_serialized() -> usize { + PRESETS_SERIALIZED.with(|c| c.get()) +} + #[derive(Default, Serialize, Deserialize)] #[serde(default)] struct ViewFile { @@ -187,6 +235,8 @@ struct PrefsFile { } fn presets_json(s: &Session) -> String { + #[cfg(test)] + PRESETS_SERIALIZED.with(|c| c.set(c.get() + 1)); let presets = &s.presets; let f = PresetsFile { user: presets.iter().filter(|p| !p.builtin).cloned().collect(), @@ -414,8 +464,9 @@ impl Session { for p in &mut self.presets { p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite); } + let mut ids: std::collections::HashSet = self.presets.iter().map(|p| p.id.clone()).collect(); for u in f.user { - if !self.presets.iter().any(|p| p.id == u.id) { + if ids.insert(u.id.clone()) { self.presets.push(u); } } @@ -447,7 +498,8 @@ impl Session { { self.selection = Selection::single(*first); } - let presets_written = presets_json(self); + // presets.json is as read: saved again only after the list changes (hash 0 = unknown bytes) + let presets_saved = PresetsSaved::state_of(self); // photo ids are per library: drop decoded sources of the previous one self.media.clear_sources(); if on_disk { @@ -464,7 +516,7 @@ impl Session { unsaved_error: None, retry_at: None, forgot_local: None, - presets_written, + presets_saved, view_written, settings_warnings: unlocked_warning(lock.as_ref()).into_iter().chain(settings.warnings).collect(), warnings_reported: 0, @@ -526,14 +578,21 @@ impl Session { log::error!("library: compaction: {e}"); lib.last_error = Some(format!("compaction: {e}")); } + let due = self.library.as_ref().is_some_and(|l| l.presets_saved.differs(self) && !l.blocked.contains(&"presets.json")); + if !due { + return Ok(()); + } let presets = presets_json(self); + let mut state = PresetsSaved::state_of(self); + state.hash = bytes_hash(presets.as_bytes()); let Some(lib) = self.library.as_mut() else { return Ok(()) }; - if presets != lib.presets_written && !lib.blocked.contains(&"presets.json") { - if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) { - log::error!("library: presets: {e}"); - } else { - lib.presets_written = presets; - } + if state.hash == lib.presets_saved.hash { + lib.presets_saved = state; + } else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) { + // the state stays stale: the next save tries again + log::error!("library: presets: {e}"); + } else { + lib.presets_saved = state; } Ok(()) } diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index 8c36aa74..c3675e22 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -23,6 +23,73 @@ pub struct PresetFile { pub presets: Vec, } +/// The session's presets (built-in, then the user's) with a generation that changes on every +/// mutable access, so saving and the UI's caches skip their O(presets) work while the list is +/// untouched: a Lightroom collection imported whole is 10 000+ presets and ~40 MB of JSON. +/// +/// Reads go through `Deref` (`iter`, `len`, `find`…) and keep the generation; `push`, `iter_mut`, +/// `remove`… go through `DerefMut` and bump it, whether or not anything is then changed. +/// Generations are unique across all lists in the process. +#[derive(Clone, Debug)] +pub struct PresetList { + items: Vec, + generation: u64, +} + +static PRESET_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +fn next_generation() -> u64 { + PRESET_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +impl PresetList { + pub fn new(items: Vec) -> PresetList { + PresetList { items, generation: next_generation() } + } + + /// Changes whenever the list may have changed. + pub fn generation(&self) -> u64 { + self.generation + } +} + +impl Default for PresetList { + fn default() -> PresetList { + PresetList::new(Vec::new()) + } +} + +impl std::ops::Deref for PresetList { + type Target = Vec; + fn deref(&self) -> &Vec { + &self.items + } +} + +impl std::ops::DerefMut for PresetList { + fn deref_mut(&mut self) -> &mut Vec { + self.generation = next_generation(); + &mut self.items + } +} + +impl<'a> IntoIterator for &'a PresetList { + type Item = &'a Preset; + type IntoIter = std::slice::Iter<'a, Preset>; + fn into_iter(self) -> Self::IntoIter { + self.items.iter() + } +} + +impl<'a> IntoIterator for &'a mut PresetList { + type Item = &'a mut Preset; + type IntoIter = std::slice::IterMut<'a, Preset>; + fn into_iter(self) -> Self::IntoIter { + self.generation = next_generation(); + self.items.iter_mut() + } +} + /// Lower-case ASCII slug for ids (`"Warm & Soft"` → `warm-soft`). pub fn slug(s: &str) -> String { let mut out = String::new(); @@ -107,22 +174,33 @@ impl Session { /// Add imported presets. Identical presets (same name, group and settings) already present are /// skipped; id clashes get a fresh id. Returns the ids added. pub fn add_presets(&mut self, presets: Vec) -> Vec { + use std::collections::{HashMap, HashSet}; + // indexed: a collection of 10 000+ presets was quadratic (minutes) to import + let mut ids: HashSet = self.presets.iter().map(|q| q.id.clone()).collect(); + let mut by_name: HashMap<(String, String), Vec> = HashMap::new(); + for (i, q) in self.presets.iter().enumerate() { + by_name.entry((q.name.clone(), q.group.clone())).or_default().push(i); + } let mut added = Vec::new(); for mut p in presets { - if self.presets.iter().any(|q| q.name == p.name && q.group == p.group && q.settings == p.settings) { + let key = (p.name.clone(), p.group.clone()); + let same = by_name.get(&key).is_some_and(|ix| ix.iter().any(|&i| self.presets.get(i).is_some_and(|q| q.settings == p.settings))); + if same { continue; } - if p.id.trim().is_empty() || p.id.starts_with("lc.") || self.presets.iter().any(|q| q.id == p.id) { + if p.id.trim().is_empty() || p.id.starts_with("lc.") || ids.contains(&p.id) { let base = format!("user.{}", slug(&format!("{}-{}", p.group, p.name))); let mut id = base.clone(); let mut n = 2; - while self.presets.iter().any(|q| q.id == id) { + while ids.contains(&id) { id = format!("{base}-{n}"); n += 1; } p.id = id; } p.builtin = false; + ids.insert(p.id.clone()); + by_name.entry(key).or_default().push(self.presets.len()); added.push(p.id.clone()); self.presets.push(p); } diff --git a/crates/engine/src/tests_presets_scale.rs b/crates/engine/src/tests_presets_scale.rs new file mode 100644 index 00000000..866b7deb --- /dev/null +++ b/crates/engine/src/tests_presets_scale.rs @@ -0,0 +1,126 @@ +//! A library with thousands of user presets (a Lightroom preset collection imported whole) stays +//! responsive: commands that don't touch presets never re-serialize them, and opening or importing +//! doesn't compare every preset with every other. + +use std::path::PathBuf; +use std::time::Instant; + +use lightcraft_develop::Preset; +use serde_json::json; + +use crate::Session; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-presets-scale-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn many(n: usize) -> Vec { + (0..n) + .map(|i| Preset { + id: format!("user.coll-{}.look-{i}", i / 100), + name: format!("Look {i}"), + group: format!("Collection {}", i / 100), + settings: json!({"light": {"exposure": (i % 40) as f64 / 10.0 - 2.0, "contrast": (i % 50) as f64}, "color": {"vibrance": (i % 30) as f64}}), + favorite: false, + builtin: false, + }) + .collect() +} + +#[test] +fn develop_commands_do_not_reserialize_unchanged_presets() { + let lib = temp_dir("no-reserialize"); + let mut s = Session::new(); + s.open_library(&lib, true).unwrap(); + let added = s.add_presets(many(2000)); + assert_eq!(added.len(), 2000); + s.persist().unwrap(); + assert!(std::fs::metadata(lib.join("presets.json")).unwrap().len() > 100_000, "presets saved"); + let before = crate::library::presets_serialized(); + for i in 0..20 { + s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap(); + } + assert_eq!(crate::library::presets_serialized(), before, "slider commands left the preset list alone"); + // a preset change is still saved + let pid = added[7].clone(); + s.execute("preset.favorite", &json!({"id": pid})).unwrap(); + assert!(crate::library::presets_serialized() > before); + s.close_library().unwrap(); + drop(s); + let mut t = Session::new(); + t.open_library(&lib, false).unwrap(); + assert!(t.presets.iter().any(|p| p.id == pid && p.favorite), "favourite persisted"); + assert_eq!(t.presets.iter().filter(|p| !p.builtin).count(), 2000); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn preset_list_generation_follows_mutable_access_only() { + let mut s = Session::new(); + let g0 = s.presets.generation(); + let _ = s.presets.iter().filter(|p| p.builtin).count(); + let _ = s.presets.len(); + assert_eq!(s.presets.generation(), g0, "reads keep the generation"); + s.presets.push(many(1).remove(0)); + let g1 = s.presets.generation(); + assert_ne!(g1, g0); + for p in &mut s.presets { + p.favorite = false; + } + assert_ne!(s.presets.generation(), g1); + // generations are unique across sessions (UI caches key on them alone) + let other = Session::new(); + assert_ne!(other.presets.generation(), s.presets.generation()); +} + +#[test] +fn importing_thousands_of_presets_is_not_quadratic() { + let mut s = Session::new(); + let t = Instant::now(); + let added = s.add_presets(many(20_000)); + assert_eq!(added.len(), 20_000); + // the same presets again are all skipped as duplicates + assert!(s.add_presets(many(20_000)).is_empty()); + // a clashing id with different settings gets a fresh, unique one + let mut clash = many(1).remove(0); + clash.settings = json!({"light": {"exposure": 3.0}}); + let id = s.add_presets(vec![clash]).remove(0); + assert_ne!(id, "user.coll-0.look-0"); + assert_eq!(s.presets.iter().filter(|p| p.id == id).count(), 1); + // quadratic was ~200 M comparisons here (tens of seconds in debug builds) + assert!(t.elapsed().as_secs() < 20, "took {:?}", t.elapsed()); +} + +/// Timing with a real preset collection: `LIGHTCRAFT_BENCH_PRESETS=path/to/presets.json cargo test +/// -p lightcraft-engine --release presets_scale_bench -- --ignored --nocapture`. The file is copied +/// into a temporary library; the original is only read. +#[test] +#[ignore] +fn presets_scale_bench() { + let Ok(src) = std::env::var("LIGHTCRAFT_BENCH_PRESETS") else { + eprintln!("set LIGHTCRAFT_BENCH_PRESETS"); + return; + }; + let lib = temp_dir("bench"); + std::fs::copy(&src, lib.join("presets.json")).unwrap(); + let mut s = Session::new(); + let t = Instant::now(); + s.open_library(&lib, true).unwrap(); + eprintln!("open library: {:?} ({} presets)", t.elapsed(), s.presets.len()); + let t = Instant::now(); + for i in 0..20 { + s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap(); + } + eprintln!("develop.set: {:?} per command", t.elapsed() / 20); + let pid = s.presets.iter().rev().find(|p| !p.builtin).map(|p| p.id.clone()).unwrap(); + let t = Instant::now(); + s.execute("preset.apply", &json!({"id": pid, "amount": 100})).unwrap(); + eprintln!("preset.apply: {:?}", t.elapsed()); + let t = Instant::now(); + s.execute("preset.favorite", &json!({"id": pid})).unwrap(); + eprintln!("preset.favorite (saves presets.json): {:?}", t.elapsed()); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 88eccfc4..3ce1331a 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1638,6 +1638,51 @@ mod tests { h.settle(SETTLE); } + /// A collection of thousands of presets (a Lightroom library imported whole) lists only the + /// rows in view: a few dozen widgets and thumbnails a frame, not one per preset (every frame + /// built all of them and froze the app for seconds); scrolling brings later rows in. + #[test] + fn presets_panel_with_thousands_of_presets_builds_only_visible_rows() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let many = (0..5000) + .map(|i| lightcraft_develop::Preset { + id: format!("user.big.{i}"), + name: format!("Look {i}"), + group: format!("Big {:02}", i / 500), + settings: json!({"light": {"exposure": (i % 20) as f64 / 10.0}}), + favorite: false, + builtin: false, + }) + .collect(); + assert_eq!(h.app.session.add_presets(many).len(), 5000); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.app.ui.preset_thumbs = true; + h.settle(SETTLE); + h.step(); + let rows = |h: &Headless| h.app.widgets.iter().filter(|(w, _)| w.starts_with("preset:")).map(|(w, _)| w.clone()).collect::>(); + let first = rows(&h); + assert!((5..60).contains(&first.len()), "{} rows built", first.len()); + assert!(h.app.renderer.variant_textures() < 60, "{} thumbnails", h.app.renderer.variant_textures()); + assert!(h.app.widgets.iter().any(|(w, _)| w == "presetGroup:Big 09"), "every group header is listed"); + let started = std::time::Instant::now(); + h.step(); + eprintln!("presets panel frame with 5000 presets: {:?}", started.elapsed()); + // scroll far down over the list: later presets come into view + let r = h.request("ui.hoverWidget", json!({"id": first[first.len() / 2]}), t); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..20 { + h.request("ui.scroll", json!({"dy": -2000}), t); + h.step(); + } + h.step(); + let later = rows(&h); + assert!((5..60).contains(&later.len()), "{} rows built", later.len()); + let deep = later.iter().filter_map(|w| w.strip_prefix("preset:user.big.")).filter_map(|n| n.parse::().ok()).max(); + assert!(deep.is_some_and(|n| n > 500), "scrolled rows: {later:?}"); + } + /// The filter bar drives `library.filter` and saves the view as a smart album. #[test] fn filter_bar_filters_and_saves_a_smart_album() { diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 6081128d..4e797a57 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1235,16 +1235,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() { d.preset.clear(); } - for p in &app.session.presets { - if ui - .selectable_label( - d.preset == p.id, - format!("{} — {}", crate::i18n::builtin_label(&p.group, p.builtin), crate::i18n::builtin_label(&p.name, p.builtin)), - ) - .clicked() - { - d.preset = p.id.clone(); - } + let selected = (!d.preset.is_empty()).then_some(d.preset.as_str()); + if let Some(pid) = crate::panels::presets::picker_rows(ui, "import-preset", &app.session.presets, selected) { + d.preset = pid; } }); }); diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 081757bc..3d90c00c 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -4,9 +4,11 @@ //! files. use std::collections::BTreeMap; +use std::sync::Arc; use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; use lightcraft_develop::{ControlSpec, Preset, Section, Track}; +use lightcraft_engine::presets::PresetList; use serde_json::json; use crate::icons::{Icon, paint}; @@ -118,16 +120,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone()); let thumbs = app.ui.preset_thumbs && active.is_some(); let row_h = if thumbs { 54.0 } else { 26.0 }; + let groups = grouped(ui.ctx(), &app.session.presets); let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; - let mut groups: BTreeMap> = BTreeMap::new(); - for p in &app.session.presets { - groups.entry(p.group.clone()).or_default().push(p.clone()); - } - for (g, items) in groups { - // A group holding stock presets is a stock group (shown in the UI language), even - // when user presets share its name; a group of user presets keeps its name. - let builtin = items.iter().any(|p| p.builtin); + // Only the rows in view are laid out, drawn and given thumbnails: a Lightroom collection + // imported whole is 10 000+ presets, and building every row each frame took seconds. + let visible = ui.clip_rect().expand(row_h); + for grp in groups.iter() { + let (g, builtin) = (grp.name.clone(), grp.builtin); let open_id = egui::Id::new(("preset-group", &g)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); @@ -156,7 +156,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if !open { continue; } - for pr in items { + let (first, end) = rows_in_view(ui.cursor().top(), row_h, grp.items.len(), visible); + if first > 0 { + ui.allocate_space(vec2(ui.available_width(), first as f32 * row_h)); + } + for &i in grp.items.get(first..end).unwrap_or_default() { + let Some(pr) = app.session.presets.get(i).cloned() else { continue }; let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); register(ui.ctx(), format!("preset:{pid}"), r); @@ -219,10 +224,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { crate::panels::dialogs::prompt(app, "Rename Preset", "Preset name", &name, "preset.rename", json!({"id": pid}), "name"); } ui.menu_button(crate::i18n::tr("Move to Group"), |ui| { - let mut groups: Vec = app.session.presets.iter().filter(|p| !p.builtin).map(|p| p.group.clone()).collect(); - groups.sort(); - groups.dedup(); - for g in groups.iter().filter(|g| **g != group) { + for g in groups.iter().filter(|x| x.user && x.name != group).map(|x| &x.name) { if ui.button(g).clicked() { let _ = app.run("preset.move", json!({"id": pid, "group": g})); } @@ -245,6 +247,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } }); } + if end < grp.items.len() { + ui.allocate_space(vec2(ui.available_width(), (grp.items.len() - end) as f32 * row_h)); + } } ui.add_space(30.0); }); @@ -265,6 +270,107 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// A preset group as the panel lists it: indices into the session's presets. +struct Group { + name: String, + /// Holds a stock preset: a stock group (shown in the UI language), even when user presets + /// share its name; a group of user presets keeps its name. + builtin: bool, + /// Holds a user preset (a Move to Group target). + user: bool, + items: Vec, +} + +/// The presets by group (groups sorted by name, presets in list order), rebuilt only when the +/// list changed (its generation). +fn grouped(ctx: &egui::Context, presets: &PresetList) -> Arc> { + let id = egui::Id::new("presets-grouped"); + if let Some((generation, groups)) = ctx.data(|d| d.get_temp::<(u64, Arc>)>(id)) + && generation == presets.generation() + { + return groups; + } + let mut by_name: BTreeMap<&str, Group> = BTreeMap::new(); + for (i, p) in presets.iter().enumerate() { + let g = by_name.entry(&p.group).or_insert_with(|| Group { name: p.group.clone(), builtin: false, user: false, items: Vec::new() }); + g.builtin |= p.builtin; + g.user |= !p.builtin; + g.items.push(i); + } + let groups = Arc::new(by_name.into_values().collect::>()); + ctx.data_mut(|d| d.insert_temp(id, (presets.generation(), groups.clone()))); + groups +} + +/// The presets as the rows of a picker (a combo box's list), in list order with a header row +/// wherever the group changes. Only the rows in view are built (10 000+ presets made an open +/// picker take seconds a frame). Returns the id of the preset clicked. +pub fn picker_rows(ui: &mut egui::Ui, salt: &str, presets: &PresetList, selected: Option<&str>) -> Option { + let id = egui::Id::new("preset-picker-rows"); + let rows = match ui.ctx().data(|d| d.get_temp::<(u64, Arc>)>(id)) { + Some((generation, rows)) if generation == presets.generation() => rows, + _ => { + let mut rows = Vec::with_capacity(presets.len() + 16); + let mut group: Option<&str> = None; + for (i, p) in presets.iter().enumerate() { + if group != Some(p.group.as_str()) { + group = Some(&p.group); + rows.push(PickRow::Header(crate::i18n::builtin_label(&p.group, p.builtin).to_string())); + } + rows.push(PickRow::Preset(i)); + } + let rows = Arc::new(rows); + ui.ctx().data_mut(|d| d.insert_temp(id, (presets.generation(), rows.clone()))); + rows + } + }; + let row_h = ui.spacing().interact_size.y; + let mut out = None; + egui::ScrollArea::vertical().id_salt(("preset-picker", salt)).max_height(300.0).auto_shrink([false, true]).show_rows( + ui, + row_h, + rows.len(), + |ui, range| { + for row in rows.get(range).unwrap_or_default() { + let size = vec2(ui.available_width(), row_h); + match row { + PickRow::Header(g) => { + ui.add_sized(size, egui::Label::new(egui::RichText::new(g).size(10.5).weak())); + } + PickRow::Preset(i) => { + let Some(p) = presets.get(*i) else { continue }; + let label = crate::i18n::builtin_label(&p.name, p.builtin); + if ui.add_sized(size, egui::Button::selectable(selected == Some(p.id.as_str()), label)).clicked() { + out = Some(p.id.clone()); + } + } + } + } + }, + ); + out +} + +#[derive(Clone)] +enum PickRow { + /// A group's name (in the UI language for stock groups). + Header(String), + /// An index into the session's presets. + Preset(usize), +} + +/// The rows `first..end` of a block of `n` rows `row_h` high starting at `top` that meet +/// `visible`. +fn rows_in_view(top: f32, row_h: f32, n: usize, visible: Rect) -> (usize, usize) { + if row_h <= 0.0 || !top.is_finite() { + return (0, n); + } + // `as usize` saturates (negative and NaN give 0) + let first = (((visible.top() - top) / row_h).floor() as usize).min(n); + let end = (((visible.bottom() - top) / row_h).ceil() as usize).clamp(first, n); + (first, end) +} + /// The part of a `size` image that covers `r` (centre crop), as texture coordinates. pub fn cover_uv(r: Rect, size: [usize; 2]) -> Rect { let a = size[0] as f32 / size[1].max(1) as f32; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 68313fc9..e2b1e137 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -210,15 +210,8 @@ fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Optio if ui.selectable_label(current.is_none(), crate::i18n::tr(none_label)).clicked() { out = Some(None); } - let mut group = ""; - for p in &app.session.presets { - if p.group != group { - group = &p.group; - ui.label(RichText::new(crate::i18n::builtin_label(group, p.builtin)).size(10.5).weak()); - } - if ui.selectable_label(current == Some(p.id.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)).clicked() { - out = Some(Some(p.id.clone())); - } + if let Some(pid) = crate::panels::presets::picker_rows(ui, id, &app.session.presets, current) { + out = Some(Some(pid)); } }); register(ui.ctx(), format!("combo:{id}"), r.response.rect); @@ -290,10 +283,8 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { if ui.selectable_label(current.is_none(), crate::i18n::tr("LightCraft Default")).clicked() { pick = Some(json!({"camera": cam, "preset": null})); } - for p in &app.session.presets { - if ui.selectable_label(current == Some(p.id.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)).clicked() { - pick = Some(json!({"camera": cam, "preset": p.id})); - } + if let Some(pid) = crate::panels::presets::picker_rows(ui, &id, &app.session.presets, current) { + pick = Some(json!({"camera": cam, "preset": pid})); } }); register(ui.ctx(), format!("combo:{id}"), r.response.rect); diff --git a/docs/whats-new.md b/docs/whats-new.md index 81d9ad7f..01f6fc69 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -155,6 +155,14 @@ file name, then id, instead of import order. Recently Added keeps a batch in file name order, and Auto-Stack orders a burst by file name, so the first file is the cover. +### Large preset collections +- A library holding thousands of presets (a Lightroom collection imported whole: 11 000 presets, a 42 MB + presets.json) stays responsive. Every command used to re-serialize the whole list to see whether it had changed + (~0.6 s per slider tick); now the list is saved only after it changes, and a slider tick takes ~2 ms. +- The Presets panel builds only the rows in view (and their thumbnails) instead of every preset on every frame; the + preset pickers in Import and Settings ▸ Import scroll the same way. +- Importing a large collection and opening its library no longer compare every preset with every other. + ## 2026-10-10 ### Dialogs fit the window