diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index a9956703d..a9aa66b7f 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -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 { + 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, within: &Filter) -> Vec { 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)), diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 3a5575ea8..910091648 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -510,6 +510,44 @@ pub fn specs() -> Vec { 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", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..c1f1c0931 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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)>, /// 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 = 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; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 0305623f2..be6fab1dd 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -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, + /// 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(()) }; diff --git a/crates/engine/src/tests_hold_view.rs b/crates/engine/src/tests_hold_view.rs new file mode 100644 index 000000000..3bb998b8c --- /dev/null +++ b/crates/engine/src/tests_hold_view.rs @@ -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 = 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 = 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::>(), "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::>()); + // 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 = 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"); +} diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 8a2599e5c..131049d13 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -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); +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..a38116ab2 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -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", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..aee0b24e1 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -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", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..d9c05f1bf 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -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", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..5ec9e8bdc 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -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": "写真の参照先を再設定", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..44a4910af 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -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", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..2a9f10379 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -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": "Отклонено", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..5d79898d5 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -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…": "Перейменувати…", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..5c0bc0901 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -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": "重新载入已更改的文件", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..863d4d3cd 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -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": "重新載入已變更的檔案", diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index fe6905cad..78996f409 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -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 { "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] diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index b986b8995..4872378da 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -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| { diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index d1acd15a5..510931a51 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -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, 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), } } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..02416ec1a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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:`); 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:`; 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`) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..a46a67a72 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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