Merge PR #1056
This commit is contained in:
commit
478f20054b
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue