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::density::{dp, spacing};
24use teksilo_core::styles::{RadioStyle, RadioStyleConfig, RadioVariant};
25use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
26use teksilo_core::widget_id::WidgetId;
27use teksilo_tokens::{Color, CornerRadius, InputTokens, TargetRole};
28
29// IntUI design tokens for RadioButton. The recipe owns its own dimensions.
30pub const RADIO_VISUAL_SIZE: f32 = 19.0;
31pub const RADIO_HIT_AREA: f32 = 24.0;
32pub const RADIO_LABEL_GAP: f32 = 6.0;
33pub const RADIO_INNER_DOT_SIZE: f32 = 7.0;
34
35/// Dimension recipe for `RecipeRadioStyle`. All tuneable sizes are collected
36/// here so an app can pass a customised `RadioRecipe` to `RecipeRadioStyle::new`
37/// without writing a full `RadioStyle` impl.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct RadioRecipe {
40    pub visual_size: f32,
41    pub hit_area: f32,
42    pub label_gap: f32,
43    pub inner_dot_size: f32,
44}
45
46impl RadioRecipe {
47    /// This recipe's dimensions resolved against a density's [`InputTokens`].
48    ///
49    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
50    /// ladder — so the shipped values below are the Compact column by
51    /// construction and cannot drift from it.
52    pub fn for_tokens(tokens: &InputTokens) -> Self {
53        Self {
54            visual_size: RADIO_VISUAL_SIZE,
55            hit_area: dp(RADIO_HIT_AREA, TargetRole::Target, tokens),
56            label_gap: spacing(RADIO_LABEL_GAP, tokens),
57            inner_dot_size: RADIO_INNER_DOT_SIZE,
58        }
59    }
60}
61
62impl Default for RadioRecipe {
63    fn default() -> Self {
64        Self::for_tokens(&InputTokens::default())
65    }
66}
67
68/// Default `RadioStyle` shipped with Teksilo. Colors come from
69/// `theme.colors.{accent, accent_hover, accent_pressed, accent_disabled,
70/// border, border_strong, border_focused}`.
71#[derive(Debug, Default, Clone, Copy)]
72pub struct RecipeRadioStyle {
73    pub recipe: RadioRecipe,
74}
75
76impl RecipeRadioStyle {
77    pub fn new(recipe: RadioRecipe) -> Self {
78        Self { recipe }
79    }
80
81    /// This style with every dimension resolved against a density's
82    /// [`InputTokens`], as `RecipeRadioStyle::for_tokens(&ctx.theme().input)` at
83    /// the widget's own build site.
84    ///
85    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
86    /// tree gets exactly the values this module documents.
87    pub fn for_tokens(tokens: &InputTokens) -> Self {
88        Self {
89            recipe: RadioRecipe::for_tokens(tokens),
90        }
91    }
92}
93
94impl RadioStyle for RecipeRadioStyle {
95    fn make_body(&self, cfg: &RadioStyleConfig, ctx: &mut BuildContext) -> WidgetId {
96        let focus_origin = cfg
97            .is_focused
98            .zip(&cfg.is_hovered)
99            .map(|(focused, hovered)| {
100                if *focused {
101                    Some(if *hovered {
102                        FocusOrigin::POINTER
103                    } else {
104                        FocusOrigin::Keyboard
105                    })
106                } else {
107                    None
108                }
109            });
110
111        ctx.add(RadioBody {
112            is_selected: cfg.is_selected.clone(),
113            is_hovered: cfg.is_hovered.clone(),
114            is_pressed: cfg.is_pressed.clone(),
115            is_disabled: cfg.is_disabled.clone(),
116            focus_origin,
117            variant: cfg.variant,
118            recipe: self.recipe,
119        })
120    }
121}
122
123struct RadioBody {
124    is_selected: Signal<bool>,
125    is_hovered: Signal<bool>,
126    is_pressed: Signal<bool>,
127    is_disabled: Signal<bool>,
128    focus_origin: Signal<Option<FocusOrigin>>,
129    variant: RadioVariant,
130    recipe: RadioRecipe,
131}
132
133impl std::fmt::Debug for RadioBody {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_struct("RadioBody")
136            .field("variant", &self.variant)
137            .finish()
138    }
139}
140
141impl Widget for RadioBody {
142    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
143        let id = ctx.self_id();
144        let registry = ctx.binding_registry();
145        self.is_selected
146            .bind_to(id, registry, BindingLevel::RepaintOnly);
147        self.is_hovered
148            .bind_to(id, registry, BindingLevel::RepaintOnly);
149        self.is_pressed
150            .bind_to(id, registry, BindingLevel::RepaintOnly);
151        self.is_disabled
152            .bind_to(id, registry, BindingLevel::RepaintOnly);
153        self.focus_origin
154            .bind_to(id, registry, BindingLevel::RepaintOnly);
155        vec![]
156    }
157
158    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
159        Size::new(self.recipe.visual_size, self.recipe.visual_size).into()
160    }
161
162    fn place_children(
163        &self,
164        _bounds: Rect,
165        _proposal: SizeProposal,
166        _children: &mut [WidgetPlacement],
167        _ctx: &LayoutContext,
168    ) {
169    }
170
171    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
172        let colors = &ctx.theme.colors;
173        let selected = self.is_selected.get();
174        let disabled = self.is_disabled.get();
175        let pressed = self.is_pressed.get();
176        let hovered = self.is_hovered.get();
177        let focused = self.focus_origin.get().is_some();
178
179        // Border color — focus wins over everything (a selected focused
180        // radio still shows the accent ring; there's no external ring).
181        let border_color = if focused {
182            colors.border_focused
183        } else if disabled {
184            colors.accent_disabled
185        } else if selected {
186            colors.accent
187        } else if hovered {
188            colors.border_strong
189        } else {
190            colors.border
191        };
192
193        let border_width = if focused {
194            ctx.theme.shape.focus_ring_width
195        } else {
196            ctx.theme.shape.border_width
197        };
198
199        // Variant-specific outer corner shape.
200        let outer_corner = match self.variant {
201            RadioVariant::Circle => CornerRadius::uniform(self.recipe.visual_size / 2.0),
202            RadioVariant::Square => CornerRadius::uniform(0.0),
203            RadioVariant::Rounded => CornerRadius::uniform(self.recipe.visual_size * 0.25),
204        };
205
206        // Outer ring background — transparent (the ring is the border).
207        canvas.fill_rounded_rect(bounds, outer_corner, Color::TRANSPARENT);
208        if border_width > 0.0 && border_color != Color::TRANSPARENT {
209            canvas.stroke_rounded_rect(bounds, outer_corner, border_color, border_width);
210        }
211
212        // Inner dot — only when selected. The Square variant uses a small
213        // inner square instead of a dot (some accessibility kits prefer
214        // the distinguishability vs. a circle).
215        if !selected {
216            return;
217        }
218        let dot_color = if disabled {
219            colors.accent_disabled
220        } else if pressed {
221            colors.accent_pressed
222        } else if hovered {
223            colors.accent_hover
224        } else {
225            colors.accent
226        };
227        let dot_size = self.recipe.inner_dot_size;
228        let dot_rect = Rect::new(
229            bounds.x + (bounds.width - dot_size) / 2.0,
230            bounds.y + (bounds.height - dot_size) / 2.0,
231            dot_size,
232            dot_size,
233        );
234        let dot_corner = match self.variant {
235            RadioVariant::Circle => CornerRadius::uniform(dot_size / 2.0),
236            RadioVariant::Square => CornerRadius::uniform(0.0),
237            RadioVariant::Rounded => CornerRadius::uniform(dot_size * 0.25),
238        };
239        canvas.fill_rounded_rect(dot_rect, dot_corner, dot_color);
240    }
241
242    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
243        // AT semantics live on the parent RadioButton; the body is
244        // presentational.
245    }
246}