This commit is contained in:
Brandon Thomas 2026-10-13 02:02:17 -04:00
commit 478f20054b
9 changed files with 460 additions and 51 deletions

View File

@ -222,7 +222,8 @@ pub struct Session {
pub previous_active: Option<PhotoId>,
/// Groups last used for Copy (Lightroom remembers them).
pub copy_groups: Vec<lightcraft_develop::SettingsGroup>,
pub presets: Vec<lightcraft_develop::Preset>,
/// 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<String>,
/// 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;

View File

@ -88,7 +88,8 @@ pub struct Library {
retry_at: Option<web_time::Instant>,
/// What forgetting untouched Local records did when the library opened.
pub forgot_local: Option<lightcraft_catalog::ForgetPlan>,
presets_written: String,
/// What presets.json holds (see [`PresetsSaved`]).
presets_saved: PresetsSaved,
view_written: Vec<u8>,
/// 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<String>,
}
/// 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<String>,
profile_recent: Vec<String>,
/// 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<usize> = 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<String> = 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(())
}

View File

@ -23,6 +23,73 @@ pub struct PresetFile {
pub presets: Vec<Preset>,
}
/// 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<Preset>,
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<Preset>) -> 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<Preset>;
fn deref(&self) -> &Vec<Preset> {
&self.items
}
}
impl std::ops::DerefMut for PresetList {
fn deref_mut(&mut self) -> &mut Vec<Preset> {
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<Preset>) -> Vec<String> {
use std::collections::{HashMap, HashSet};
// indexed: a collection of 10 000+ presets was quadratic (minutes) to import
let mut ids: HashSet<String> = self.presets.iter().map(|q| q.id.clone()).collect();
let mut by_name: HashMap<(String, String), Vec<usize>> = 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);
}

View File

@ -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<Preset> {
(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);
}

View File

@ -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::<Vec<_>>();
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::<usize>().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() {

View File

@ -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;
}
});
});

View File

@ -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<String, Vec<Preset>> = 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<String> = 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<usize>,
}
/// 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<Vec<Group>> {
let id = egui::Id::new("presets-grouped");
if let Some((generation, groups)) = ctx.data(|d| d.get_temp::<(u64, Arc<Vec<Group>>)>(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::<Vec<_>>());
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<String> {
let id = egui::Id::new("preset-picker-rows");
let rows = match ui.ctx().data(|d| d.get_temp::<(u64, Arc<Vec<PickRow>>)>(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;

View File

@ -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);

View File

@ -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