diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs index 24cc2061c..50807e7cf 100644 --- a/crates/engine/src/cmd/organize.rs +++ b/crates/engine/src/cmd/organize.rs @@ -188,12 +188,27 @@ pub fn specs() -> Vec { Ok(json!({"changed": commit_ops(s, "Split Stack", vec![op])?})) } ), - cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| { - let stacks = stacks_of(s, &s.targets(p)?); - let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false); - let collapsed = bool_or(p, "collapsed", !first); - set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" }) - }), + // `ids` names the stacks' photos without selecting them (a click on a stack badge) + CommandSpec { + explicit_targets: true, + ..cmd!( + "stack.toggle", + "Expand/Collapse Stack", + ["Photo", "Stack"], + Some("S"), + "{ids?, collapsed?: bool} — the stacks of the named photos (else of the selection)", + in_stack, + |s, p| { + let stacks = stacks_of(s, &s.targets(p)?); + if stacks.is_empty() { + return Err(bad("stack.toggle", "none of the photos is in a stack")); + } + let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false); + let collapsed = bool_or(p, "collapsed", !first); + set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" }) + } + ) + }, cmd!("stack.expandAll", "Expand All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| { let all: Vec = s.catalog.stacks().map(|st| st.id).collect(); set_collapsed(s, all, false, "Expand All Stacks") diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index 344838432..ea0414256 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -356,3 +356,27 @@ fn rename_huge_start_is_a_bad_param() { let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits + 1, "ids": ids})); assert!(r.is_err(), "{r:?}"); } + +#[test] +fn stack_toggle_by_ids_leaves_the_selection_alone() { + let mut s = Session::with_demo(); + let vis = s.visible_cloned(); + let (loose, top, member) = (vis[0], vis[1], vis[2]); + s.execute("library.select", &json!({"ids": [top.0, member.0], "active": top.0})).unwrap(); + s.execute("stack.group", &json!({})).unwrap(); + // a loose photo is selected: the stack is named by `ids` (a badge click), not selected + s.execute("library.select", &json!({"ids": [loose.0]})).unwrap(); + let r = s.execute("stack.toggle", &json!({"ids": [member.0]})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + assert!(!s.catalog.stack_of(top).unwrap().collapsed); + assert_eq!((s.selection.ids.clone(), s.selection.active), (vec![loose], Some(loose)), "the selection is untouched"); + s.execute("stack.toggle", &json!({"ids": [top.0]})).unwrap(); + assert!(s.catalog.stack_of(top).unwrap().collapsed); + // naming only loose photos is refused without a commit, as is an unknown id + let commits = s.commits(); + assert!(s.execute("stack.toggle", &json!({"ids": [loose.0]})).is_err()); + assert!(s.execute("stack.toggle", &json!({"ids": [999_999]})).is_err()); + assert_eq!(s.commits(), commits); + // without `ids` the selection still decides (a loose photo selected: not available) + assert!(matches!(s.execute("stack.toggle", &json!({})), Err(crate::EngineError::Disabled(..)))); +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 8618cf2f6..7b68421ae 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2759,13 +2759,27 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { film_badges(p, &t, fr, ph); } if let Some(st) = app.session.catalog.stack_of(*id) { + let collapsed = st.collapsed; let text = - if st.collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) }; + if collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) }; let g = p.layout_no_wrap(text, t.semibold(9.5), Color32::WHITE); let br = Rect::from_min_size(fr.min + vec2(3.0, 3.0), vec2(g.size().x + 22.0, 15.0)); - p.rect_filled(br, 7.5, Color32::from_black_alpha(170)); + // like the grid's badge: a click expands / collapses the stack + let badge = ui.interact(br, egui::Id::new(("film-stack-badge", id.0)), Sense::click()).on_hover_text(if collapsed { + "Stack — click to expand" + } else { + "Stack — click to collapse" + }); + crate::widgets::announce(&badge, crate::i18n::tr(if collapsed { "Expand Stack" } else { "Collapse Stack" })); + register(ui.ctx(), format!("filmstack:{}", id.0), br); + let p = ui.painter(); + let fill = if badge.hovered() { Color32::from_black_alpha(230) } else { Color32::from_black_alpha(170) }; + p.rect_filled(br, 7.5, fill); crate::icons::paint(p, Rect::from_min_size(br.min + vec2(4.0, 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Stack, Color32::WHITE); p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + if badge.clicked() { + super::grid::toggle_stack(app, ui.ctx(), *id); + } } } if resp.clicked() { diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 2b7a6caa8..49b864389 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1026,6 +1026,7 @@ pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: } else { "Stack — click to collapse (S)" }); + crate::widgets::announce(&resp, crate::i18n::tr(if collapsed { "Expand Stack" } else { "Collapse Stack" })); register(ui.ctx(), format!("stack:{}", id.0), br); let fill = if resp.hovered() { Color32::from_black_alpha(220) } else { Color32::from_black_alpha(165) }; p.rect_filled(br, 10.0, fill); @@ -1036,7 +1037,15 @@ pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: p.galley(pos2(br.min.x + 24.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); // a double-click (Lightroom's way) toggles once: its second click would undo the first if resp.clicked() && !resp.double_clicked() { - let _ = app.run("stack.toggle", json!({"ids": [id.0]})); + toggle_stack(app, ui.ctx(), id); + } +} + +/// A click on a stack badge (grid or filmstrip): expand / collapse that photo's stack, selected or +/// not. The selection stays as it is (Compare keeps its pair). +pub fn toggle_stack(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { + if let Err(e) = app.run("stack.toggle", json!({"ids": [id.0]})) { + app.toast(ctx, e); } } diff --git a/crates/ui-egui/src/tests_filmstrip.rs b/crates/ui-egui/src/tests_filmstrip.rs index 959c93484..bf572c1d6 100644 --- a/crates/ui-egui/src/tests_filmstrip.rs +++ b/crates/ui-egui/src/tests_filmstrip.rs @@ -1,9 +1,9 @@ -//! Headless test of the filmstrip's file-name labels (#266): a name with multi-byte (CJK) -//! characters is drawn without panicking. +//! Headless tests of the filmstrip: file-name labels (#266) — a name with multi-byte (CJK) +//! characters is drawn without panicking — and its stack badges, which expand / collapse a stack. use std::time::Duration; -use lightcraft_catalog::Op; +use lightcraft_catalog::{Op, Stack, StackId}; use serde_json::json; use crate::headless::Headless; @@ -28,3 +28,33 @@ fn filmstrip_shows_a_cjk_file_name() { let film = format!("film:{}", first.0); assert!(h.app.widgets.iter().any(|(w, _)| *w == film), "{film} drawn in the filmstrip"); } + +#[test] +fn filmstrip_stack_badge_expands_and_collapses_the_stack() { + let mut session = lightcraft_engine::Session::with_demo(); + let vis = session.visible_cloned(); + let (loose, top, member) = (vis[0], vis[1], vis[2]); + let stack = Stack { id: StackId(1), photos: vec![top, member], collapsed: true }; + session.catalog.apply(Op::AddStack { stack }).unwrap(); + let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + // the loose photo is the one in the loupe: the stack isn't selected + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [loose.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(!h.app.session.visible_cloned().contains(&member), "collapsed: only the top shows"); + let badge = format!("filmstack:{}", top.0); + let r = h.request("ui.clickWidget", json!({"id": badge}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(!h.app.session.catalog.stack(StackId(1)).unwrap().collapsed, "a click on the badge expands the stack"); + assert!(h.app.session.visible_cloned().contains(&member)); + assert_eq!(h.app.session.selection.active, Some(loose), "the loupe keeps its photo"); + let r = h.request("ui.clickWidget", json!({"id": badge}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.session.catalog.stack(StackId(1)).unwrap().collapsed, "a second click collapses it"); + assert!(!h.app.session.visible_cloned().contains(&member)); +} diff --git a/crates/ui-egui/src/tests_grid.rs b/crates/ui-egui/src/tests_grid.rs index 6c85ecf59..af113c4ee 100644 --- a/crates/ui-egui/src/tests_grid.rs +++ b/crates/ui-egui/src/tests_grid.rs @@ -217,3 +217,22 @@ fn double_clicking_a_stack_badge_toggles_the_stack_once() { h.settle(SETTLE); assert!(!collapsed(&h), "a single click toggles it"); } + +#[test] +fn grid_stack_badge_toggles_a_stack_that_is_not_selected() { + let mut h = grid(ViewMode::PhotoGrid); + // a stack on screen, and a loose photo as the active one + let top = PhotoId(2001); + select(&mut h, top); + let vis = h.app.session.visible().to_vec(); + let i = vis.iter().position(|p| *p == top).unwrap(); + let loose = vis[i.saturating_sub(1)..].iter().copied().find(|p| h.app.session.catalog.stack_of(*p).is_none()).unwrap(); + select(&mut h, loose); + assert!(!h.app.session.selection.contains(top)); + assert!(widget(&h, &format!("stack:{}", top.0)).is_some(), "badge drawn"); + let r = h.request("ui.clickWidget", json!({"id": format!("stack:{}", top.0)}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.session.catalog.stack_of(top).unwrap().collapsed, "a click on an unselected stack's badge toggles it"); + assert_eq!((h.app.session.selection.ids.clone(), h.app.session.selection.active), (vec![loose], Some(loose)), "and leaves the selection alone"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 349ddec0c..90dd01248 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-20 · **Change:** minor (LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; earlier: LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; earlier: LRC-PRINT-PAGE: Print Page Options) · **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 @@ -107,7 +107,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE), with a user-triggered ↑ action or targeted A key to name all shown suggestions at or above one score in one undo step; with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | -| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse (a click or double-click on a grid badge toggles the stack once, #2233), auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | +| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges (a click expands / collapses the stack, selected or not), expand/collapse (a click or double-click on a grid badge toggles the stack once, #2233), auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests_album_remove.rs`, `crates/ui-egui/src/tests_album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | @@ -801,6 +801,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-19 | patch | LR-VIEW-ZOOM: the slow slider-drag verdict is described as per photo and control, as the code keys it; status unchanged | | 2026-10-15 | minor | LR-VIEW-ZOOM: a slow slider-drag verdict needs two drags over 20 ms and expires after 30 s (doubling to 240 s); hidden-window frame times are ignored; the slow set forgets its oldest entry, not all (issue #330); status unchanged | | 2026-10-15 | minor | LR-BEHAV-BG-TASKS: Reload from Disk reads its files on a worker thread with a cancellable activity row instead of freezing the window on a selection of raws; its result is committed through the command wrapper (XMP sidecars auto-written as before) and an older reload finishing late no longer overwrites a newer one; status unchanged (✅) | +| 2026-10-19 | patch | LR-LIB-STACK: the filmstrip's stack badge expands / collapses the stack like the grid's, and a badge click works on a stack that isn't selected (it is selected first). Statuses and feature counts unchanged. | | 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. | | 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) | | 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) |