perf(ui-egui): #1057 sidebar caches survive develop edits
Library-wide caches that don't read develop state (folder/keyword trees, keyword chips/ticks/names, counts, person names, By Date, filter-bar values, suggestions, smart-album problems, the Missing count, face hints) key on the library revision; people, album counts and the rule dialog's count key on the view revision. The justified grid layout adds the catalog revision to its key since crops and rotations change cell shapes without changing the list. The filmstrip shares the visible list instead of copying it per frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dfd6cbeea6
commit
f6b41efb16
|
|
@ -1454,7 +1454,10 @@ mod drop_tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Results recomputed only when the catalog (or their inputs) change.
|
||||
/// Results recomputed only when the catalog (or their inputs) change. Views that don't read
|
||||
/// develop state key on [`lightcraft_catalog::Catalog::library_revision`], so a slider drag (a
|
||||
/// develop edit per tick) doesn't redo them; the others on
|
||||
/// [`lightcraft_catalog::Catalog::view_revision`] or the full revision.
|
||||
#[derive(Default)]
|
||||
pub struct Caches {
|
||||
keyword_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>>)>,
|
||||
|
|
@ -1466,7 +1469,7 @@ pub struct Caches {
|
|||
/// The Keyword List's tick boxes, by library revision and selection.
|
||||
keyword_ticks: Option<(u64, u64, std::sync::Arc<panels::keyword_list::Ticks>)>,
|
||||
folder_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::FolderNode>>)>,
|
||||
people: Option<(u64, lightcraft_catalog::Filter, std::sync::Arc<Vec<lightcraft_catalog::Person>>)>,
|
||||
people: Option<(lightcraft_catalog::ViewRevision, lightcraft_catalog::Filter, std::sync::Arc<Vec<lightcraft_catalog::Person>>)>,
|
||||
suggestions: Option<(u64, std::sync::Arc<Vec<String>>)>,
|
||||
counts: Option<(u64, LibraryCounts)>,
|
||||
date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>,
|
||||
|
|
@ -1546,10 +1549,10 @@ impl Caches {
|
|||
/// The folders the library's photos were imported from.
|
||||
pub fn folder_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::FolderNode>> {
|
||||
match &self.folder_tree {
|
||||
Some((r, t)) if *r == cat.revision => t.clone(),
|
||||
Some((r, t)) if *r == cat.library_revision => t.clone(),
|
||||
_ => {
|
||||
let t = std::sync::Arc::new(cat.folder_tree());
|
||||
self.folder_tree = Some((cat.revision, t.clone()));
|
||||
self.folder_tree = Some((cat.library_revision, t.clone()));
|
||||
t
|
||||
}
|
||||
}
|
||||
|
|
@ -1557,10 +1560,10 @@ impl Caches {
|
|||
/// The library's keyword tree.
|
||||
pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>> {
|
||||
match &self.keyword_tree {
|
||||
Some((r, t)) if *r == cat.revision => t.clone(),
|
||||
Some((r, t)) if *r == cat.library_revision => t.clone(),
|
||||
_ => {
|
||||
let t = std::sync::Arc::new(cat.keyword_tree());
|
||||
self.keyword_tree = Some((cat.revision, t.clone()));
|
||||
self.keyword_tree = Some((cat.library_revision, t.clone()));
|
||||
t
|
||||
}
|
||||
}
|
||||
|
|
@ -1575,14 +1578,14 @@ impl Caches {
|
|||
) -> std::sync::Arc<Vec<panels::keywording::Chip>> {
|
||||
let key = key_of((selection, export));
|
||||
match &self.keyword_names {
|
||||
Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(),
|
||||
Some((r, k, c)) if *r == cat.library_revision && *k == key => c.clone(),
|
||||
_ => {
|
||||
let c = std::sync::Arc::new(if export {
|
||||
panels::keywording::will_export(cat, selection)
|
||||
} else {
|
||||
panels::keywording::with_containing(cat, selection)
|
||||
});
|
||||
self.keyword_names = Some((cat.revision, key, c.clone()));
|
||||
self.keyword_names = Some((cat.library_revision, key, c.clone()));
|
||||
c
|
||||
}
|
||||
}
|
||||
|
|
@ -1595,10 +1598,10 @@ impl Caches {
|
|||
) -> std::sync::Arc<Vec<panels::keywording::Chip>> {
|
||||
let key = key_of(selection);
|
||||
match &self.keyword_chips {
|
||||
Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(),
|
||||
Some((r, k, c)) if *r == cat.library_revision && *k == key => c.clone(),
|
||||
_ => {
|
||||
let c = std::sync::Arc::new(panels::keywording::chips(cat, selection));
|
||||
self.keyword_chips = Some((cat.revision, key, c.clone()));
|
||||
self.keyword_chips = Some((cat.library_revision, key, c.clone()));
|
||||
c
|
||||
}
|
||||
}
|
||||
|
|
@ -1611,10 +1614,10 @@ impl Caches {
|
|||
) -> std::sync::Arc<panels::keyword_list::Ticks> {
|
||||
let key = key_of(selection);
|
||||
match &self.keyword_ticks {
|
||||
Some((r, k, t)) if *r == cat.revision && *k == key => t.clone(),
|
||||
Some((r, k, t)) if *r == cat.library_revision && *k == key => t.clone(),
|
||||
_ => {
|
||||
let t = std::sync::Arc::new(panels::keyword_list::Ticks::of(cat, selection));
|
||||
self.keyword_ticks = Some((cat.revision, key, t.clone()));
|
||||
self.keyword_ticks = Some((cat.library_revision, key, t.clone()));
|
||||
t
|
||||
}
|
||||
}
|
||||
|
|
@ -1627,11 +1630,13 @@ impl Caches {
|
|||
filter: &lightcraft_catalog::Filter,
|
||||
) -> std::sync::Arc<Vec<lightcraft_catalog::Person>> {
|
||||
let key = lightcraft_catalog::Filter { person: None, ..filter.clone() };
|
||||
// a develop edit only counts again when the filter reads develop state (Edited, …)
|
||||
let rev = cat.view_revision(cat.filter_reads_develop(&key));
|
||||
match &self.people {
|
||||
Some((r, f, t)) if *r == cat.revision && *f == key => t.clone(),
|
||||
Some((r, f, t)) if *r == rev && *f == key => t.clone(),
|
||||
_ => {
|
||||
let t = std::sync::Arc::new(cat.people_in(&key));
|
||||
self.people = Some((cat.revision, key, t.clone()));
|
||||
self.people = Some((rev, key, t.clone()));
|
||||
t
|
||||
}
|
||||
}
|
||||
|
|
@ -1639,7 +1644,7 @@ impl Caches {
|
|||
/// All Photos / Picks / Recently Deleted counts.
|
||||
pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts {
|
||||
if let Some((r, c)) = self.counts
|
||||
&& r == cat.revision
|
||||
&& r == cat.library_revision
|
||||
{
|
||||
return c;
|
||||
}
|
||||
|
|
@ -1654,16 +1659,16 @@ impl Caches {
|
|||
c.deleted += 1;
|
||||
}
|
||||
}
|
||||
self.counts = Some((cat.revision, c));
|
||||
self.counts = Some((cat.library_revision, c));
|
||||
c
|
||||
}
|
||||
/// Everyone named on a face in the library (for completing a name as it is typed).
|
||||
pub fn person_names(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<String>> {
|
||||
match &self.person_names {
|
||||
Some((r, v)) if *r == cat.revision => v.clone(),
|
||||
Some((r, v)) if *r == cat.library_revision => v.clone(),
|
||||
_ => {
|
||||
let v = std::sync::Arc::new(cat.people().into_iter().map(|p| p.name).collect::<Vec<_>>());
|
||||
self.person_names = Some((cat.revision, v.clone()));
|
||||
self.person_names = Some((cat.library_revision, v.clone()));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
|
@ -1671,10 +1676,10 @@ impl Caches {
|
|||
/// The By Date tree.
|
||||
pub fn date_groups(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::DateGroup>> {
|
||||
match &self.date_groups {
|
||||
Some((r, g)) if *r == cat.revision => g.clone(),
|
||||
Some((r, g)) if *r == cat.library_revision => g.clone(),
|
||||
_ => {
|
||||
let g = std::sync::Arc::new(cat.date_groups());
|
||||
self.date_groups = Some((cat.revision, g.clone()));
|
||||
self.date_groups = Some((cat.library_revision, g.clone()));
|
||||
g
|
||||
}
|
||||
}
|
||||
|
|
@ -1682,7 +1687,7 @@ impl Caches {
|
|||
/// Cameras, lenses and keywords in the library (filter bar pickers).
|
||||
pub fn filter_values(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<FilterValues> {
|
||||
match &self.filter_values {
|
||||
Some((r, v)) if *r == cat.revision => v.clone(),
|
||||
Some((r, v)) if *r == cat.library_revision => v.clone(),
|
||||
_ => {
|
||||
let distinct = |mut v: Vec<String>| {
|
||||
v.retain(|s| !s.trim().is_empty());
|
||||
|
|
@ -1696,14 +1701,14 @@ impl Caches {
|
|||
lenses: distinct(lib().map(|p| p.meta.lens.clone()).collect()),
|
||||
keywords: cat.keywords().into_iter().map(|(k, _)| k).collect(),
|
||||
});
|
||||
self.filter_values = Some((cat.revision, v.clone()));
|
||||
self.filter_values = Some((cat.library_revision, v.clone()));
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Keyword suggestions for a photo with `current` keywords and the typed `prefix`.
|
||||
pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc<Vec<String>> {
|
||||
let k = key_of((cat.revision, current, prefix, n));
|
||||
let k = key_of((cat.library_revision, current, prefix, n));
|
||||
match &self.suggestions {
|
||||
Some((h, v)) if *h == k => v.clone(),
|
||||
_ => {
|
||||
|
|
@ -1714,8 +1719,9 @@ impl Caches {
|
|||
}
|
||||
}
|
||||
/// Every album's photo count for the sidebar (a smart album evaluates its rules, which
|
||||
/// scans the catalog). Recomputed when the catalog changes (photos, metadata, album rules
|
||||
/// all bump its revision) and — only while some smart album has an "in the last…" rule —
|
||||
/// scans the catalog). Recomputed when the library changes (photos, metadata, album rules
|
||||
/// all bump its revision; a develop edit only while some smart album's rules read develop
|
||||
/// state) and — only while some smart album has an "in the last…" rule —
|
||||
/// when `now` (the session clock, ISO) enters a new minute, so such counts follow the clock
|
||||
/// within a minute without rescanning every frame.
|
||||
/// [`RulesView`] for `rules` edited in smart album `editing` (`None`: a new album), within
|
||||
|
|
@ -1731,7 +1737,10 @@ impl Caches {
|
|||
) -> std::sync::Arc<RulesView> {
|
||||
let relative = rules.depends_on_now();
|
||||
let minute = if relative { now.get(..16).unwrap_or(now) } else { "" };
|
||||
let k = key_of((cat.revision, serde_json::to_string(rules).unwrap_or_default(), editing, &folder, minute, i18n::language().code()));
|
||||
// the count only changes with a develop edit when the rules (or the albums they test) read develop state
|
||||
let reads_develop = rules.reads_develop() || rules.albums_tested().into_iter().any(|a| cat.album_reads_develop(a));
|
||||
let rev = cat.view_revision(reads_develop);
|
||||
let k = key_of((rev, serde_json::to_string(rules).unwrap_or_default(), editing, &folder, minute, i18n::language().code()));
|
||||
if let Some((key, v)) = &self.rules_view
|
||||
&& *key == k
|
||||
{
|
||||
|
|
@ -1756,7 +1765,7 @@ impl Caches {
|
|||
cat: &lightcraft_catalog::Catalog,
|
||||
) -> std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, Vec<lightcraft_catalog::rules::Problem>>> {
|
||||
if let Some((rev, v)) = &self.smart_problems
|
||||
&& *rev == cat.revision
|
||||
&& *rev == cat.library_revision
|
||||
{
|
||||
return v.clone();
|
||||
}
|
||||
|
|
@ -1764,7 +1773,7 @@ impl Caches {
|
|||
cat.albums().filter(|a| a.is_smart()).map(|a| (a.id, cat.smart_album_problems(a.id))).filter(|(_, p)| !p.is_empty()).collect(),
|
||||
);
|
||||
self.smart_problem_scans += 1;
|
||||
self.smart_problems = Some((cat.revision, v.clone()));
|
||||
self.smart_problems = Some((cat.library_revision, v.clone()));
|
||||
v
|
||||
}
|
||||
|
||||
|
|
@ -1775,7 +1784,8 @@ impl Caches {
|
|||
) -> std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>> {
|
||||
let relative = cat.albums().any(|a| a.smart.as_ref().is_some_and(|f| f.depends_on_now()));
|
||||
let minute = if relative { now.get(..16).unwrap_or(now) } else { "" };
|
||||
let k = key_of((cat.revision, minute));
|
||||
// plain albums' counts never change with a develop edit; smart albums' do when their rules read develop state
|
||||
let k = key_of((cat.view_revision(cat.smart_albums_read_develop()), minute));
|
||||
match &self.album_counts {
|
||||
Some((h, v)) if *h == k => v.clone(),
|
||||
_ => {
|
||||
|
|
@ -1879,6 +1889,56 @@ mod cache_tests {
|
|||
assert_eq!(c.smart_problem_scans, 2);
|
||||
}
|
||||
|
||||
/// A develop edit (one slider tick: settings, edit time and a History step) changes no
|
||||
/// library-wide view unless it reads develop state: the sidebar's counts and rule-dialog count
|
||||
/// aren't worked out again, while a smart album and a rule testing Has Edits / Cropped follow
|
||||
/// the edit.
|
||||
#[test]
|
||||
fn develop_edits_only_redo_views_that_read_develop_state() {
|
||||
let mut cat = Catalog::default();
|
||||
let mut c = super::Caches::default();
|
||||
for i in 0..3u64 {
|
||||
cat.apply(photo(i + 1, "2026-09-30T11:00:00", if i == 0 { 5 } else { 1 })).unwrap();
|
||||
}
|
||||
cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap();
|
||||
let now = "2026-09-30T12:00:00";
|
||||
let rating =
|
||||
|v: u64| -> RuleSet { serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": v}]})).unwrap() };
|
||||
let cropped: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "cropped", "op": "is", "value": true}]})).unwrap();
|
||||
let develop = |cat: &mut Catalog, id: u64, f: &dyn Fn(&mut lightcraft_develop::DevelopSettings)| {
|
||||
let mut s = (*cat.photo(PhotoId(id)).unwrap().develop).clone();
|
||||
f(&mut s);
|
||||
let settings = std::sync::Arc::new(s);
|
||||
let step = lightcraft_catalog::HistoryStep { label: "Edit".into(), settings: settings.clone() };
|
||||
let set = Op::SetDevelop { id: PhotoId(id), settings, label: "Edit".into(), edited: Some(now.into()) };
|
||||
cat.apply(Op::Batch { ops: vec![set, Op::PushHistory { id: PhotoId(id), step }] }).unwrap();
|
||||
};
|
||||
c.album_counts(&cat, now);
|
||||
c.rules_view(&cat, &rating(3), None, None, now);
|
||||
let (tree, counts) = (c.keyword_tree(&cat), c.counts(&cat).total);
|
||||
for i in 0..20 {
|
||||
develop(&mut cat, 2, &|s| s.light.exposure = i as f64 / 10.0);
|
||||
c.album_counts(&cat, now);
|
||||
c.rules_view(&cat, &rating(3), None, None, now);
|
||||
assert!(std::sync::Arc::ptr_eq(&tree, &c.keyword_tree(&cat)));
|
||||
assert_eq!(c.counts(&cat).total, counts);
|
||||
}
|
||||
assert_eq!((c.album_count_scans, c.rules_view_scans), (1, 1), "slider ticks don't recount");
|
||||
// views that read develop state follow the edit
|
||||
assert_eq!(c.rules_view(&cat, &cropped, None, None, now).count, 0);
|
||||
cat.apply(smart(11, serde_json::json!({"rules": [{"field": "edited", "op": "is", "value": true}]}))).unwrap();
|
||||
assert_eq!(c.album_counts(&cat, now)[&AlbumId(11)], 1, "photo 2 has edits");
|
||||
develop(&mut cat, 3, &|s| s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.1, 0.9, 0.9));
|
||||
assert_eq!(c.album_counts(&cat, now)[&AlbumId(11)], 2, "a smart album testing Has Edits counts the new edit");
|
||||
assert_eq!(c.rules_view(&cat, &cropped, None, None, now).count, 1, "a rule testing Cropped sees the crop");
|
||||
// a rule that tests such an album through an Album rule follows it too
|
||||
let via: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 11}]})).unwrap();
|
||||
assert_eq!(c.rules_view(&cat, &via, None, None, now).count, 2);
|
||||
develop(&mut cat, 1, &|s| s.light.exposure = 1.0);
|
||||
assert_eq!(c.rules_view(&cat, &via, None, None, now).count, 3);
|
||||
assert_eq!(c.album_counts(&cat, now)[&AlbumId(11)], 3);
|
||||
}
|
||||
|
||||
/// Without "in the last…" rules the clock never causes a rescan.
|
||||
#[test]
|
||||
fn absolute_rules_ignore_the_clock() {
|
||||
|
|
|
|||
|
|
@ -487,7 +487,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
// once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and
|
||||
// kept, view render cached) so stepping to them is instant
|
||||
if !interacting && !app.renderer.is_pending(Slot::Main) && app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) {
|
||||
let ids = app.session.visible_cloned();
|
||||
let ids = app.session.visible_shared().1;
|
||||
if let Some(i) = ids.iter().position(|p| *p == id) {
|
||||
let next = ids.get(i + 1).copied();
|
||||
let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied();
|
||||
|
|
@ -2132,7 +2132,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
|||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_filled(r, 0.0, t.canvas);
|
||||
ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 4.0)), 0.0, Color32::from_gray(0x20));
|
||||
let ids = app.session.visible_cloned();
|
||||
// shared, not copied: the strip is drawn every frame
|
||||
let ids = app.session.visible_shared().1;
|
||||
let active = app.session.selection.active;
|
||||
let cell_w = 120.0;
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
|
|
|
|||
|
|
@ -555,7 +555,8 @@ pub fn hints_for(app: &mut LightcraftApp, photo: u64) -> Option<std::sync::Arc<H
|
|||
if !app.caches.faces_active {
|
||||
return None;
|
||||
}
|
||||
let (rev, indexed) = (app.session.catalog.revision, app.caches.faces_indexed);
|
||||
// faces and names are metadata: a develop edit (a slider tick in the loupe) doesn't ask again
|
||||
let (rev, indexed) = (app.session.catalog.library_revision, app.caches.faces_indexed);
|
||||
if let Some((p, r, i, h)) = &app.caches.face_hints
|
||||
&& (*p, *r, *i) == (photo, rev, indexed)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -64,13 +64,15 @@ type RunsKey = (u64, Option<SortKey>, GroupBy);
|
|||
|
||||
/// What the grid derives from the visible photos, kept across frames. Everything is keyed by the
|
||||
/// session's visible-list generation ([`lightcraft_engine::Session::visible_shared`]), which
|
||||
/// changes whenever the list is recomputed (any catalog change: crops, orientation, dates, stacks;
|
||||
/// or another source, filter or sort) — so an unchanged frame neither copies nor hashes the ids.
|
||||
/// changes whenever the list is recomputed (a library change: dates, stacks, photos; a develop
|
||||
/// edit when the filter or sort reads it; or another source, filter or sort) — so an unchanged
|
||||
/// frame neither copies nor hashes the ids. Photo shapes (crop, orientation) are develop state:
|
||||
/// the justified layout keys on the catalog revision too.
|
||||
#[derive(Default)]
|
||||
pub struct GridCache {
|
||||
runs: Option<(RunsKey, Arc<Vec<DateRun>>)>,
|
||||
/// Layout by (runs key, width, thumbnail size, square).
|
||||
layout: Option<((RunsKey, u32, u32, bool), Arc<GridLayout>)>,
|
||||
/// Layout by (runs key, width, thumbnail size, square, catalog revision for photo shapes).
|
||||
layout: Option<((RunsKey, u32, u32, bool, u64), Arc<GridLayout>)>,
|
||||
/// Stack index by generation.
|
||||
stacks: Option<(u64, Arc<StackIndex>)>,
|
||||
/// How many of the photos are browsed (Local) ones, by generation.
|
||||
|
|
@ -211,8 +213,10 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
let stats = &mut app.caches.grid_stats;
|
||||
let runs = app.caches.grid.runs(stats, &app.session.catalog, &ids, runs_key);
|
||||
// the layout only changes with the photos (and their shapes), the grouping, the width and
|
||||
// the thumbnail size
|
||||
let lay_key = (runs_key, avail_w.to_bits(), target.to_bits(), square);
|
||||
// the thumbnail size; a develop edit (crop, rotation) changes a shape without changing the
|
||||
// list, so justified rows also key on the catalog revision
|
||||
let shapes = if square { 0 } else { app.session.catalog.revision };
|
||||
let lay_key = (runs_key, avail_w.to_bits(), target.to_bits(), square, shapes);
|
||||
let lay = match &app.caches.grid.layout {
|
||||
Some((k, l)) if *k == lay_key => l.clone(),
|
||||
_ => {
|
||||
|
|
|
|||
|
|
@ -732,7 +732,8 @@ fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize {
|
|||
}
|
||||
let id = egui::Id::new("missing-count");
|
||||
let now = ui.input(|i| i.time);
|
||||
let rev = app.session.catalog.revision;
|
||||
// which files the library expects is not develop state: a slider drag doesn't check them all again
|
||||
let rev = app.session.catalog.library_revision;
|
||||
let mut job: Job = ui.data(|d| d.get_temp(id)).unwrap_or(Job { rev: u64::MAX, at: f64::MIN, ..Default::default() });
|
||||
let fresh = now - job.at < 5.0 && job.rev == rev;
|
||||
if !fresh && !job.running.load(Relaxed) {
|
||||
|
|
|
|||
|
|
@ -132,6 +132,32 @@ fn grid_headers_selection_scrolling_stacks_and_shape_changes() {
|
|||
assert_cheap_frames(&mut h);
|
||||
}
|
||||
|
||||
/// A develop edit (a Quick Develop click, a slider tick) leaves the list in view as it is: the
|
||||
/// grid's date runs and stack index aren't rebuilt, and the square grid's layout neither; the
|
||||
/// justified grid lays out again since a crop or rotation can change a photo's shape.
|
||||
#[test]
|
||||
fn develop_edits_keep_the_grid_view() {
|
||||
for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] {
|
||||
let mut h = grid(view);
|
||||
let (generation, _) = h.app.session.visible_shared();
|
||||
let before = h.app.caches.grid_stats.clone();
|
||||
let id = h.app.session.visible()[10];
|
||||
select(&mut h, id);
|
||||
for v in [0.5, 1.0] {
|
||||
let r = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": v}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
assert_eq!(h.app.session.visible_shared().0, generation, "{view:?}: the query didn't run again");
|
||||
let s = &h.app.caches.grid_stats;
|
||||
assert_eq!((s.view_builds, s.stack_index_builds), (before.view_builds, before.stack_index_builds), "{view:?}");
|
||||
if view == ViewMode::SquareGrid {
|
||||
assert_eq!(s.layout_builds, before.layout_builds, "square cells don't depend on shapes");
|
||||
}
|
||||
assert_cheap_frames(&mut h);
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected photos whose thumbnails have not loaded (or can't) are still marked selected in the
|
||||
/// justified grid (issue #298): the active one white, the others grey.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Lightroom parity checklist
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-LIB-SMARTALBUM: develop edits no longer recount the sidebar unless a rule reads develop state, #1057; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
|
||||
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
|
||||
|
|
@ -96,7 +96,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
|||
| LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters |
|
||||
| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:<id>`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`) |
|
||||
| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:<id>`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) |
|
||||
| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists |
|
||||
| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per library revision (develop edits recount only while a smart album's rules read develop state: Has Edits, Edit Date, Cropped, Treatment, sizes; #1057) (and per minute while an “in the last…” rule is used), counted without building id lists |
|
||||
| LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service |
|
||||
| LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library |
|
||||
| LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS |
|
||||
|
|
@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | patch | LR-LIB-SMARTALBUM: sidebar counts key on the library revision, so develop edits recount them only while a smart album reads develop state (issue #1057); status unchanged |
|
||||
| 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references |
|
||||
| 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) |
|
||||
| 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |
|
||||
|
|
|
|||
Loading…
Reference in New Issue