Browse / github.com/storytold/lightcraft / patch
LR-BEHAV-MULTISELECT: click selection as in Lightroom Classic (frame click selects alone, Shift/Ctrl keep the active photo)
LR-BEHAV-MULTISELECT: click selection as in Lightroom Classic (frame click selects alone, Shift/Ctrl keep the active photo) ## The problem Culling a wedding means working through about 3,000 raws (Canon EOS R5 C, 45 MP CR3; Sony A9 as second body). The grid and the filmstrip are where you select and compare, so how a click treats the selection matters on every frame. Two things got in the way, on real weddings: - **Shift-click and Ctrl-click moved the active photo.** Extending a selection jumped the loupe to the photo just clicked, so the photo you were judging disappeared. - **No quick way back to one photo.** When several photos (or all of them) were selected, nothing matched Lightroom Classic's habit:
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- OneSlips (imported from pull request #3030, open)
- Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0- Size
- +412 −35 in 12 files
- Review
- pending
- Shared
- 2026-10-28
- Id
d74fc00e2a1c83cacd6dc379
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/library.rs crates/engine/src/cmd/library/selection.rs crates/engine/src/cmd/library/specs.rs crates/engine/src/cmd/library/tests_select.rs crates/engine/src/lib.rs crates/engine/src/view.rs crates/ui-egui/src/panels/detail/filmstrip.rs crates/ui-egui/src/panels/grid.rs crates/ui-egui/src/tests.rs crates/ui-egui/src/tests/frame_click.rs crates/ui-egui/src/tests/panels.rs docs/parity-checklist.md
@@ -36,3 +36,7 @@ mod enable_filters_tests; #[cfg(test)] #[path = "library/tests_source_album_filter.rs"] mod source_album_filter_tests;++#[cfg(test)]+#[path = "library/tests_select.rs"]+mod tests_select;@@ -238,7 +238,12 @@ pub(super) fn select_by(s: &mut Session, p: &Value) -> Result<Value> { } let active = s.selection.active.filter(|a| ids.contains(a)).or(ids.first().copied()); let n = ids.len();- s.selection = Selection { ids, active };+ let selection = Selection { ids, active };+ if selection != s.selection {+ // chosen by flag, rating or label: no longer "every photo of the view"+ s.all_selected = None;+ }+ s.selection = selection; Ok(json!({"selected": n})) } @@ -329,7 +329,7 @@ pub fn specs() -> Vec<CommandSpec> { "Select Photos", [], None,- "{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library",+ "{ids: [id], active?: id, mode?: replace|add|toggle|range|click} — every id must be a photo of the library. As in Lightroom: toggle (⌘/Ctrl-click) and range (Shift-click, from the active photo) keep the active photo; click (a plain click) on a photo of a selection of several makes it the active one and keeps the selection, elsewhere it selects that photo alone", always, |s, p| { let ids = crate::cmd::photo_ids(p, "library.select")?.unwrap_or_default();@@ -342,6 +342,7 @@ pub fn specs() -> Vec<CommandSpec> { return Err(bad("library.select", format!("no such photo {}", id.0))); } s.end_interaction()?;+ let before = s.selection.clone(); match mode { "replace" => { s.selection = Selection { ids: ids.clone(), active: active.or(ids.first().copied()) };@@ -355,6 +356,11 @@ pub fn specs() -> Vec<CommandSpec> { } } "toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)),+ "click" => {+ if let Some(id) = ids.first() {+ s.selection.click(*id);+ }+ } "range" => { let vis = s.visible_cloned(); if let Some(id) = ids.first() {@@ -363,16 +369,30 @@ pub fn specs() -> Vec<CommandSpec> { } other => return Err(bad("library.select", format!("unknown mode `{other}`"))), }+ // a selection changed by hand is no longer "every photo of the view" (a click that only makes another+ // selected photo the active one keeps it)+ if s.selection.ids != before.ids {+ s.all_selected = None;+ } s.active_mask = None; s.active_spot = None; Ok(json!({"selected": s.selection.ids.len()})) } ),- cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| {- let vis = s.visible_cloned();- s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis };- Ok(json!({"selected": s.selection.ids.len()}))- }),+ cmd!(+ "library.selectAll",+ "Select All",+ ["Edit"],+ Some("Cmd+A"),+ "{} — every photo of the view; photos that join the view later (an import still running) are selected too, until the selection or the view changes",+ always,+ |s, _| {+ let vis = s.visible_cloned();+ s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis };+ s.all_selected = s.visible_view.clone();+ Ok(json!({"selected": s.selection.ids.len()}))+ }+ ), cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| { Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default()) }),@@ -422,6 +442,7 @@ pub fn specs() -> Vec<CommandSpec> { cmd!("library.selectNone", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| { let a = s.selection.active; s.selection = Selection { ids: a.into_iter().collect(), active: a };+ s.all_selected = None; ok() }), cmd!("library.next", "Next Photo", [], Some("Right"), "{}", always, |s, _| step(s, 1)),new file mode 100644@@ -0,0 +1,166 @@+//! Selecting as in Lightroom: Shift / ⌘-click keep the active photo, a click inside the selection+//! keeps it, and Select All keeps up with a view that grows.+use lightcraft_catalog::{Op, Photo, PhotoId, Source};+use serde_json::json;++use crate::{Result, Session};++fn add(s: &mut Session, id: u64) -> Result<()> {+ let photo = Photo::new(+ PhotoId(id),+ Source::File { path: format!("/lightcraft-select/{id:03}.jpg") },+ &format!("{id:03}.jpg"),+ "JPEG",+ 40,+ 30,+ "2026-10-22",+ );+ s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;+ Ok(())+}++fn session(n: u64) -> Result<Session> {+ let mut s = Session::new();+ for id in 1..=n {+ add(&mut s, id)?;+ }+ s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?;+ Ok(s)+}++#[test]+fn shift_and_ctrl_clicks_keep_the_active_photo_and_a_click_inside_keeps_the_selection() -> Result<()> {+ let mut s = session(10)?;+ s.execute("library.select", &json!({"ids": [3], "mode": "click"}))?;+ s.execute("library.select", &json!({"ids": [7], "mode": "range"}))?;+ assert_eq!(s.selection.active, Some(PhotoId(3)), "Shift-click: the loupe stays on 3");+ assert_eq!(s.selection.ids.len(), 5);+ s.execute("library.select", &json!({"ids": [9], "mode": "toggle"}))?;+ assert_eq!(s.selection.active, Some(PhotoId(3)), "⌘-click: the loupe stays on 3");+ assert_eq!(s.selection.ids.len(), 6);+ s.execute("library.select", &json!({"ids": [5], "mode": "click"}))?;+ assert_eq!((s.selection.active, s.selection.ids.len()), (Some(PhotoId(5)), 6), "a click inside: 5 active, the set stays");+ s.execute("library.select", &json!({"ids": [10], "mode": "click"}))?;+ assert_eq!((s.selection.active, s.selection.ids.len()), (Some(PhotoId(10)), 1), "a click outside replaces it");+ Ok(())+}++#[test]+fn select_all_takes_in_the_photos_that_join_the_view() -> Result<()> {+ let mut s = session(5)?;+ s.execute("library.selectAll", &json!({}))?;+ assert_eq!(s.selection.ids.len(), 5);+ // an import still running adds photos to the view+ for id in 6..=9 {+ add(&mut s, id)?;+ }+ assert_eq!(s.visible().len(), 9);+ assert_eq!(s.selection.ids.len(), 9, "every photo of the view stays selected");+ // a selection changed by hand is the user's: new photos don't join it+ s.execute("library.select", &json!({"ids": [2], "mode": "toggle"}))?;+ add(&mut s, 10)?;+ assert_eq!(s.visible().len(), 10);+ assert_eq!(s.selection.ids.len(), 8);+ // nor do they in another view+ s.execute("library.selectAll", &json!({}))?;+ s.execute("library.filter", &json!({"text": "00"}))?;+ add(&mut s, 11)?;+ let _ = s.visible();+ assert_eq!(s.selection.ids.len(), 10);+ Ok(())+}++#[test]+fn select_all_ends_with_another_view_and_does_not_come_back_with_its_view() -> Result<()> {+ let mut s = session(2)?;+ s.execute("library.selectAll", &json!({}))?;+ // narrowed by hand to one photo, then a view that shows only that photo+ s.execute("library.select", &json!({"ids": [1]}))?;+ s.execute("library.filter", &json!({"text": "001"}))?;+ assert_eq!(s.visible().len(), 1);+ assert_eq!(s.selection.ids, vec![PhotoId(1)]);+ // back to the first view: photo 2 stays deselected (the old Select All is over)+ s.execute("library.filter", &json!({"text": ""}))?;+ assert_eq!(s.visible().len(), 2);+ assert_eq!(s.selection.ids, vec![PhotoId(1)]);+ Ok(())+}++#[test]+fn a_click_that_changes_nothing_keeps_select_all_and_select_by_ends_it() -> Result<()> {+ let mut s = session(3)?;+ s.execute("library.selectAll", &json!({}))?;+ let active = s.selection.active.map(|a| a.0);+ // a plain click on the active photo of the selection changes nothing: still "every photo of the view"+ s.execute("library.select", &json!({"ids": [active], "mode": "click"}))?;+ add(&mut s, 4)?;+ assert_eq!(s.visible().len(), 4);+ assert_eq!(s.selection.ids.len(), 4, "the new photo joins Select All");+ // narrowed by flag, then every photo picked again by flag: chosen by hand, new photos don't join+ s.execute("library.selectBy", &json!({"flag": "pick"}))?;+ s.execute("library.selectBy", &json!({"flag": "none"}))?;+ assert_eq!(s.selection.ids.len(), 4);+ add(&mut s, 5)?;+ assert_eq!(s.visible().len(), 5);+ assert_eq!(s.selection.ids.len(), 4, "Select All ended with the selection by flag");+ Ok(())+}++#[test]+fn a_click_that_only_moves_the_active_photo_keeps_select_all() -> Result<()> {+ let mut s = session(3)?;+ s.execute("library.selectAll", &json!({}))?;+ let other = s.selection.ids.iter().find(|id| Some(**id) != s.selection.active).map(|id| id.0);+ // a plain click on another photo of the selection (grid or filmstrip): it becomes active, the set stays+ s.execute("library.select", &json!({"ids": [other], "mode": "click"}))?;+ assert_eq!(s.selection.active.map(|a| a.0), other);+ add(&mut s, 4)?;+ assert_eq!(s.visible().len(), 4);+ assert_eq!(s.selection.ids.len(), 4, "the new photo joins Select All");+ Ok(())+}++/// Missing Photos fills in as the background checks find files gone: a photo found gone after Select All joins the+/// selection (the checks are not another view).+#[test]+fn select_all_in_missing_photos_takes_in_a_photo_the_checks_find_gone() -> Result<()> {+ use std::sync::Arc;+ use std::time::{Duration, Instant};+ let mut s = session(2)?;+ // every file is gone (none of these paths exist); answered by a worker, as in the app+ s.media.availability.set_probe(Arc::new(|_: &str| false));+ s.media.availability.run_in_background(Arc::new(|| {}));+ s.source = crate::LibrarySource::Missing;+ let settle = |s: &mut Session, n: usize| {+ let until = Instant::now() + Duration::from_secs(10);+ while s.visible().len() < n && Instant::now() < until {+ std::thread::sleep(Duration::from_millis(5));+ }+ s.visible().len()+ };+ assert_eq!(settle(&mut s, 2), 2);+ s.execute("library.selectAll", &json!({}))?;+ add(&mut s, 3)?;+ assert_eq!(settle(&mut s, 3), 3, "the check finds photo 3 gone");+ assert_eq!(s.selection.ids.len(), 3, "photo 3 joins Select All");+ Ok(())+}++/// An arrow key after Select All moves the selection on: Select All is over, and a photo that leaves the view and+/// comes back is not selected again (CodeRabbit on #3030).+#[test]+fn select_all_ends_when_an_arrow_key_moves_the_selection() -> Result<()> {+ let mut s = session(2)?;+ s.execute("library.filter", &json!({"flag": "none"}))?;+ s.execute("library.selectAll", &json!({}))?;+ s.execute("library.select", &json!({"ids": [1], "active": 1, "mode": "click"}))?;+ s.execute("library.next", &json!({}))?;+ assert_eq!(s.selection.ids, vec![PhotoId(2)]);+ // photo 1 leaves the view (rejected), then comes back+ s.execute("photo.flag", &json!({"flag": "reject", "ids": [1]}))?;+ assert_eq!(s.visible().len(), 1);+ s.execute("photo.flag", &json!({"flag": "none", "ids": [1]}))?;+ assert_eq!(s.visible().len(), 2);+ assert_eq!(s.selection.ids, vec![PhotoId(2)], "photo 1 is not selected again");+ Ok(())+}@@ -240,6 +240,10 @@ pub struct Session { /// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]). visible: Arc<[PhotoId]>, visible_key: Option<(u64, String)>,+ /// The view [`Session::visible`] last worked out, told apart as a held view is: by source, filter, sort and+ /// smart-album rules, not by the background checks of Missing Photos that fill it in. What Select All+ /// (`Session::all_selected`) is tied to.+ pub(crate) visible_view: Option<String>, /// The minute used for a cached view whose rules depend on the session clock. visible_clock_minute: Option<String>, /// A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop@@ -250,6 +254,11 @@ pub struct Session { /// place in the order ([`Session::visible`]). It is also what a held view holds ([`Session::hold_view`]). /// Another source, filter or sort is another view, and so is a smart album whose rules were changed. base: Option<(String, u64, Vec<PhotoId>)>,+ /// Select All (`library.selectAll`) was the last change to the selection, in the view named here: while the+ /// selection is still exactly what the view shows, photos that join the view (an import or the finishing+ /// queue still adding them) join the selection too (⌘A during an import had selected 312 of 569). Any other+ /// change to the selection, and any other view, ends it.+ pub(crate) all_selected: Option<String>, /// How many times the view followed an edit from the list it had, and how many times it was worked out from /// every photo (`ui.inspect` → `perf`): what tells a slow library from a slow edit. pub view_followed: u64,@@ -456,9 +465,11 @@ impl Session { selection: Selection::default(), visible: Vec::new().into(), visible_key: None,+ visible_view: None, visible_clock_minute: None, hold_view: false, base: None,+ all_selected: None, view_followed: 0, view_worked_out: 0, #[cfg(test)]@@ -1142,7 +1153,7 @@ impl Session { }; // what the view shows now is what the next edit is followed from, and what it holds until it is // refreshed (taken before stacks are arranged, so a photo folded into a stack is held with it)- self.base = (!whole || holds).then(|| (view, self.catalog.revision, visible.clone()));+ self.base = (!whole || holds).then(|| (view.clone(), self.catalog.revision, visible.clone())); if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() { // the shown folder lost its last photo (deleted, moved, removed): everything, not // an empty grid under the name of a folder that is gone from the sidebar@@ -1189,9 +1200,23 @@ impl Session { if self.source != LibrarySource::RecentlyDeleted { visible = self.catalog.arrange_stacks(&visible); }+ // another view ends Select All: coming back to its view later must not select what was deselected meanwhile+ // (the view as a held view tells it apart: a photo the Missing Photos checks add keeps Select All)+ // and so does a selection that is no longer every photo the view showed (an arrow key moved it on)+ if self.all_selected.as_deref().is_some_and(|k| k != view) || self.selection.ids[..] != self.visible[..] {+ self.all_selected = None;+ }+ if self.all_selected.as_deref() == Some(view.as_str()) && self.selection.ids[..] == self.visible[..] && visible[..] != self.visible[..] {+ // still "every photo of this view": the photos that came in are selected too+ self.selection.ids = visible.clone();+ if self.selection.active.is_none_or(|a| !visible.contains(&a)) {+ self.selection.active = visible.first().copied();+ }+ } self.visible = visible.into(); self.visible_gen = VISIBLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.visible_key = Some(key);+ self.visible_view = Some(view); self.visible_clock_minute = clocked.then(|| clock_minute(&now).to_owned()); } &self.visible@@ -105,18 +105,25 @@ impl Selection { pub fn contains(&self, id: PhotoId) -> bool { self.ids.contains(&id) }+ /// ⌘/Ctrl-click, as in Lightroom: `id` joins or leaves the selection and the active photo (the+ /// one the loupe shows) stays. Taking the active photo itself out makes the nearest selected+ /// photo before it (else after it) the active one; a photo added to an empty selection is it. pub fn toggle(&mut self, id: PhotoId) { if let Some(i) = self.ids.iter().position(|x| *x == id) { self.ids.remove(i); if self.active == Some(id) {- self.active = self.ids.last().copied();+ self.active = self.ids.get(i.saturating_sub(1)).or(self.ids.first()).copied(); } } else { self.ids.push(id);- self.active = Some(id);+ if self.active.is_none_or(|a| !self.ids.contains(&a)) {+ self.active = Some(id);+ } } }- /// Shift-click: select the range between the active photo and `id` in `order`.+ /// Shift-click, as in Lightroom: the photos between the active one and `id` in `order` join the+ /// selection, and the active photo stays (the loupe keeps showing it). With no active photo in+ /// `order`, `id` alone is selected. pub fn extend_to(&mut self, id: PhotoId, order: &[PhotoId]) { let anchor = self.active.unwrap_or(id); let (Some(a), Some(b)) = (order.iter().position(|x| *x == anchor), order.iter().position(|x| *x == id)) else {@@ -124,12 +131,23 @@ impl Selection { return; }; let (lo, hi) = (a.min(b), a.max(b));- for x in &order[lo..=hi] {+ for x in order.get(lo..=hi).unwrap_or_default() { if !self.ids.contains(x) { self.ids.push(*x); } }- self.active = Some(id);+ if self.active.is_none() {+ self.active = Some(id);+ }+ }+ /// A plain click, as in Lightroom: on a photo of a selection of several it becomes the active+ /// photo and the selection stays; anywhere else the photo alone is selected.+ pub fn click(&mut self, id: PhotoId) {+ if self.ids.len() > 1 && self.contains(id) {+ self.active = Some(id);+ } else {+ *self = Selection::single(id);+ } } } @@ -253,4 +271,48 @@ mod selection_state_tests { assert_eq!(s.state_of(ids[1]), SelectionState::Selected); assert_eq!(s.state_of(PhotoId(9)), SelectionState::NotSelected); }++ fn ids(n: u64) -> Vec<PhotoId> {+ (1..=n).map(PhotoId).collect()+ }++ /// Client note 19a: Shift-click selects the range and leaves the active photo where it was.+ #[test]+ fn shift_click_extends_the_range_and_keeps_the_active_photo() {+ let order = ids(10);+ let mut s = Selection::single(order[2]);+ s.extend_to(order[6], &order);+ assert_eq!(s.active, Some(order[2]), "the loupe stays on the photo it showed");+ assert_eq!(s.ids.len(), 5);+ assert!(order[2..=6].iter().all(|id| s.contains(*id)));+ }++ /// Client note 19b: ⌘/Ctrl-click adds and removes photos without changing the active one.+ #[test]+ fn ctrl_click_adds_and_removes_without_moving_the_active_photo() {+ let order = ids(5);+ let mut s = Selection::single(order[0]);+ s.toggle(order[3]);+ assert_eq!(s.active, Some(order[0]));+ assert!(s.contains(order[3]));+ s.toggle(order[3]);+ assert_eq!((s.active, s.ids.len()), (Some(order[0]), 1));+ // the active photo itself taken out: another selected photo takes its place+ s.toggle(order[2]);+ s.toggle(order[0]);+ assert_eq!(s.active, Some(order[2]));+ }++ /// Client note 19c: a click inside the selection makes that photo active and keeps the+ /// selection; a click outside it replaces the selection.+ #[test]+ fn a_click_inside_the_selection_keeps_it() {+ let order = ids(8);+ let mut s = Selection::single(order[1]);+ s.extend_to(order[5], &order);+ s.click(order[3]);+ assert_eq!((s.active, s.ids.len()), (Some(order[3]), 5), "the whole set stays");+ s.click(order[7]);+ assert_eq!(s, Selection::single(order[7]), "a photo outside it replaces the selection");+ } }@@ -116,6 +116,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim); } let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0));+ // the picture: a click outside it (on the frame) selects this photo alone (note 1)+ let mut picture = img_area; crate::panels::grid::request_thumb(app, *id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 8); let thumb = app.renderer.thumb(*id); if thumb.is_none() {@@ -128,6 +130,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let [tw, th] = tex.size; let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32); let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s));+ picture = fr; p.image(tex.tex.id(), fr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); if sel { p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside);@@ -163,6 +166,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { } } }+ register(ui, format!("filmpic:{}", id.0), picture); if resp.clicked() { let m = ui.input(|i| i.modifiers); let compare = app.ui.view == crate::state::ViewMode::Compare;@@ -173,14 +177,9 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { // ⌘/Ctrl- and Shift-clicks change the selection; the pair on screen stays (#187) crate::panels::compare::select_with_modifier(app, *id, !m.command) } else {- let mode = if m.command {- "toggle"- } else if m.shift {- "range"- } else {- "replace"- };- app.run("library.select", json!({"ids": [id.0], "mode": mode}))+ // on the picture: the active photo changes, the set stays (note 19c); on the frame: this photo alone+ let on_picture = resp.interact_pointer_pos().is_none_or(|at| picture.contains(at));+ app.run("library.select", json!({"ids": [id.0], "mode": crate::panels::grid::click_mode(m, on_picture)})) }; if compare && let Err(e) = r { app.toast(ui.ctx(), e);@@ -763,6 +763,21 @@ pub(crate) fn skips_the_real_render(embedded_is_final: bool, from_embedded: bool /// Rendered thumbnails replacing embedded previews: after everything on screen. pub const BACKGROUND_THUMB_PRIORITY: u32 = 3; +/// The `library.select` mode of a click on a photo cell, as in Lightroom Classic: Shift = range, ⌘/Ctrl = toggle; a+/// plain click on the picture of a selected photo makes it the active one and keeps the selection (note 19c), a plain+/// click on the frame around the picture selects that photo alone (test notes 2026-10-24 note 1).+pub(crate) fn click_mode(m: egui::Modifiers, on_picture: bool) -> &'static str {+ if m.shift {+ "range"+ } else if m.command {+ "toggle"+ } else if on_picture {+ "click"+ } else {+ "replace"+ }+}+ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square: bool, onscreen: bool, ppp: f32) { let t = Tokens::get(ui.ctx()); let Some(photo) = app.session.catalog.photo(id).cloned() else { return };@@ -794,12 +809,15 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square r }; // thumbnail+ // the picture itself: a click on it keeps a selection, a click on the frame around it selects this photo alone (note 1)+ let picture: Rect; let size = thumb_px(img_rect.width().max(img_rect.height()), ppp); request_thumb(app, id, size, if onscreen { 10 } else { 5 }); if let Some(tex) = app.renderer.thumb(id) { let [tw, th] = tex.size; // Preserve the decoded preview's ratio when it differs from the catalog dimensions. let fit = super::detail::fit_texture_rect(img_rect, [tw, th]);+ picture = fit; p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); if active { p.rect_stroke(fit.expand(if square { 2.0 } else { 0.0 }), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);@@ -808,6 +826,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } } else { let ph = img_rect.shrink(if square { 20.0 } else { 0.0 });+ picture = ph; p.rect_filled(ph, 0.0, t.skeleton); // not drawn yet (or unreadable): the selection is still visible if active {@@ -928,19 +947,14 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } return; }+ register(ui, format!("thumbpic:{}", id.0), picture); if resp.clicked() { let m = ui.input(|i| i.modifiers);- let mode = if m.shift {- "range"- } else if m.command {- "toggle"- } else {- "replace"- };- let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode}));+ let on_picture = resp.interact_pointer_pos().is_none_or(|at| picture.contains(at));+ let _ = app.run("library.select", json!({"ids": [id.0], "mode": click_mode(m, on_picture)})); } if resp.double_clicked() {- let _ = app.run("library.select", json!({"ids": [id.0]}));+ let _ = app.run("library.select", json!({"ids": [id.0], "mode": "click"})); let _ = app.run("view.detail", json!({})); } // drag the selection (this photo joins it, or replaces it when it wasn't selected)@@ -27,6 +27,7 @@ mod error_kinds; mod filmstrip; mod focus_point; mod folder_drop;+mod frame_click; mod grading; mod grid; mod guided_guides;new file mode 100644@@ -0,0 +1,79 @@+//! A plain click on the frame around a selected thumbnail (outside the picture) selects that photo alone; a click on+//! the picture keeps the selection and makes it the active photo, as in Lightroom Classic (test notes 2026-10-24+//! note 1). Grid and filmstrip.++use std::time::Duration;++use serde_json::json;++use crate::headless::Headless;+use crate::state::ViewMode;+use crate::{LightcraftApp, Services};++const T: Duration = Duration::from_secs(20);+const SETTLE: Duration = Duration::from_secs(120);++fn widget(h: &Headless, id: &str) -> egui::Rect {+ h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} drawn"))+}++fn click_at(h: &mut Headless, at: egui::Pos2) {+ h.events.push(egui::Event::PointerMoved(at));+ h.events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });+ h.step();+ h.events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });+ h.step();+ // the next click is apart from this one (no double-click)+ for _ in 0..40 {+ h.step();+ }+ h.settle(SETTLE);+}++fn select_all(h: &mut Headless) {+ let r = h.request("engine.execute", json!({"command": "library.selectAll", "params": {}}), T);+ assert_eq!(r["ok"], true, "{r}");+ h.settle(SETTLE);+}++/// Clicks the picture and then the frame of the second photo's cell (`cell` / `pic` widget prefixes).+fn picture_keeps_frame_replaces(h: &mut Headless, cell: &str, pic: &str) {+ let ids = h.app.session.visible_cloned();+ assert!(ids.len() >= 3, "a few photos");+ let second = ids[1];+ select_all(h);+ let n = h.app.session.selection.ids.len();+ assert_eq!(n, ids.len());+ let picture = widget(h, &format!("{pic}{}", second.0));+ click_at(h, picture.center());+ assert_eq!(h.app.session.selection.ids.len(), n, "a click on the picture keeps the selection");+ assert_eq!(h.app.session.selection.active, Some(second), "and makes that photo the active one");+ let frame = widget(h, &format!("{cell}{}", second.0));+ let picture = widget(h, &format!("{pic}{}", second.0));+ // the frame above the picture (where the file name sits)+ let at = egui::pos2(frame.center().x, (frame.top() + picture.top()) / 2.0);+ assert!(frame.contains(at) && !picture.contains(at), "{at:?} on the frame: {frame:?} / {picture:?}");+ click_at(h, at);+ assert_eq!(h.app.session.selection.ids, vec![second], "a click on the frame selects that photo alone");+ assert_eq!(h.app.session.selection.active, Some(second));+}++#[test]+fn a_click_on_a_grid_cells_frame_selects_that_photo_alone() {+ let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });+ let mut h = Headless::new(app, [1200.0, 800.0], 1.0);+ h.app.ui.view = ViewMode::SquareGrid;+ h.app.ui.thumb_size = 200.0;+ h.settle(SETTLE);+ picture_keeps_frame_replaces(&mut h, "thumb:", "thumbpic:");+}++#[test]+fn a_click_on_a_filmstrip_cells_frame_selects_that_photo_alone() {+ let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });+ let mut h = Headless::new(app, [1200.0, 800.0], 1.0);+ let r = h.request("ui.set", json!({"view": "detail"}), T);+ assert_eq!(r["ok"], true, "{r}");+ h.settle(SETTLE);+ picture_keeps_frame_replaces(&mut h, "film:", "filmpic:");+}@@ -87,7 +87,7 @@ fn assert_inside_right_panel(h: &Headless, what: &str) { for (id, r) in &h.app.widgets { // widgets of the photo area (pins, filmstrip cells clipped at its edge) and of the tool // strip are outside on purpose- if ["panel:", "film:", "maskPin"].iter().any(|p| id.starts_with(p))+ if ["panel:", "film:", "filmpic:", "maskPin"].iter().any(|p| id.starts_with(p)) || r.bottom() <= panel.top() || r.right() <= panel.left() + 1.5 || r.left() >= panel.right() - 1.5@@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (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) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (LR-BEHAV-MULTISELECT, MENU-EDIT-SELECTALL, KEY-SELECTALL: Shift / ⌘-click keep the active photo, a click on the picture keeps the selection, a click on the frame selects that photo alone, Select All grows with the view; 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) · **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@@ -457,7 +457,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-BEHAV-AUTOSAVE | Instant autosave | P0 | ✅ | `crates/catalog/src/journal.rs`, `crates/engine/src/library.rs` | every change fsynced before the command returns; a failed write fails the command ("saved in memory but not written to disk"), keeps the change queued and retries; top-bar warning until saved; a write that fails part-way is cut back to the last whole record so the retry never reads back as damage (logs from older builds with such a fragment load in full); quitting saves queued changes in the closing snapshot even when the log can't be appended to; versioned catalog format (`journal::VERSION`): older libraries are upgraded on open, a library from a newer LightCraft is refused untouched ("written by a newer version"); a damaged or hand-edited snapshot that still reads is made consistent on open (`Catalog::repair`: records filed under another id, stacks of missing, repeated or too few photos, folders inside themselves, id counters behind the ids in use) instead of panicking or hanging later | | LR-BEHAV-UNDO | Global undo | P0 | ✅ | `cmd:edit.undo`, `crates/engine/src/tests.rs` (`rating_flag_undo_redo`), `crates/engine/src/tests/filesystem_history.rs` | covers ratings, albums, deletes, edits; partial file/XMP moves keep unfinished repairs across retries and discarded history; merged renames move directly to their final catalog path |-| LR-BEHAV-MULTISELECT | Multi-selection | P0 | ✅ | `cmd:library.select` (`replace`/`add`/`toggle`/`range`), `cmd:library.selectAll` | |+| LR-BEHAV-MULTISELECT | Multi-selection | P0 | ✅ | `cmd:library.select` (`replace`/`add`/`toggle`/`range`/`click`), `cmd:library.selectAll` | as in Lightroom Classic: Shift-click selects the range from the active photo and ⌘/Ctrl-click adds or removes a photo, both without changing the active photo or the loupe; a plain click on the picture of a photo inside a selection of several makes it the active photo and keeps the selection, a click on the frame around the picture (outside the image) selects that photo alone (`grid::click_mode`), a click outside the selection replaces it (grid, filmstrip, double-click); Select All also selects photos that join the view later (an import still running) until the selection or the view changes | | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | |@@ -523,7 +523,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-EDIT-COPYPASTE | Copy / paste (edit settings) | P0 | ✅ | `cmd:develop.copy`, `cmd:develop.paste` | | | MENU-EDIT-CHOOSECOPY | Choose Edit Settings to Copy… | P0 | ✅ | `cmd:dialog.copySettings` | Return copies, Esc cancels | | MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; only copied groups can be pasted; Return pastes, Esc cancels |-| MENU-EDIT-SELECTALL | Select All | P0 | ✅ | `cmd:library.selectAll` | |+| MENU-EDIT-SELECTALL | Select All | P0 | ✅ | `cmd:library.selectAll` | every photo of the view; photos that join the view afterwards (an import or the finishing queue still adding them) are selected too while the selection is untouched (⌘A during an import had selected 312 of 569 photos); collapsed stacks give their top photo | | MENU-EDIT-SELECTNONE | Select None | P0 | ✅ | `cmd:library.selectNone` | | | MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ✅ | `cmd:library.selectBy` | Edit → Select by: picks, rejects, unflagged, ★…★★★★★ and higher, unrated, colour labels; `add` extends the selection | | MENU-EDIT-FIND | Find… | P0 | ✅ | `cmd:view.focusSearch`, `crates/ui-egui/src/panels/topbar.rs` | ⌘F focuses the search field |@@ -614,7 +614,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-PHOTOSHOP | External editor — ⇧⌘E | P2 | ✅ | `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal` | ⇧⌘E as observed (Edit in PhotoCraft, LightCraft's Photoshop); ⌥⌘E the other editor; the export dialog is ⇧E | | KEY-ROTATE | Rotate — ⌘[ / ⌘] | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | | KEY-ZOOM | Zoom in / out — ⌘= / ⌘− | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `crates/ui-egui/src/tests/zoom_keys.rs` | the keys zoom the photo only: egui's own interface zoom on ⌘= / ⌘− / ⌘0 (Ctrl on Windows and Linux, where no native menu bar took the keys first) is off (issue #566) |-| KEY-SELECTALL | Select all — ⌘A | P0 | ✅ | `cmd:library.selectAll` | |+| KEY-SELECTALL | Select all — ⌘A | P0 | ✅ | `cmd:library.selectAll` | every photo of the view; photos that join the view afterwards (an import or the finishing queue still adding them) are selected too while the selection is untouched (⌘A during an import had selected 312 of 569 photos); collapsed stacks give their top photo | | KEY-SELECTNONE | Select none — ⌘D | P0 | ✅ | `cmd:library.selectNone` | secondary binding (primary ⌘⇧A) | | KEYC-SELECTONLYACTIVE | Select only active photo — ⌘⇧D / Ctrl+Shift+D | P1 | 🟡 | `cmd:library.selectOnlyActive`, `crates/ui-egui/src/shortcuts.rs` (`select_only_active_*` tests) | Edit → Select Only Active Photo; keeps the active photo, narrows the selection to it and leaves the edit Undo step alone; works in any view that has an active photo; rebindable in Help → Keyboard Shortcuts; text fields and dialogs keep their keys; covered by headless key tests, the macOS native-menu path is not verified | | KEY-PASTESELECTED | Paste selected — ⇧⌘V | P0 | ✅ | `cmd:dialog.pasteSettings` | |@@ -799,6 +799,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | LR-BEHAV-MULTISELECT, MENU-EDIT-SELECTALL, KEY-SELECTALL: as in Lightroom Classic, Shift-click and ⌘/Ctrl-click keep the active photo and the loupe; a plain click on the picture of a selected photo makes it the active one and keeps the selection (`library.select` mode `click`), a click on the frame around the picture selects that photo alone; Select All also takes photos that join the view while an import runs; status unchanged (✅) | | 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged | | 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) |