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. Its
8//! hit-test is its own [`shape`](SceneItem::shape) — the box, exact, for a
9//! square-cornered rectangle, and a rounded rect once `corner_radius` is set
10//! (see `shape` for what that costs a click in a corner) — and it has zero
11//! arena overhead.
12//!
13//! Like all lightweight items, `RectItem` is constructed with its geometry
14//! relative to a local origin (`Rect::new(0.0, 0.0, w, h)`) and placed in
15//! the scene by `Scene::add_item(item, scene_pos)`, where `scene_pos` becomes
16//! the item's anchor in scene coordinates.
17//!
18//! Fill and stroke colours are [`ColorProp`]s, so they accept a plain
19//! [`Color`](teksilo_tokens::Color), a theme role
20//! ([`SurfaceRole`](teksilo_tokens::SurfaceRole) / `TextRole` / `BorderRole`),
21//! a reactive `Signal<Color>`, or a `Signal<Role>` — resolved against the
22//! active theme at paint time (so role-based fills desaturate automatically in
23//! an inactive window). Change a colour live via
24//! [`SceneModel::set_item_fill`](crate::SceneModel::set_item_fill) /
25//! [`set_item_stroke`](crate::SceneModel::set_item_stroke).
26//!
27//! ## When to use
28//!
29//! Use `RectItem` for background tiles, card backgrounds, selection highlights,
30//! grid cells, or any rectangular decoration in the lightweight tier. For
31//! arbitrary shapes, use [`PathItem`](crate::PathItem); a tap / hover /
32//! context-menu handler needs no widget (wire one through
33//! `Scene::handlers_mut`), but for interactive content needing keyboard
34//! focus, embed a full widget with `Scene::add_widget`.
35//!
36//! ## Example
37//!
38//! ```ignore
39//! use teksilo_scene::{SceneModel, RectItem};
40//! use teksilo_canvas::{Point, Rect};
41//! use teksilo_tokens::Color;
42//! use teksilo_i18n::lit;
43//!
44//! let model = SceneModel::new();
45//!
46//! let item = RectItem::new(Rect::new(0.0, 0.0, 120.0, 80.0))
47//!     .fill(Color::new(0.9, 0.95, 1.0, 1.0))
48//!     .corner_radius(8.0)
49//!     .stroke_cosmetic(Color::new(0.6, 0.7, 0.85, 1.0), 1.0)
50//!     .label(lit!("Card background"))
51//!     .draggable(true);
52//!
53//! model.add_item(item, Point::new(40.0, 40.0));
54//! ```
55
56use accesskit::Role;
57use teksilo_canvas::{Canvas, Rect, StrokeStyle};
58use teksilo_core::accessibility::AccessNodeBuilder;
59use teksilo_core::binding::BindingLevel;
60use teksilo_core::build_context::BuildContext;
61use teksilo_core::color_prop::ColorProp;
62use teksilo_core::widget_id::WidgetId;
63use teksilo_tokens::CornerRadius;
64
65use crate::flags::ItemFlags;
66use crate::item::{AppearanceWrite, SceneItem, SceneItemA11yContext, SceneItemPaintContext};
67use crate::items::{AccessSubtreeMode, ItemA11yOverrides};
68use teksilo_i18n::LocalizedString;
69
70/// A rectangle with optional fill and stroke, in local item coordinates.
71///
72/// Construct with `RectItem::new(Rect::new(0.0, 0.0, w, h))` and place
73/// in the scene via `Scene::add_item(rect, local_pos)`.
74#[derive(Debug)]
75pub struct RectItem {
76    local_bounds: Rect,
77    fill: Option<ColorProp>,
78    stroke: Option<(ColorProp, StrokeStyle)>,
79    corner_radius: f32,
80    label: Option<String>,
81    flags: ItemFlags,
82    a11y: ItemA11yOverrides,
83}
84
85impl RectItem {
86    /// A rectangle of the given size in local item coordinates. The
87    /// passed `local_bounds` is stored verbatim — typically
88    /// `Rect::new(0.0, 0.0, w, h)`. No fill, no stroke — set at least
89    /// one or the item is invisible.
90    pub fn new(local_bounds: Rect) -> Self {
91        Self {
92            local_bounds,
93            fill: None,
94            stroke: None,
95            corner_radius: 0.0,
96            label: None,
97            flags: ItemFlags::default(),
98            a11y: ItemA11yOverrides::default(),
99        }
100    }
101
102    /// Fill colour. Accepts a plain [`Color`](teksilo_tokens::Color), a theme
103    /// role, a `Signal<Color>`, or a `Signal<Role>` — resolved against the
104    /// active theme at paint time.
105    pub fn fill(mut self, color: impl Into<ColorProp>) -> Self {
106        self.fill = Some(color.into());
107        self
108    }
109
110    /// Stroke colour and width in **scene-coordinate** pixels — the border
111    /// scales with the view zoom (a 1px border becomes 2px at 2× zoom).
112    pub fn stroke(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
113        self.stroke = Some((color.into(), StrokeStyle::solid(width.max(0.0))));
114        self
115    }
116
117    /// Cosmetic stroke: the border holds a constant **device-pixel** width at
118    /// any zoom (a hairline that never thins out or thickens). Ideal for grid
119    /// cells and card outlines in a pannable/zoomable scene.
120    pub fn stroke_cosmetic(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
121        self.stroke = Some((color.into(), StrokeStyle::hairline(width.max(0.0))));
122        self
123    }
124
125    /// Stroke with an explicit [`StrokeStyle`] — dashed, dotted, or custom caps
126    /// / joins. E.g. `.stroke_styled(color, StrokeStyle::dashed(2.0, 6.0, 4.0))`
127    /// for a dashed outline, or `StrokeStyle::dotted(1.5, 3.0)` for a dotted
128    /// guide. The style is stored verbatim, so all of `StrokeStyle`'s knobs
129    /// (dash pattern/offset, `Logical` vs `Device` space) apply.
130    ///
131    /// A dashing style costs a CPU-rasterized path: `Canvas` diverts it away
132    /// from the decoration-rect / SDF pipelines, which cannot express a dash.
133    /// Solid and cosmetic strokes keep the cheap route.
134    pub fn stroke_styled(mut self, color: impl Into<ColorProp>, style: StrokeStyle) -> Self {
135        self.stroke = Some((color.into(), style));
136        self
137    }
138
139    /// Rounded corners for fill and stroke, in scene-coordinate pixels.
140    /// Default `0.0` (square corners). A positive radius routes fill/stroke
141    /// through the SDF rounded-rect path.
142    pub fn corner_radius(mut self, radius: f32) -> Self {
143        self.corner_radius = radius.max(0.0);
144        self
145    }
146
147    /// Human-readable label used for debug and the default AT name.
148    /// Accepts anything convertible into `LocalizedString` — most
149    /// commonly `tr!(...)`, or `lit!(...)` for a deliberately
150    /// untranslated one. A bare `&str` does not convert.
151    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
152        let ls: LocalizedString = label.into();
153        self.label = Some(ls.resolve_now());
154        self
155    }
156
157    /// Opt the rectangle into drag-to-move.
158    pub fn draggable(mut self, draggable: bool) -> Self {
159        self.flags.set(ItemFlags::IS_DRAGGABLE, draggable);
160        self
161    }
162
163    crate::items::item_a11y_builders!();
164}
165
166impl SceneItem for RectItem {
167    fn local_bounds(&self) -> Rect {
168        self.local_bounds
169    }
170
171    fn set_local_bounds(&mut self, bounds: Rect) {
172        self.local_bounds = bounds;
173    }
174
175    fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>) {
176        let lb = self.local_bounds;
177        let radius = self.corner_radius;
178        if let Some(prop) = &self.fill {
179            let fill = prop.resolve(ctx.theme, ctx.enabled);
180            if radius > 0.0 {
181                canvas.fill_rounded_rect(lb, CornerRadius::uniform(radius), fill);
182            } else {
183                canvas.fill_rect(lb, fill);
184            }
185        }
186        if let Some((prop, style)) = &self.stroke {
187            let color = prop.resolve(ctx.theme, ctx.enabled);
188            if radius > 0.0 {
189                canvas.stroke_rounded_rect(lb, CornerRadius::uniform(radius), color, style.clone());
190            } else {
191                canvas.stroke_rect(lb, color, style.clone());
192            }
193        }
194    }
195
196    fn set_fill(&mut self, fill: Option<ColorProp>) -> AppearanceWrite<ColorProp> {
197        AppearanceWrite::Accepted {
198            was: std::mem::replace(&mut self.fill, fill),
199        }
200    }
201
202    fn set_stroke(
203        &mut self,
204        stroke: Option<(ColorProp, StrokeStyle)>,
205    ) -> AppearanceWrite<(ColorProp, StrokeStyle)> {
206        AppearanceWrite::Accepted {
207            was: std::mem::replace(&mut self.stroke, stroke),
208        }
209    }
210
211    fn register_bindings(&self, ctx: &mut BuildContext, view_id: WidgetId) {
212        let registry = ctx.binding_registry();
213        if let Some(p) = &self.fill {
214            p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
215        }
216        if let Some((p, _)) = &self.stroke {
217            p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
218        }
219    }
220
221    /// The rectangle — as a **rounded** rectangle when
222    /// [`corner_radius`](RectItem::corner_radius) is set, because that is what
223    /// it paints and the corners it leaves are genuinely transparent. Closed
224    /// form: no flattening, one comparison more than a plain box.
225    ///
226    /// A click in the transparent corner of a rounded card therefore misses
227    /// it, which is a real (if small) change from the pre-`ItemShape`
228    /// behaviour: `(1 - pi/4) r^2` of area per corner, reaching at most
229    /// `r * (sqrt(2) - 1)` from the corner point — 8 dp in from the corner of a
230    /// 20 dp radius.
231    ///
232    /// # What that means for accessibility
233    ///
234    /// The item's AccessKit node keeps advertising the **whole rectangle**:
235    /// AccessKit bounds are an AABB by construction, so every non-rectangular
236    /// element in every tier advertises a little more than it fills (a rounded
237    /// `Button` in the widget tier does the same). What changed here is only
238    /// what a *pointer* aimed at a transparent corner does, and it now matches
239    /// the pixels.
240    ///
241    /// The divergence does not cost an AT client the element, because nothing
242    /// on the AT path re-enters this shape: a client identifies an element by
243    /// node id, announces and spatially navigates by the advertised rectangle,
244    /// and an AccessKit action arrives addressed to the node rather than as a
245    /// synthetic press at a coordinate. Note also what does **not** help: the
246    /// view's miss-only slop pass widens an item's box for a coarse pointer,
247    /// but it offers nothing to an item already at or above the density's
248    /// target size on its short axis — which is every card big enough for a
249    /// large radius to matter (`a_large_item_earns_no_grab_slop_even_for_a_finger`
250    /// pins exactly that). An app that needs corner-inclusive pointer
251    /// targeting sets `corner_radius(0.0)` or overrides
252    /// [`SceneItem::shape`].
253    fn shape(&self) -> crate::shape::ItemShape {
254        if self.corner_radius > 0.0 {
255            crate::shape::ItemShape::rounded_rect(self.local_bounds, self.corner_radius)
256        } else {
257            crate::shape::ItemShape::bounds(self.local_bounds)
258        }
259    }
260
261    fn thumbnail_color(&self) -> teksilo_tokens::Color {
262        // Fill dominates; fall through to stroke; fall through to the default
263        // grey if the rect has no visible chrome or its colour is role-based
264        // (role colours can't resolve without a theme here).
265        crate::items::fill_or_stroke_hint(self.fill.as_ref(), self.stroke.as_ref())
266            .unwrap_or_else(|| teksilo_tokens::Color::new(0.6, 0.6, 0.6, 1.0))
267    }
268
269    fn label(&self) -> Option<String> {
270        self.label.clone()
271    }
272
273    fn initial_flags(&self) -> ItemFlags {
274        self.flags
275    }
276
277    fn access_subtree_mode(&self) -> AccessSubtreeMode {
278        self.a11y.subtree_mode()
279    }
280
281    fn accessibility(&self, builder: &mut AccessNodeBuilder, _ctx: &SceneItemA11yContext) {
282        builder.set_role(Role::GraphicsObject);
283        if let Some(label) = self.label() {
284            builder.set_name(label);
285        }
286        self.a11y.apply(builder);
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use teksilo_canvas::{Canvas, Point, Transform2D};
294    use teksilo_core::signal::Signal;
295    use teksilo_tokens::{Color, SurfaceRole};
296
297    fn test_ctx<'a>(theme: &'a teksilo_core::styles::Theme) -> SceneItemPaintContext<'a> {
298        SceneItemPaintContext::new(Transform2D::identity(), None, theme)
299    }
300
301    #[test]
302    fn rect_item_local_bounds_round_trip() {
303        let r = Rect::new(0.0, 0.0, 30.0, 40.0);
304        let item = RectItem::new(r);
305        assert_eq!(item.local_bounds(), r);
306    }
307
308    #[test]
309    fn rect_item_default_shape_is_its_box() {
310        let item = RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0));
311        let shape = item.shape();
312        assert!(shape.contains(Point::new(20.0, 20.0), 1.0));
313        assert!(!shape.contains(Point::new(-5.0, 20.0), 1.0));
314        // The extreme corner of a square tile is still the tile.
315        assert!(shape.contains(Point::new(0.0, 0.0), 1.0));
316        assert_eq!(shape.bounding_rect(), Rect::new(0.0, 0.0, 50.0, 50.0));
317    }
318
319    #[test]
320    fn rect_item_corner_radius_carves_its_corners_out_of_the_shape() {
321        let item = RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0)).corner_radius(10.0);
322        let shape = item.shape();
323        // The very corner is transparent, so it is not the item.
324        assert!(!shape.contains(Point::new(0.5, 0.5), 1.0));
325        // A point on the corner arc, and the body, still are.
326        assert!(shape.contains(Point::new(10.0, 3.0), 1.0));
327        assert!(shape.contains(Point::new(25.0, 25.0), 1.0));
328        assert!(shape.contains(Point::new(0.0, 25.0), 1.0), "mid-edge");
329        // The box it advertises is unchanged - only the silhouette narrows.
330        assert_eq!(shape.bounding_rect(), Rect::new(0.0, 0.0, 50.0, 50.0));
331    }
332
333    #[test]
334    fn rect_item_paint_emits_fill_and_stroke() {
335        let theme = teksilo_core::presets::intui::light();
336        let mut canvas = Canvas::new();
337        let item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
338            .fill(Color::RED)
339            .stroke(Color::BLUE, 2.0);
340        item.paint(&mut canvas, &test_ctx(&theme));
341        let frame = canvas.into_render_frame();
342        assert!(
343            !frame.draw_order.is_empty(),
344            "paint must emit at least one draw command"
345        );
346    }
347
348    #[test]
349    fn rect_item_static_fill_paints_its_colour() {
350        let theme = teksilo_core::presets::intui::light();
351        let mut canvas = Canvas::new();
352        RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
353            .fill(Color::RED)
354            .paint(&mut canvas, &test_ctx(&theme));
355        let frame = canvas.into_render_frame();
356        assert!(
357            frame
358                .decorations
359                .iter()
360                .any(|d| d.color == Color::RED.to_array()),
361            "static fill must emit its exact colour"
362        );
363    }
364
365    #[test]
366    fn rect_item_role_fill_resolves_against_theme() {
367        // #1 keystone: the paint ctx carries the theme, so a role fill resolves
368        // to the theme's surface colour rather than a frozen constant.
369        let theme = teksilo_core::presets::intui::light();
370        let expected = ColorProp::from(SurfaceRole::Sunken).resolve(&theme, true);
371        let mut canvas = Canvas::new();
372        RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
373            .fill(SurfaceRole::Sunken)
374            .paint(&mut canvas, &test_ctx(&theme));
375        let frame = canvas.into_render_frame();
376        assert!(
377            frame
378                .decorations
379                .iter()
380                .any(|d| d.color == expected.to_array()),
381            "role fill must resolve against ctx.theme"
382        );
383    }
384
385    #[test]
386    fn rect_item_signal_fill_re_resolves_on_change() {
387        // #2 reactive: a Signal<Color> fill re-resolves each paint.
388        let theme = teksilo_core::presets::intui::light();
389        let sig = Signal::new(Color::GREEN);
390        let item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)).fill(sig.clone());
391
392        let mut c1 = Canvas::new();
393        item.paint(&mut c1, &test_ctx(&theme));
394        assert!(
395            c1.into_render_frame()
396                .decorations
397                .iter()
398                .any(|d| d.color == Color::GREEN.to_array())
399        );
400
401        sig.set(Color::RED);
402        let mut c2 = Canvas::new();
403        item.paint(&mut c2, &test_ctx(&theme));
404        assert!(
405            c2.into_render_frame()
406                .decorations
407                .iter()
408                .any(|d| d.color == Color::RED.to_array()),
409            "signal fill must re-resolve to the new value"
410        );
411    }
412
413    #[test]
414    fn rect_item_corner_radius_emits_rounded_shape() {
415        // #4: a positive corner radius routes the fill through the SDF
416        // rounded-rect path (a Shape), not a plain rect Decoration.
417        let theme = teksilo_core::presets::intui::light();
418        let mut canvas = Canvas::new();
419        RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0))
420            .fill(Color::RED)
421            .corner_radius(6.0)
422            .paint(&mut canvas, &test_ctx(&theme));
423        let frame = canvas.into_render_frame();
424        assert!(
425            !frame.shapes.is_empty(),
426            "rounded fill must emit an SDF shape"
427        );
428    }
429
430    #[test]
431    fn rect_item_stroke_styled_stores_dash_pattern() {
432        // #5: a styled stroke stores the caller's StrokeStyle verbatim.
433        // Storage only — see the two tests below for what reaches pixels.
434        // This assertion passed for the whole time a dashed RectItem drew
435        // solid, which is why it is not on its own enough.
436        let item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
437            .stroke_styled(Color::BLUE, StrokeStyle::dashed(2.0, 6.0, 4.0));
438        let (_, style) = item.stroke.as_ref().expect("stroke set");
439        assert!(
440            style.dash_pattern.is_some(),
441            "dashed stroke must keep its dash pattern"
442        );
443    }
444
445    #[test]
446    fn rect_item_dashed_stroke_reaches_the_frame_as_a_dashed_path() {
447        // The stored pattern has to survive `paint`. A square-cornered rect
448        // used to decompose into four solid `DecorationRect`s, which carry
449        // no dash field — the item drew a solid box.
450        let theme = teksilo_core::presets::intui::light();
451        let mut canvas = Canvas::new();
452        RectItem::new(Rect::new(0.0, 0.0, 100.0, 50.0))
453            .stroke_styled(Color::BLUE, StrokeStyle::dashed(2.0, 6.0, 4.0))
454            .paint(&mut canvas, &test_ctx(&theme));
455        let frame = canvas.into_render_frame();
456
457        assert!(
458            frame.decorations.is_empty(),
459            "a dashed border must not become solid decoration rects"
460        );
461        assert_eq!(frame.paths.len(), 1);
462        assert_eq!(
463            frame.paths[0].stroke_style.dash_pattern,
464            Some(vec![6.0, 4.0])
465        );
466    }
467
468    #[test]
469    fn rect_item_dashed_rounded_stroke_reaches_the_frame_as_a_dashed_path() {
470        // Same for the rounded branch, which used to emit a `ShapeQuad` —
471        // also dash-less, and with no shader that could derive one.
472        let theme = teksilo_core::presets::intui::light();
473        let mut canvas = Canvas::new();
474        RectItem::new(Rect::new(0.0, 0.0, 100.0, 50.0))
475            .corner_radius(8.0)
476            .stroke_styled(Color::BLUE, StrokeStyle::dashed(2.0, 6.0, 4.0))
477            .paint(&mut canvas, &test_ctx(&theme));
478        let frame = canvas.into_render_frame();
479
480        assert!(frame.shapes.is_empty(), "a ShapeQuad cannot carry a dash");
481        assert_eq!(frame.paths.len(), 1);
482        assert_eq!(
483            frame.paths[0].stroke_style.dash_pattern,
484            Some(vec![6.0, 4.0])
485        );
486    }
487
488    #[test]
489    fn rect_item_set_fill_replaces_colour() {
490        // #2: the SceneItem mutation hook swaps the fill in place.
491        let theme = teksilo_core::presets::intui::light();
492        let mut item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)).fill(Color::RED);
493        assert!(item.set_fill(Some(ColorProp::from(Color::BLUE))).accepted());
494        let mut canvas = Canvas::new();
495        item.paint(&mut canvas, &test_ctx(&theme));
496        assert!(
497            canvas
498                .into_render_frame()
499                .decorations
500                .iter()
501                .any(|d| d.color == Color::BLUE.to_array())
502        );
503    }
504}