Merge PR #2300: LR-LIB-STACK: the filmstrip stack badge expands/collapses the stack; badges work on unselected stacks

crates/ui-egui/src/panels/grid.rs: badge keeps main's double-click guard and calls #2300's toggle_stack

crates/ui-egui/src/tests_grid.rs: both sides' badge tests

docs/parity-checklist.md: header and revision rows from both sides; rows take both changes
This commit is contained in:
aiongg
2026-10-22 02:39:32 +00:00
7 changed files with 126 additions and 14 deletions
+21 -6
View File
@@ -188,12 +188,27 @@ pub fn specs() -> Vec<CommandSpec> {
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<StackId> = s.catalog.stacks().map(|st| st.id).collect();
set_collapsed(s, all, false, "Expand All Stacks")
+24
View File
@@ -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(..))));
}
+16 -2
View File
@@ -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() {
+10 -1
View File
@@ -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);
}
}
+33 -3
View File
@@ -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));
}
+19
View File
@@ -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");
}
+3 -2
View File
@@ -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 (🟡) |