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::density::{dp, spacing};
25use teksilo_core::styles::{CheckboxState, CheckboxStyle, CheckboxStyleConfig, CheckboxVariant};
26use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
27use teksilo_core::widget_id::WidgetId;
28use teksilo_tokens::{Color, CornerRadius, InputTokens, TargetRole};
29
30// IntUI design tokens for Checkbox. The recipe owns its own dimensions.
31pub const CHECKBOX_BOX_VISUAL_SIZE: f32 = 19.0;
32pub const CHECKBOX_BOX_HIT_AREA: f32 = 24.0;
33pub const CHECKBOX_LABEL_GAP: f32 = 6.0;
34pub const CHECKBOX_CORNER_RADIUS: f32 = 3.0;
35
36/// Configurable dimensions for [`RecipeCheckboxStyle`].
37///
38/// Fields default to the IntUI `CHECKBOX_*` constants. Override individual
39/// values to tune sizing without replacing the entire style.
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct CheckboxRecipe {
42    pub box_visual_size: f32,
43    pub box_hit_area: f32,
44    pub label_gap: f32,
45    pub corner_radius: f32,
46}
47
48impl CheckboxRecipe {
49    /// This recipe's dimensions resolved against a density's [`InputTokens`].
50    ///
51    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
52    /// ladder — so the shipped values below are the Compact column by
53    /// construction and cannot drift from it.
54    pub fn for_tokens(tokens: &InputTokens) -> Self {
55        Self {
56            box_visual_size: CHECKBOX_BOX_VISUAL_SIZE,
57            box_hit_area: dp(CHECKBOX_BOX_HIT_AREA, TargetRole::Target, tokens),
58            label_gap: spacing(CHECKBOX_LABEL_GAP, tokens),
59            corner_radius: CHECKBOX_CORNER_RADIUS,
60        }
61    }
62}
63
64impl Default for CheckboxRecipe {
65    fn default() -> Self {
66        Self::for_tokens(&InputTokens::default())
67    }
68}
69
70/// Default `CheckboxStyle` shipped with Teksilo. Colors come from
71/// `theme.colors.{accent, accent_hover, accent_pressed, accent_disabled,
72/// border, border_strong, border_focused, text_on_accent, text_disabled}`.
73#[derive(Debug, Default, Clone, Copy)]
74pub struct RecipeCheckboxStyle {
75    pub recipe: CheckboxRecipe,
76}
77
78impl RecipeCheckboxStyle {
79    pub fn new(recipe: CheckboxRecipe) -> Self {
80        Self { recipe }
81    }
82
83    /// This style with every dimension resolved against a density's
84    /// [`InputTokens`], as `RecipeCheckboxStyle::for_tokens(&ctx.theme().input)` at
85    /// the widget's own build site.
86    ///
87    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
88    /// tree gets exactly the values this module documents.
89    pub fn for_tokens(tokens: &InputTokens) -> Self {
90        Self {
91            recipe: CheckboxRecipe::for_tokens(tokens),
92        }
93    }
94}
95
96impl CheckboxStyle for RecipeCheckboxStyle {
97    fn make_body(&self, cfg: &CheckboxStyleConfig, ctx: &mut BuildContext) -> WidgetId {
98        let focus_origin = cfg
99            .is_focused
100            .zip(&cfg.is_hovered)
101            .map(|(focused, hovered)| {
102                if *focused {
103                    Some(if *hovered {
104                        FocusOrigin::POINTER
105                    } else {
106                        FocusOrigin::Keyboard
107                    })
108                } else {
109                    None
110                }
111            });
112
113        ctx.add(CheckboxBody {
114            state: cfg.state.clone(),
115            is_hovered: cfg.is_hovered.clone(),
116            is_pressed: cfg.is_pressed.clone(),
117            is_disabled: cfg.is_disabled.clone(),
118            focus_origin,
119            variant: cfg.variant,
120            recipe: self.recipe,
121        })
122    }
123}
124
125struct CheckboxBody {
126    state: Signal<CheckboxState>,
127    is_hovered: Signal<bool>,
128    is_pressed: Signal<bool>,
129    is_disabled: Signal<bool>,
130    focus_origin: Signal<Option<FocusOrigin>>,
131    variant: CheckboxVariant,
132    recipe: CheckboxRecipe,
133}
134
135impl std::fmt::Debug for CheckboxBody {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.debug_struct("CheckboxBody")
138            .field("variant", &self.variant)
139            .finish()
140    }
141}
142
143impl Widget for CheckboxBody {
144    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
145        let id = ctx.self_id();
146        let registry = ctx.binding_registry();
147        self.state.bind_to(id, registry, BindingLevel::RepaintOnly);
148        self.is_hovered
149            .bind_to(id, registry, BindingLevel::RepaintOnly);
150        self.is_pressed
151            .bind_to(id, registry, BindingLevel::RepaintOnly);
152        self.is_disabled
153            .bind_to(id, registry, BindingLevel::RepaintOnly);
154        self.focus_origin
155            .bind_to(id, registry, BindingLevel::RepaintOnly);
156        vec![]
157    }
158
159    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
160        Size::new(self.recipe.box_visual_size, self.recipe.box_visual_size).into()
161    }
162
163    fn place_children(
164        &self,
165        _bounds: Rect,
166        _proposal: SizeProposal,
167        _children: &mut [WidgetPlacement],
168        _ctx: &LayoutContext,
169    ) {
170    }
171
172    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
173        let colors = &ctx.theme.colors;
174        let state = self.state.get();
175        let is_filled = matches!(state, CheckboxState::Checked | CheckboxState::Indeterminate);
176        let disabled = self.is_disabled.get();
177        let pressed = self.is_pressed.get();
178        let hovered = self.is_hovered.get();
179        let focused = self.focus_origin.get().is_some();
180
181        // Box background — Accent family when filled, transparent
182        // otherwise. Disabled overrides everything.
183        let bg = if disabled {
184            if is_filled {
185                colors.accent_disabled
186            } else {
187                Color::TRANSPARENT
188            }
189        } else if is_filled {
190            if pressed {
191                colors.accent_pressed
192            } else if hovered {
193                colors.accent_hover
194            } else {
195                colors.accent
196            }
197        } else {
198            Color::TRANSPARENT
199        };
200
201        // Border — focus wins over everything (a filled focused checkbox
202        // still shows the accent border, since there's no external ring).
203        let border_color = if focused {
204            colors.border_focused
205        } else if disabled {
206            colors.accent_disabled
207        } else if is_filled {
208            Color::TRANSPARENT
209        } else if hovered {
210            colors.border_strong
211        } else {
212            colors.border
213        };
214
215        let border_width = if focused {
216            ctx.theme.shape.focus_ring_width
217        } else {
218            ctx.theme.shape.border_width
219        };
220
221        // Variant-specific corner shape. Square uses theme corner_radius,
222        // Rounded doubles it for a softer look, Circle uses half-size for
223        // a perfect circle.
224        let corner = match self.variant {
225            CheckboxVariant::Square => CornerRadius::uniform(self.recipe.corner_radius),
226            CheckboxVariant::Rounded => CornerRadius::uniform(self.recipe.corner_radius * 2.0),
227            CheckboxVariant::Circle => CornerRadius::uniform(self.recipe.box_visual_size / 2.0),
228        };
229
230        canvas.fill_rounded_rect(bounds, corner, bg);
231        if border_width > 0.0 && border_color != Color::TRANSPARENT {
232            canvas.stroke_rounded_rect(bounds, corner, border_color, border_width);
233        }
234
235        // Glyph — check or dash, depending on state. Painted at 75% of
236        // the box, centered.
237        if matches!(state, CheckboxState::Unchecked) {
238            return;
239        }
240        let glyph_color = if disabled {
241            colors.text_disabled
242        } else {
243            colors.text_on_accent
244        };
245        let glyph_size = self.recipe.box_visual_size * 0.75;
246        let glyph_rect = Rect::new(
247            bounds.x + (bounds.width - glyph_size) / 2.0,
248            bounds.y + (bounds.height - glyph_size) / 2.0,
249            glyph_size,
250            glyph_size,
251        );
252        let stroke_w = (glyph_size * 0.18).max(1.5);
253
254        let mut path = Path::new();
255        match state {
256            CheckboxState::Checked => {
257                // Checkmark: V-shape from (0.2, 0.55) → (0.43, 0.78) → (0.8, 0.28).
258                path.move_to(Point::new(
259                    glyph_rect.x + glyph_size * 0.20,
260                    glyph_rect.y + glyph_size * 0.55,
261                ));
262                path.line_to(Point::new(
263                    glyph_rect.x + glyph_size * 0.43,
264                    glyph_rect.y + glyph_size * 0.78,
265                ));
266                path.line_to(Point::new(
267                    glyph_rect.x + glyph_size * 0.80,
268                    glyph_rect.y + glyph_size * 0.28,
269                ));
270            }
271            CheckboxState::Indeterminate => {
272                // Horizontal dash centered in the box.
273                path.move_to(Point::new(
274                    glyph_rect.x + glyph_size * 0.20,
275                    glyph_rect.y + glyph_size * 0.50,
276                ));
277                path.line_to(Point::new(
278                    glyph_rect.x + glyph_size * 0.80,
279                    glyph_rect.y + glyph_size * 0.50,
280                ));
281            }
282            CheckboxState::Unchecked => unreachable!(),
283        }
284        canvas.stroke_path(&path, glyph_color, stroke_w);
285    }
286
287    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
288        // Accessibility lives on the parent Checkbox widget; the body is
289        // presentational.
290    }
291}