Skip to main content

teksilo_widgets/primitives/
rect_widget.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! RectWidget — a leaf widget that paints a filled and/or stroked rounded rectangle.
5//!
6//! `RectWidget` has no intrinsic content: it fills whatever space its parent
7//! proposes (or reports `0×0` when unconstrained) and draws a fill (solid color
8//! or gradient), an optional border (a uniform stroke positioned inside / center
9//! / outside, or per-side edge fills for an underline), and an optional corner
10//! radius. It is the low-level building block for card backgrounds, focus rings,
11//! dividers, underlined fields, and highlight overlays.
12//!
13//! The fill accepts `impl Into<PaintProp>` — anything `Into<ColorProp>` (a raw
14//! `Color`, a theme role such as `SurfaceRole::Hover`, or a `Signal<Color>`) for
15//! a solid, plus `PaintProp::Linear` / `Radial` for a gradient. Border color
16//! accepts `impl Into<ColorProp>`, so reactive interaction-driven colors require
17//! no extra wiring.
18//!
19//! ```rust
20//! # use teksilo_tokens::{Color, CornerRadius};
21//! # use teksilo_widgets::primitives::RectWidget;
22//! // A pill-shaped accent badge background:
23//! let _w = RectWidget::new()
24//!     .background(Color::from_rgba(0.2, 0.5, 1.0, 1.0))
25//!     .corner_radius(CornerRadius::uniform(12.0));
26//! ```
27
28use teksilo_canvas::{Canvas, Paint, Rect, Size, SizeProposal};
29use teksilo_tokens::{Color, CornerRadius};
30
31use teksilo_core::accessibility::AccessNodeBuilder;
32use teksilo_core::color_prop::ColorProp;
33use teksilo_core::paint_prop::PaintProp;
34use teksilo_core::signal::Prop;
35use teksilo_core::styles::{BorderPosition, BorderSides, apply_border_position};
36use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
37
38/// A leaf widget that paints a filled and/or stroked rounded rectangle.
39///
40/// See the [module documentation](self) for the full feature description.
41/// All visual properties accept `impl Into<ColorProp>` (colors/roles/signals) or
42/// `impl Into<Prop<f32>>` / `impl Into<Prop<CornerRadius>>` (static or reactive)
43/// — so the common "fill with theme surface, border with theme border" setup is
44/// just `.background(SurfaceRole::Main).border_color(BorderRole::Default)`.
45pub struct RectWidget {
46    background: PaintProp,
47    border_color: ColorProp,
48    border_width: Prop<f32>,
49    corner_radius: Prop<CornerRadius>,
50    /// `None` = a uniform stroke on all four sides (honouring
51    /// `border_position`). `Some(..)` = per-side edge fills (e.g. a
52    /// bottom-only underline), drawn with `border_color`.
53    border_sides: Prop<Option<BorderSides>>,
54    /// Where a uniform stroke sits relative to the rect edge. Default
55    /// `Center` matches the SDF stroke's native behaviour.
56    border_position: BorderPosition,
57}
58
59impl RectWidget {
60    /// Create a fully transparent, zero-border rectangle with no corner radius.
61    pub fn new() -> Self {
62        Self {
63            background: PaintProp::Solid(ColorProp::Static(Color::TRANSPARENT)),
64            border_color: ColorProp::Static(Color::TRANSPARENT),
65            border_width: Prop::Static(0.0),
66            corner_radius: Prop::Static(CornerRadius::ZERO),
67            border_sides: Prop::Static(None),
68            border_position: BorderPosition::Center,
69        }
70    }
71
72    /// Fill. Accepts `Color`, a theme role (`SurfaceRole`, etc.), a
73    /// `Signal<Color>`, or a [`PaintProp`] (e.g. a gradient).
74    pub fn background(mut self, paint: impl Into<PaintProp>) -> Self {
75        self.background = paint.into();
76        self
77    }
78
79    /// Per-side border widths (e.g. [`BorderSides::bottom`] for an
80    /// underline). When set, overrides the uniform stroke; sides are
81    /// drawn as edge fills in `border_color`.
82    pub fn border_sides(mut self, sides: impl Into<Prop<Option<BorderSides>>>) -> Self {
83        self.border_sides = sides.into();
84        self
85    }
86
87    /// Where a uniform stroke sits relative to the rect edge
88    /// (inside / center / outside). Ignored when `border_sides` is set.
89    pub fn border_position(mut self, position: BorderPosition) -> Self {
90        self.border_position = position;
91        self
92    }
93
94    /// Border color. Accepts `Color`, a theme role (`BorderRole`, etc.),
95    /// or a `Signal<Color>`.
96    pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self {
97        self.border_color = color.into();
98        self
99    }
100
101    /// Stroke width, in logical pixels. Accepts a static value or a reactive `Signal<f32>`.
102    pub fn border_width(mut self, width: impl Into<Prop<f32>>) -> Self {
103        self.border_width = width.into();
104        self
105    }
106
107    /// Corner radius for the fill and stroke. Accepts a `CornerRadius` (per-corner
108    /// control) or a reactive `Signal<CornerRadius>`.
109    pub fn corner_radius(mut self, radius: impl Into<Prop<CornerRadius>>) -> Self {
110        self.corner_radius = radius.into();
111        self
112    }
113}
114
115impl Default for RectWidget {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl std::fmt::Debug for RectWidget {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.debug_struct("RectWidget").finish()
124    }
125}
126
127impl Widget for RectWidget {
128    fn build(
129        &mut self,
130        ctx: &mut teksilo_core::build_context::BuildContext,
131    ) -> Vec<teksilo_core::widget_id::WidgetId> {
132        let self_id = ctx.self_id();
133        let registry = ctx.binding_registry();
134        self.background.register_if_bound(
135            self_id,
136            registry,
137            teksilo_core::binding::BindingLevel::RepaintOnly,
138        );
139        self.border_color.register_if_bound(
140            self_id,
141            registry,
142            teksilo_core::binding::BindingLevel::RepaintOnly,
143        );
144        self.border_width.register_if_bound(
145            self_id,
146            registry,
147            teksilo_core::binding::BindingLevel::RepaintOnly,
148        );
149        self.corner_radius.register_if_bound(
150            self_id,
151            registry,
152            teksilo_core::binding::BindingLevel::RepaintOnly,
153        );
154        self.border_sides.register_if_bound(
155            self_id,
156            registry,
157            teksilo_core::binding::BindingLevel::RepaintOnly,
158        );
159        Vec::new()
160    }
161
162    fn layout_response(
163        &self,
164        proposal: SizeProposal,
165        _ctx: &LayoutContext,
166    ) -> teksilo_core::widget::LayoutResponse {
167        // RectWidget has no intrinsic content — it accepts whatever space is offered.
168        // With an exact proposal it fills the space; with unspecified it reports 0x0.
169        proposal.resolve(0.0, 0.0).into()
170    }
171
172    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
173        let radius = self.corner_radius.get();
174
175        // Fill — a solid color or a gradient. Gradient endpoints are
176        // rect-local, so only the size is needed.
177        let paint = self.background.resolve(
178            ctx.theme,
179            ctx.effective_enabled,
180            Size::new(bounds.width, bounds.height),
181        );
182        let skip_fill = matches!(&paint, Paint::Solid(c) if c.a() <= 0.0);
183        if !skip_fill {
184            canvas.fill_rounded_rect(bounds, radius, paint);
185        }
186
187        // Border — per-side edge fills, or a uniform stroke.
188        let bc = self.border_color.resolve(ctx.theme, ctx.effective_enabled);
189        if bc.a() <= 0.0 {
190            return;
191        }
192        match self.border_sides.get() {
193            Some(sides) => paint_border_sides(canvas, bounds, sides, bc),
194            None => {
195                let bw = self.border_width.get();
196                if bw > 0.0 {
197                    let stroke_rect = apply_border_position(bounds, bw, self.border_position);
198                    canvas.stroke_rounded_rect(stroke_rect, radius, bc, bw);
199                }
200            }
201        }
202    }
203
204    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {}
205}
206
207/// Draw the non-zero edges of a per-side border as filled rects.
208/// `Leading`/`Trailing` map to left/right (LTR); RTL flipping is a
209/// follow-up — the common case (a bottom underline) is direction-neutral.
210fn paint_border_sides(canvas: &mut Canvas, bounds: Rect, sides: BorderSides, color: Color) {
211    if sides.top > 0.0 {
212        canvas.fill_rect(
213            Rect::new(bounds.x, bounds.y, bounds.width, sides.top),
214            color,
215        );
216    }
217    if sides.bottom > 0.0 {
218        canvas.fill_rect(
219            Rect::new(
220                bounds.x,
221                bounds.y + bounds.height - sides.bottom,
222                bounds.width,
223                sides.bottom,
224            ),
225            color,
226        );
227    }
228    if sides.leading > 0.0 {
229        canvas.fill_rect(
230            Rect::new(bounds.x, bounds.y, sides.leading, bounds.height),
231            color,
232        );
233    }
234    if sides.trailing > 0.0 {
235        canvas.fill_rect(
236            Rect::new(
237                bounds.x + bounds.width - sides.trailing,
238                bounds.y,
239                sides.trailing,
240                bounds.height,
241            ),
242            color,
243        );
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use teksilo_core::signal::Signal;
251    use teksilo_core::widget_tree::WidgetTree;
252
253    #[test]
254    fn static_background_paints_correctly() {
255        let mut tree = WidgetTree::new();
256        tree.add(
257            RectWidget::new()
258                .background(Color::RED)
259                .corner_radius(CornerRadius::uniform(4.0)),
260        );
261        tree.layout(SizeProposal::exact(100.0, 40.0));
262        let frame = tree.render();
263        assert_eq!(frame.shapes.len(), 1);
264        assert_eq!(frame.shapes[0].color, Color::RED.to_array());
265    }
266
267    #[test]
268    fn accent_role_desaturates_when_window_inactive() {
269        use teksilo_tokens::SurfaceRole;
270
271        let theme = teksilo_core::presets::intui::light();
272        let accent = theme.colors.accent.to_array();
273        let inactive_accent = theme.colors.for_inactive_window().accent.to_array();
274        assert_ne!(accent, inactive_accent);
275
276        let mut tree = WidgetTree::new().with_theme(theme);
277        tree.add(
278            RectWidget::new()
279                .background(SurfaceRole::Accent)
280                .corner_radius(CornerRadius::uniform(4.0)),
281        );
282        tree.layout(SizeProposal::exact(100.0, 40.0));
283
284        // Active: the role resolves to the vivid accent.
285        let frame = tree.render();
286        assert_eq!(frame.shapes.len(), 1);
287        assert_eq!(
288            frame.shapes[0].color, accent,
289            "active window paints the vivid accent"
290        );
291
292        // Inactive: the paint walker swaps in the accent-desaturated theme
293        // projection, so the *same* SurfaceRole::Accent resolves to the muted
294        // colour — the systemic theme-side path that greys every accent control
295        // (Toggle, Button, Tab, Segment, Checkbox/Radio, Slider, ProgressBar)
296        // with no per-widget code.
297        tree.set_window_active(false);
298        let frame = tree.render();
299        assert_eq!(frame.shapes.len(), 1);
300        assert_eq!(
301            frame.shapes[0].color, inactive_accent,
302            "inactive window desaturates the accent"
303        );
304
305        // Reactivate: vivid accent returns.
306        tree.set_window_active(true);
307        let frame = tree.render();
308        assert_eq!(frame.shapes[0].color, accent);
309    }
310
311    #[test]
312    fn background_reads_from_state() {
313        let color = Signal::new(Color::BLUE);
314        let mut tree = WidgetTree::new();
315        let w = tree.add(
316            RectWidget::new()
317                .background(color.clone())
318                .corner_radius(CornerRadius::uniform(4.0)),
319        );
320        color.bind_to(
321            w,
322            tree.binding_registry(),
323            teksilo_core::binding::BindingLevel::RepaintOnly,
324        );
325        tree.layout(SizeProposal::exact(100.0, 40.0));
326        let frame = tree.render();
327        assert_eq!(frame.shapes[0].color, Color::BLUE.to_array());
328    }
329
330    #[test]
331    fn underline_draws_a_bottom_decoration() {
332        let mut tree = WidgetTree::new();
333        tree.add(
334            RectWidget::new()
335                .border_color(Color::RED)
336                .border_sides(Some(BorderSides::bottom(2.0))),
337        );
338        tree.layout(SizeProposal::exact(100.0, 40.0));
339        let frame = tree.render();
340        // The bottom underline is an edge-fill decoration in the border color.
341        let underline = frame
342            .decorations
343            .iter()
344            .find(|d| d.color == Color::RED.to_array())
345            .expect("underline decoration present");
346        // rect = [x, y, w, h]; bottom edge sits at y = height - width.
347        assert_eq!(underline.rect[1], 38.0);
348        assert_eq!(underline.rect[3], 2.0);
349        // No uniform stroke shape was emitted.
350        assert!(frame.shapes.iter().all(|s| s.stroke_width == 0.0));
351    }
352
353    #[test]
354    fn gradient_background_emits_linear_gradient_paint() {
355        use teksilo_canvas::render_frame::PaintData;
356        use teksilo_core::paint_prop::{GradientStopProp, PaintProp};
357
358        let mut tree = WidgetTree::new();
359        tree.add(RectWidget::new().background(PaintProp::Linear {
360            stops: vec![
361                GradientStopProp {
362                    offset: 0.0,
363                    color: Color::RED.into(),
364                },
365                GradientStopProp {
366                    offset: 1.0,
367                    color: Color::BLUE.into(),
368                },
369            ],
370            angle_deg: 90.0,
371        }));
372        tree.layout(SizeProposal::exact(100.0, 40.0));
373        let frame = tree.render();
374        assert_eq!(frame.shapes.len(), 1);
375        assert!(matches!(
376            frame.shapes[0].paint_data,
377            PaintData::LinearGradient { .. }
378        ));
379    }
380
381    #[test]
382    fn background_updates_on_state_change() {
383        let color = Signal::new(Color::RED);
384        let mut tree = WidgetTree::new();
385        let w = tree.add(
386            RectWidget::new()
387                .background(color.clone())
388                .corner_radius(CornerRadius::uniform(4.0)),
389        );
390        color.bind_to(
391            w,
392            tree.binding_registry(),
393            teksilo_core::binding::BindingLevel::RepaintOnly,
394        );
395
396        tree.layout(SizeProposal::exact(100.0, 40.0));
397        let frame = tree.render();
398        assert_eq!(frame.shapes[0].color, Color::RED.to_array());
399
400        // Change the state
401        color.set(Color::GREEN);
402        tree.layout(SizeProposal::exact(100.0, 40.0));
403        let frame = tree.render();
404        assert_eq!(frame.shapes[0].color, Color::GREEN.to_array());
405    }
406}