Merge PR #2054: LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched

crates/ui-egui/src/tests_grid.rs: both sides' tests

docs/whats-new.md: both entries under one 2026-10-16 heading

docs/parity-checklist.md: header and revision rows from both sides
This commit is contained in:
aiongg
2026-10-22 02:40:53 +00:00
5 changed files with 367 additions and 47 deletions
+47 -29
View File
@@ -157,38 +157,56 @@ impl Catalog {
/// never split by a header. Empty for sort keys without a date and for [`GroupBy::None`]
/// ([`GroupBy::Auto`] means days here).
pub fn date_runs(&self, ids: &[PhotoId], key: SortKey, by: GroupBy) -> Vec<DateRun> {
if by == GroupBy::None || ids.is_empty() {
if !groups_by_date(key, by) || ids.is_empty() {
return Vec::new();
}
let n = by.prefix();
let date_of = |id: &PhotoId| -> String {
let Some(p) = self.photo(*id) else { return String::new() };
let d = match key {
SortKey::CaptureDate => p.captured.as_deref(),
SortKey::ImportDate => Some(p.imported.as_str()),
SortKey::EditDate => p.edited.as_deref(),
_ => None,
};
d.filter(|d| iso_seconds(d.get(..10).unwrap_or("")).is_some()).and_then(|d| d.get(..n)).unwrap_or("").to_string()
};
if !matches!(key, SortKey::CaptureDate | SortKey::ImportDate | SortKey::EditDate) {
return Vec::new();
}
let stacks = self.stack_index();
let mut runs: Vec<DateRun> = Vec::new();
let mut stack_key: std::collections::HashMap<crate::StackId, String> = Default::default();
for (i, id) in ids.iter().enumerate() {
let k = match stacks.get(id) {
Some((sid, _)) => stack_key.entry(*sid).or_insert_with(|| date_of(id)).clone(),
None => date_of(id),
};
match runs.last_mut() {
Some(r) if r.key == k => r.count += 1,
_ => runs.push(DateRun { label: group_label(&k), key: k, start: i, count: 1 }),
}
}
runs
date_runs_of(ids, &self.stack_index(), |_, id| self.date_group(id, key, by))
}
/// The date group of photo `id` on its own for [`Catalog::date_runs`] (`2026-09-30` /
/// `2026-09` / `2026`; empty without a usable date): a stack's members take the group of its
/// first photo in the list there.
pub fn date_group(&self, id: PhotoId, key: SortKey, by: GroupBy) -> String {
let Some(p) = self.photo(id) else { return String::new() };
let d = match key {
SortKey::CaptureDate => p.captured.as_deref(),
SortKey::ImportDate => Some(p.imported.as_str()),
SortKey::EditDate => p.edited.as_deref(),
_ => None,
};
d.filter(|d| iso_seconds(d.get(..10).unwrap_or("")).is_some()).and_then(|d| d.get(..by.prefix())).unwrap_or("").to_string()
}
}
/// Whether [`Catalog::date_runs`] has runs for sort key `key` grouped `by`.
pub fn groups_by_date(key: SortKey, by: GroupBy) -> bool {
by != GroupBy::None && matches!(key, SortKey::CaptureDate | SortKey::ImportDate | SortKey::EditDate)
}
/// [`Catalog::date_runs`] from each photo's own group (`group(position, id)`, see
/// [`Catalog::date_group`]) and the stack index ([`Catalog::stack_index`]).
pub fn date_runs_of<K: AsRef<str>>(
ids: &[PhotoId],
stacks: &std::collections::HashMap<PhotoId, (crate::StackId, usize)>,
mut group: impl FnMut(usize, PhotoId) -> K,
) -> Vec<DateRun> {
let mut runs: Vec<DateRun> = Vec::new();
let mut stack_key: std::collections::HashMap<crate::StackId, String> = Default::default();
for (i, id) in ids.iter().enumerate() {
let own;
let k: &str = match stacks.get(id) {
Some((sid, _)) => stack_key.entry(*sid).or_insert_with(|| group(i, *id).as_ref().to_string()),
None => {
own = group(i, *id);
own.as_ref()
}
};
match runs.last_mut() {
Some(r) if r.key == k => r.count += 1,
_ => runs.push(DateRun { label: group_label(k), key: k.to_string(), start: i, count: 1 }),
}
}
runs
}
#[cfg(test)]
+171 -18
View File
@@ -42,6 +42,9 @@ pub struct GridStats {
pub layout_builds: u64,
/// Per-view data (date runs, row index) built.
pub view_builds: u64,
/// Photos whose date and shape were read from the catalog (every photo of a list read whole;
/// only the photos an edit touched of one followed: [`GridCache`]).
pub photos_read: u64,
/// Stack indexes built.
pub stack_index_builds: u64,
/// Wall-clock time of the last [`show`].
@@ -62,6 +65,10 @@ type StackIndex = HashMap<PhotoId, (StackId, usize)>;
/// (`None`: no headers) and the grouping.
type RunsKey = (u64, Option<SortKey>, GroupBy);
/// The most photos an edit may touch for the grid to read only those again (each costs a lookup
/// and a date parse; past this, reading the whole list is as quick).
const MAX_FOLLOWED: usize = 4096;
/// 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;
@@ -75,21 +82,165 @@ pub struct GridCache {
stacks: Option<(u64, Arc<StackIndex>)>,
/// How many of the photos are browsed (Local) ones, by generation.
local: Option<(u64, usize)>,
/// What was read of each photo of the list last shown.
read: Option<ListRead>,
}
/// What the grid read of each photo of a list, position by position: its own date group (a
/// stack's members take their first photo's when the runs are made) and its cell's shape. A new
/// list that differs from this one by the photos an edit touched (an import's batch, a capture
/// time, a crop) is read again only for those: at 180,000 photos, reading every photo again took
/// most of a 100 ms frame for each batch an import added.
struct ListRead {
ids: Arc<[PhotoId]>,
/// The catalog revision it was read at.
revision: u64,
/// The date and grouping of the groups (`None`: the list has no date runs, no group is read).
dates: Option<(SortKey, GroupBy)>,
/// Each photo's group, an index into `names`.
groups: Vec<u32>,
names: Vec<String>,
named: HashMap<String, u32>,
/// Each photo's shape; `None` for the square grid, whose cells are all alike.
shapes: Option<Vec<f32>>,
}
impl ListRead {
fn new(ids: Arc<[PhotoId]>, revision: u64, dates: Option<(SortKey, GroupBy)>, shaped: bool) -> ListRead {
let n = ids.len();
let groups = Vec::with_capacity(if dates.is_some() { n } else { 0 });
ListRead { ids, revision, dates, groups, names: Vec::new(), named: HashMap::new(), shapes: shaped.then(|| Vec::with_capacity(n)) }
}
/// Read photo `id` from the catalog and put it next.
fn read(&mut self, cat: &Catalog, id: PhotoId) {
if let Some((key, by)) = self.dates {
let name = cat.date_group(id, key, by);
let next = u32::try_from(self.names.len()).unwrap_or(u32::MAX);
let g = match self.named.get(&name) {
Some(g) => *g,
None => {
self.named.insert(name.clone(), next);
self.names.push(name);
next
}
};
self.groups.push(g);
}
if let Some(shapes) = self.shapes.as_mut() {
shapes.push(shape_of(cat, id));
}
}
/// Read every photo of `ids`.
fn whole(stats: &mut GridStats, cat: &Catalog, ids: &Arc<[PhotoId]>, dates: Option<(SortKey, GroupBy)>, shaped: bool) -> ListRead {
let mut r = ListRead::new(ids.clone(), cat.revision, dates, shaped);
if r.dates.is_some() || shaped {
for id in ids.iter() {
r.read(cat, *id);
}
stats.photos_read += ids.len() as u64;
}
r
}
/// `ids` from what was read of the list before it: the photos the catalog says changed since
/// are read again, the others are taken over. `None` when the list is not this one with
/// those photos changed, added or removed (another view, order or grouping; an edit to more
/// than photos of its own, such as a stack): read it whole then.
fn follow(self, stats: &mut GridStats, cat: &Catalog, ids: &Arc<[PhotoId]>, dates: Option<(SortKey, GroupBy)>, shaped: bool) -> Option<ListRead> {
if self.dates != dates || self.shapes.is_some() != shaped {
// (a switch between the photo grid and the square grid reads every photo once, on
// purpose: the other mode's data was never read, so there is nothing to follow)
return None;
}
let touched: HashSet<PhotoId> = cat.touched_since(self.revision)?.into_iter().collect();
if touched.len() > MAX_FOLLOWED {
return None;
}
let mut r = ListRead::new(ids.clone(), cat.revision, dates, shaped);
// (the groups' names go on, unpruned: pruning would renumber every photo's group; they are
// only reset by a whole read, and grow by a short string per day or month an edit puts a
// photo in that no photo was in before)
r.names = self.names;
r.named = self.named;
let mut was = 0;
let mut fresh = 0u64;
for id in ids.iter() {
if touched.contains(id) {
r.read(cat, *id);
fresh += 1;
continue;
}
// (the photos touched are read again wherever they are now)
while self.ids.get(was).is_some_and(|w| touched.contains(w)) {
was += 1;
}
// an untouched photo is where it was, among the others: else this is another list
if self.ids.get(was) != Some(id) {
return None;
}
if dates.is_some() {
r.groups.push(*self.groups.get(was)?);
}
if let (Some(to), Some(from)) = (r.shapes.as_mut(), self.shapes.as_ref()) {
to.push(*from.get(was)?);
}
was += 1;
}
// no untouched photo left the list
if self.ids.get(was..).is_some_and(|rest| rest.iter().any(|w| !touched.contains(w))) {
return None;
}
stats.photos_read += fresh;
Some(r)
}
}
impl GridCache {
fn runs(&mut self, stats: &mut GridStats, cat: &Catalog, ids: &[PhotoId], key: RunsKey) -> Arc<Vec<DateRun>> {
/// The date runs of `ids`, reading what has to be read of them (`square`: the cells' shapes
/// are not wanted).
fn runs(&mut self, stats: &mut GridStats, cat: &Catalog, ids: &Arc<[PhotoId]>, key: RunsKey, square: bool) -> Arc<Vec<DateRun>> {
let shaped = !square;
match &self.runs {
Some((k, r)) if *k == key => r.clone(),
Some((k, r)) if *k == key && self.read.as_ref().is_some_and(|read| read.shapes.is_some() == shaped) => r.clone(),
_ => {
stats.view_builds += 1;
let r = Arc::new(key.1.map(|sort| cat.date_runs(ids, sort, key.2)).unwrap_or_default());
let dates = key.1.map(|sort| (sort, key.2)).filter(|(sort, by)| lightcraft_catalog::dates::groups_by_date(*sort, *by));
let read = match self.read.take() {
// (the same list, read at this revision)
Some(r) if Arc::ptr_eq(&r.ids, ids) && r.revision == cat.revision && r.dates == dates && r.shapes.is_some() == shaped => r,
Some(r) => r.follow(stats, cat, ids, dates, shaped).unwrap_or_else(|| ListRead::whole(stats, cat, ids, dates, shaped)),
None => ListRead::whole(stats, cat, ids, dates, shaped),
};
let r = Arc::new(match dates {
Some(_) if !ids.is_empty() => {
let stacks = self.stacks(stats, cat, key.0);
let group = |i: usize, _: PhotoId| read.groups.get(i).and_then(|g| read.names.get(*g as usize)).map_or("", String::as_str);
lightcraft_catalog::dates::date_runs_of(ids, &stacks, group)
}
_ => Vec::new(),
});
self.read = Some(read);
self.runs = Some((key, r.clone()));
r
}
}
}
/// The shape of each cell of the list last shown (width / height), in list order; empty for
/// the square grid.
fn shapes(&self) -> &[f32] {
self.read.as_ref().and_then(|r| r.shapes.as_deref()).unwrap_or(&[])
}
/// The date runs and the cell shapes the grid shows now (tests compare them with a grid made
/// afresh).
#[cfg(test)]
pub(crate) fn runs_and_shapes(&self) -> Option<(Arc<Vec<DateRun>>, Vec<f32>)> {
Some((self.runs.as_ref()?.1.clone(), self.shapes().to_vec()))
}
fn stacks(&mut self, stats: &mut GridStats, cat: &Catalog, generation: u64) -> Arc<StackIndex> {
match &self.stacks {
Some((g, s)) if *g == generation => s.clone(),
@@ -114,18 +265,14 @@ impl GridCache {
}
}
/// Width / height of each photo's cell: its cropped, oriented shape.
fn aspects(cat: &Catalog, ids: &[PhotoId]) -> Vec<f32> {
ids.iter()
.map(|id| {
let p = cat.photo(*id);
let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0));
let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes());
let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT);
let (w, h) = if swap { (h, w) } else { (w, h) };
(w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3)
})
.collect()
/// Width / height of a photo's cell: its cropped, oriented shape.
pub(crate) fn shape_of(cat: &Catalog, id: PhotoId) -> f32 {
let p = cat.photo(id);
let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0));
let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes());
let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT);
let (w, h) = if swap { (h, w) } else { (w, h) };
(w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3)
}
/// The rows (of a layout's `rows`, top to bottom) that meet the y range `top..=bottom`, found by
@@ -211,7 +358,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
};
let runs_key = (generation, group_key, by);
let stats = &mut app.caches.grid_stats;
let runs = app.caches.grid.runs(stats, &app.session.catalog, &ids, runs_key);
let runs = app.caches.grid.runs(stats, &app.session.catalog, &ids, runs_key, square);
// 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);
@@ -219,9 +366,15 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
Some((k, l)) if *k == lay_key => l.clone(),
_ => {
stats.layout_builds += 1;
let aspects = if square { vec![1.0; ids.len()] } else { aspects(&app.session.catalog, &ids) };
let ones;
let shapes = if square {
ones = vec![1.0; ids.len()];
&ones[..]
} else {
app.caches.grid.shapes()
};
let spans: Vec<(usize, usize)> = runs.iter().map(|r| (r.start, r.count)).collect();
let l = Arc::new(layout(&aspects, &spans, avail_w, target, square));
let l = Arc::new(layout(shapes, &spans, avail_w, target, square));
app.caches.grid.layout = Some((lay_key, l.clone()));
l
}
+142
View File
@@ -236,3 +236,145 @@ fn grid_stack_badge_toggles_a_stack_that_is_not_selected() {
assert!(h.app.session.catalog.stack_of(top).unwrap().collapsed, "a click on an unselected stack's badge toggles it");
assert_eq!((h.app.session.selection.ids.clone(), h.app.session.selection.active), (vec![loose], Some(loose)), "and leaves the selection alone");
}
/// What the grid shows (its date runs, and each cell's shape) is what a grid made afresh for the
/// same list shows.
fn assert_as_made_afresh(h: &mut Headless, what: &str) {
let vis = h.app.session.visible().to_vec();
if vis.is_empty() {
// (no photo matches: the grid says so and draws none)
return;
}
let (runs, shapes) = h.app.caches.grid.runs_and_shapes().expect("the grid was drawn");
let cat = &h.app.session.catalog;
let by = crate::panels::grid::resolve_group(h.app.session.sort.group, h.app.ui.thumb_size);
assert_eq!(*runs, cat.date_runs(&vis, h.app.session.sort.key, by), "date runs {what}");
// (the square grid's cells are all alike: it reads no shape)
let fresh: Vec<f32> =
if h.app.ui.view == ViewMode::SquareGrid { Vec::new() } else { vis.iter().map(|id| crate::panels::grid::shape_of(cat, *id)).collect() };
assert_eq!(shapes, fresh, "cell shapes {what}");
}
fn photo(h: &mut Headless, n: u64, captured: &str) -> Op {
let id = h.app.session.catalog.alloc_photo_id();
let (w, h_) = if n.is_multiple_of(3) { (4000, 6000) } else { (6000, 4000) };
let mut p = Photo::new(
id,
Source::File { path: format!("/lightcraft-grid-test/NEW_{n:05}.jpg") },
&format!("NEW_{n:05}.jpg"),
"JPEG",
w,
h_,
"2026-01-02T00:00:00",
);
p.captured = Some(captured.to_string());
Op::AddPhoto { photo: Box::new(p) }
}
/// An import lands its photos a batch at a time: the grid reads the batch's photos, not the
/// whole list again (at 180,000 photos that was most of a 100 ms frame per batch). An edit to a
/// photo's date or shape reads that photo.
#[test]
fn a_batch_of_new_photos_or_an_edit_to_one_is_read_photo_by_photo() {
let mut h = grid(ViewMode::PhotoGrid);
let read = h.app.caches.grid_stats.photos_read;
assert_eq!(read, N, "the first frame reads every photo");
// sixteen photos, on days already shown, a new day, and with no date at all
let ops: Vec<Op> = (0..16)
.map(|k| {
let day = ["2026-12-28T09:30:00", "2026-11-03T10:00:00", "2027-01-05T12:00:00", "nope"][k as usize % 4];
photo(&mut h, k, day)
})
.collect();
h.app.session.commit("Import", Op::Batch { ops }).unwrap();
h.step();
assert_eq!(h.app.caches.grid_stats.photos_read - read, 16, "only the batch's photos are read");
assert!(widget(&h, "group:2027-01-05").is_some(), "the new day heads the grid");
assert_as_made_afresh(&mut h, "after the batch");
// a photo taken on another day: it moves there, and only it is read
let read = h.app.caches.grid_stats.photos_read;
h.app.session.commit("Capture time", Op::SetCaptured { id: PhotoId(500), captured: Some("2027-02-01T08:00:00".into()) }).unwrap();
h.step();
assert_eq!(h.app.caches.grid_stats.photos_read - read, 1, "only the photo edited is read");
assert!(widget(&h, "group:2027-02-01").is_some(), "its new day heads the grid");
assert_as_made_afresh(&mut h, "after a capture time");
}
/// Whatever the edit, the grid shows what a grid made afresh would: followed photo by photo when
/// the list changed by the photos the edit touched, else read whole.
#[test]
fn a_grid_followed_through_edits_shows_what_a_grid_made_afresh_shows() {
for seed in [11u64, 12, 13, 14, 15] {
let mut h = grid(ViewMode::PhotoGrid);
let mut dice = seed;
let mut roll = |sides: u64| {
dice = lightcraft_catalog::mix64(dice);
dice % sides
};
for step in 0..120 {
let vis = h.app.session.visible().to_vec();
if vis.is_empty() {
// (no picks shown: back to every photo)
h.app.session.filter.flag = None;
continue;
}
let id = vis[roll(vis.len() as u64) as usize].0;
let n = roll(1000);
match roll(12) {
0 => {
let ops: Vec<Op> = (0..1 + roll(20))
.map(|k| photo(&mut h, n * 100 + k, &format!("2026-{:02}-{:02}T10:00:00", 1 + n % 12, 1 + k % 28)))
.collect();
h.app.session.commit("Import", Op::Batch { ops }).unwrap();
}
1 => {
let _ = h.app.session.commit("Delete", Op::SetDeleted { id: PhotoId(id), deleted: true });
}
2 => {
let _ = h
.app
.session
.commit("Capture time", Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2026-{:02}-03T10:00:00", 1 + n % 12)) });
}
3 => {
h.app.session.execute("library.select", &json!({"ids": [id]})).unwrap();
let r = h.app.session.execute("photo.rotateRight", &json!({}));
assert!(r.is_ok(), "{r:?}");
}
4 => {
let mut d = (*h.app.session.catalog.photo(PhotoId(id)).unwrap().develop).clone();
d.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.0, 0.9 - 0.1 * (n % 4) as f64, 1.0);
h.app
.session
.commit("Crop", Op::SetDevelop { id: PhotoId(id), settings: std::sync::Arc::new(d), label: "Crop".into(), edited: None })
.unwrap();
}
5 => h.app.session.commit("Rating", Op::SetRating { id: PhotoId(id), rating: (n % 6) as u8 }).unwrap(),
6 => {
let key = ["captureDate", "fileName", "importDate"][n as usize % 3];
let r = h.app.session.execute("library.sort", &json!({"key": key, "ascending": n.is_multiple_of(2)}));
assert!(r.is_ok(), "{r:?}");
}
7 => {
// a stack: an edit to more than its photos, read whole
let other = vis[n as usize % vis.len()].0;
let _ = h.app.session.execute("stack.group", &json!({"ids": [id, other], "top": id, "collapsed": n.is_multiple_of(2)}));
}
8 => {
let _ = h.app.session.execute("edit.undo", &json!({}));
}
9 => {
// picks, for the filter below to show some
for k in 0..1 + n % 40 {
let p = vis[(n as usize * 7 + k as usize * 13) % vis.len()];
h.app.session.commit("Flag", Op::SetFlag { id: p, flag: lightcraft_catalog::Flag::Pick }).unwrap();
}
}
10 => h.app.ui.view = if h.app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid },
_ => h.app.session.filter.flag = n.is_multiple_of(2).then_some(lightcraft_catalog::Flag::Pick),
}
h.step();
assert_as_made_afresh(&mut h, &format!("seed {seed}, after step {step}"));
}
}
}
+2
View File
@@ -1,4 +1,5 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
@@ -903,3 +904,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-16 | patch | LR-META-XMP: a TIFF or JPEG imported beside a raw of its stem reads its own `<file>.xmp` and takes nothing from the raw's `IMG_0001.xmp`, whether it arrives with the raw or after it (a stem sidecar belongs to one file: a raw first, then the first by name); status unchanged (✅) |
| 2026-10-16 | minor | LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan row says how many folders and files it has listed while it lists the folder (an indeterminate bar, not "0 of N"), and ✕ stops the listing; statuses unchanged |
| 2026-10-19 | patch | LRC-LIB-LIBFOLDERS: Synchronize Folder on a folder that is up to date says so with one Close instead of four empty choices and a Synchronize that started an empty job; status unchanged (✅) |
| 2026-10-16 | minor | LR-VIEW-PHOTOGRID: when the photos shown change by those an edit touched (an import's batch, a capture time, a crop), the grid reads only those photos' dates and shapes again instead of every photo of the list (a 16-photo batch at 181,749 photos: grid frame 101 ms → 7 ms); status unchanged |
+5
View File
@@ -58,6 +58,11 @@
that moves on its own, and then counts the photos it checks. The dialog says the same.
- ✕ on the row stops the listing too, not only the checks after it.
### The photo grid keeps up with imports into a large library
- While photos are imported into a large library, the grid takes in each batch as it arrives instead of reading
every photo of the view again: at about 180,000 photos a batch cost the window a tenth of a second, over and over
for as long as the import ran. Changing one photo's capture time or crop is quicker too.
## 2026-10-15
### Constrain Crop tightens the crop you see