From 02263f359ae43fdcea6a4c212597fcabc5dba964 Mon Sep 17 00:00:00 2001 From: insideTheFlux Date: Sat, 31 Oct 2026 10:30:24 -0700 Subject: [PATCH 1/2] LRC-LIB-VISUALSEARCH: Find Similar Photos reads the photos off the UI thread The first search reads a thumbnail-level source of every photo in the library, one at a time on the thread that draws the window. In a large library the window stopped responding for as long as that took, and Windows offered to close the program (#3468). The command is split into plan / measure / keep / apply, as photo.reload is. The app measures on a worker thread under an activity row that counts and can stop; a stopped search leaves the view alone and keeps what it read. A control request gets the same {photos} when the search ends. Opening another library stops the search. Fixes #3468 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvoftM8ZqGRb62UWw9DAKP --- crates/engine/src/cmd.rs | 1 + crates/engine/src/cmd/cull.rs | 43 +-- crates/engine/src/cmd/similar.rs | 399 +++++++++++++++++++++++ crates/ui-egui/src/app/frame.rs | 4 + crates/ui-egui/src/control.rs | 6 + crates/ui-egui/src/find_similar.rs | 110 +++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus/run.rs | 4 + crates/ui-egui/src/panels/grid.rs | 17 +- crates/ui-egui/src/tests.rs | 1 + crates/ui-egui/src/tests/find_similar.rs | 301 +++++++++++++++++ docs/background-tasks.md | 1 + docs/parity-checklist.md | 5 +- docs/whats-new.md | 12 + 14 files changed, 851 insertions(+), 54 deletions(-) create mode 100644 crates/engine/src/cmd/similar.rs create mode 100644 crates/ui-egui/src/find_similar.rs create mode 100644 crates/ui-egui/src/tests/find_similar.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index daddc7f6ea..8136827ba8 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -39,6 +39,7 @@ mod print; mod query; pub mod recheck; pub mod reload; +pub mod similar; mod slideshow; mod sync; mod xmp; diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index 383ed890d9..c847852261 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -105,43 +105,6 @@ fn burst_params(p: &Value) -> Result<(f64, f32)> { Ok((num("burstGap", BURST_GAP, MAX_GAP)?, num("similarity", SAME, 1.0)? as f32)) } -/// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by -/// content), most similar first; the view is filtered to them. -fn find_similar(s: &mut Session, p: &Value) -> Result { - const C: &str = "library.findSimilar"; - let id = super::photo_id(p, C)?.or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?; - let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32; - let all: Vec = s.catalog.photos().filter(|q| q.in_library()).map(|q| q.id).collect(); - let sig = |s: &mut Session, id: PhotoId| -> Option<[f32; 64]> { - let key = s.catalog.photo(id).map(|p| crate::media::content_key(p))?; - if let Some(v) = s.signatures.get(&key) { - return Some(*v); - } - let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?; - let v = lightcraft_pipeline::cull::signature(&src); - s.signatures.insert(key, v); - Some(v) - }; - let me = sig(s, id).ok_or_else(|| super::bad(C, "can't read the photo"))?; - let mut hits: Vec<(PhotoId, f32)> = Vec::new(); - for q in all { - if let Some(v) = sig(s, q) { - let sim = lightcraft_pipeline::cull::similarity(&me, &v); - if sim >= min { - hits.push((q, sim.clamp(0.0, 1.0))); - } - } - } - hits.sort_by(|a, b| b.1.total_cmp(&a.1)); - if p.get("filter").and_then(Value::as_bool).unwrap_or(true) { - s.shelved_filter = None; - s.filter.only = hits.iter().map(|h| h.0).collect(); - // an empty `only` means "no constraint": no hits is its own state that shows no photos - s.filter.only_none = s.filter.only.is_empty(); - } - Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::>()})) -} - pub fn specs() -> Vec { vec![ cmd!( @@ -149,9 +112,9 @@ pub fn specs() -> Vec { "Find Similar Photos", ["Photo"], None, - "{id?, similarity?: 0..1 (0.8), filter?: bool (true)} — photos that look like the active one (composition and tones), most similar first; filters the view to them (Clear Filters to go back) → {photos: [{id, similarity}]}", - always, - find_similar + "{id?, similarity?: 0..1 (0.8), filter?: bool (true)} — photos that look like the active one (composition and tones), most similar first; filters the view to them (Clear Filters to go back) → {photos: [{id, similarity}]}. In the app the photos are read on a worker thread under an activity row: a control request is answered with this when the search ends, and from a menu the answer is {background: true, count}", + super::similar::no_search_running, + super::similar::run ), cmd!( "photo.analyze", diff --git a/crates/engine/src/cmd/similar.rs b/crates/engine/src/cmd/similar.rs new file mode 100644 index 0000000000..b8af1b2494 --- /dev/null +++ b/crates/engine/src/cmd/similar.rs @@ -0,0 +1,399 @@ +//! Find Similar (`library.findSimilar`), split so the app can read the photos on a worker thread: the +//! first search reads a thumbnail-level source of every photo in the library, and in a large +//! library that froze the window for as long as it took (issue #3468). [`plan`] lists what is to be +//! read (no file-system calls), [`measure`] reads it without the session (cancellable, with +//! progress), [`keep`] adds what it found to the session's signatures and [`apply`] answers from +//! those. The command itself (the CLI, MCP without an app) does all of them at once. +//! +//! A search keeps no decoded source and learns nothing from one: it only looks, and in a large library +//! it would push out of memory what the grid and the loupe are showing. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Mutex, PoisonError}; + +use lightcraft_catalog::PhotoId; +use serde_json::{Value, json}; + +use super::bad; +use crate::media::{SourceLevel, SourceRef, content_key}; +use crate::{Result, Session}; + +const C: &str = "library.findSimilar"; + +/// The kind of the row in the activity stack that a search on a worker thread holds. +pub const KIND: &str = "similar"; + +/// A content key and that content's look-alike signature. +pub type Signature = (String, [f32; 64]); + +/// Whether a search can start (the command's `enabled`): one at a time, for what a second one would +/// read the first is reading. +pub fn no_search_running(s: &Session) -> std::result::Result<(), String> { + if s.activity.list().iter().any(|r| r.kind == KIND) { Err("a search for similar photos is already running".to_string()) } else { Ok(()) } +} + +/// What a search asks. +#[derive(Debug)] +pub struct Search { + id: PhotoId, + min: f32, + filter: bool, +} + +/// A content whose signature isn't known yet, and how to read it: from the first of its photos that +/// can be read (a copy that is offline gives way to the next). +struct Content { + key: String, + sources: Vec, +} + +/// What a search has to read: the searched photo first, as nothing can be compared without it. +pub struct Reads { + me: Option, + rest: Vec, +} + +impl Reads { + /// How many contents are to be read. + pub fn len(&self) -> usize { + self.rest.len().saturating_add(usize::from(self.me.is_some())) + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// The search `p` asks for and what it has to read, once for each content not known yet. No +/// file-system calls. +pub fn plan(s: &mut Session, p: &Value) -> Result<(Search, Reads)> { + let id = super::photo_id(p, C)?.or(s.active()).ok_or_else(|| bad(C, "no photo"))?; + let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32; + let filter = p.get("filter").and_then(Value::as_bool).unwrap_or(true); + let photo = s.catalog.photo(id).ok_or_else(|| bad(C, "can't read the photo"))?; + let my_key = content_key(photo); + let me = + (!s.signatures.contains_key(&my_key)).then(|| Content { sources: vec![s.media.source_ref(photo, SourceLevel::Thumb)], key: my_key.clone() }); + let mut rest: Vec = Vec::new(); + let mut at = std::collections::HashMap::new(); + for q in s.catalog.photos().filter(|q| q.in_library()) { + let key = content_key(q); + // (the searched photo's content is known by the time these are read, or the search has ended) + if key == my_key || s.signatures.contains_key(&key) { + continue; + } + let source = s.media.source_ref(q, SourceLevel::Thumb); + match at.get(&key).and_then(|i: &usize| rest.get_mut(*i)) { + Some(content) => content.sources.push(source), + None => { + at.insert(key.clone(), rest.len()); + rest.push(Content { key, sources: vec![source] }); + } + } + } + Ok((Search { id, min, filter }, Reads { me, rest })) +} + +/// `content`'s signature, or `None` when none of its photos can be read or `cancel` is set first. A +/// decoder's panic is that photo's failure, not the search's. +fn signature_of(content: &Content, cancel: &AtomicBool) -> Option<[f32; 64]> { + content.sources.iter().find_map(|source| { + if cancel.load(Ordering::Relaxed) { + return None; + } + crate::guard::catch("Find Similar", || { + crate::memory::in_background(|| source.load_source()).ok().map(|decoded| lightcraft_pipeline::cull::signature(&decoded.image)) + }) + .ok() + .flatten() + }) +} + +/// Read what [`plan`] listed, several photos at once. Doesn't touch the session. `progress(done)` is +/// called from the threads doing the reading; once `cancel` is set no further file is started, and +/// what was read until then comes back. A searched photo that can't be read ends it there. +pub fn measure(reads: Reads, cancel: &AtomicBool, progress: &(dyn Fn(usize) + Sync)) -> Vec { + let found = Mutex::new(Vec::with_capacity(reads.len().min(4096))); + let done = AtomicUsize::new(0); + let keep = |key: String, v: [f32; 64]| found.lock().unwrap_or_else(PoisonError::into_inner).push((key, v)); + if let Some(me) = reads.me { + let Some(v) = signature_of(&me, cancel) else { return Vec::new() }; + keep(me.key, v); + done.store(1, Ordering::Relaxed); + progress(1); + } + let lanes = crate::export::export_parallelism(reads.rest.len()); + let queue = Mutex::new(reads.rest.into_iter()); + let work = || { + while !cancel.load(Ordering::Relaxed) { + let next = queue.lock().unwrap_or_else(PoisonError::into_inner).next(); + let Some(content) = next else { break }; + if let Some(v) = signature_of(&content, cancel) { + keep(content.key, v); + } + progress(done.fetch_add(1, Ordering::Relaxed).saturating_add(1)); + } + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::scope(|sc| { + for k in 1..lanes.max(1) { + // a lane that can't start leaves its photos to the others (and to this thread) + if std::thread::Builder::new().name(format!("similar-{k}")).spawn_scoped(sc, work).is_err() { + break; + } + } + work(); + }); + #[cfg(target_arch = "wasm32")] + { + let _ = lanes; + work(); + } + found.into_inner().unwrap_or_else(PoisonError::into_inner) +} + +/// Add what [`measure`] found to the signatures, also when the search was stopped: they hold for the +/// contents they were read from, and the next search doesn't read those again. +pub fn keep(s: &mut Session, found: Vec) { + s.signatures.extend(found); +} + +/// Answer `search` from the signatures known now, most similar first, and filter the view to the +/// photos found. A photo with none (it can't be read, or came after the [`plan`]) isn't compared. +pub fn apply(s: &mut Session, search: &Search) -> Result { + let known = s.catalog.photo(search.id).and_then(|photo| s.signatures.get(&content_key(photo))); + let me = *known.ok_or_else(|| bad(C, "can't read the photo"))?; + let mut hits: Vec<(PhotoId, f32)> = Vec::new(); + for q in s.catalog.photos().filter(|q| q.in_library()) { + if let Some(v) = s.signatures.get(&content_key(q)) { + let sim = lightcraft_pipeline::cull::similarity(&me, v); + if sim >= search.min { + hits.push((q.id, sim.clamp(0.0, 1.0))); + } + } + } + hits.sort_by(|a, b| b.1.total_cmp(&a.1)); + if search.filter { + s.shelved_filter = None; + s.filter.only = hits.iter().map(|h| h.0).collect(); + // an empty `only` means "no constraint": no hits is its own state that shows no photos + s.filter.only_none = s.filter.only.is_empty(); + } + Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::>()})) +} + +/// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by +/// content), most similar first; the view is filtered to them. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let (search, reads) = plan(s, p)?; + let found = measure(reads, &AtomicBool::new(false), &|_| {}); + keep(s, found); + apply(s, &search) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use lightcraft_catalog::{Op, Photo, Source}; + use lightcraft_raster::Rgb32f; + + use super::*; + + /// Reads per file, the files that can't be read and the file whose decoder panics. + #[derive(Default)] + struct Files { + reads: Mutex>, + unreadable: Mutex>, + panics: Mutex>, + /// Set once this many reads have started. + cancel_after: Option<(usize, Arc)>, + } + + impl Files { + fn reads_of(&self, path: &str) -> usize { + self.reads.lock().unwrap().get(path).copied().unwrap_or(0) + } + fn reads(&self) -> usize { + self.reads.lock().unwrap().values().sum() + } + } + + /// A session of photos `/p/.jpg`, each with its content hash when it has one. Odd numbers + /// in a name mean a picture that gets brighter to the right, others one that does downwards. + fn session(photos: &[(&str, Option<&str>)], files: Files) -> (Session, Arc) { + let files = Arc::new(files); + let mut s = Session::new(); + for (i, (name, hash)) in photos.iter().enumerate() { + let source = Source::File { path: format!("/p/{name}.jpg") }; + let mut p = Photo::new(PhotoId(i as u64 + 1), source, &format!("{name}.jpg"), "JPEG", 64, 64, "2026-01-01T00:00:00"); + p.content_hash = hash.map(str::to_string); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let seen = files.clone(); + s.media.file_loader = Some(Arc::new(move |path: &str, _| { + let started = { + let mut reads = seen.reads.lock().unwrap(); + *reads.entry(path.to_string()).or_default() += 1; + reads.values().sum::() + }; + if let Some((after, cancel)) = &seen.cancel_after + && started >= *after + { + cancel.store(true, Ordering::SeqCst); + } + if seen.panics.lock().unwrap().as_deref() == Some(path) { + panic!("a decoder fails on {path}"); + } + if seen.unreadable.lock().unwrap().iter().any(|f| f == path) { + return Err(format!("{path}: gone")); + } + let across = path.bytes().filter(u8::is_ascii_digit).any(|b| b % 2 == 1); + let image = Rgb32f::from_fn(64, 64, |x, y| { + let v = if across { x } else { y }; + [v as f32 / 64.0; 3] + }); + Ok((image, Default::default())) + })); + (s, files) + } + + fn ids(r: &Value) -> Vec { + r["photos"].as_array().unwrap().iter().map(|h| h["id"].as_u64().unwrap()).collect() + } + + /// A search on a worker thread holds a row of its kind: the command is off until the row goes. + #[test] + fn a_running_search_turns_the_command_off() { + let (mut s, files) = session(&[("a1", None), ("b2", None)], Files::default()); + let row = s.activity.start(KIND, "Find Similar Photos", crate::activity::Cancel::No); + let off = s.commands().into_iter().find(|c| c.id == C).is_some_and(|c| !c.enabled); + assert!(off, "the menu shows it off"); + assert!(matches!(s.execute(C, &json!({"id": 1})), Err(crate::EngineError::Disabled(..)))); + assert_eq!(files.reads(), 0); + drop(row); + assert!(s.execute(C, &json!({"id": 1})).is_ok()); + } + + #[test] + fn making_the_plan_reads_no_file() { + let (mut s, files) = session(&[("a1", None), ("b2", None), ("c1", None)], Files::default()); + let (_, reads) = plan(&mut s, &json!({"id": 1})).unwrap(); + assert_eq!(reads.len(), 3); + assert_eq!(files.reads(), 0); + } + + #[test] + fn the_command_reads_each_photo_and_finds_the_ones_alike() { + let names: Vec = (0..40).map(|i| format!("p{i}")).collect(); + let photos: Vec<(&str, Option<&str>)> = names.iter().map(|n| (n.as_str(), None)).collect(); + let (mut s, files) = session(&photos, Files::default()); + let r = s.execute("library.findSimilar", &json!({"id": 1})).unwrap(); + assert_eq!(files.reads(), 40, "each once"); + assert!(ids(&r).contains(&1) && ids(&r).len() < 40, "{r}"); + // known now: no file is read again + let again = s.execute("library.findSimilar", &json!({"id": 1})).unwrap(); + assert_eq!(files.reads(), 40); + assert_eq!(ids(&again), ids(&r)); + } + + #[test] + fn a_content_is_read_once_and_an_unreadable_copy_gives_way_to_the_next() { + let files = Files::default(); + files.unreadable.lock().unwrap().push("/p/copy2.jpg".into()); + let photos = [("me1", None), ("copy2", Some("h")), ("copy4", Some("h")), ("copy6", Some("h"))]; + let (mut s, files) = session(&photos, files); + let r = s.execute("library.findSimilar", &json!({"id": 1, "similarity": -1.0})).unwrap(); + assert_eq!(files.reads_of("/p/copy2.jpg"), 1); + assert_eq!(files.reads_of("/p/copy4.jpg"), 1, "the next copy stands in"); + assert_eq!(files.reads_of("/p/copy6.jpg"), 0, "its content is known by then"); + assert_eq!(ids(&r).len(), 4, "every copy is compared: {r}"); + } + + #[test] + fn a_searched_photo_that_cannot_be_read_ends_the_search_before_any_other_is_read() { + let files = Files::default(); + files.unreadable.lock().unwrap().push("/p/me1.jpg".into()); + let (mut s, files) = session(&[("me1", None), ("b2", None), ("c1", None)], files); + let e = s.execute("library.findSimilar", &json!({"id": 1})).unwrap_err(); + assert!(e.to_string().contains("can't read the photo"), "{e}"); + assert_eq!(files.reads(), 1, "only the searched photo"); + assert!(s.signatures.is_empty()); + assert_eq!(s.filter, Default::default(), "the view stays as it was"); + } + + #[test] + fn a_photo_nobody_has_is_refused_at_once() { + let (mut s, files) = session(&[("a1", None)], Files::default()); + assert!(matches!(plan(&mut s, &json!({"id": 99})), Err(crate::EngineError::BadParams { .. }))); + assert_eq!(files.reads(), 0); + } + + #[test] + fn a_photo_whose_decoder_panics_is_left_out_and_the_search_goes_on() { + let files = Files::default(); + *files.panics.lock().unwrap() = Some("/p/bad3.jpg".into()); + let (mut s, _) = session(&[("me1", None), ("bad3", None), ("ok1", None), ("ok5", None)], files); + let r = s.execute("library.findSimilar", &json!({"id": 1, "similarity": -1.0})).unwrap(); + assert_eq!(ids(&r).len(), 3, "{r}"); + assert!(!ids(&r).contains(&2), "{r}"); + } + + /// Stopped after some reads: what was read is kept, the view is left alone, and the next search + /// reads only the rest. + #[test] + fn a_stopped_search_keeps_what_it_read_and_the_next_reads_the_rest() { + let cancel = Arc::new(AtomicBool::new(false)); + let files = Files { cancel_after: Some((5, cancel.clone())), ..Default::default() }; + let names: Vec = (0..64).map(|i| format!("p{i}")).collect(); + let photos: Vec<(&str, Option<&str>)> = names.iter().map(|n| (n.as_str(), None)).collect(); + let (mut s, _) = session(&photos, files); + let (_, reads) = plan(&mut s, &json!({"id": 1})).unwrap(); + let found = measure(reads, &cancel, &|_| {}); + let read = found.len(); + assert!((1..64).contains(&read), "stopped along the way: {read}"); + keep(&mut s, found); + assert_eq!(s.filter, Default::default()); + let (_, rest) = plan(&mut s, &json!({"id": 1})).unwrap(); + assert_eq!(rest.len(), 64 - read); + } + + #[test] + fn a_stopped_search_reads_nothing_more() { + let (mut s, files) = session(&[("a1", None), ("b2", None), ("c1", None)], Files::default()); + let (_, reads) = plan(&mut s, &json!({"id": 1})).unwrap(); + assert!(measure(reads, &AtomicBool::new(true), &|_| {}).is_empty()); + assert_eq!(files.reads(), 0); + } + + /// What changed while the files were read: a photo deleted since isn't an answer, and one added + /// since has no signature to compare. + #[test] + fn photos_that_changed_during_the_search_are_answered_as_they_are_now() { + let (mut s, _) = session(&[("a1", None), ("b1", None), ("c1", None)], Files::default()); + let (search, reads) = plan(&mut s, &json!({"id": 1, "similarity": -1.0})).unwrap(); + let found = measure(reads, &AtomicBool::new(false), &|_| {}); + s.catalog.apply(Op::RemovePhoto { id: PhotoId(2) }).unwrap(); + let added = Photo::new(PhotoId(9), Source::File { path: "/p/new1.jpg".into() }, "new1.jpg", "JPEG", 64, 64, "2026-01-01T00:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(added) }).unwrap(); + keep(&mut s, found); + let mut answer = ids(&apply(&mut s, &search).unwrap()); + answer.sort_unstable(); + assert_eq!(answer, [1, 3]); + } + + #[test] + fn progress_counts_every_content_read_or_not() { + let files = Files::default(); + files.unreadable.lock().unwrap().push("/p/b2.jpg".into()); + let (mut s, _) = session(&[("a1", None), ("b2", None), ("c1", None), ("d2", None)], files); + let (_, reads) = plan(&mut s, &json!({"id": 1})).unwrap(); + let top = AtomicUsize::new(0); + let found = measure(reads, &AtomicBool::new(false), &|done| { + top.fetch_max(done, Ordering::SeqCst); + }); + assert_eq!(top.load(Ordering::SeqCst), 4); + assert_eq!(found.len(), 3); + } +} diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index a434881ae5..502b9fbd17 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -304,6 +304,10 @@ impl LightcraftApp { if self.shown_library == Some(generation) { return; } + // (the first library shown takes the place of none: nothing was searching another one) + if self.shown_library.is_some() { + crate::find_similar::library_changed(self); + } self.shown_library = Some(generation); if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { let path = l.dir.to_string_lossy().into_owned(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a7b70e3143..617b2e1fbf 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -345,6 +345,12 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest ctx.request_repaint(); return started; } + if id == "library.findSimilar" { + // reads every photo: on a worker thread, answered when it is done + let started = crate::find_similar::ask(app, ¶ms, req.reply.clone()); + ctx.request_repaint(); + return started; + } let r = app.run(id, params); ctx.request_repaint(); wrap(r) diff --git a/crates/ui-egui/src/find_similar.rs b/crates/ui-egui/src/find_similar.rs new file mode 100644 index 0000000000..d96424317c --- /dev/null +++ b/crates/ui-egui/src/find_similar.rs @@ -0,0 +1,110 @@ +//! Find Similar Photos in the app. The first search reads every photo of the library, which on the +//! UI thread left the window not responding for as long as that took: hours in a library of tens of +//! thousands of raws (issue #3468). The photos are read on a worker thread instead, under a row in +//! the activity stack that counts them and can stop. What was read before a stop is kept, so the +//! next search goes on from there. +//! +//! From a menu the command answers at once and says how it ended in a toast. A request from the +//! control channel (an agent, `mcp --connect`) gets the same `{photos}` as ever, when the search +//! ends; one that gave up waiting can ask again and is answered from what was read. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; + +use lightcraft_engine::cmd::similar; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::control::{self, Outcome}; + +/// The activity stack's label (the menu item's name, already translated). +pub const LABEL: &str = "Find Similar Photos"; + +/// How a search ended. +enum End { + /// The command's answer. + Found(Value), + /// Nothing was asked of the view any more: why. + Dropped(&'static str), + Failed(String), +} + +/// Start a search for `p`: `end` runs on the UI thread when it is over. How many contents it has to +/// read, or why it can't start. +fn start(app: &mut LightcraftApp, p: &Value, end: impl FnOnce(&mut LightcraftApp, &egui::Context, End) + Send + 'static) -> Result { + similar::no_search_running(&app.session).map_err(|why| format!("library.findSimilar: {why}"))?; + let (search, reads) = similar::plan(&mut app.session, p).map_err(|e| e.to_string())?; + let count = reads.len(); + let library = app.session.library_generation(); + let cancel = Arc::new(AtomicBool::new(false)); + let (flag, stopped) = (cancel.clone(), cancel.clone()); + let work = move |task: lightcraft_engine::activity::TaskHandle| { + let total = count as u64; + task.progress(0, total); + lightcraft_engine::guard::catch(LABEL, || similar::measure(reads, &flag, &|done| task.progress(done as u64, total))) + }; + let done = move |app: &mut LightcraftApp, ctx: &egui::Context, found: Result, String>| { + let ended = match found { + _ if app.session.library_generation() != library => End::Dropped("another library was opened before the search ended"), + Err(e) => End::Failed(e), + Ok(found) => { + similar::keep(&mut app.session, found); + if stopped.load(Ordering::Relaxed) { + End::Dropped("the search was stopped") + } else { + match app.session.execute_fn("library.findSimilar", |s| similar::apply(s, &search)) { + Ok(answer) => End::Found(answer), + Err(e) => End::Failed(e.to_string()), + } + } + } + }; + end(app, ctx, ended); + }; + crate::tasks::spawn_tracked(app, LABEL, similar::KIND, cancel, work, done)?; + Ok(count) +} + +/// `library.findSimilar` from a menu: start reading → `{background: true, count}`. The grid shows +/// what it found, and a toast says how many. +pub fn command(app: &mut LightcraftApp, p: &Value) -> Result { + let count = start(app, p, |app, ctx, end| match end { + End::Found(answer) => { + let n = answer["photos"].as_array().map_or(0, Vec::len); + app.ui.view = crate::state::ViewMode::PhotoGrid; + app.toast(ctx, crate::i18n::tr_format!("{n} similar photo{} · View ▸ Clear Filters to see all", if n == 1 { "" } else { "s" }, n = n)); + } + // (whoever stopped it knows; a search of another library has nothing to say about this one) + End::Dropped(_) => {} + End::Failed(e) => app.toast(ctx, e), + })?; + Ok(json!({"background": true, "count": count})) +} + +/// `library.findSimilar` from the control channel: `reply` gets the command's answer when the search +/// ends, and the view stays where the agent left it. +pub fn ask(app: &mut LightcraftApp, p: &Value, reply: Sender) -> Outcome { + let started = start(app, p, move |_, _, end| { + let answer = match end { + End::Found(answer) => control::success(answer), + End::Dropped(why) => control::failure(format!("library.findSimilar: {why}")), + End::Failed(e) => control::failure(e), + }; + // (a caller that gave up has no one to send to; what was read stays known for its next request) + let _ = reply.send(answer); + }); + match started { + Ok(_) => Outcome::Later, + Err(e) => Outcome::Done(control::failure(e)), + } +} + +/// Another library was opened: stop a search of the one before, which has nothing left to find (it +/// could go on reading for hours). Its row goes when the photo being read is done. +pub fn library_changed(app: &LightcraftApp) { + for row in app.session.activity.list().into_iter().filter(|r| r.kind == similar::KIND) { + // (a row that has just ended can't be stopped: nothing to do) + let _ = app.session.activity.cancel(row.id); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7a67d87109..c7a06d4051 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,6 +24,7 @@ pub mod dialogs; pub mod docking; mod drop_overlay; pub mod export_task; +mod find_similar; mod focus_scan; mod frame_rate; pub mod headless; diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index ec9835a39f..ccfb85d0a7 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -158,6 +158,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let n = r["photos"].as_array().map_or(0, Vec::len); - app.ui.view = crate::state::ViewMode::PhotoGrid; - app.toast( - ui.ctx(), - crate::i18n::tr_format!("{n} similar photo{} · View ▸ Clear Filters to see all", if n == 1 { "" } else { "s" }, n = n), - ); - } - Err(e) => app.toast(ui.ctx(), e), - } + let can_search = lightcraft_engine::cmd::similar::no_search_running(&app.session).is_ok(); + if ui.add_enabled(can_search, egui::Button::new(crate::i18n::tr("Find Similar Photos"))).clicked() + && let Err(e) = app.run("library.findSimilar", json!({"id": id.0})) + { + app.toast(ui.ctx(), e); } let live = super::compare::live(app, id); let set_reference = ui diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 09e7703d77..7987469d1c 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -31,6 +31,7 @@ mod empty_library; mod error_kinds; mod filmstrip; mod filmstrip_drag; +mod find_similar; mod focus_point; mod folder_drop; mod grading; diff --git a/crates/ui-egui/src/tests/find_similar.rs b/crates/ui-egui/src/tests/find_similar.rs new file mode 100644 index 0000000000..4ac537dc28 --- /dev/null +++ b/crates/ui-egui/src/tests/find_similar.rs @@ -0,0 +1,301 @@ +//! Find Similar Photos reads every photo of the library the first time, which left the window not +//! responding for as long as that took (issue #3468): the photos are read on a worker thread under a +//! row of the activity stack. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::Receiver; +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use lightcraft_raster::Rgb32f; +use serde_json::{Value, json}; + +use crate::find_similar::LABEL; +use crate::headless::Headless; +use crate::state::ViewMode; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(60); +const PHOTOS: u64 = 12; +/// How long a read waits for a drive that isn't opened, and how long the frames may take while it does. +const GUARD: Duration = Duration::from_secs(20); +const FRAMES: Duration = Duration::from_secs(15); + +/// The photos' files: a read waits for `open` (a slow drive), at most `guard`, and is counted, as +/// are the reads made on the UI thread. +struct Drive { + open: Arc, + reads: Arc, + on_ui: Arc, +} + +fn app_on_a_slow_drive(guard: Duration) -> (Headless, Drive) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() }); + for i in 0..PHOTOS { + let path = format!("/drive/IMG_{i:04}.arw"); + let p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:04}.arw"), "ARW", 64, 64, "2026-01-01T00:00:00"); + app.session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let drive = Drive { open: Arc::new(AtomicBool::new(false)), reads: Arc::new(AtomicUsize::new(0)), on_ui: Arc::new(AtomicUsize::new(0)) }; + let (ui, open, reads, on_ui) = (std::thread::current().id(), drive.open.clone(), drive.reads.clone(), drive.on_ui.clone()); + let deadline = Instant::now() + guard; + app.session.media.file_loader = Some(Arc::new(move |path: &str, _| { + // (the grid reads the photos for its thumbnails too, on threads of its own) + let thread = std::thread::current(); + if thread.name().is_some_and(|n| n.starts_with("similar-") || n.contains(LABEL)) { + reads.fetch_add(1, Ordering::SeqCst); + } + if thread.id() == ui { + on_ui.fetch_add(1, Ordering::SeqCst); + } + // (a hang guard: the read goes on after it, so a regression fails the test instead of hanging it) + while !open.load(Ordering::SeqCst) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(1)); + } + // even photos get brighter to the right, odd ones downwards + let across = path.bytes().rfind(u8::is_ascii_digit).is_some_and(|d| d % 2 == 0); + let image = Rgb32f::from_fn(64, 64, |x, y| { + let v = if across { x } else { y }; + [v as f32 / 64.0; 3] + }); + Ok((image, Default::default())) + })); + (Headless::new(app, [1200.0, 800.0], 1.0), drive) +} + +fn rows(h: &Headless) -> Vec { + h.app.session.activity.list().into_iter().filter(|r| r.kind == "similar").collect() +} + +/// The row's wait is over once the task has gone. +fn until_done(h: &mut Headless) { + assert!(h.step_until(T, |h| h.app.tasks.is_empty()), "the search never ended"); +} + +fn ask(h: &mut Headless) -> Receiver { + h.send("engine.execute", json!({"command": "library.findSimilar", "params": {"id": 1}})).unwrap() +} + +fn answer(h: &mut Headless, rx: &Receiver) -> Value { + let t0 = Instant::now(); + loop { + h.step(); + if let Ok(v) = rx.try_recv() { + return v; + } + assert!(t0.elapsed() < T, "never answered"); + std::thread::sleep(Duration::from_millis(1)); + } +} + +/// The issue's report: from the menu, the command answers at once while the files are read, the +/// window takes its frames, the row counts and can stop, and the grid shows what was found. +#[test] +fn a_search_from_the_menu_reads_off_the_ui_thread_and_the_window_goes_on() { + let (mut h, drive) = app_on_a_slow_drive(GUARD); + h.app.ui.view = ViewMode::Detail; + let t0 = Instant::now(); + let r = h.app.run("library.findSimilar", json!({"id": 1})).unwrap(); + assert_eq!(r, json!({"background": true, "count": PHOTOS})); + for _ in 0..3 { + h.step(); + } + // nothing has been read yet: the command and the frames waited for none of it + assert!(t0.elapsed() < FRAMES, "the window waited for the files: {:?}", t0.elapsed()); + assert_eq!(h.app.session.filter, Default::default(), "no answer before the files are read"); + let row = &rows(&h)[0]; + assert_eq!(row.label, LABEL); + assert_eq!(row.total, PHOTOS); + assert!(row.cancellable, "the row can stop the search"); + + drive.open.store(true, Ordering::SeqCst); + until_done(&mut h); + assert_eq!(drive.on_ui.load(Ordering::SeqCst), 0, "a file was read on the UI thread"); + assert_eq!(drive.reads.load(Ordering::SeqCst), PHOTOS as usize); + assert!(rows(&h).is_empty(), "the row goes with the search"); + let shown = h.app.session.filter.only.len(); + assert_eq!(shown, PHOTOS as usize / 2, "the photos alike are shown"); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.starts_with(&format!("{shown} similar photos")), "{toast}"); +} + +/// One search at a time: the menu row is off while one runs and on again after it, and a second +/// request is refused without touching the first. +#[test] +fn a_second_search_waits_for_the_first() { + // what the menu bar draws the row from + let row_is_on = |h: &Headless| crate::menus::menu_entries(&h.app).into_iter().find(|e| e.id == "library.findSimilar").is_some_and(|e| e.enabled); + let (mut h, drive) = app_on_a_slow_drive(GUARD); + assert!(row_is_on(&h)); + h.app.run("library.findSimilar", json!({"id": 1})).unwrap(); + assert!(!row_is_on(&h), "the row is off while one runs"); + let e = h.app.run("library.findSimilar", json!({"id": 2})).unwrap_err(); + assert!(e.contains("already running"), "{e}"); + assert_eq!(rows(&h).len(), 1); + drive.open.store(true, Ordering::SeqCst); + until_done(&mut h); + assert!(row_is_on(&h)); + assert!(h.app.session.filter.only.contains(&PhotoId(1)), "the first search answered"); +} + +/// ✕ on the row: the view stays as it was, nothing is said, and the next search doesn't read again +/// what was read. +#[test] +fn stopping_leaves_the_view_alone_and_the_next_search_reads_only_the_rest() { + let (mut h, drive) = app_on_a_slow_drive(GUARD); + h.app.ui.view = ViewMode::Detail; + h.app.run("library.findSimilar", json!({"id": 1})).unwrap(); + // stopped while the searched photo is being read: that one read is all it does + assert!(h.step_until(T, |_| drive.reads.load(Ordering::SeqCst) == 1), "the first read never started"); + h.app.session.activity.cancel(rows(&h)[0].id).unwrap(); + drive.open.store(true, Ordering::SeqCst); + until_done(&mut h); + assert!(rows(&h).is_empty()); + assert_eq!(h.app.session.filter, Default::default()); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert!(h.app.ui.toast.is_none(), "{:?}", h.app.ui.toast); + assert_eq!(drive.reads.load(Ordering::SeqCst), 1); + + let r = h.app.run("library.findSimilar", json!({"id": 1})).unwrap(); + assert_eq!(r["count"], PHOTOS - 1, "what was read isn't read again"); + until_done(&mut h); + assert_eq!(drive.reads.load(Ordering::SeqCst), PHOTOS as usize); + assert_eq!(h.app.session.filter.only.len(), PHOTOS as usize / 2); +} + +/// Nothing to read: the answer comes on the next frames, with no photo read. +#[test] +fn a_search_of_what_is_known_reads_nothing() { + let (mut h, drive) = app_on_a_slow_drive(Duration::ZERO); + drive.open.store(true, Ordering::SeqCst); + h.app.run("library.findSimilar", json!({"id": 1})).unwrap(); + until_done(&mut h); + let reads = drive.reads.load(Ordering::SeqCst); + let r = h.app.run("library.findSimilar", json!({"id": 2})).unwrap(); + assert_eq!(r["count"], 0); + until_done(&mut h); + assert_eq!(drive.reads.load(Ordering::SeqCst), reads); + assert!(h.app.session.filter.only.contains(&PhotoId(2))); +} + +/// A refusal comes at once and starts nothing (no photo active, a photo the library doesn't have). +#[test] +fn a_search_that_cannot_start_leaves_no_row() { + let (mut h, drive) = app_on_a_slow_drive(Duration::ZERO); + assert!(h.app.run("library.findSimilar", json!({"id": 999})).is_err()); + assert!(h.app.run("library.findSimilar", json!({"id": "x"})).is_err()); + assert!(h.app.tasks.is_empty()); + assert!(rows(&h).is_empty()); + assert_eq!(drive.reads.load(Ordering::SeqCst), 0); +} + +/// A searched photo that can't be read says so where the person is. +#[test] +fn a_photo_that_cannot_be_read_says_so() { + let (mut h, _) = app_on_a_slow_drive(Duration::ZERO); + h.app.session.media.file_loader = Some(Arc::new(|path: &str, _| Err(format!("{path}: gone")))); + h.app.run("library.findSimilar", json!({"id": 1})).unwrap(); + until_done(&mut h); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("can't read the photo"), "{toast}"); + assert_eq!(h.app.session.filter, Default::default()); +} + +/// A control request is answered when the search ends, with the command's `{photos}`, and the window +/// takes its frames meanwhile. An agent's view isn't switched for it. +#[test] +fn a_control_request_is_answered_when_the_search_ends() { + let (mut h, drive) = app_on_a_slow_drive(GUARD); + h.app.ui.view = ViewMode::Detail; + let t0 = Instant::now(); + let rx = ask(&mut h); + for _ in 0..3 { + h.step(); + } + assert!(t0.elapsed() < FRAMES, "the frames waited for the files: {:?}", t0.elapsed()); + assert!(rx.try_recv().is_err(), "no answer before the files are read"); + assert_eq!(rows(&h).len(), 1); + drive.open.store(true, Ordering::SeqCst); + let reply = answer(&mut h, &rx); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["photos"][0]["id"], 1, "{reply}"); + assert_eq!(reply["result"]["photos"].as_array().unwrap().len(), PHOTOS as usize / 2, "{reply}"); + assert_eq!(drive.on_ui.load(Ordering::SeqCst), 0); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert!(h.app.ui.toast.is_none(), "{:?}", h.app.ui.toast); +} + +/// `activity.cancel` stops the search; the request is told so. +#[test] +fn stopping_answers_the_request_with_an_error() { + let (mut h, drive) = app_on_a_slow_drive(GUARD); + let rx = ask(&mut h); + for _ in 0..3 { + h.step(); + } + let id = rows(&h)[0].id; + let r = h.request("engine.execute", json!({"command": "activity.cancel", "params": {"id": id}}), T); + assert_eq!(r["ok"], true, "{r}"); + drive.open.store(true, Ordering::SeqCst); + let reply = answer(&mut h, &rx); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("stopped")), "{reply}"); + assert_eq!(h.app.session.filter, Default::default()); +} + +/// A request that comes while a search runs is refused at once, not left to wait. +#[test] +fn a_second_request_is_refused_at_once() { + let (mut h, drive) = app_on_a_slow_drive(GUARD); + let first = ask(&mut h); + for _ in 0..3 { + h.step(); + } + let second = ask(&mut h); + let refused = answer(&mut h, &second); + assert_eq!(refused["ok"], false, "{refused}"); + assert!(refused["error"].as_str().is_some_and(|e| e.contains("already running")), "{refused}"); + drive.open.store(true, Ordering::SeqCst); + assert_eq!(answer(&mut h, &first)["ok"], true); +} + +/// A caller whose request timed out asks again: what was read stays known, so it isn't read again. +#[test] +fn a_request_nobody_waits_for_any_more_leaves_what_it_read() { + let (mut h, drive) = app_on_a_slow_drive(Duration::ZERO); + drive.open.store(true, Ordering::SeqCst); + drop(ask(&mut h)); + // (a frame takes the request and starts the search) + h.step(); + until_done(&mut h); + let reads = drive.reads.load(Ordering::SeqCst); + assert_eq!(reads, PHOTOS as usize); + let rx = ask(&mut h); + assert_eq!(answer(&mut h, &rx)["ok"], true); + assert_eq!(drive.reads.load(Ordering::SeqCst), reads); +} + +/// Another library was opened: the search stops, and neither its answer nor what it read reaches the +/// new library. +#[test] +fn another_library_stops_the_search_and_gets_nothing_from_it() { + let (mut h, drive) = app_on_a_slow_drive(GUARD); + let rx = ask(&mut h); + for _ in 0..3 { + h.step(); + } + let dir = std::env::temp_dir().join(format!("lc-ui-similar-other-{}", std::process::id())); + h.app.session.open_library(&dir, false).unwrap(); + h.step(); + drive.open.store(true, Ordering::SeqCst); + let reply = answer(&mut h, &rx); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("another library")), "{reply}"); + let read = drive.reads.load(Ordering::SeqCst); + assert!(read < PHOTOS as usize, "it stopped reading: {read}"); + assert!(h.app.session.signatures.is_empty()); + assert_eq!(h.app.session.filter, Default::default()); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 0c9281dfc2..6f2b003477 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -61,6 +61,7 @@ guard.detail(&file_name); | Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state | | Find Missing Photos | `crates/ui-egui/src/tasks.rs` | a generic background task with a row (`kind: Some(..)`) | | Reload from Disk | `crates/ui-egui/src/reload.rs`, `crates/engine/src/cmd/reload.rs` | a command split into plan / probe / apply so the app reads the files on a worker thread (`tasks::spawn_tracked`: progress and `Cancel::Flag`); the result is committed through `Session::execute_fn` (undo step, save, XMP auto-write), and dropped for a photo a later-started reload has already updated; `wait: true` runs it in place | +| Find Similar Photos | `crates/ui-egui/src/find_similar.rs`, `crates/engine/src/cmd/similar.rs` | a command split into plan / measure / apply, whose reads are kept when it is stopped; a control request is answered when it ends (`Outcome::Later`) | | Reading focus points | `crates/engine/src/focus_scan.rs` | a worker paced by the UI's per-frame pump (`focus.pump {pace}`); `Cancel::Flag`; commits in batches without undo steps | ## Testing diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5e851e721d..02f51706d4 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-30 · **Change:** minor (LR-BEHAV-ACCESS: the Flatpak may talk to the accessibility bus; LR-LIB-ALBUM: album commands leave out photos the library no longer has and record nothing when they add nothing; LR-BEHAV-DRAGDROP: a right-click on a text field while photos are carried from the grid opens no Cut / Copy / Paste menu; LR-BEHAV-DRAGDROP: a right-click while photos are carried from the grid opens no context menu; LR-LIB-VIRTUALCOPY: default numbering is preflighted across the requested batch and explicit names bypass suffix allocation; LR-BEHAV-DRAGDROP: only the primary button drags photos out of the grid and drops them; KEYC-RATING: deleting a keyword while Enable Filters has the filter put aside no longer leaves an empty one there; LR-BEHAV-DRAGDROP: a drop's destination is named with its parent when another folder or album has the same name; LR-VIEW-FILMSTRIP: photos drag from the filmstrip to the same sidebar targets as from the grid; LRC-LIB-LIBFOLDERS: a deep row's count follows its name in a narrow panel; LR-FILT-FLAG / LRC-LIB-FILTERBAR: the flag toggles combine, Picked + Unflagged, #2588; LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP, LR-VIEW-COMPARE, LR-VIEW-SURVEY, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: the active photo's selection ring shows on light themes and in Compare, Survey and the face tiles, and needs a loaded thumbnail; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-META-LOCATION: Info GPS text is fully validated before metadata changes; LR-MAP-TRACKLOG decodes GPX character references in point fields; malformed references error; CDATA stays literal; LR-BEHAV-ACCESS: the Thumbnail size control is a slider to screen readers; LR-BEHAV-ACCESS: the Detail photo is named with its file name; LR-VIEW-PHOTOGRID: the grid's empty-view message names its shortcut as this platform writes it and as it is bound; LR-BEHAV-ACCESS: the grid's date headers are named as they read; LR-BEHAV-ACCESS: the sidebar's disclosure triangles name their row; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌘⌥⇧K Set Keyword Shortcut and ⇧K; KEYC-METADATA: ⌘⇧K / Ctrl+Shift+K Edit Keywords; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LRC-LIB-FOLDERS: Local's built-in places (Pictures, Desktop, Downloads, Home) offer no Rename Folder… or Move Folder To…, and `folder.rename` / `folder.move` refuse the home folder and its Pictures, Desktop and Downloads; LRC-LIB-CATALOG-SWITCH: a library's catalog.snap or folder given at launch opens that library; LR-IMP-ADD-DIALOG / LR-IMP-MOVE: checked rename-sequence exhaustion; LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left, and for each photo's sidecar, up to 16 files at a time; LRC-LIB-LIBFOLDERS: Don't ask again in the Move Photos question is kept only when the move started; a refused move leaves the question on; LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket (#2237, part of #2056); import loupe remains open; counts unchanged; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-MERGE-HDR / LR-MERGE-PANO / LR-MERGE-HDRPANO: the dialog makes a new preview each time it opens, also after a failed one, #2708; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LRC-LIB-MISSING: `library.missing` from the control channel checks the files on a worker thread instead of the UI thread; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-CROP-AUTO: Auto straighten reads the vertical vanishing point and the horizon; LR-META-EXIF-FULL: reject extra EXIF clock components so invalid capture-time candidates fall through to valid metadata; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-META-XMP / LRC-MAP-TRACKLOG: malformed ISO times are not projected or matched; LR-IMP-FORMATS: JPEG ICC APP2 chunk validation; LR-BEHAV-TITLEBAR: a dialog leaves the title bar undimmed, as a macOS sheet does; LR-BEHAV-ACCESS: the bars are toolbars and the panels landmarks for screen readers; LR-BEHAV-ACCESS: every dialog and prompt is exposed as a dialog (a modal one, an alert dialog for the prompts); LR-BEHAV-ACCESS: icon-only buttons are named by their label, with the shortcut as the description; MENU-HELP-HELP: on macOS the Help menu is registered with the application only once the menu bar is installed (muda ignores the call before), so AppKit knows which menu is Help; LR-BEHAV-ACCESS: a slider's value field is named ` value`, with how to use it as its description; MENU-BAR: every shortcut the app shows on macOS lists its modifiers ⌃⌥⇧⌘, as the menu bar does; MENU-BAR: on macOS two menu rows that run the same command each get their own native row and label (File ▸ Export… and File ▸ Export with Preset ▸ Custom…; File ▸ New Album… and Photo ▸ Add to Album ▸ New Album…); LR-BEHAV-DIALOGS: a one-line prompt that is refused stays open and says why, and a New Folder on Disk says it was made; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-VIEW-HISTOGRAM: the shadow and highlight clipping buttons take their tile and chevron from the theme, so the chevron reads in every theme; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-MASK-BRUSH: extreme brush segments skip the intermediate dabs; LR-MASK-BRUSH: with the mask overlay hidden, the stroke being painted no longer tints the photo) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (LRC-LIB-VISUALSEARCH: Find Similar Photos reads the photos on a worker thread, so a large library no longer freezes the window; LR-BEHAV-ACCESS: the Flatpak may talk to the accessibility bus; LR-LIB-ALBUM: album commands leave out photos the library no longer has and record nothing when they add nothing; LR-BEHAV-DRAGDROP: a right-click on a text field while photos are carried from the grid opens no Cut / Copy / Paste menu; LR-BEHAV-DRAGDROP: a right-click while photos are carried from the grid opens no context menu; LR-LIB-VIRTUALCOPY: default numbering is preflighted across the requested batch and explicit names bypass suffix allocation; LR-BEHAV-DRAGDROP: only the primary button drags photos out of the grid and drops them; KEYC-RATING: deleting a keyword while Enable Filters has the filter put aside no longer leaves an empty one there; LR-BEHAV-DRAGDROP: a drop's destination is named with its parent when another folder or album has the same name; LR-VIEW-FILMSTRIP: photos drag from the filmstrip to the same sidebar targets as from the grid; LRC-LIB-LIBFOLDERS: a deep row's count follows its name in a narrow panel; LR-FILT-FLAG / LRC-LIB-FILTERBAR: the flag toggles combine, Picked + Unflagged, #2588; LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP, LR-VIEW-COMPARE, LR-VIEW-SURVEY, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: the active photo's selection ring shows on light themes and in Compare, Survey and the face tiles, and needs a loaded thumbnail; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-META-LOCATION: Info GPS text is fully validated before metadata changes; LR-MAP-TRACKLOG decodes GPX character references in point fields; malformed references error; CDATA stays literal; LR-BEHAV-ACCESS: the Thumbnail size control is a slider to screen readers; LR-BEHAV-ACCESS: the Detail photo is named with its file name; LR-VIEW-PHOTOGRID: the grid's empty-view message names its shortcut as this platform writes it and as it is bound; LR-BEHAV-ACCESS: the grid's date headers are named as they read; LR-BEHAV-ACCESS: the sidebar's disclosure triangles name their row; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌘⌥⇧K Set Keyword Shortcut and ⇧K; KEYC-METADATA: ⌘⇧K / Ctrl+Shift+K Edit Keywords; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LRC-LIB-FOLDERS: Local's built-in places (Pictures, Desktop, Downloads, Home) offer no Rename Folder… or Move Folder To…, and `folder.rename` / `folder.move` refuse the home folder and its Pictures, Desktop and Downloads; LRC-LIB-CATALOG-SWITCH: a library's catalog.snap or folder given at launch opens that library; LR-IMP-ADD-DIALOG / LR-IMP-MOVE: checked rename-sequence exhaustion; LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left, and for each photo's sidecar, up to 16 files at a time; LRC-LIB-LIBFOLDERS: Don't ask again in the Move Photos question is kept only when the move started; a refused move leaves the question on; LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket (#2237, part of #2056); import loupe remains open; counts unchanged; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-MERGE-HDR / LR-MERGE-PANO / LR-MERGE-HDRPANO: the dialog makes a new preview each time it opens, also after a failed one, #2708; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LRC-LIB-MISSING: `library.missing` from the control channel checks the files on a worker thread instead of the UI thread; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-CROP-AUTO: Auto straighten reads the vertical vanishing point and the horizon; LR-META-EXIF-FULL: reject extra EXIF clock components so invalid capture-time candidates fall through to valid metadata; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-META-XMP / LRC-MAP-TRACKLOG: malformed ISO times are not projected or matched; LR-IMP-FORMATS: JPEG ICC APP2 chunk validation; LR-BEHAV-TITLEBAR: a dialog leaves the title bar undimmed, as a macOS sheet does; LR-BEHAV-ACCESS: the bars are toolbars and the panels landmarks for screen readers; LR-BEHAV-ACCESS: every dialog and prompt is exposed as a dialog (a modal one, an alert dialog for the prompts); LR-BEHAV-ACCESS: icon-only buttons are named by their label, with the shortcut as the description; MENU-HELP-HELP: on macOS the Help menu is registered with the application only once the menu bar is installed (muda ignores the call before), so AppKit knows which menu is Help; LR-BEHAV-ACCESS: a slider's value field is named ` value`, with how to use it as its description; MENU-BAR: every shortcut the app shows on macOS lists its modifiers ⌃⌥⇧⌘, as the menu bar does; MENU-BAR: on macOS two menu rows that run the same command each get their own native row and label (File ▸ Export… and File ▸ Export with Preset ▸ Custom…; File ▸ New Album… and Photo ▸ Add to Album ▸ New Album…); LR-BEHAV-DIALOGS: a one-line prompt that is refused stays open and says why, and a New Folder on Disk says it was made; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-VIEW-HISTOGRAM: the shadow and highlight clipping buttons take their tile and chevron from the theme, so the chevron reads in every theme; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; 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; LR-META-XMP: fewer false unimported-XMP warnings; LR-MASK-BRUSH: extreme brush segments skip the intermediate dabs; LR-MASK-BRUSH: with the mask overlay hidden, the stroke being painted no longer tints the photo) · **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 @@ -716,7 +716,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm one, × to hide for the session, or ↑ / hovered-or-focused A key to confirm all currently shown non-hidden suggestions at or above that face's score, including ties, in one undo step). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | -| LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | +| LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`), `crates/engine/src/cmd/similar.rs`, `crates/ui-egui/src/find_similar.rs` | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`); the app reads the photos on a worker thread under an activity row with ✕ | | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`), `crates/ui-egui/src/missing_sweep.rs`, `crates/ui-egui/src/panels/left.rs` (`locate_missing_item`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike; `library.missing` from the control channel or `mcp --connect` checks the files on a worker thread (a "Missing Photos" row in the activity stack, ✕ stops it) and answers when it ends, so a large library on a network share never stalls the window; the same request meanwhile shares the running check | | LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable; the DNG is decoded and compared with the raw data, written to a temp file, synced, read back and only then given a free name (never replacing a file) — a failed write leaves no DNG and the photo on its raw (issue #106) | | LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (a row in the activity stack, ✕ to stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | @@ -1026,3 +1026,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-29 | patch | LR-BEHAV-TITLEBAR: a dialog's backdrop leaves the integrated title bar undimmed, as a macOS sheet does (the bar's controls stay inactive, a click on it is not a click outside the dialog, dragging its empty space still moves the window); status unchanged (🟡) | | 2026-10-29 | minor | LR-MASK-OBJECTS: an Object / Describe selection's stored grid is enlarged along the photo's edges (fast guided filter of its logits by log luminance, a 1.5-cell window), so a raised Exposure no longer draws an outline around the subject (#3272); existing AI masks are 1–2 px tighter at edges; status unchanged (🟡) | | 2026-10-30 | patch | LR-MASK-COLORRANGE: a colour-range mask keeps its first 64 samples however it is loaded (sidecar `lc:settings`, presets, `develop.merge`, `render --settings`, catalog and history), not only through `mask.add` / `mask.update`, so a document with thousands can't stall a render or lose the GPU device (issue #2556); status unchanged (✅) | +| 2026-10-31 | patch | LRC-LIB-VISUALSEARCH: Find Similar Photos reads the photos on a worker thread under an activity row with ✕ instead of on the UI thread, where a large library left the window not responding (#3468). A stopped search leaves the view as it was and keeps what it read; a control request gets the same `{photos}` when the search ends. | diff --git a/docs/whats-new.md b/docs/whats-new.md index 98a0ac7157..c4ee6a4c44 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,17 @@ # What's new in LightCraft +## 2026-10-31 + +### Find Similar Photos no longer freezes the window +- The first search reads every photo in the library, and it did that on the thread that draws the window, so in a large + library the app stopped responding and Windows offered to close it (issue #3468). The photos are now read in the + background: a "Find Similar Photos" row in the activity stack counts them, you can keep working meanwhile, and its ✕ + stops the search. A stopped search leaves the view as it was, and the next one goes on from what was read. The menu + item is off while a search runs, and opening another library stops it. When a search ends the grid shows the photos it + found, also from the Photo menu, and a message says how many. `library.findSimilar` from the control channel and + `mcp --connect` still gets `{photos}`, now when the search ends; a caller that stops waiting (the control server + gives a request 60 s) can ask again, and what was read isn't read again. + ## 2026-10-30 ### The Flatpak may talk to the accessibility bus From 612f2add7422483fcb8bd777c2b5dc74bc8eac83 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:04:05 +0100 Subject: [PATCH 2/2] LRC-LIB-VISUALSEARCH: what's new says a second request waits for the running search to end --- docs/whats-new.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index d5421722c1..f30185bfd2 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -18,7 +18,8 @@ item is off while a search runs, and opening another library stops it. When a search ends the grid shows the photos it found, also from the Photo menu, and a message says how many. `library.findSimilar` from the control channel and `mcp --connect` still gets `{photos}`, now when the search ends; a caller that stops waiting (the control server - gives a request 60 s) can ask again, and what was read isn't read again. + gives a request 60 s) can ask again once that search has ended (while it runs, a new request is refused as already + running), and what was read isn't read again. ## 2026-10-30