Skip to main content

teksilo_scene/
items.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Built-in `SceneItem` implementations.
5//!
6//! Five lightweight items cover the common decoration cases:
7//!
8//! - [`RectItem`] — filled / stroked rectangle. Backgrounds, tiles,
9//!   simple decorations.
10//! - [`PathItem`] — arbitrary vector path with optional fill and
11//!   stroke. The "connector lines between cards" workhorse, with
12//!   per-segment hit-test for stroke-only paths.
13//! - [`ImageItem`] — a raster image at a local-coord rectangle.
14//! - [`TextItem`] — unstyled text in a local-coord rectangle, static
15//!   string or signal-bound.
16//! - [`GroupItem`] — a group container with optional fill / stroke /
17//!   inline label. Visually a labelled box; non-visual groups serve
18//!   as logical AT containers ([`Scene::add_a11y_group`](crate::Scene::add_a11y_group)).
19//!
20//! All built-ins store their geometry in **local item coordinates**
21//! anchored at the origin. Apps construct an item with its size at
22//! origin (`RectItem::new(Rect::new(0.0, 0.0, w, h))`) and place it
23//! in the scene with `Scene::add_item(item, local_pos)`.
24
25use teksilo_canvas::StrokeStyle;
26use teksilo_core::accessibility::AccessNodeBuilder;
27use teksilo_core::color_prop::ColorProp;
28use teksilo_tokens::Color;
29
30pub mod group;
31pub mod image;
32pub mod path;
33pub mod rect;
34pub mod text;
35
36pub use group::GroupItem;
37pub use image::ImageItem;
38pub use path::PathItem;
39pub use rect::RectItem;
40use teksilo_i18n::LocalizedString;
41pub use text::{TextAlign, TextItem};
42
43/// A concrete-colour "hint" extracted from a [`ColorProp`] without a theme —
44/// used by `thumbnail_color` impls that have no paint context. `Static` and
45/// `Bound` colours resolve directly; role-based colours need a theme to
46/// resolve, so they yield `None` (the caller falls back to a neutral tint).
47///
48/// **Known limitation:** [`SceneItem::thumbnail_color`](crate::SceneItem::thumbnail_color)
49/// is theme-free by signature, so an item whose fill/stroke is a **theme role**
50/// (rather than a `Color` or `Signal<Color>`) renders as the caller's neutral
51/// grey in a [`SceneMinimap`](crate::SceneMinimap). Use a concrete `Color` or a
52/// `Signal<Color>` for items you want faithfully represented on a minimap.
53pub(crate) fn color_prop_hint(prop: &ColorProp) -> Option<Color> {
54    match prop {
55        ColorProp::Static(c) => Some(*c),
56        ColorProp::Bound(s) => Some(s.get()),
57        // Role-based (static or dynamic) colours can't resolve without a theme.
58        _ => None,
59    }
60}
61
62/// The fill-then-stroke thumbnail-colour fallback shared by the colour-bearing
63/// built-ins. `None` when neither slot yields a theme-free colour — the caller
64/// then picks its own neutral fallback. See [`color_prop_hint`] for the
65/// role-colour limitation.
66pub(crate) fn fill_or_stroke_hint(
67    fill: Option<&ColorProp>,
68    stroke: Option<&(ColorProp, StrokeStyle)>,
69) -> Option<Color> {
70    fill.and_then(color_prop_hint)
71        .or_else(|| stroke.and_then(|(c, _)| color_prop_hint(c)))
72}
73
74/// How the AT walker treats descendants of an item.
75///
76/// Mirrors the widget-tier `AccessSubtreeMode`: `Inherit` is the
77/// default (descendants emit normally); `Exclude` prunes them from
78/// the AT tree; `Merge` collapses them into the parent so the
79/// subtree reads as a single AT element. Used for "card with rect +
80/// label + indicator dot reads as one card" patterns.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82pub enum AccessSubtreeMode {
83    /// Descendants emit their own AT nodes normally. Default.
84    #[default]
85    Inherit,
86    /// Descendants are pruned from the AT tree; the parent item
87    /// emits as a single AT node with no children.
88    Exclude,
89    /// Descendants' label / description / actions are folded into
90    /// the parent AT node; descendants are then pruned. The subtree
91    /// reads as one AT element — useful for "card with icon + label
92    /// + badge = one selectable card" patterns.
93    Merge,
94}
95
96/// Builder-level accessibility overrides shared by every built-in
97/// `SceneItem`. Mirrors the widget-level `.access_*` chain — names
98/// match so muscle memory carries over.
99#[derive(Debug, Default, Clone)]
100pub struct ItemA11yOverrides {
101    pub(crate) label: Option<LocalizedString>,
102    pub(crate) description: Option<LocalizedString>,
103    pub(crate) role: Option<accesskit::Role>,
104    pub(crate) hidden: bool,
105    pub(crate) subtree_mode: AccessSubtreeMode,
106    /// String value announced for a data-bearing item (e.g. `"42 %"`).
107    pub(crate) value: Option<LocalizedString>,
108    /// Numeric value + optional range/step, for slider/gauge-like items whose
109    /// magnitude AT should describe.
110    pub(crate) numeric_value: Option<f64>,
111    pub(crate) min_numeric_value: Option<f64>,
112    pub(crate) max_numeric_value: Option<f64>,
113    pub(crate) numeric_value_step: Option<f64>,
114}
115
116impl ItemA11yOverrides {
117    /// Read access for the AT walker.
118    pub fn subtree_mode(&self) -> AccessSubtreeMode {
119        self.subtree_mode
120    }
121
122    /// Apply the configured overrides to an [`AccessNodeBuilder`]
123    /// after the item's own `accessibility` impl has populated the
124    /// default fields. Replaces matching fields rather than merging.
125    pub(crate) fn apply(&self, builder: &mut AccessNodeBuilder) {
126        if let Some(role) = self.role {
127            builder.set_role(role);
128        }
129        if let Some(ref label) = self.label {
130            builder.set_name(label.resolve_now());
131        }
132        if let Some(ref desc) = self.description {
133            builder.set_description(desc.resolve_now());
134        }
135        if self.hidden {
136            builder.set_hidden();
137        }
138        if let Some(ref value) = self.value {
139            builder.set_value(value.resolve_now());
140        }
141        if let Some(n) = self.numeric_value {
142            builder.set_numeric_value(n);
143        }
144        if let Some(n) = self.min_numeric_value {
145            builder.set_min_numeric_value(n);
146        }
147        if let Some(n) = self.max_numeric_value {
148            builder.set_max_numeric_value(n);
149        }
150        if let Some(n) = self.numeric_value_step {
151            builder.set_numeric_value_step(n);
152        }
153    }
154}
155
156/// Emit the `.access_*` builder chain on a struct that holds an
157/// `a11y: ItemA11yOverrides` field. Built-in items invoke this inside
158/// their inherent impl block so they all share the same translated +
159/// `_literal` method names. Custom items can do the same.
160#[doc(hidden)]
161#[macro_export]
162macro_rules! item_a11y_builders {
163    () => {
164        /// Override the AT name announced for this item. Accepts
165        /// anything convertible into `LocalizedString` — most
166        /// commonly `tr!(...)` for translated labels, or any plain
167        /// string (which auto-converts via `From<String>`).
168        pub fn access_label(mut self, label: impl Into<LocalizedString>) -> Self {
169            let ls: LocalizedString = label.into();
170            self.a11y.label = Some(ls);
171            self
172        }
173
174        /// Long-form context appended to the item's announcement.
175        pub fn access_description(mut self, description: impl Into<LocalizedString>) -> Self {
176            let ls: LocalizedString = description.into();
177            self.a11y.description = Some(ls);
178            self
179        }
180
181        /// Override the AccessKit role for this item.
182        pub fn access_role(mut self, role: accesskit::Role) -> Self {
183            self.a11y.role = Some(role);
184            self
185        }
186
187        /// Hide this item from the AT tree.
188        pub fn access_hidden(mut self, hidden: bool) -> Self {
189            self.a11y.hidden = hidden;
190            self
191        }
192
193        /// Set the AT subtree mode. `Merge` collapses descendants
194        /// into this item's AT node; `Exclude` prunes them; the
195        /// default `Inherit` lets them emit normally.
196        pub fn access_subtree(mut self, mode: $crate::items::AccessSubtreeMode) -> Self {
197            self.a11y.subtree_mode = mode;
198            self
199        }
200
201        /// Convenience: collapse all descendants into this item's
202        /// AT node so the subtree reads as one element.
203        pub fn access_merge_subtree(mut self) -> Self {
204            self.a11y.subtree_mode = $crate::items::AccessSubtreeMode::Merge;
205            self
206        }
207
208        /// Convenience: prune all descendants from the AT tree.
209        pub fn access_exclude_subtree(mut self) -> Self {
210            self.a11y.subtree_mode = $crate::items::AccessSubtreeMode::Exclude;
211            self
212        }
213
214        /// Announce a string value for this item (e.g. a formatted data
215        /// reading like `"42 %"`). Mirrors the widget-tier `.access_value`.
216        pub fn access_value(mut self, value: impl Into<LocalizedString>) -> Self {
217            let ls: LocalizedString = value.into();
218            self.a11y.value = Some(ls);
219            self
220        }
221
222        /// Announce a numeric value for this item, for slider/gauge-like data
223        /// marks whose magnitude assistive tech should describe. Pair with
224        /// [`access_numeric_range`](Self::access_numeric_range) /
225        /// [`access_numeric_step`](Self::access_numeric_step) for full
226        /// range semantics.
227        pub fn access_numeric_value(mut self, value: f64) -> Self {
228            self.a11y.numeric_value = Some(value);
229            self
230        }
231
232        /// Announce the numeric min/max bounds for this item.
233        pub fn access_numeric_range(mut self, min: f64, max: f64) -> Self {
234            self.a11y.min_numeric_value = Some(min);
235            self.a11y.max_numeric_value = Some(max);
236            self
237        }
238
239        /// Announce the numeric step (per-arrow increment) for this item.
240        pub fn access_numeric_step(mut self, step: f64) -> Self {
241            self.a11y.numeric_value_step = Some(step);
242            self
243        }
244    };
245}
246
247pub(crate) use item_a11y_builders;
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::item::SceneItem;
253    use teksilo_canvas::Rect;
254    use teksilo_i18n::lit;
255
256    #[test]
257    fn literal_twins_match_translated_setters_via_observable_state() {
258        // The `_literal` twin must produce the same observable state
259        // as its translated counterpart — they're a grep-marker for
260        // explicitly-untranslated call sites, not a behavior split.
261        // We compare via the public `SceneItem::label` getter.
262        let r = Rect::new(0.0, 0.0, 10.0, 10.0);
263
264        let translated = RectItem::new(r).label(lit!("Hello"));
265        let literal = RectItem::new(r).label(lit!("Hello"));
266        // The builder shadows the trait getter — disambiguate via UFCS.
267        assert_eq!(SceneItem::label(&translated), SceneItem::label(&literal));
268
269        let t1 = TextItem::new(lit!("hi"), r);
270        let t2 = TextItem::new(lit!("hi"), r);
271        assert_eq!(t1.local_bounds(), t2.local_bounds());
272
273        let mut h1 = crate::item_handlers::SceneItemHandlerSet::new();
274        h1.tooltip(lit!("Tip"));
275        let mut h2 = crate::item_handlers::SceneItemHandlerSet::new();
276        h2.tooltip(lit!("Tip"));
277        assert_eq!(
278            h1.tooltip.as_ref().map(|t| t.resolve_now()),
279            h2.tooltip.as_ref().map(|t| t.resolve_now())
280        );
281    }
282
283    #[test]
284    fn access_subtree_mode_round_trips() {
285        let r = Rect::new(0.0, 0.0, 10.0, 10.0);
286        let item = RectItem::new(r).access_merge_subtree();
287        assert_eq!(item.access_subtree_mode(), AccessSubtreeMode::Merge);
288        let item = RectItem::new(r).access_subtree(AccessSubtreeMode::Exclude);
289        assert_eq!(item.access_subtree_mode(), AccessSubtreeMode::Exclude);
290        let item = RectItem::new(r);
291        assert_eq!(item.access_subtree_mode(), AccessSubtreeMode::Inherit);
292    }
293
294    #[test]
295    fn access_value_and_numeric_fields_round_trip() {
296        // #9: the value / numeric overrides store their fields. End-to-end
297        // walker coverage (the fields reaching an AccessKit node) lives in
298        // `view/tests.rs`, where a real `WidgetTree` walks the AT tree.
299        let o = ItemA11yOverrides {
300            value: Some(lit!("42 %")),
301            numeric_value: Some(0.42),
302            min_numeric_value: Some(0.0),
303            max_numeric_value: Some(1.0),
304            numeric_value_step: Some(0.1),
305            ..Default::default()
306        };
307        assert_eq!(o.numeric_value, Some(0.42));
308        assert_eq!(o.min_numeric_value, Some(0.0));
309        assert_eq!(o.max_numeric_value, Some(1.0));
310        assert_eq!(o.numeric_value_step, Some(0.1));
311        assert_eq!(
312            o.value.as_ref().map(|v| v.resolve_now()),
313            Some("42 %".to_string())
314        );
315    }
316}