LR-LIB-SMARTALBUM: a view can hold its photos until it is refreshed

A view is asked again after every edit, so a photo that an edit makes stop
matching it leaves the grid at once and every photo after it moves up: a
keyword given in a smart album of photos without keywords takes the photo
away from under the cursor, and in a random order, with no landmarks, the
shift reads as a new shuffle (the seed does not change).

View > Hold View Until Refreshed (`library.holdView`, off by default, kept
with the library's preferences): a photo the view shows stays in it when an
edit makes it stop matching a smart album's rules, Picks or the filter bar.
View > Refresh View (`library.refresh`, also in the sort menu while the hold
is on), Reshuffle, another source, filter or sort, or new rules for the
smart album shown, shows what matches now.

- Photos that start matching still appear at once.
- A photo that is deleted, or taken out of the album or folder shown, still
  leaves at once: a held photo has to be where the view looks
  (`Catalog::query_keeping`, `within`).
- A source with no filter over it holds nothing and is queried as before.
- A refresh keeps the seed.
- The grid's count no longer reads "3 of 2" when a held view shows more than
  now match its source.

Each edit still asks the whole library again; that cost is not addressed
here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-14 07:41:44 -04:00
co-authored by Claude Opus 5.5
parent a4b53b28cc
commit 73b9cc3708
20 changed files with 405 additions and 16 deletions
+14 -1
View File
@@ -369,8 +369,21 @@ pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering {
impl Catalog {
/// Photos matching `filter`, in `sort` order (ties broken by file name, then id).
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> {
self.query_keeping(filter, sort, &std::collections::HashSet::new(), filter)
}
/// [`Catalog::query`], and with them the photos of `keep` that still match `within`, each at its place in `sort`
/// order: a view that holds the photos it showed when an edit makes them stop matching `filter` (a smart
/// album's rules, the filter bar), as long as they are still where the view looks (`within`: in the library,
/// the album, the folder; not deleted).
pub fn query_keeping(&self, filter: &Filter, sort: &Sort, keep: &std::collections::HashSet<PhotoId>, within: &Filter) -> Vec<PhotoId> {
let root = filter.library_root();
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect();
let within_root = within.library_root();
let mut v: Vec<&Photo> = self
.photos()
.map(|p| p.as_ref())
.filter(|p| filter.matches_in(p, self, root.as_deref()) || (keep.contains(&p.id) && within.matches_in(p, self, within_root.as_deref())))
.collect();
v.sort_by(|a, b| {
let o = match sort.key {
SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
+38
View File
@@ -510,6 +510,44 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}
),
cmd!(
"library.refresh",
"Refresh View",
["View"],
None,
"{} — show what matches now: a view that holds its photos (`library.holdView`) lets go of the ones that stopped matching a smart album's rules, Picks or the filter bar since it was opened, sorted or last refreshed",
always,
|s, _| {
s.refresh_view();
ok()
}
),
cmd!(
"library.holdView",
"Hold View Until Refreshed",
["View"],
None,
"{on?: bool (toggles when omitted)} → {hold} — when on, an edit that makes a photo stop matching the view (a keyword given in a smart album of photos without keywords, a flag taken off in Picks, a rating under the filter bar's) keeps it in the view until `library.refresh`, Reshuffle, or another source, filter or sort; photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder shown still takes it out of the view. Changing a smart album's rules is another view too. Off (the default): the view follows every edit. Kept with the library's preferences; asking for the state it already has changes nothing",
always,
|s, p| {
let on = match p.get("on") {
None | Some(Value::Null) => !s.hold_view,
Some(Value::Bool(on)) => *on,
Some(_) => return Err(bad("library.holdView", "on must be true or false")),
};
if on != s.hold_view {
let was = s.hold_view;
s.hold_view = on;
if let Err(e) = s.save_prefs() {
// not kept: not changed either, so asking again does what was asked
s.hold_view = was;
return Err(e);
}
s.refresh_view();
}
Ok(json!({"hold": s.hold_view}))
}
),
cmd!(
"library.shuffle",
"Reshuffle",
+70 -12
View File
@@ -190,6 +190,13 @@ pub struct Session {
/// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]).
visible: Arc<[PhotoId]>,
visible_key: Option<(u64, String)>,
/// A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop
/// matching a smart album's rules, Picks or the filter bar leaves it where it is. Off: the photo goes at once.
pub hold_view: bool,
/// The view that is held and the photos in it, before stacks are arranged ([`Session::hold_view`]). Another
/// source, filter or sort is another view, and starts from what matches; so does a smart album whose rules
/// were changed. `None` for a view that nothing can stop matching (a source with no filter over it).
held: Option<(String, Vec<PhotoId>)>,
/// Identifies the current `visible` list: a new value (unique in the process) every time it
/// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids.
visible_gen: u64,
@@ -345,6 +352,8 @@ impl Session {
selection: Selection::default(),
visible: Vec::new().into(),
visible_key: None,
hold_view: false,
held: None,
visible_gen: 0,
total: None,
undo: Vec::new(),
@@ -853,6 +862,16 @@ impl Session {
pub fn visible(&mut self) -> &[PhotoId] {
let mut key =
(self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse, self.library_folder));
// What a held view is told apart by: what is shown and how, and for a smart album its rules (rules that were
// changed ask something else). Not the checks of Missing Photos below: they fill the view in, they are
// no other view.
let asks = matches!(self.source, LibrarySource::Picks)
|| matches!(self.source, LibrarySource::Album(a) if self.catalog.album(a).is_some_and(|al| al.is_smart()));
let rules = match self.source {
LibrarySource::Album(a) if asks => self.catalog.album(a).map(|al| format!("{:?}", al.smart)).unwrap_or_default(),
_ => String::new(),
};
let view = format!("{}|{rules}", key.1);
if self.source == LibrarySource::Missing && self.media.availability.is_background() {
// the view fills in as the background checks find files gone
key.1.push_str(&format!("|{}", self.media.availability.generation()));
@@ -860,18 +879,33 @@ impl Session {
if self.visible_key.as_ref() != Some(&key) {
// "in the last N days" rules count back from the session's clock
lightcraft_catalog::rules::set_now(Some((self.clock)()));
let mut f = self.source.to_filter(&self.filter, &self.catalog);
if self.source == LibrarySource::Folder {
// no folder chosen: nothing (an empty path matches nothing)
let b = self.browse.clone().unwrap_or_default();
f.folder = Some(b.path);
f.subfolders = b.subfolders;
}
if self.source == LibrarySource::LibraryFolder {
// no folder chosen: nothing (`.` names no folder)
f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into()));
}
let mut visible = self.catalog.query(&f, &self.sort);
let f = self.view_filter(self.source, &self.filter);
// A held view keeps the photos it shows that stopped matching what is asked of them (a smart album's
// rules, a flag, the filter bar), as long as they are still where the view looks: in the library for
// a view that is only a question, else in the album or folder shown. Taking a photo out of an album
// or the library takes it out of the view at once, held or not.
// (only a view that asks something can lose a photo to an edit: the others are queried as ever, at no
// cost for the hold)
let holds = self.hold_view && (asks || self.filter != Filter::default());
let held = self.held.as_ref().filter(|(held, _)| holds && *held == view).map(|(_, ids)| ids);
let mut visible = match held {
Some(ids) => {
let keep: std::collections::HashSet<PhotoId> = ids.iter().copied().collect();
let within = if asks {
self.view_filter(LibrarySource::All, &Filter::default())
} else if self.source == LibrarySource::RecentlyAdded {
// (how far back it reaches was worked out for `f`: not a second pass over the library)
Filter { imported_from: f.imported_from.clone(), ..Default::default() }
} else {
self.view_filter(self.source, &Filter::default())
};
self.catalog.query_keeping(&f, &self.sort, &keep, &within)
}
None => self.catalog.query(&f, &self.sort),
};
// what the view shows now is what it holds until it is refreshed (taken before stacks are arranged, so
// a photo folded into a stack is held with it)
self.held = holds.then(|| (view, visible.clone()));
if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() {
// the shown folder lost its last photo (deleted, moved, removed): everything, not
// an empty grid under the name of a folder that is gone from the sidebar
@@ -921,6 +955,28 @@ impl Session {
&self.visible
}
/// `source` as a filter over `base` (the filter bar's, or none), with the folder it shows.
fn view_filter(&self, source: LibrarySource, base: &Filter) -> Filter {
let mut f = source.to_filter(base, &self.catalog);
if source == LibrarySource::Folder {
// no folder chosen: nothing (an empty path matches nothing)
let b = self.browse.clone().unwrap_or_default();
f.folder = Some(b.path);
f.subfolders = b.subfolders;
}
if source == LibrarySource::LibraryFolder {
// no folder chosen: nothing (`.` names no folder)
f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into()));
}
f
}
/// Let go of what a held view kept: the view shows what matches now ([`Session::hold_view`]).
pub fn refresh_view(&mut self) {
self.held = None;
self.visible_key = None;
}
/// Whether the library still holds a photo imported from the folder a `LibraryFolder` source
/// shows.
fn folder_holds_photos(&self) -> bool {
@@ -1022,6 +1078,8 @@ mod tests_forget_local;
#[cfg(test)]
mod tests_history_cursor;
#[cfg(test)]
mod tests_hold_view;
#[cfg(test)]
mod tests_import;
#[cfg(test)]
mod tests_import_move;
+5
View File
@@ -232,6 +232,8 @@ struct PrefsFile {
/// Days after which untouched Local records of unbrowsed folders are forgotten (missing =
/// the default, 0 = never).
forget_local_days: Option<u32>,
/// A view holds the photos it shows until it is refreshed (`library.holdView`).
hold_view: bool,
}
fn presets_json(s: &Session) -> String {
@@ -455,6 +457,8 @@ impl Session {
self.import_defaults = prefs.import;
self.cache_mb = prefs.cache_mb;
self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS);
self.hold_view = prefs.hold_view;
self.refresh_view();
self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from);
if let Some(d) = &self.smart_previews_dir {
self.media.smart_dir = Some(d.clone());
@@ -699,6 +703,7 @@ impl Session {
cache_mb: self.cache_mb,
smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
forget_local_days: Some(self.forget_local_days),
hold_view: self.hold_view,
})
.unwrap_or_default();
let Some(lib) = self.library.as_mut() else { return Ok(()) };
+200
View File
@@ -0,0 +1,200 @@
//! A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop
//! matching a smart album's rules, Picks or the filter bar leaves it where it is, and the others where they are.
use lightcraft_catalog::PhotoId;
use serde_json::json;
use crate::{LibrarySource, Session};
/// The demo library with no photo keyworded, showing a smart album of the photos without keywords, at random.
fn unkeyworded() -> Session {
let mut s = Session::with_demo();
let all: Vec<u64> = s.visible_cloned().iter().map(|p| p.0).collect();
s.execute("photo.setMeta", &json!({"ids": all, "keywords": []})).unwrap();
let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "is", "value": 0}]}});
let album = s.execute("album.createSmart", &json!({"name": "Unkeyworded", "rules": rules})).unwrap()["id"].as_u64().unwrap();
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
s.execute("library.sort", &json!({"key": "random"})).unwrap();
s
}
fn hold(s: &mut Session) {
assert_eq!(s.execute("library.holdView", &json!({"on": true})).unwrap()["hold"], true);
}
fn keyword(s: &mut Session, id: PhotoId) {
s.execute("photo.setMeta", &json!({"ids": [id.0], "addKeywords": ["travel"]})).unwrap();
}
#[test]
fn a_keyword_given_in_a_held_view_leaves_the_photo_and_the_order_where_they_are() {
// Given a smart album of the photos without keywords, at random, held
let mut s = unkeyworded();
hold(&mut s);
let (before, seed) = (s.visible_cloned(), s.sort.seed);
assert!(before.len() > 10);
// When photos in it are given a keyword, one after the other
for at in [3, 7, 0] {
keyword(&mut s, before[at]);
// Then nothing moves: the same photos in the same places, under the same shuffle
assert_eq!(s.visible_cloned(), before, "after a keyword on the photo at {at}");
assert_eq!(s.sort.seed, seed);
}
// When the view is refreshed, Then they are gone and the others keep their order
s.execute("library.refresh", &json!({})).unwrap();
let after = s.visible_cloned();
let expected: Vec<PhotoId> = before.iter().copied().filter(|id| ![before[3], before[7], before[0]].contains(id)).collect();
assert_eq!(after, expected);
assert_eq!(s.sort.seed, seed, "a refresh is not a reshuffle");
}
#[test]
fn a_view_that_is_not_held_follows_every_edit_as_before() {
let mut s = unkeyworded();
assert!(!s.hold_view, "off unless asked for");
let before = s.visible_cloned();
keyword(&mut s, before[3]);
let after = s.visible_cloned();
assert!(!after.contains(&before[3]), "the photo leaves at once");
assert_eq!(after, before.iter().copied().filter(|id| *id != before[3]).collect::<Vec<_>>(), "the others keep their order");
}
#[test]
fn a_held_view_still_takes_in_what_starts_matching_and_lets_go_of_what_is_deleted() {
let mut s = unkeyworded();
let all = s.visible_cloned();
// one photo has a keyword before the view is held: it is not in it
keyword(&mut s, all[5]);
hold(&mut s);
let before = s.visible_cloned();
assert!(!before.contains(&all[5]));
// its keyword taken off again: it matches, and comes in at its place in the shuffle
s.execute("photo.setMeta", &json!({"ids": [all[5].0], "keywords": []})).unwrap();
assert_eq!(s.visible_cloned(), all, "back where the shuffle puts it");
// a photo that was kept after it stopped matching, then deleted, is not kept in the view
keyword(&mut s, all[2]);
assert!(s.visible_cloned().contains(&all[2]));
s.execute("library.select", &json!({"ids": [all[2].0]})).unwrap();
s.execute("photo.delete", &json!({})).unwrap();
assert!(!s.visible_cloned().contains(&all[2]), "Recently Deleted is somewhere else");
}
#[test]
fn another_source_filter_sort_or_shuffle_is_another_view() {
let mut s = unkeyworded();
hold(&mut s);
let before = s.visible_cloned();
// Reshuffle: a new order of what matches now
keyword(&mut s, before[1]);
assert!(s.visible_cloned().contains(&before[1]));
s.execute("library.shuffle", &json!({})).unwrap();
assert!(!s.visible_cloned().contains(&before[1]), "a reshuffle refreshes");
// another sort
let now = s.visible_cloned();
keyword(&mut s, now[1]);
s.execute("library.sort", &json!({"key": "fileName"})).unwrap();
assert!(!s.visible_cloned().contains(&now[1]), "another sort refreshes");
// the filter bar
let now = s.visible_cloned();
keyword(&mut s, now[1]);
s.execute("library.filter", &json!({"rating": 0, "text": "a"})).unwrap();
assert!(!s.visible_cloned().contains(&now[1]), "another filter refreshes");
s.execute("library.filter", &json!({"text": ""})).unwrap();
// away and back
let now = s.visible_cloned();
let source = s.source;
keyword(&mut s, now[1]);
s.execute("library.source", &json!({"kind": "all"})).unwrap();
let _ = s.visible_cloned();
s.source = source;
assert!(!s.visible_cloned().contains(&now[1]), "coming back to it refreshes");
}
#[test]
fn picks_and_the_filter_bar_hold_too_and_an_album_lets_go_of_what_is_taken_out_of_it() {
let mut s = Session::with_demo();
let all = s.visible_cloned();
// Picks
s.execute("library.select", &json!({"ids": [all[0].0, all[1].0, all[2].0]})).unwrap();
s.execute("photo.flag", &json!({"flag": "pick"})).unwrap();
s.execute("library.source", &json!({"kind": "picks"})).unwrap();
hold(&mut s);
let picks = s.visible_cloned();
assert!(picks.len() >= 3, "{picks:?}");
s.execute("library.select", &json!({"ids": [picks[1].0]})).unwrap();
s.execute("photo.flag", &json!({"flag": "none"})).unwrap();
assert_eq!(s.visible_cloned(), picks, "a pick unflagged stays until the view is refreshed");
s.execute("library.refresh", &json!({})).unwrap();
assert_eq!(s.visible_cloned(), picks.iter().copied().filter(|id| *id != picks[1]).collect::<Vec<_>>());
// the filter bar over an album: a rating under the filter's stays, a photo taken out of the album goes
let album = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap();
let ids: Vec<u64> = all.iter().take(5).map(|p| p.0).collect();
s.execute("library.select", &json!({"ids": ids})).unwrap();
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
s.execute("album.addPhotos", &json!({"id": album, "ids": ids})).unwrap();
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
s.execute("library.filter", &json!({"rating": 3})).unwrap();
let shown = s.visible_cloned();
assert_eq!(shown.len(), 5);
s.execute("library.select", &json!({"ids": [shown[0].0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 1})).unwrap();
assert_eq!(s.visible_cloned().len(), 5, "rated under the filter: held");
s.execute("album.removePhotos", &json!({"id": album, "ids": [shown[0].0, shown[1].0]})).unwrap();
let left = s.visible_cloned();
assert!(!left.contains(&shown[0]) && !left.contains(&shown[1]), "out of the album is out of the view: {left:?}");
assert_eq!(left.len(), 3);
assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(album)));
}
#[test]
fn turning_the_hold_off_lets_go_at_once() {
let mut s = unkeyworded();
hold(&mut s);
let before = s.visible_cloned();
keyword(&mut s, before[4]);
assert!(s.visible_cloned().contains(&before[4]));
assert_eq!(s.execute("library.holdView", &json!({})).unwrap()["hold"], false, "toggles without `on`");
assert!(!s.visible_cloned().contains(&before[4]));
}
#[test]
fn changing_the_rules_of_the_smart_album_shown_is_another_view() {
let mut s = unkeyworded();
hold(&mut s);
let before = s.visible_cloned();
let LibrarySource::Album(album) = s.source else { panic!("a smart album is shown") };
// the album now asks for photos with a keyword: none has one, and none is kept from what it asked before
let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "gt", "value": 0}]}});
s.execute("album.setRules", &json!({"id": album.0, "rules": rules})).unwrap();
assert!(s.visible_cloned().is_empty(), "the old rules' photos are not held under the new ones");
// and undoing the change brings the first question back, with what matches it
s.undo_step().unwrap();
assert_eq!(s.visible_cloned(), before);
}
#[test]
fn a_source_with_no_filter_over_it_holds_nothing() {
// All Photos, an album or a folder with no filter: nothing an edit does can make a photo stop matching, so
// nothing is kept (and nothing is paid for the hold)
let mut s = Session::with_demo();
hold(&mut s);
let all = s.visible_cloned();
keyword(&mut s, all[0]);
assert_eq!(s.visible_cloned(), all);
assert!(s.held.is_none());
s.execute("library.filter", &json!({"rating": 1})).unwrap();
let _ = s.visible_cloned();
assert!(s.held.is_some(), "a filter is a question: its view is held");
}
#[test]
fn asking_for_the_state_it_has_changes_nothing_and_a_word_is_not_a_bool() {
let mut s = unkeyworded();
hold(&mut s);
let before = s.visible_cloned();
keyword(&mut s, before[2]);
hold(&mut s);
assert!(s.visible_cloned().contains(&before[2]), "on, asked for again: the held photos stay");
assert!(s.execute("library.holdView", &json!({"on": "false"})).is_err());
assert!(s.hold_view, "a refused call changes nothing");
}
+24
View File
@@ -675,3 +675,27 @@ fn an_album_filter_loop_is_refused() {
assert!(r.is_err_and(|e| e.to_string().contains("include itself")), "refused");
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().smart.as_ref().and_then(|f| f.album), None);
}
#[test]
fn the_hold_of_the_view_is_kept_with_the_library_and_holds_nothing_over_from_another() {
let dir = temp_dir("holdview");
let mut s = open(&dir, true);
assert!(!s.hold_view, "off in a library that never asked for it");
s.execute("library.holdView", &json!({"on": true})).unwrap();
// a filter over the demo photos, and one of them edited out from under it: held
s.execute("library.filter", &json!({"rating": 0, "flag": "none"})).unwrap();
let shown = s.visible_cloned();
let id = *shown.first().expect("the seeded library has unflagged photos");
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("photo.flag", &json!({"flag": "pick"})).unwrap();
assert!(s.visible_cloned().contains(&id));
s.close_library().unwrap();
drop(s);
// opened again: the hold is on, and what was held is not (a library opens on what matches)
let mut s = open(&dir, false);
assert!(s.hold_view);
s.execute("library.filter", &json!({"rating": 0, "flag": "none"})).unwrap();
assert!(!s.visible_cloned().contains(&id));
s.close_library().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
+2
View File
@@ -581,6 +581,7 @@
"Friday": "Freitag",
"Full": "Vollständig",
"Full Screen Preview": "Vollbildvorschau",
"Hold View Until Refreshed": "Ansicht bis zum Aktualisieren beibehalten",
"Lights Out": "Licht aus",
"Full screen mode": "Vollbildmodus",
"Full size": "Volle Größe",
@@ -1173,6 +1174,7 @@
"Refining the mask's detail…": "Maskendetails werden verfeinert…",
"Refresh Source": "Quelle aktualisieren",
"Refresh Source (/)": "Quelle aktualisieren (/)",
"Refresh View": "Ansicht aktualisieren",
"Reject": "Ablehnen",
"Reject below focus": "Unter Schärfegrenzwert ablehnen",
"Rejected": "Abgelehnt",
+2
View File
@@ -563,6 +563,7 @@
"Friday": "Viernes",
"Full": "Completa",
"Full Screen Preview": "Vista previa a pantalla completa",
"Hold View Until Refreshed": "Mantener vista hasta actualizar",
"Lights Out": "Luces apagadas",
"Full screen mode": "Modo de pantalla completa",
"Full size": "Tamaño completo",
@@ -1145,6 +1146,7 @@
"Refining the mask's detail…": "Refinando el detalle de la máscara…",
"Refresh Source": "Actualizar origen",
"Refresh Source (/)": "Actualizar origen (/)",
"Refresh View": "Actualizar vista",
"Reject": "Rechazar",
"Reject below focus": "Rechazar por debajo del enfoque",
"Rejected": "Rechazada",
+2
View File
@@ -545,6 +545,7 @@
"Friday": "Vendredi",
"Full": "Complet",
"Full Screen Preview": "Aperçu en plein écran",
"Hold View Until Refreshed": "Conserver la vue jusqu’à l’actualisation",
"Lights Out": "Lumières éteintes",
"Full screen mode": "Mode plein écran",
"Full size": "Taille complète",
@@ -1119,6 +1120,7 @@
"Refining the mask's detail…": "Affinage du détail du masque…",
"Refresh Source": "Actualiser la source",
"Refresh Source (/)": "Actualiser la source (/)",
"Refresh View": "Actualiser la vue",
"Reject": "Rejeter",
"Reject below focus": "Rejeter en dessous de la netteté",
"Rejected": "Rejetée",
+2
View File
@@ -509,6 +509,7 @@
"Has Edits": "編集",
"Has GPS": "GPS情報",
"High Contrast B&W": "ハイコントラスト白黒",
"Hold View Until Refreshed": "更新するまで表示を保持",
"Home": "ホーム",
"Horizontal inset (% of short edge)": "水平方向の余白(短辺に対する%)",
"Hours": "時間",
@@ -684,6 +685,7 @@
"Refine Saturation": "彩度を微調整",
"Refining the mask's detail…": "マスクの細部を微調整中…",
"Refresh Source": "コピー元を更新",
"Refresh View": "表示を更新",
"Reject": "不採用",
"Reliability": "信頼性",
"Relink Photo": "写真の参照先を再設定",
+2
View File
@@ -554,6 +554,7 @@
"Friday": "Sexta-feira",
"Full": "Completa",
"Full Screen Preview": "Pré-visualização em tela cheia",
"Hold View Until Refreshed": "Manter visualização até atualizar",
"Lights Out": "Luzes apagadas",
"Full screen mode": "Modo tela cheia",
"Full size": "Tamanho completo",
@@ -1137,6 +1138,7 @@
"Refining the mask's detail…": "Refinando os detalhes da máscara…",
"Refresh Source": "Atualizar origem",
"Refresh Source (/)": "Atualizar origem (/)",
"Refresh View": "Atualizar visualização",
"Reject": "Rejeitar",
"Reject below focus": "Rejeitar abaixo do foco",
"Rejected": "Rejeitada",
+2
View File
@@ -455,6 +455,7 @@
"Friday": "Пятница",
"Full": "Полный",
"Full Screen Preview": "Полноэкранный просмотр",
"Hold View Until Refreshed": "Удерживать вид до обновления",
"Lights Out": "Выключить свет",
"Full screen mode": "Полноэкранный режим",
"Full size": "Полный размер",
@@ -942,6 +943,7 @@
"Refine Mask Edges": "Уточнить края маски",
"Refresh Source": "Обновить источник",
"Refresh Source (/)": "Обновить источник (/)",
"Refresh View": "Обновить вид",
"Reject": "Отклонить",
"Reject below focus": "Отклонять при недостаточной резкости",
"Rejected": "Отклонено",
+2
View File
@@ -96,6 +96,7 @@
"Focal Length": "Фокусна відстань",
"Grab bag": "Випадкова добірка",
"Group name": "Назва групи",
"Hold View Until Refreshed": "Утримувати вигляд до оновлення",
"Home": "Домашня тека",
"Horizontal inset (% of short edge)": "Відступ по горизонталі (% короткого краю)",
"ISO Speed": "Чутливість ISO",
@@ -233,6 +234,7 @@
"Red": "Червоний",
"Red Eye": "Червоні очі",
"Redo": "Повторити",
"Refresh View": "Оновити вигляд",
"Remove": "Видалити",
"Rename": "Перейменувати",
"Rename…": "Перейменувати…",
+2
View File
@@ -515,6 +515,7 @@
"HDR panoramas": "HDR 全景",
"Has Edits": "有编辑",
"Has GPS": "有 GPS",
"Hold View Until Refreshed": "刷新前保持视图",
"Home": "主文件夹",
"Horizontal inset (% of short edge)": "水平边距 (占短边 %)",
"Hours": "小时",
@@ -631,6 +632,7 @@
"Refine Edges": "细化边缘",
"Refine Saturation": "细化饱和度",
"Refining the mask's detail…": "正在细化蒙版细节…",
"Refresh View": "刷新视图",
"Reliability": "可靠性",
"Relink Photo": "重新链接照片",
"Reload Changed Files": "重新载入已更改的文件",
+2
View File
@@ -515,6 +515,7 @@
"HDR panoramas": "HDR 全景",
"Has Edits": "有編輯",
"Has GPS": "有 GPS",
"Hold View Until Refreshed": "重新整理前保持檢視",
"Home": "個人資料夾",
"Horizontal inset (% of short edge)": "水平邊距(短邊百分比)",
"Hours": "小時",
@@ -631,6 +632,7 @@
"Refine Edges": "調整邊緣",
"Refine Saturation": "調整飽和度",
"Refining the mask's detail…": "正在調整遮色片細節…",
"Refresh View": "重新整理檢視",
"Reliability": "可靠度",
"Relink Photo": "重新連結照片",
"Reload Changed Files": "重新載入已變更的檔案",
+14
View File
@@ -167,6 +167,8 @@ const LAYOUT: &[(&str, &[&str])] = &[
"@Stacks",
"view.filterBar",
"library.clearFilter",
"library.refresh",
"library.holdView",
"---",
"compare.swap",
"compare.makeSelect",
@@ -329,6 +331,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
"view.autoAdvance" => Some(u.auto_advance),
"view.filterBar" => Some(u.filter_bar),
"view.navigator" => Some(u.navigator),
"library.holdView" => Some(app.session.hold_view),
"view.fullScreenPreview" => Some(u.fullscreen),
"view.lightsOut" => Some(u.lights_out != crate::state::LightsOut::On),
"view.enterFullScreen" => Some(app.window_is_fullscreen),
@@ -987,6 +990,17 @@ mod tests {
})
}
#[test]
fn view_menu_offers_the_hold_and_the_refresh_and_checks_the_hold_while_it_is_on() {
let mut a = app();
let view = |a: &LightcraftApp| menu_bar(a).into_iter().find(|(t, _)| t == "View").expect("View menu").1;
let items = view(&a);
assert!(matches!(find(&items, "library.refresh"), Some(MenuNode::Item { enabled: true, .. })));
assert!(matches!(find(&items, "library.holdView"), Some(MenuNode::Item { checked: Some(false), .. })), "off unless asked for");
a.session.execute("library.holdView", &json!({"on": true})).expect("hold the view");
assert!(matches!(find(&view(&a), "library.holdView"), Some(MenuNode::Item { checked: Some(true), .. })));
}
/// The Sort submenu is expanded by hand: Reshuffle appears once (not again from the registry),
/// only while sorting at random, and the direction items are off for a shuffle.
#[test]
+8
View File
@@ -272,6 +272,14 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if cur.key == Random && ui.button(crate::i18n::tr("Reshuffle")).clicked() {
let _ = app.run("library.shuffle", json!({}));
}
// a view that holds its photos shows what matches now when asked to
if app.session.hold_view {
let r = ui.button(crate::i18n::tr("Refresh View"));
crate::widgets::register(ui.ctx(), "button:refreshView", r.rect);
if r.clicked() {
let _ = app.run("library.refresh", json!({}));
}
}
ui.separator();
// a shuffle has no direction worth choosing
ui.add_enabled_ui(cur.key != Random, |ui| {
+2 -1
View File
@@ -18,7 +18,8 @@ const TAIL: f32 = 170.0;
/// "14 of 120 photos" when a filter narrows the source, else "14 photos".
pub fn count_text(matching: usize, total: Option<usize>, filtering: bool) -> String {
match total {
Some(t) if filtering && t != matching => crate::i18n::tr_format!("{matching} of {t} photos", matching = matching, t = t),
// (a held view can show more than now match its source: no "3 of 2")
Some(t) if filtering && t > matching => crate::i18n::tr_format!("{matching} of {t} photos", matching = matching, t = t),
_ => crate::i18n::tr_format!("{matching} photos", matching = matching),
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry; LR-LIB-SMARTALBUM: a view can hold its photos until refreshed) · **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`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) |
| 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`, `cmd:library.holdView`, `cmd:library.refresh`, `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; View ▸ Hold View Until Refreshed (`library.holdView`, off by default) keeps a photo that an edit made stop matching (a smart album, Picks, the filter bar) in the view until Refresh View, Reshuffle or another source, filter or sort |
| 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 |
@@ -822,4 +822,5 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-11 | minor | LR-IMP-FORMATS and LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes from sensor data (57% of its C-RAW files opened as a preview: QP-map escape); status unchanged (🟡) |
| 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. |
| 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` |
| 2026-10-13 | minor | LR-LIB-SMARTALBUM: `cmd:library.holdView` and `cmd:library.refresh`: a view can hold the photos it shows until it is refreshed; status unchanged (✅) |
| 2026-09-30 – 2026-10-11 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) |
+9
View File
@@ -8,6 +8,15 @@
## 2026-10-13
### A view can hold its photos until you refresh it
- View ▸ Hold View Until Refreshed (off unless you turn it on; kept with the library). With it on, an edit that makes
a photo stop matching the view keeps it in the view: a keyword given in a smart album of photos without keywords,
a flag taken off in Picks, a rating that falls under the filter bar's. With it off, as before, the photo leaves the
grid at once and every photo after it moves up. View ▸ Refresh View (also in the sort menu while the hold is on),
Reshuffle, another source, filter or sort, or new rules for the smart album shows what matches now.
- Photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder
shown still takes it out of the view.
### HDR merges keep the lens correction
- An HDR made with Photo ▸ Photo Merge ▸ HDR now keeps the lens correction the photos carry (Sony's, Panasonic's
and Leica's, and the one some DNGs carry, e.g. from drones): Optics ▸ Enable Lens Corrections straightens it as it