From 084cd3ddd36c68c145fdc00ed08b270c32885ca4 Mon Sep 17 00:00:00 2001 From: helgehelge123 <117832631+helgehelge123@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:06:27 +0200 Subject: [PATCH] Stack badges: filmstrip badge expands/collapses, badges work on unselected stacks Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/detail.rs | 17 +++++++++++-- crates/ui-egui/src/panels/grid.rs | 13 +++++++++- crates/ui-egui/src/tests_filmstrip.rs | 35 ++++++++++++++++++++++++--- crates/ui-egui/src/tests_grid.rs | 18 ++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 94462e6dc..07e1ca7f6 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2643,13 +2643,26 @@ 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" + }); + 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 c6365b784..aec87d3a9 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1035,7 +1035,18 @@ pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: paint(p, Rect::from_min_size(br.min + vec2(6.0, 3.0), vec2(14.0, 14.0)), Icon::Stack, Color32::WHITE); p.galley(pos2(br.min.x + 24.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); if resp.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. The +/// photo is selected first when it isn't, as `stack.toggle` acts on a selected stack. +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 959c93484..6aedb93ac 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,32 @@ 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)); + 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 7aafb212e..713db7ecf 100644 --- a/crates/ui-egui/src/tests_grid.rs +++ b/crates/ui-egui/src/tests_grid.rs @@ -172,3 +172,21 @@ fn an_unedited_raws_thumbnail_skips_the_sensor_decode() { assert!(!skips_the_real_render(false, true)); assert!(!skips_the_real_render(false, false)); } + +#[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"); +}