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, 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::{AppearanceWrite, 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    ///
143    /// A dashing style costs a CPU-rasterized path: `Canvas` diverts it away
144    /// from the decoration-rect / SDF pipelines, which cannot express a dash.
145    /// Solid and cosmetic strokes keep the cheap route.
146    pub fn stroke_styled(mut self, color: impl Into<ColorProp>, style: StrokeStyle) -> Self {
147        self.stroke = Some((color.into(), style));
148        self
149    }
150
151    /// Rounded corners for fill and stroke. Default `0.0`.
152    pub fn corner_radius(mut self, radius: f32) -> Self {
153        self.corner_radius = radius.max(0.0);
154        self
155    }
156
157    /// Whether the group has any visual chrome configured.
158    pub fn is_visual(&self) -> bool {
159        self.fill.is_some() || self.stroke.is_some() || self.show_label
160    }
161
162    crate::items::item_a11y_builders!();
163}
164
165impl SceneItem for GroupItem {
166    fn local_bounds(&self) -> Rect {
167        self.local_bounds
168    }
169
170    fn set_local_bounds(&mut self, bounds: Rect) {
171        self.local_bounds = bounds;
172    }
173
174    fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>) {
175        if !self.is_visual() {
176            return;
177        }
178        let lb = self.local_bounds;
179        if let Some(prop) = &self.fill {
180            let fill = prop.resolve(ctx.theme, ctx.enabled);
181            if self.corner_radius > 0.0 {
182                canvas.fill_rounded_rect(
183                    lb,
184                    teksilo_tokens::CornerRadius::uniform(self.corner_radius),
185                    fill,
186                );
187            } else {
188                canvas.fill_rect(lb, fill);
189            }
190        }
191        if let Some((prop, style)) = &self.stroke {
192            let color = prop.resolve(ctx.theme, ctx.enabled);
193            if self.corner_radius > 0.0 {
194                canvas.stroke_rounded_rect(
195                    lb,
196                    teksilo_tokens::CornerRadius::uniform(self.corner_radius),
197                    color,
198                    style.clone(),
199                );
200            } else {
201                canvas.stroke_rect(lb, color, style.clone());
202            }
203        }
204        if self.show_label
205            && let Some(label) = &self.label
206        {
207            // Label colour: explicit override, else the stroke colour, else
208            // black — each resolved against the active theme.
209            let color = self
210                .label_color
211                .as_ref()
212                .or_else(|| self.stroke.as_ref().map(|(c, _)| c))
213                .map(|p| p.resolve(ctx.theme, ctx.enabled))
214                .unwrap_or(Color::BLACK);
215            let (dx, dy) = self.label_inset;
216            let label_bounds = Rect::new(
217                lb.x + dx,
218                lb.y + dy,
219                (lb.width - 2.0 * dx).max(0.0),
220                (lb.height - 2.0 * dy).max(0.0),
221            );
222            canvas.draw_text(
223                label,
224                label_bounds,
225                &teksilo_tokens::TextStyle::default(),
226                color,
227            );
228        }
229    }
230
231    fn set_fill(&mut self, fill: Option<ColorProp>) -> AppearanceWrite<ColorProp> {
232        AppearanceWrite::Accepted {
233            was: std::mem::replace(&mut self.fill, fill),
234        }
235    }
236
237    fn set_stroke(
238        &mut self,
239        stroke: Option<(ColorProp, StrokeStyle)>,
240    ) -> AppearanceWrite<(ColorProp, StrokeStyle)> {
241        AppearanceWrite::Accepted {
242            was: std::mem::replace(&mut self.stroke, stroke),
243        }
244    }
245
246    fn register_bindings(&self, ctx: &mut BuildContext, view_id: WidgetId) {
247        let registry = ctx.binding_registry();
248        if let Some(p) = &self.fill {
249            p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
250        }
251        if let Some((p, _)) = &self.stroke {
252            p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
253        }
254        if let Some(p) = &self.label_color {
255            p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
256        }
257    }
258
259    /// A visual group (fill / stroke / inline label) is its box, so apps can
260    /// wire group-level click handlers. A **logical-only** group is
261    /// [`ItemShape::none`](crate::ItemShape::none): it must let clicks fall
262    /// through to the items it contains, or it would capture every event over
263    /// its rectangle and block everything inside it.
264    fn shape(&self) -> crate::shape::ItemShape {
265        if self.is_visual() {
266            crate::shape::ItemShape::bounds(self.local_bounds)
267        } else {
268            crate::shape::ItemShape::none()
269        }
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::{Point, 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), 1.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), 1.0));
326        assert!(!g.shape().contains(Point::new(150.0, 50.0), 1.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), 1.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), 1.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), 1.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. Storage only — the
377        // test below is the one that says it reaches pixels.
378        let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0))
379            .stroke_styled(Color::BLACK, StrokeStyle::dashed(2.0, 6.0, 4.0));
380        let (_, style) = g.stroke.as_ref().expect("stroke set");
381        assert!(style.dash_pattern.is_some());
382    }
383
384    #[test]
385    fn group_item_dashed_stroke_reaches_the_frame_as_a_dashed_path() {
386        // Both branches: the square-cornered one used to emit four solid
387        // `DecorationRect` edges, the rounded one a dash-less `ShapeQuad`.
388        let theme = teksilo_core::presets::intui::light();
389        let ctx = SceneItemPaintContext::new(Transform2D::identity(), None, &theme);
390        for radius in [0.0, 8.0] {
391            let mut canvas = teksilo_canvas::Canvas::new();
392            GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0))
393                .corner_radius(radius)
394                .stroke_styled(Color::BLACK, StrokeStyle::dashed(2.0, 6.0, 4.0))
395                .paint(&mut canvas, &ctx);
396            let frame = canvas.into_render_frame();
397            assert!(
398                frame.decorations.is_empty() && frame.shapes.is_empty(),
399                "radius {radius}: neither a DecorationRect nor a ShapeQuad \
400                 can carry a dash pattern"
401            );
402            assert_eq!(frame.paths.len(), 1, "radius {radius}");
403            assert_eq!(
404                frame.paths[0].stroke_style.dash_pattern,
405                Some(vec![6.0, 4.0]),
406                "radius {radius}"
407            );
408        }
409    }
410
411    #[test]
412    fn group_item_role_fill_resolves_against_theme() {
413        // #1/#2: a role fill resolves against the ctx theme.
414        use teksilo_tokens::SurfaceRole;
415        let theme = teksilo_core::presets::intui::light();
416        let expected = ColorProp::from(SurfaceRole::Container).resolve(&theme, true);
417        let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0)).fill(SurfaceRole::Container);
418        let ctx = SceneItemPaintContext::new(Transform2D::identity(), None, &theme);
419        let mut c = teksilo_canvas::Canvas::new();
420        g.paint(&mut c, &ctx);
421        assert!(
422            c.into_render_frame()
423                .decorations
424                .iter()
425                .any(|d| d.color == expected.to_array())
426        );
427    }
428}