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}