mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
+47
-29
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user