Stack badges: filmstrip badge expands/collapses, badges work on unselected stacks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
helgehelge123
2026-10-19 10:11:00 +02:00
co-authored by Claude Opus 5.5
parent d999e382ba
commit 084cd3ddd3
4 changed files with 77 additions and 6 deletions
+15 -2
View File
@@ -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() {
+12 -1
View File
@@ -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);
}
}
+32 -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,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));
}
+18
View File
@@ -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");
}