stack.toggle: ids name the stacks without selecting them; badges keep the selection and are named for screen readers

A badge click no longer selects the photo first: stack.toggle opts into explicit_targets, so
`ids` skip the selection guard and the command refuses ids with no stacked photo. Compare keeps
its pair. Both badges announce Expand Stack / Collapse Stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
helgehelge123
2026-10-19 23:05:15 +02:00
co-authored by Claude Opus 5.5
parent 35be24bcbe
commit fb3babf8be
6 changed files with 51 additions and 11 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(..))));
}
+1
View File
@@ -2654,6 +2654,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
} 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) };
+3 -5
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);
@@ -1039,12 +1040,9 @@ pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid:
}
}
/// A click on a stack badge (grid or filmstrip): expand / collapse that photo's stack. The
/// photo is selected first when it isn't, as `stack.toggle` acts on a selected stack.
/// 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 !app.session.selection.contains(id) {
let _ = app.run("library.select", json!({"ids": [id.0]}));
}
if let Err(e) = app.run("stack.toggle", json!({"ids": [id.0]})) {
app.toast(ctx, e);
}
+1
View File
@@ -51,6 +51,7 @@ fn filmstrip_stack_badge_expands_and_collapses_the_stack() {
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);
+1
View File
@@ -189,4 +189,5 @@ fn grid_stack_badge_toggles_a_stack_that_is_not_selected() {
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");
}