Skip to main content

teksilo_widgets/styles/
recipe_radio_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `RadioStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeRadioStyle` ships the IntUI look out of the box; apps that
7//! want a different design language (Material 3 radio with growing
8//! ripple, brutalist square, custom mark) write their own
9//! `impl RadioStyle` block and install it per-call
10//! (`RadioButton::style(...)`) or theme-wide
11//! (`theme.style_slots.radio = Some(Rc::new(MyRadio))`).
12//!
13//! The body is a small leaf widget (`RadioBody`) that paints the
14//! outer ring + inner dot directly to canvas — same trade-off as
15//! `RecipeCheckboxStyle` / `RecipeToggleStyle`.
16
17use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
18use teksilo_core::accessibility::AccessNodeBuilder;
19use teksilo_core::binding::BindingLevel;
20use teksilo_core::build_context::BuildContext;
21use teksilo_core::focus::FocusOrigin;
22use teksilo_core::signal::Signal;
23use teksilo_core::styles::{RadioStyle, RadioStyleConfig, RadioVariant};
24use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
25use teksilo_core::widget_id::WidgetId;
26use teksilo_tokens::{Color, CornerRadius};
27
28// IntUI design tokens for RadioButton. The recipe owns its own dimensions.
29pub const RADIO_VISUAL_SIZE: f32 = 19.0;
30pub const RADIO_HIT_AREA: f32 = 24.0;
31pub const RADIO_LABEL_GAP: f32 = 6.0;
32pub const RADIO_INNER_DOT_SIZE: f32 = 7.0;
33
34/// Dimension recipe for `RecipeRadioStyle`. All tuneable sizes are collected
35/// here so an app can pass a customised `RadioRecipe` to `RecipeRadioStyle::new`
36/// without writing a full `RadioStyle` impl.
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct RadioRecipe {
39    pub visual_size: f32,
40    pub hit_area: f32,
41    pub label_gap: f32,
42    pub inner_dot_size: f32,
43}
44
45impl Default for RadioRecipe {
46    fn default() -> Self {
47        Self {
48            visual_size: RADIO_VISUAL_SIZE,
49            hit_area: RADIO_HIT_AREA,
50            label_gap: RADIO_LABEL_GAP,
51            inner_dot_size: RADIO_INNER_DOT_SIZE,
52        }
53    }
54}
55
56/// Default `RadioStyle` shipped with Teksilo. Colors come from
57/// `theme.colors.{accent, accent_hover, accent_pressed, accent_disabled,
58/// border, border_strong, border_focused}`.
59#[derive(Debug, Default, Clone, Copy)]
60pub struct RecipeRadioStyle {
61    pub recipe: RadioRecipe,
62}
63
64impl RecipeRadioStyle {
65    pub fn new(recipe: RadioRecipe) -> Self {
66        Self { recipe }
67    }
68}
69
70impl RadioStyle for RecipeRadioStyle {
71    fn make_body(&self, cfg: &RadioStyleConfig, ctx: &mut BuildContext) -> WidgetId {
72        let focus_origin = cfg
73            .is_focused
74            .zip(&cfg.is_hovered)
75            .map(|(focused, hovered)| {
76                if *focused {
77                    Some(if *hovered {
78                        FocusOrigin::Pointer
79                    } else {
80                        FocusOrigin::Keyboard
81                    })
82                } else {
83                    None
84                }
85            });
86
87        ctx.add(RadioBody {
88            is_selected: cfg.is_selected.clone(),
89            is_hovered: cfg.is_hovered.clone(),
90            is_pressed: cfg.is_pressed.clone(),
91            is_disabled: cfg.is_disabled.clone(),
92            focus_origin,
93            variant: cfg.variant,
94            recipe: self.recipe,
95        })
96    }
97}
98
99struct RadioBody {
100    is_selected: Signal<bool>,
101    is_hovered: Signal<bool>,
102    is_pressed: Signal<bool>,
103    is_disabled: Signal<bool>,
104    focus_origin: Signal<Option<FocusOrigin>>,
105    variant: RadioVariant,
106    recipe: RadioRecipe,
107}
108
109impl std::fmt::Debug for RadioBody {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.debug_struct("RadioBody")
112            .field("variant", &self.variant)
113            .finish()
114    }
115}
116
117impl Widget for RadioBody {
118    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
119        let id = ctx.self_id();
120        let registry = ctx.binding_registry();
121        self.is_selected
122            .bind_to(id, registry, BindingLevel::RepaintOnly);
123        self.is_hovered
124            .bind_to(id, registry, BindingLevel::RepaintOnly);
125        self.is_pressed
126            .bind_to(id, registry, BindingLevel::RepaintOnly);
127        self.is_disabled
128            .bind_to(id, registry, BindingLevel::RepaintOnly);
129        self.focus_origin
130            .bind_to(id, registry, BindingLevel::RepaintOnly);
131        vec![]
132    }
133
134    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
135        Size::new(self.recipe.visual_size, self.recipe.visual_size).into()
136    }
137
138    fn place_children(
139        &self,
140        _bounds: Rect,
141        _proposal: SizeProposal,
142        _children: &mut [WidgetPlacement],
143        _ctx: &LayoutContext,
144    ) {
145    }
146
147    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
148        let colors = &ctx.theme.colors;
149        let selected = self.is_selected.get();
150        let disabled = self.is_disabled.get();
151        let pressed = self.is_pressed.get();
152        let hovered = self.is_hovered.get();
153        let focused = self.focus_origin.get().is_some();
154
155        // Border color — focus wins over everything (a selected focused
156        // radio still shows the accent ring; there's no external ring).
157        let border_color = if focused {
158            colors.border_focused
159        } else if disabled {
160            colors.accent_disabled
161        } else if selected {
162            colors.accent
163        } else if hovered {
164            colors.border_strong
165        } else {
166            colors.border
167        };
168
169        let border_width = if focused {
170            ctx.theme.shape.focus_ring_width
171        } else {
172            ctx.theme.shape.border_width
173        };
174
175        // Variant-specific outer corner shape.
176        let outer_corner = match self.variant {
177            RadioVariant::Circle => CornerRadius::uniform(self.recipe.visual_size / 2.0),
178            RadioVariant::Square => CornerRadius::uniform(0.0),
179            RadioVariant::Rounded => CornerRadius::uniform(self.recipe.visual_size * 0.25),
180        };
181
182        // Outer ring background — transparent (the ring is the border).
183        canvas.fill_rounded_rect(bounds, outer_corner, Color::TRANSPARENT);
184        if border_width > 0.0 && border_color != Color::TRANSPARENT {
185            canvas.stroke_rounded_rect(bounds, outer_corner, border_color, border_width);
186        }
187
188        // Inner dot — only when selected. The Square variant uses a small
189        // inner square instead of a dot (some accessibility kits prefer
190        // the distinguishability vs. a circle).
191        if !selected {
192            return;
193        }
194        let dot_color = if disabled {
195            colors.accent_disabled
196        } else if pressed {
197            colors.accent_pressed
198        } else if hovered {
199            colors.accent_hover
200        } else {
201            colors.accent
202        };
203        let dot_size = self.recipe.inner_dot_size;
204        let dot_rect = Rect::new(
205            bounds.x + (bounds.width - dot_size) / 2.0,
206            bounds.y + (bounds.height - dot_size) / 2.0,
207            dot_size,
208            dot_size,
209        );
210        let dot_corner = match self.variant {
211            RadioVariant::Circle => CornerRadius::uniform(dot_size / 2.0),
212            RadioVariant::Square => CornerRadius::uniform(0.0),
213            RadioVariant::Rounded => CornerRadius::uniform(dot_size * 0.25),
214        };
215        canvas.fill_rounded_rect(dot_rect, dot_corner, dot_color);
216    }
217
218    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
219        // AT semantics live on the parent RadioButton; the body is
220        // presentational.
221    }
222}