Skip to main content

teksilo_scene/items/
group.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`GroupItem`] — labelled box / logical AT container.
5//!
6//! Visually a labelled rectangle with optional fill, stroke, and
7//! inline label. Without any chrome it's a logical-only container
8//! that announces itself to AT but draws nothing — the lightweight
9//! analogue of an `A11yGroup`.
10//!
11//! ## When to use
12//!
13//! Use [`GroupItem`] when you need to:
14//! - Draw a visible boundary box around a cluster of related items
15//!   (e.g. a lane in a Kanban board, an "Act 1" region on a corkboard).
16//! - Provide a named AT group that screen readers announce without
17//!   any visible chrome — call [`GroupItem::label`] but omit `fill`
18//!   and `stroke`, leaving `is_visual()` false.
19//!
20//! ## Example
21//!
22//! ```ignore
23//! use teksilo_scene::{Scene, GroupItem};
24//! use teksilo_canvas::{Point, Rect};
25//! use teksilo_tokens::Color;
26//! use teksilo_i18n::lit;
27//!
28//! let mut scene = Scene::new();
29//! // A visible "Act 1" box with a rounded border.
30//! let group = GroupItem::new(Rect::new(0.0, 0.0, 400.0, 600.0))
31//!     .label(lit!("Act 1"))
32//!     .show_label(true)
33//!     .stroke(Color::new(0.6, 0.6, 0.6, 1.0), 1.5)
34//!     .corner_radius(8.0);
35//! let _id = scene.add_item(group, Point::new(20.0, 20.0));
36//! ```
37
38use accesskit::Role;
39use teksilo_canvas::{Canvas, Point, Rect, StrokeStyle};
40use teksilo_core::accessibility::AccessNodeBuilder;
41use teksilo_core::binding::BindingLevel;
42use teksilo_core::build_context::BuildContext;
43use teksilo_core::color_prop::ColorProp;
44use teksilo_core::widget_id::WidgetId;
45use teksilo_tokens::Color;
46
47use crate::item::{SceneItem, SceneItemA11yContext, SceneItemPaintContext};
48use crate::items::{AccessSubtreeMode, ItemA11yOverrides};
49use teksilo_i18n::LocalizedString;
50
51/// A group container with optional fill / stroke / inline label, in
52/// local item coordinates.
53///
54/// Visually, GroupItem renders a labelled box around its members.
55/// Logically, it's the AT-grouping primitive: with no chrome and a
56/// label set, it announces itself to AT but draws nothing.
57#[derive(Debug)]
58pub struct GroupItem {
59    local_bounds: Rect,
60    label: Option<String>,
61    show_label: bool,
62    fill: Option<ColorProp>,
63    stroke: Option<(ColorProp, StrokeStyle)>,
64    corner_radius: f32,
65    label_inset: (f32, f32),
66    label_color: Option<ColorProp>,
67    a11y: ItemA11yOverrides,
68}
69
70impl GroupItem {
71    /// A group covering `local_bounds` in local coordinates. No
72    /// chrome by default — call `fill` / `stroke` / `show_label` to
73    /// give it visible outline / background / inline label.
74    pub fn new(local_bounds: Rect) -> Self {
75        Self {
76            local_bounds,
77            label: None,
78            show_label: false,
79            fill: None,
80            stroke: None,
81            corner_radius: 0.0,
82            label_inset: (8.0, 4.0),
83            label_color: None,
84            a11y: ItemA11yOverrides::default(),
85        }
86    }
87
88    /// Human-readable label, used as the default AT group name and
89    /// (when `show_label` is enabled) rendered inline at top-leading.
90    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
91        let ls: LocalizedString = label.into();
92        self.label = Some(ls.resolve_now());
93        self
94    }
95
96    /// Render the label inline at paint time.
97    pub fn show_label(mut self, show: bool) -> Self {
98        self.show_label = show;
99        self
100    }
101
102    /// Override the inset of the inline label from the local origin.
103    pub fn label_inset(mut self, dx: f32, dy: f32) -> Self {
104        self.label_inset = (dx, dy);
105        self
106    }
107
108    /// Override the inline label colour. Defaults to the stroke colour if set,
109    /// else `Color::BLACK`. Accepts a plain [`Color`], a theme role, or a
110    /// reactive signal.
111    pub fn label_color(mut self, color: impl Into<ColorProp>) -> Self {
112        self.label_color = Some(color.into());
113        self
114    }
115
116    /// Background fill colour. Accepts a plain [`Color`], a theme role, a
117    /// `Signal<Color>`, or a `Signal<Role>` — resolved against the active theme
118    /// at paint time.
119    pub fn fill(mut self, color: impl Into<ColorProp>) -> Self {
120        self.fill = Some(color.into());
121        self
122    }
123
124    /// Border stroke (colour + scene-coord pixel width) — scales with zoom.
125    pub fn stroke(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
126        self.stroke = Some((color.into(), StrokeStyle::solid(width.max(0.0))));
127        self
128    }
129
130    /// Cosmetic border stroke: holds a constant **device-pixel** width at any
131    /// zoom. With `corner_radius > 0` the rounded outline goes through the SDF
132    /// cosmetic path; otherwise `stroke_rect` emits four `CosmeticLine` edges
133    /// (one per side), which are hard-edged and crisp at any zoom.
134    pub fn stroke_cosmetic(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
135        self.stroke = Some((color.into(), StrokeStyle::hairline(width.max(0.0))));
136        self
137    }
138
139    /// Border stroke with an explicit [`StrokeStyle`] — dashed / dotted /
140    /// custom caps. E.g. `.stroke_styled(color, StrokeStyle::dashed(2.0, 6.0, 4.0))`
141    /// for a dashed lane boundary.
142    pub fn stroke_styled(mut self, color: impl Into<ColorProp>, style: StrokeStyle) -> Self {
143        self.stroke = Some((color.into(), style));
144        self
145    }
146
147    /// Rounded corners for fill and stroke. Default `0.0`.
148    pub fn corner_radius(mut self, radius: f32) -> Self {
149        self.corner_radius = radius.max(0.0);
150        self
151    }
152
153    /// Whether the group has any visual chrome configured.
154    pub fn is_visual(&self) -> bool {
155        self.fill.is_some() || self.stroke.is_some() || self.show_label
156    }
157
158    crate::items::item_a11y_builders!();
159}
160
161impl SceneItem for GroupItem {
162    fn local_bounds(&self) -> Rect {
163        self.local_bounds
164    }
165
166    fn set_local_bounds(&mut self, bounds: Rect) {
167        self.local_bounds = bounds;
168    }
169
170    fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>) {
171        if !self.is_visual() {
172            return;
173        }
174        let lb = self.local_bounds;
175        if let Some(prop) = &self.fill {
176            let fill = prop.resolve(ctx.theme, ctx.enabled);
177            if self.corner_radius > 0.0 {
178                canvas.fill_rounded_rect(
179                    lb,
180                    teksilo_tokens::CornerRadius::uniform(self.corner_radius),
181                    fill,
182                );
183            } else {
184                canvas.fill_rect(lb, fill);
185            }
186        }
187        if let Some((prop, style)) = &self.stroke {
188            let color = prop.resolve(ctx.theme, ctx.enabled);
189            if self.corner_radius > 0.0 {
190                canvas.stroke_rounded_rect(
191                    lb,
192                    teksilo_tokens::CornerRadius::uniform(self.corner_radius),
193                    color,
194                    style.clone(),
195                );
196            } else {
197                canvas.stroke_rect(lb, color, style.clone());
198            }
199        }
200        if self.show_label
201            && let Some(label) = &self.label
202        {
203            // Label colour: explicit override, else the stroke colour, else
204            // black — each resolved against the active theme.
205            let color = self
206                .label_color
207                .as_ref()
208                .or_else(|| self.stroke.as_ref().map(|(c, _)| c))
209                .map(|p| p.resolve(ctx.theme, ctx.enabled))
210                .unwrap_or(Color::BLACK);
211            let (dx, dy) = self.label_inset;
212            let label_bounds = Rect::new(
213                lb.x + dx,
214                lb.y + dy,
215                (lb.width - 2.0 * dx).max(0.0),
216                (lb.height - 2.0 * dy).max(0.0),
217            );
218            canvas.draw_text(
219                label,
220                label_bounds,
221                &teksilo_tokens::TextStyle::default(),
222                color,
223            );
224        }
225    }
226
227    fn set_fill(&mut self, fill: Option<ColorProp>) -> bool {
228        self.fill = fill;
229        true
230    }
231
232    fn set_stroke(&mut self, stroke: Option<(ColorProp, StrokeStyle)>) -> bool {
233        self.stroke = stroke;
234        true
235    }
236
237    fn register_bindings(&self, ctx: &mut BuildContext, view_id: WidgetId) {
238        let registry = ctx.binding_registry();
239        if let Some(p) = &self.fill {
240            p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
241        }
242        if let Some((p, _)) = &self.stroke {
243            p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
244        }
245        if let Some(p) = &self.label_color {
246            p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
247        }
248    }
249
250    /// Non-visual GroupItems pass clicks through to items beneath.
251    /// Visual groups (with fill / stroke / inline label) AABB-hit-test
252    /// so apps can wire group-level click handlers.
253    fn shape_contains(&self, local_pt: Point) -> bool {
254        if self.is_visual() {
255            self.local_bounds.contains(local_pt)
256        } else {
257            false
258        }
259    }
260
261    /// Override the default AABB-only snapshot: logical-only groups
262    /// (no fill, no stroke, no inline label) must MISS for dispatch
263    /// so clicks fall through to items beneath. Without this
264    /// override the snapshot would AABB-hit and capture every event
265    /// over the group's rect, blocking the items it contains.
266    fn clone_shape_test(&self) -> Box<dyn Fn(Point, f32) -> bool + 'static> {
267        let is_visual = self.is_visual();
268        let bounds = self.local_bounds;
269        Box::new(move |p, _view_scale| is_visual && bounds.contains(p))
270    }
271
272    fn thumbnail_color(&self) -> Color {
273        // Visual groups: fill dominates, then stroke (role-based colours have
274        // no theme here and fall through). Logical groups are invisible —
275        // paint as fully transparent so minimap consumers can suppress them.
276        if let Some(c) = crate::items::fill_or_stroke_hint(self.fill.as_ref(), self.stroke.as_ref())
277        {
278            return c;
279        }
280        if self.is_visual() {
281            return Color::new(0.6, 0.6, 0.6, 1.0);
282        }
283        Color::new(0.0, 0.0, 0.0, 0.0)
284    }
285
286    fn label(&self) -> Option<String> {
287        self.label.clone()
288    }
289
290    fn accessibility(&self, builder: &mut AccessNodeBuilder, _ctx: &SceneItemA11yContext) {
291        builder.set_role(Role::Group);
292        if let Some(label) = self.label() {
293            builder.set_name(label);
294        }
295        self.a11y.apply(builder);
296    }
297
298    fn access_subtree_mode(&self) -> AccessSubtreeMode {
299        self.a11y.subtree_mode()
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use teksilo_canvas::Transform2D;
307    use teksilo_i18n::lit;
308
309    #[test]
310    fn group_item_does_not_hit_test_through_aabb() {
311        let g = GroupItem::new(Rect::new(0.0, 0.0, 1000.0, 1000.0));
312        assert!(!g.shape_contains(Point::new(500.0, 500.0)));
313    }
314
315    #[test]
316    fn group_item_default_is_not_visual() {
317        let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0));
318        assert!(!g.is_visual());
319    }
320
321    #[test]
322    fn group_item_with_fill_is_visual_and_hit_tests() {
323        let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0)).fill(Color::RED);
324        assert!(g.is_visual());
325        assert!(g.shape_contains(Point::new(50.0, 50.0)));
326        assert!(!g.shape_contains(Point::new(150.0, 50.0)));
327    }
328
329    #[test]
330    fn group_item_with_stroke_only_is_visual() {
331        let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0)).stroke(Color::BLACK, 1.0);
332        assert!(g.is_visual());
333        assert!(g.shape_contains(Point::new(50.0, 50.0)));
334    }
335
336    #[test]
337    fn group_item_with_label_only_is_not_visual() {
338        let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0)).label(lit!("Act 1"));
339        assert!(!g.is_visual());
340        assert!(!g.shape_contains(Point::new(50.0, 50.0)));
341    }
342
343    #[test]
344    fn group_item_with_show_label_is_visual() {
345        let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0))
346            .label(lit!("Act 1"))
347            .show_label(true);
348        assert!(g.is_visual());
349        assert!(g.shape_contains(Point::new(50.0, 50.0)));
350    }
351
352    #[test]
353    fn group_item_visual_paint_emits_draws() {
354        let invisible = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0));
355        let visible = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0))
356            .fill(Color::RED)
357            .stroke(Color::BLACK, 2.0)
358            .corner_radius(8.0);
359
360        let theme = teksilo_core::presets::intui::light();
361        let ctx = SceneItemPaintContext::new(Transform2D::identity(), None, &theme);
362
363        let mut c1 = teksilo_canvas::Canvas::new();
364        invisible.paint(&mut c1, &ctx);
365        let f1 = c1.into_render_frame();
366        assert!(f1.draw_order.is_empty(), "invisible group emitted draws");
367
368        let mut c2 = teksilo_canvas::Canvas::new();
369        visible.paint(&mut c2, &ctx);
370        let f2 = c2.into_render_frame();
371        assert!(!f2.draw_order.is_empty(), "visible group emitted no draws");
372    }
373
374    #[test]
375    fn group_item_stroke_styled_stores_dash_pattern() {
376        // #5: a dashed lane boundary keeps its pattern.
377        let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0))
378            .stroke_styled(Color::BLACK, StrokeStyle::dashed(2.0, 6.0, 4.0));
379        let (_, style) = g.stroke.as_ref().expect("stroke set");
380        assert!(style.dash_pattern.is_some());
381    }
382
383    #[test]
384    fn group_item_role_fill_resolves_against_theme() {
385        // #1/#2: a role fill resolves against the ctx theme.
386        use teksilo_tokens::SurfaceRole;
387        let theme = teksilo_core::presets::intui::light();
388        let expected = ColorProp::from(SurfaceRole::Container).resolve(&theme, true);
389        let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0)).fill(SurfaceRole::Container);
390        let ctx = SceneItemPaintContext::new(Transform2D::identity(), None, &theme);
391        let mut c = teksilo_canvas::Canvas::new();
392        g.paint(&mut c, &ctx);
393        assert!(
394            c.into_render_frame()
395                .decorations
396                .iter()
397                .any(|d| d.color == expected.to_array())
398        );
399    }
400}