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 07e1ca7f6..22891a10d 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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) }; diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index aec87d3a9..f527eacb5 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); @@ -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); } diff --git a/crates/ui-egui/src/tests_filmstrip.rs b/crates/ui-egui/src/tests_filmstrip.rs index 6aedb93ac..bf572c1d6 100644 --- a/crates/ui-egui/src/tests_filmstrip.rs +++ b/crates/ui-egui/src/tests_filmstrip.rs @@ -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); diff --git a/crates/ui-egui/src/tests_grid.rs b/crates/ui-egui/src/tests_grid.rs index 713db7ecf..1506ff06e 100644 --- a/crates/ui-egui/src/tests_grid.rs +++ b/crates/ui-egui/src/tests_grid.rs @@ -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"); }