Skip to main content

teksilo_scene/items/
rect.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`RectItem`] — filled / stroked rectangle in local item coords.
5//!
6//! `RectItem` is the simplest and cheapest lightweight scene item: a rectangle
7//! in local item coordinates with an optional fill and/or stroke. It uses the
8//! default AABB hit-test (exact for a rectangle) and has zero arena overhead.
9//!
10//! Like all lightweight items, `RectItem` is constructed with its geometry
11//! relative to a local origin (`Rect::new(0.0, 0.0, w, h)`) and placed in
12//! the scene by `Scene::add_item(item, scene_pos)`, where `scene_pos` becomes
13//! the item's anchor in scene coordinates.
14//!
15//! Fill and stroke colours are [`ColorProp`]s, so they accept a plain
16//! [`Color`](teksilo_tokens::Color), a theme role
17//! ([`SurfaceRole`](teksilo_tokens::SurfaceRole) / `TextRole` / `BorderRole`),
18//! a reactive `Signal<Color>`, or a `Signal<Role>` — resolved against the
19//! active theme at paint time (so role-based fills desaturate automatically in
20//! an inactive window). Change a colour live via
21//! [`SceneModel::set_item_fill`](crate::SceneModel::set_item_fill) /
22//! [`set_item_stroke`](crate::SceneModel::set_item_stroke).
23//!
24//! ## When to use
25//!
26//! Use `RectItem` for background tiles, card backgrounds, selection highlights,
27//! grid cells, or any rectangular decoration in the lightweight tier. For
28//! arbitrary shapes, use [`PathItem`](crate::PathItem); for interactive content needing focus
29//! or event handlers, embed a full widget with `Scene::add_widget`.
30//!
31//! ## Example
32//!
33//! ```ignore
34//! use teksilo_scene::{SceneModel, RectItem};
35//! use teksilo_canvas::{Point, Rect};
36//! use teksilo_tokens::Color;
37//! use teksilo_i18n::lit;
38//!
39//! let model = SceneModel::new();
40//!
41//! let item = RectItem::new(Rect::new(0.0, 0.0, 120.0, 80.0))
42//!     .fill(Color::new(0.9, 0.95, 1.0, 1.0))
43//!     .corner_radius(8.0)
44//!     .stroke_cosmetic(Color::new(0.6, 0.7, 0.85, 1.0), 1.0)
45//!     .label(lit!("Card background"))
46//!     .draggable(true);
47//!
48//! model.add_item(item, Point::new(40.0, 40.0));
49//! ```
50
51use accesskit::Role;
52use teksilo_canvas::{Canvas, Rect, StrokeStyle};
53use teksilo_core::accessibility::AccessNodeBuilder;
54use teksilo_core::binding::BindingLevel;
55use teksilo_core::build_context::BuildContext;
56use teksilo_core::color_prop::ColorProp;
57use teksilo_core::widget_id::WidgetId;
58use teksilo_tokens::CornerRadius;
59
60use crate::flags::ItemFlags;
61use crate::item::{SceneItem, SceneItemA11yContext, SceneItemPaintContext};
62use crate::items::{AccessSubtreeMode, ItemA11yOverrides};
63use teksilo_i18n::LocalizedString;
64
65/// A rectangle with optional fill and stroke, in local item coordinates.
66///
67/// Construct with `RectItem::new(Rect::new(0.0, 0.0, w, h))` and place
68/// in the scene via `Scene::add_item(rect, local_pos)`.
69#[derive(Debug)]
70pub struct RectItem {
71    local_bounds: Rect,
72    fill: Option<ColorProp>,
73    stroke: Option<(ColorProp, StrokeStyle)>,
74    corner_radius: f32,
75    label: Option<String>,
76    flags: ItemFlags,
77    a11y: ItemA11yOverrides,
78}
79
80impl RectItem {
81    /// A rectangle of the given size in local item coordinates. The
82    /// passed `local_bounds` is stored verbatim — typically
83    /// `Rect::new(0.0, 0.0, w, h)`. No fill, no stroke — set at least
84    /// one or the item is invisible.
85    pub fn new(local_bounds: Rect) -> Self {
86        Self {
87            local_bounds,
88            fill: None,
89            stroke: None,
90            corner_radius: 0.0,
91            label: None,
92            flags: ItemFlags::default(),
93            a11y: ItemA11yOverrides::default(),
94        }
95    }
96
97    /// Fill colour. Accepts a plain [`Color`](teksilo_tokens::Color), a theme
98    /// role, a `Signal<Color>`, or a `Signal<Role>` — resolved against the
99    /// active theme at paint time.
100    pub fn fill(mut self, color: impl Into<ColorProp>) -> Self {
101        self.fill = Some(color.into());
102        self
103    }
104
105    /// Stroke colour and width in **scene-coordinate** pixels — the border
106    /// scales with the view zoom (a 1px border becomes 2px at 2× zoom).
107    pub fn stroke(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
108        self.stroke = Some((color.into(), StrokeStyle::solid(width.max(0.0))));
109        self
110    }
111
112    /// Cosmetic stroke: the border holds a constant **device-pixel** width at
113    /// any zoom (a hairline that never thins out or thickens). Ideal for grid
114    /// cells and card outlines in a pannable/zoomable scene.
115    pub fn stroke_cosmetic(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
116        self.stroke = Some((color.into(), StrokeStyle::hairline(width.max(0.0))));
117        self
118    }
119
120    /// Stroke with an explicit [`StrokeStyle`] — dashed, dotted, or custom caps
121    /// / joins. E.g. `.stroke_styled(color, StrokeStyle::dashed(2.0, 6.0, 4.0))`
122    /// for a dashed outline, or `StrokeStyle::dotted(1.5, 3.0)` for a dotted
123    /// guide. The style is stored verbatim, so all of `StrokeStyle`'s knobs
124    /// (dash pattern/offset, `Logical` vs `Device` space) apply.
125    pub fn stroke_styled(mut self, color: impl Into<ColorProp>, style: StrokeStyle) -> Self {
126        self.stroke = Some((color.into(), style));
127        self
128    }
129
130    /// Rounded corners for fill and stroke, in scene-coordinate pixels.
131    /// Default `0.0` (square corners). A positive radius routes fill/stroke
132    /// through the SDF rounded-rect path.
133    pub fn corner_radius(mut self, radius: f32) -> Self {
134        self.corner_radius = radius.max(0.0);
135        self
136    }
137
138    /// Human-readable label used for debug and the default AT name.
139    /// Accepts anything convertible into `LocalizedString` — most
140    /// commonly `tr!(...)`. Plain strings auto-convert.
141    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
142        let ls: LocalizedString = label.into();
143        self.label = Some(ls.resolve_now());
144        self
145    }
146
147    /// Opt the rectangle into drag-to-move.
148    pub fn draggable(mut self, draggable: bool) -> Self {
149        self.flags.set(ItemFlags::IS_DRAGGABLE, draggable);
150        self
151    }
152
153    crate::items::item_a11y_builders!();
154}
155
156impl SceneItem for RectItem {
157    fn local_bounds(&self) -> Rect {
158        self.local_bounds
159    }
160
161    fn set_local_bounds(&mut self, bounds: Rect) {
162        self.local_bounds = bounds;
163    }
164
165    fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>) {
166        let lb = self.local_bounds;
167        let radius = self.corner_radius;
168        if let Some(prop) = &self.fill {
169            let fill = prop.resolve(ctx.theme, ctx.enabled);
170            if radius > 0.0 {
171                canvas.fill_rounded_rect(lb, CornerRadius::uniform(radius), fill);
172            } else {
173                canvas.fill_rect(lb, fill);
174            }
175        }
176        if let Some((prop, style)) = &self.stroke {
177            let color = prop.resolve(ctx.theme, ctx.enabled);
178            if radius > 0.0 {
179                canvas.stroke_rounded_rect(lb, CornerRadius::uniform(radius), color, style.clone());
180            } else {
181                canvas.stroke_rect(lb, color, style.clone());
182            }
183        }
184    }
185
186    fn set_fill(&mut self, fill: Option<ColorProp>) -> bool {
187        self.fill = fill;
188        true
189    }
190
191    fn set_stroke(&mut self, stroke: Option<(ColorProp, StrokeStyle)>) -> bool {
192        self.stroke = stroke;
193        true
194    }
195
196    fn register_bindings(&self, ctx: &mut BuildContext, view_id: WidgetId) {
197        let registry = ctx.binding_registry();
198        if let Some(p) = &self.fill {
199            p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
200        }
201        if let Some((p, _)) = &self.stroke {
202            p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
203        }
204    }
205
206    fn thumbnail_color(&self) -> teksilo_tokens::Color {
207        // Fill dominates; fall through to stroke; fall through to the default
208        // grey if the rect has no visible chrome or its colour is role-based
209        // (role colours can't resolve without a theme here).
210        crate::items::fill_or_stroke_hint(self.fill.as_ref(), self.stroke.as_ref())
211            .unwrap_or_else(|| teksilo_tokens::Color::new(0.6, 0.6, 0.6, 1.0))
212    }
213
214    fn label(&self) -> Option<String> {
215        self.label.clone()
216    }
217
218    fn initial_flags(&self) -> ItemFlags {
219        self.flags
220    }
221
222    fn access_subtree_mode(&self) -> AccessSubtreeMode {
223        self.a11y.subtree_mode()
224    }
225
226    fn accessibility(&self, builder: &mut AccessNodeBuilder, _ctx: &SceneItemA11yContext) {
227        builder.set_role(Role::GraphicsObject);
228        if let Some(label) = self.label() {
229            builder.set_name(label);
230        }
231        self.a11y.apply(builder);
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use teksilo_canvas::{Canvas, Point, Transform2D};
239    use teksilo_core::signal::Signal;
240    use teksilo_tokens::{Color, SurfaceRole};
241
242    fn test_ctx<'a>(theme: &'a teksilo_core::styles::Theme) -> SceneItemPaintContext<'a> {
243        SceneItemPaintContext::new(Transform2D::identity(), None, theme)
244    }
245
246    #[test]
247    fn rect_item_local_bounds_round_trip() {
248        let r = Rect::new(0.0, 0.0, 30.0, 40.0);
249        let item = RectItem::new(r);
250        assert_eq!(item.local_bounds(), r);
251    }
252
253    #[test]
254    fn rect_item_default_shape_contains() {
255        let item = RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0));
256        assert!(item.shape_contains(Point::new(20.0, 20.0)));
257        assert!(!item.shape_contains(Point::new(-5.0, 20.0)));
258    }
259
260    #[test]
261    fn rect_item_paint_emits_fill_and_stroke() {
262        let theme = teksilo_core::presets::intui::light();
263        let mut canvas = Canvas::new();
264        let item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
265            .fill(Color::RED)
266            .stroke(Color::BLUE, 2.0);
267        item.paint(&mut canvas, &test_ctx(&theme));
268        let frame = canvas.into_render_frame();
269        assert!(
270            !frame.draw_order.is_empty(),
271            "paint must emit at least one draw command"
272        );
273    }
274
275    #[test]
276    fn rect_item_static_fill_paints_its_colour() {
277        let theme = teksilo_core::presets::intui::light();
278        let mut canvas = Canvas::new();
279        RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
280            .fill(Color::RED)
281            .paint(&mut canvas, &test_ctx(&theme));
282        let frame = canvas.into_render_frame();
283        assert!(
284            frame
285                .decorations
286                .iter()
287                .any(|d| d.color == Color::RED.to_array()),
288            "static fill must emit its exact colour"
289        );
290    }
291
292    #[test]
293    fn rect_item_role_fill_resolves_against_theme() {
294        // #1 keystone: the paint ctx carries the theme, so a role fill resolves
295        // to the theme's surface colour rather than a frozen constant.
296        let theme = teksilo_core::presets::intui::light();
297        let expected = ColorProp::from(SurfaceRole::Sunken).resolve(&theme, true);
298        let mut canvas = Canvas::new();
299        RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
300            .fill(SurfaceRole::Sunken)
301            .paint(&mut canvas, &test_ctx(&theme));
302        let frame = canvas.into_render_frame();
303        assert!(
304            frame
305                .decorations
306                .iter()
307                .any(|d| d.color == expected.to_array()),
308            "role fill must resolve against ctx.theme"
309        );
310    }
311
312    #[test]
313    fn rect_item_signal_fill_re_resolves_on_change() {
314        // #2 reactive: a Signal<Color> fill re-resolves each paint.
315        let theme = teksilo_core::presets::intui::light();
316        let sig = Signal::new(Color::GREEN);
317        let item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)).fill(sig.clone());
318
319        let mut c1 = Canvas::new();
320        item.paint(&mut c1, &test_ctx(&theme));
321        assert!(
322            c1.into_render_frame()
323                .decorations
324                .iter()
325                .any(|d| d.color == Color::GREEN.to_array())
326        );
327
328        sig.set(Color::RED);
329        let mut c2 = Canvas::new();
330        item.paint(&mut c2, &test_ctx(&theme));
331        assert!(
332            c2.into_render_frame()
333                .decorations
334                .iter()
335                .any(|d| d.color == Color::RED.to_array()),
336            "signal fill must re-resolve to the new value"
337        );
338    }
339
340    #[test]
341    fn rect_item_corner_radius_emits_rounded_shape() {
342        // #4: a positive corner radius routes the fill through the SDF
343        // rounded-rect path (a Shape), not a plain rect Decoration.
344        let theme = teksilo_core::presets::intui::light();
345        let mut canvas = Canvas::new();
346        RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0))
347            .fill(Color::RED)
348            .corner_radius(6.0)
349            .paint(&mut canvas, &test_ctx(&theme));
350        let frame = canvas.into_render_frame();
351        assert!(
352            !frame.shapes.is_empty(),
353            "rounded fill must emit an SDF shape"
354        );
355    }
356
357    #[test]
358    fn rect_item_stroke_styled_stores_dash_pattern() {
359        // #5: a styled stroke stores the caller's StrokeStyle verbatim.
360        let item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
361            .stroke_styled(Color::BLUE, StrokeStyle::dashed(2.0, 6.0, 4.0));
362        let (_, style) = item.stroke.as_ref().expect("stroke set");
363        assert!(
364            style.dash_pattern.is_some(),
365            "dashed stroke must keep its dash pattern"
366        );
367    }
368
369    #[test]
370    fn rect_item_set_fill_replaces_colour() {
371        // #2: the SceneItem mutation hook swaps the fill in place.
372        let theme = teksilo_core::presets::intui::light();
373        let mut item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)).fill(Color::RED);
374        assert!(item.set_fill(Some(ColorProp::from(Color::BLUE))));
375        let mut canvas = Canvas::new();
376        item.paint(&mut canvas, &test_ctx(&theme));
377        assert!(
378            canvas
379                .into_render_frame()
380                .decorations
381                .iter()
382                .any(|d| d.color == Color::BLUE.to_array())
383        );
384    }
385}