Skip to main content

teksilo_widgets/styles/
recipe_checkbox_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `CheckboxStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeCheckboxStyle` ships the IntUI look out of the box; apps that
7//! want a different design language (Material 3 round checkbox, glyph
8//! badge, custom check shape) write their own `impl CheckboxStyle` block
9//! and install it per-call (`Checkbox::style(...)`) or theme-wide
10//! (`theme.style_slots.checkbox = Some(Rc::new(MyCheckbox))`).
11//!
12//! The visual body is a small leaf widget (`CheckboxBody`) that paints
13//! the box + check / dash glyph directly onto the canvas. Same trade-off
14//! as `RecipeToggleStyle`: a leaf keeps paint-cost parity with the
15//! pre-refactor Checkbox; custom impls are free to compose primitives
16//! (`RectWidget` + `IconWidget` in a `ZStack`) if they prefer.
17
18use teksilo_canvas::{Canvas, Path, Point, Rect, Size, SizeProposal};
19use teksilo_core::accessibility::AccessNodeBuilder;
20use teksilo_core::binding::BindingLevel;
21use teksilo_core::build_context::BuildContext;
22use teksilo_core::focus::FocusOrigin;
23use teksilo_core::signal::Signal;
24use teksilo_core::styles::{CheckboxState, CheckboxStyle, CheckboxStyleConfig, CheckboxVariant};
25use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
26use teksilo_core::widget_id::WidgetId;
27use teksilo_tokens::{Color, CornerRadius};
28
29// IntUI design tokens for Checkbox. The recipe owns its own dimensions.
30pub const CHECKBOX_BOX_VISUAL_SIZE: f32 = 19.0;
31pub const CHECKBOX_BOX_HIT_AREA: f32 = 24.0;
32pub const CHECKBOX_LABEL_GAP: f32 = 6.0;
33pub const CHECKBOX_CORNER_RADIUS: f32 = 3.0;
34
35/// Configurable dimensions for [`RecipeCheckboxStyle`].
36///
37/// Fields default to the IntUI `CHECKBOX_*` constants. Override individual
38/// values to tune sizing without replacing the entire style.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct CheckboxRecipe {
41    pub box_visual_size: f32,
42    pub box_hit_area: f32,
43    pub label_gap: f32,
44    pub corner_radius: f32,
45}
46
47impl Default for CheckboxRecipe {
48    fn default() -> Self {
49        Self {
50            box_visual_size: CHECKBOX_BOX_VISUAL_SIZE,
51            box_hit_area: CHECKBOX_BOX_HIT_AREA,
52            label_gap: CHECKBOX_LABEL_GAP,
53            corner_radius: CHECKBOX_CORNER_RADIUS,
54        }
55    }
56}
57
58/// Default `CheckboxStyle` shipped with Teksilo. Colors come from
59/// `theme.colors.{accent, accent_hover, accent_pressed, accent_disabled,
60/// border, border_strong, border_focused, text_on_accent, text_disabled}`.
61#[derive(Debug, Default, Clone, Copy)]
62pub struct RecipeCheckboxStyle {
63    pub recipe: CheckboxRecipe,
64}
65
66impl RecipeCheckboxStyle {
67    pub fn new(recipe: CheckboxRecipe) -> Self {
68        Self { recipe }
69    }
70}
71
72impl CheckboxStyle for RecipeCheckboxStyle {
73    fn make_body(&self, cfg: &CheckboxStyleConfig, ctx: &mut BuildContext) -> WidgetId {
74        let focus_origin = cfg
75            .is_focused
76            .zip(&cfg.is_hovered)
77            .map(|(focused, hovered)| {
78                if *focused {
79                    Some(if *hovered {
80                        FocusOrigin::Pointer
81                    } else {
82                        FocusOrigin::Keyboard
83                    })
84                } else {
85                    None
86                }
87            });
88
89        ctx.add(CheckboxBody {
90            state: cfg.state.clone(),
91            is_hovered: cfg.is_hovered.clone(),
92            is_pressed: cfg.is_pressed.clone(),
93            is_disabled: cfg.is_disabled.clone(),
94            focus_origin,
95            variant: cfg.variant,
96            recipe: self.recipe,
97        })
98    }
99}
100
101struct CheckboxBody {
102    state: Signal<CheckboxState>,
103    is_hovered: Signal<bool>,
104    is_pressed: Signal<bool>,
105    is_disabled: Signal<bool>,
106    focus_origin: Signal<Option<FocusOrigin>>,
107    variant: CheckboxVariant,
108    recipe: CheckboxRecipe,
109}
110
111impl std::fmt::Debug for CheckboxBody {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.debug_struct("CheckboxBody")
114            .field("variant", &self.variant)
115            .finish()
116    }
117}
118
119impl Widget for CheckboxBody {
120    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
121        let id = ctx.self_id();
122        let registry = ctx.binding_registry();
123        self.state.bind_to(id, registry, BindingLevel::RepaintOnly);
124        self.is_hovered
125            .bind_to(id, registry, BindingLevel::RepaintOnly);
126        self.is_pressed
127            .bind_to(id, registry, BindingLevel::RepaintOnly);
128        self.is_disabled
129            .bind_to(id, registry, BindingLevel::RepaintOnly);
130        self.focus_origin
131            .bind_to(id, registry, BindingLevel::RepaintOnly);
132        vec![]
133    }
134
135    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
136        Size::new(self.recipe.box_visual_size, self.recipe.box_visual_size).into()
137    }
138
139    fn place_children(
140        &self,
141        _bounds: Rect,
142        _proposal: SizeProposal,
143        _children: &mut [WidgetPlacement],
144        _ctx: &LayoutContext,
145    ) {
146    }
147
148    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
149        let colors = &ctx.theme.colors;
150        let state = self.state.get();
151        let is_filled = matches!(state, CheckboxState::Checked | CheckboxState::Indeterminate);
152        let disabled = self.is_disabled.get();
153        let pressed = self.is_pressed.get();
154        let hovered = self.is_hovered.get();
155        let focused = self.focus_origin.get().is_some();
156
157        // Box background — Accent family when filled, transparent
158        // otherwise. Disabled overrides everything.
159        let bg = if disabled {
160            if is_filled {
161                colors.accent_disabled
162            } else {
163                Color::TRANSPARENT
164            }
165        } else if is_filled {
166            if pressed {
167                colors.accent_pressed
168            } else if hovered {
169                colors.accent_hover
170            } else {
171                colors.accent
172            }
173        } else {
174            Color::TRANSPARENT
175        };
176
177        // Border — focus wins over everything (a filled focused checkbox
178        // still shows the accent border, since there's no external ring).
179        let border_color = if focused {
180            colors.border_focused
181        } else if disabled {
182            colors.accent_disabled
183        } else if is_filled {
184            Color::TRANSPARENT
185        } else if hovered {
186            colors.border_strong
187        } else {
188            colors.border
189        };
190
191        let border_width = if focused {
192            ctx.theme.shape.focus_ring_width
193        } else {
194            ctx.theme.shape.border_width
195        };
196
197        // Variant-specific corner shape. Square uses theme corner_radius,
198        // Rounded doubles it for a softer look, Circle uses half-size for
199        // a perfect circle.
200        let corner = match self.variant {
201            CheckboxVariant::Square => CornerRadius::uniform(self.recipe.corner_radius),
202            CheckboxVariant::Rounded => CornerRadius::uniform(self.recipe.corner_radius * 2.0),
203            CheckboxVariant::Circle => CornerRadius::uniform(self.recipe.box_visual_size / 2.0),
204        };
205
206        canvas.fill_rounded_rect(bounds, corner, bg);
207        if border_width > 0.0 && border_color != Color::TRANSPARENT {
208            canvas.stroke_rounded_rect(bounds, corner, border_color, border_width);
209        }
210
211        // Glyph — check or dash, depending on state. Painted at 75% of
212        // the box, centered.
213        if matches!(state, CheckboxState::Unchecked) {
214            return;
215        }
216        let glyph_color = if disabled {
217            colors.text_disabled
218        } else {
219            colors.text_on_accent
220        };
221        let glyph_size = self.recipe.box_visual_size * 0.75;
222        let glyph_rect = Rect::new(
223            bounds.x + (bounds.width - glyph_size) / 2.0,
224            bounds.y + (bounds.height - glyph_size) / 2.0,
225            glyph_size,
226            glyph_size,
227        );
228        let stroke_w = (glyph_size * 0.18).max(1.5);
229
230        let mut path = Path::new();
231        match state {
232            CheckboxState::Checked => {
233                // Checkmark: V-shape from (0.2, 0.55) → (0.43, 0.78) → (0.8, 0.28).
234                path.move_to(Point::new(
235                    glyph_rect.x + glyph_size * 0.20,
236                    glyph_rect.y + glyph_size * 0.55,
237                ));
238                path.line_to(Point::new(
239                    glyph_rect.x + glyph_size * 0.43,
240                    glyph_rect.y + glyph_size * 0.78,
241                ));
242                path.line_to(Point::new(
243                    glyph_rect.x + glyph_size * 0.80,
244                    glyph_rect.y + glyph_size * 0.28,
245                ));
246            }
247            CheckboxState::Indeterminate => {
248                // Horizontal dash centered in the box.
249                path.move_to(Point::new(
250                    glyph_rect.x + glyph_size * 0.20,
251                    glyph_rect.y + glyph_size * 0.50,
252                ));
253                path.line_to(Point::new(
254                    glyph_rect.x + glyph_size * 0.80,
255                    glyph_rect.y + glyph_size * 0.50,
256                ));
257            }
258            CheckboxState::Unchecked => unreachable!(),
259        }
260        canvas.stroke_path(&path, glyph_color, stroke_w);
261    }
262
263    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
264        // Accessibility lives on the parent Checkbox widget; the body is
265        // presentational.
266    }
267}