Skip to main content

teksilo_widgets/styles/
recipe_radio_tile_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `RadioTileStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeRadioTileStyle` ships the IntUI "selectable card" chrome: a
7//! rounded surface with a reactive fill + border cascade driven by the
8//! tile's selection / hover / press / focus state, an optional elevation
9//! shadow for the `Elevated` variant, and content inset by padding.
10//!
11//! Like [`RecipeCardStyle`](crate::styles::RecipeCardStyle), the body is a
12//! single `RadioTileFrame` container widget that paints the chrome **and**
13//! positions the content with a padding inset — a bespoke frame rather than
14//! a `ZStack`, because a `ZStack` measures children at `unspecified()` and
15//! would break the height-for-width measurement of the tile's wrapping
16//! description (its documented limitation).
17//!
18//! The fill / border cascade mirrors
19//! [`RecipeStandardItemStyle`](crate::styles::RecipeStandardItemStyle): a
20//! selected tile shows the vivid `AccentSubtle` surface only while focused
21//! and window-active, else the muted `SelectedInactive`; the keyboard focus
22//! ring (`BorderRole::Focused`) appears only under
23//! `selected && focused && focus-visible`.
24
25use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
26use teksilo_core::accessibility::AccessNodeBuilder;
27use teksilo_core::binding::BindingLevel;
28use teksilo_core::build_context::BuildContext;
29use teksilo_core::color_prop::ColorProp;
30use teksilo_core::signal::{Prop, Signal};
31use teksilo_core::styles::{RadioTileStyle, RadioTileStyleConfig, RadioTileVariant};
32use teksilo_core::widget::{
33    LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
34};
35use teksilo_core::widget_id::WidgetId;
36use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};
37
38// IntUI design tokens for RadioTile. The recipe owns its own dimensions.
39// Corner radius matches Button (4 dp) / SegmentedControl (3 dp) — a control,
40// not a rounded card.
41pub const RADIO_TILE_CORNER_RADIUS: f32 = 4.0;
42pub const RADIO_TILE_PADDING: f32 = 14.0;
43pub const RADIO_TILE_BORDER_WIDTH: f32 = 1.0;
44pub const RADIO_TILE_SELECTED_BORDER_WIDTH: f32 = 1.5;
45pub const RADIO_TILE_FOCUS_RING_WIDTH: f32 = 2.0;
46/// 0..=1 multiplier on the elevation shadow alpha at paint time (Elevated variant).
47pub const RADIO_TILE_SHADOW_DENSITY: f32 = 0.5;
48/// Fixed row height (logical px) for a `RadioTileGroup` in
49/// `TileLayout::Vertical` — the compact settings-list arrangement.
50pub const RADIO_TILE_VERTICAL_ROW_HEIGHT: f32 = 44.0;
51
52/// Dimension recipe for [`RecipeRadioTileStyle`]. Mirrors the `pub const`
53/// defaults and allows per-instance overrides without a custom
54/// `RadioTileStyle` impl.
55#[derive(Debug, Clone, Copy, PartialEq)]
56pub struct RadioTileRecipe {
57    pub corner_radius: f32,
58    pub padding: f32,
59    pub border_width: f32,
60    pub selected_border_width: f32,
61    pub focus_ring_width: f32,
62    pub shadow_density: f32,
63    /// Fixed row height for `TileLayout::Vertical` compact rows.
64    pub vertical_row_height: f32,
65}
66
67impl Default for RadioTileRecipe {
68    fn default() -> Self {
69        Self {
70            corner_radius: RADIO_TILE_CORNER_RADIUS,
71            padding: RADIO_TILE_PADDING,
72            border_width: RADIO_TILE_BORDER_WIDTH,
73            selected_border_width: RADIO_TILE_SELECTED_BORDER_WIDTH,
74            focus_ring_width: RADIO_TILE_FOCUS_RING_WIDTH,
75            shadow_density: RADIO_TILE_SHADOW_DENSITY,
76            vertical_row_height: RADIO_TILE_VERTICAL_ROW_HEIGHT,
77        }
78    }
79}
80
81/// Default `RadioTileStyle` shipped with Teksilo.
82#[derive(Debug, Default, Clone, Copy)]
83pub struct RecipeRadioTileStyle {
84    pub recipe: RadioTileRecipe,
85}
86
87impl RecipeRadioTileStyle {
88    pub fn new(recipe: RadioTileRecipe) -> Self {
89        Self { recipe }
90    }
91}
92
93impl RadioTileStyle for RecipeRadioTileStyle {
94    fn vertical_row_height(&self) -> f32 {
95        self.recipe.vertical_row_height
96    }
97
98    fn make_body(&self, cfg: &RadioTileStyleConfig, ctx: &mut BuildContext) -> WidgetId {
99        let variant = cfg.variant;
100        // Unchosen tiles are transparent (matching SegmentedControl's
101        // unselected segments); selection tints the fill + border. The
102        // keyboard focus ring is drawn once around the whole `RadioTileGroup`,
103        // not per tile — so this style paints no per-tile ring.
104        let bg_role = tile_bg_signal(
105            &cfg.is_selected,
106            &cfg.is_pressed,
107            &cfg.is_hovered,
108            &cfg.is_window_active,
109            variant,
110        );
111
112        let border_width_base = self.recipe.border_width;
113
114        // Selection is shown by the tinted fill + the filled radio dot, not a
115        // bright accent border. The border stays neutral (hover strengthens it).
116        let border_state = cfg.is_hovered.map(move |hov| {
117            if *hov {
118                (BorderRole::Strong, border_width_base)
119            } else {
120                // Filled variant is fill-only (transparent border); Outlined /
121                // Elevated show a neutral 1 dp border.
122                match variant {
123                    RadioTileVariant::Filled => (BorderRole::Transparent, border_width_base),
124                    _ => (BorderRole::Default, border_width_base),
125                }
126            }
127        });
128        let border_role = border_state.map(|(r, _)| *r);
129        let border_width = border_state.map(|(_, w)| *w);
130
131        let frame = RadioTileFrame {
132            content_id: None,
133            pending_content: Some(PendingChild::Id(cfg.content)),
134            bg: ColorProp::DynamicSurfaceRole(bg_role),
135            border: ColorProp::DynamicBorderRole(border_role),
136            border_width: Prop::Bound(border_width),
137            corner_radius: self.recipe.corner_radius,
138            padding: self.recipe.padding,
139            variant,
140            shadow_density: self.recipe.shadow_density,
141            is_compact: cfg.is_compact,
142        };
143        ctx.add(frame)
144    }
145}
146
147/// Resting → interactive → selected surface cascade for a tile. The resting
148/// (unchosen) surface is transparent — matching SegmentedControl's unselected
149/// segments — except the `Filled` variant, which rests on a `Container`
150/// surface. Selection tints the fill (`AccentSubtle`), desaturating to the
151/// muted `SelectedInactive` when the window is inactive (the window-active
152/// convention, shared with `StandardListItem`).
153fn tile_bg_signal(
154    is_selected: &Signal<bool>,
155    is_pressed: &Signal<bool>,
156    is_hovered: &Signal<bool>,
157    is_window_active: &Signal<bool>,
158    variant: RadioTileVariant,
159) -> Signal<SurfaceRole> {
160    let combined = is_selected.zip3(is_pressed, is_hovered);
161    combined
162        .zip(is_window_active)
163        .map(move |((selected, pressed, hovered), window_active)| {
164            let resting = match variant {
165                RadioTileVariant::Filled => SurfaceRole::Container,
166                _ => SurfaceRole::Transparent,
167            };
168            if *selected {
169                if *window_active {
170                    SurfaceRole::AccentSubtle
171                } else {
172                    SurfaceRole::SelectedInactive
173                }
174            } else if *pressed {
175                SurfaceRole::Pressed
176            } else if *hovered {
177                SurfaceRole::Hover
178            } else {
179                resting
180            }
181        })
182}
183
184/// Single frame widget: paints the tile chrome (shadow + fill + border) and
185/// positions the content child inset by `padding`. Modeled on `CardFrame`
186/// so proposal propagation / height-for-width matches a padded container.
187struct RadioTileFrame {
188    content_id: Option<WidgetId>,
189    pending_content: Option<PendingChild>,
190    bg: ColorProp,
191    border: ColorProp,
192    border_width: Prop<f32>,
193    corner_radius: f32,
194    padding: f32,
195    variant: RadioTileVariant,
196    shadow_density: f32,
197    /// Compact single-line row: horizontal padding only, content centered
198    /// vertically in the fixed height (no over-constraint when short).
199    is_compact: bool,
200}
201
202impl std::fmt::Debug for RadioTileFrame {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        f.debug_struct("RadioTileFrame")
205            .field("variant", &self.variant)
206            .finish()
207    }
208}
209
210impl Widget for RadioTileFrame {
211    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
212        if let Some(pending) = self.pending_content.take() {
213            self.content_id = Some(match pending {
214                PendingChild::Id(id) => id,
215                PendingChild::Deferred(w) => ctx.add_boxed(w),
216            });
217        }
218        let id = ctx.self_id();
219        let registry = ctx.binding_registry();
220        self.bg
221            .register_if_bound(id, registry, BindingLevel::RepaintOnly);
222        self.border
223            .register_if_bound(id, registry, BindingLevel::RepaintOnly);
224        self.border_width
225            .register_if_bound(id, registry, BindingLevel::RepaintOnly);
226        self.content_id.into_iter().collect()
227    }
228
229    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
230        let inset = self.padding * 2.0;
231        if let Some(child_id) = self.content_id {
232            let inner_proposal = SizeProposal {
233                width: proposal.width.map(|w| (w - inset).max(0.0)),
234                height: proposal.height.map(|h| (h - inset).max(0.0)),
235            };
236            if let Some(child_size) = ctx.child_size(child_id, inner_proposal) {
237                return (Size::new(child_size.width + inset, child_size.height + inset)).into();
238            }
239        }
240        proposal.resolve(inset, inset).into()
241    }
242
243    fn place_children(
244        &self,
245        bounds: Rect,
246        _proposal: SizeProposal,
247        children: &mut [WidgetPlacement],
248        ctx: &LayoutContext,
249    ) {
250        let pad = self.padding;
251        let inner_w = (bounds.width - pad * 2.0).max(0.0);
252        for child in children.iter_mut() {
253            if self.is_compact {
254                // Compact single-line row: horizontal padding, and the content
255                // row is given its natural height centered in the fixed row
256                // height — so a short `TileLayout::Vertical` height never
257                // over-constrains the content (no clipping / overflow stripes).
258                let content_h = ctx
259                    .child_size(
260                        child.id,
261                        SizeProposal {
262                            width: Some(inner_w),
263                            height: None,
264                        },
265                    )
266                    .map(|s| s.height)
267                    .unwrap_or(bounds.height)
268                    .min(bounds.height);
269                let y = bounds.y + ((bounds.height - content_h) * 0.5).max(0.0);
270                child.origin = teksilo_canvas::Point::new(bounds.x + pad, y);
271                child.size = Size::new(inner_w, content_h);
272            } else {
273                child.origin = teksilo_canvas::Point::new(bounds.x + pad, bounds.y + pad);
274                child.size = Size::new(inner_w, (bounds.height - pad * 2.0).max(0.0));
275            }
276        }
277    }
278
279    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
280        let cr = CornerRadius::uniform(self.corner_radius);
281
282        // Elevation shadow (Elevated variant only), painted before the fill.
283        if matches!(self.variant, RadioTileVariant::Elevated) {
284            crate::shadow::paint_layered_shadow(
285                canvas,
286                bounds,
287                cr,
288                &ctx.theme.shape.shadow_md,
289                &ctx.theme.shape.shadow_inner_md,
290                self.shadow_density,
291                None,
292            );
293        }
294
295        // Fill.
296        let bg = self.bg.resolve(ctx.theme, ctx.effective_enabled);
297        canvas.fill_rounded_rect(bounds, cr, bg);
298
299        // Border — skipped when the role resolves transparent (Filled resting)
300        // or the width is zero.
301        let bc = self.border.resolve(ctx.theme, ctx.effective_enabled);
302        let bw = self.border_width.get();
303        if bw > 0.0 && bc.a() > 0.0 {
304            canvas.stroke_rounded_rect(bounds, cr, bc, bw);
305        }
306    }
307
308    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
309        // Presentational — the parent RadioTile emits Role::RadioButton.
310        builder.set_hidden();
311    }
312
313    fn children(&self) -> Vec<WidgetId> {
314        self.content_id.into_iter().collect()
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn selected_surface_tracks_window_active_resting_is_transparent() {
324        let selected = Signal::new(true);
325        let pressed = Signal::new(false);
326        let hovered = Signal::new(false);
327        let window_active = Signal::new(true);
328
329        let role = tile_bg_signal(
330            &selected,
331            &pressed,
332            &hovered,
333            &window_active,
334            RadioTileVariant::Outlined,
335        );
336        // Selected + window-active → vivid; inactive → muted.
337        assert_eq!(role.get(), SurfaceRole::AccentSubtle);
338        window_active.set(false);
339        assert_eq!(role.get(), SurfaceRole::SelectedInactive);
340
341        // Unchosen resting surface is transparent (SegmentedControl match).
342        selected.set(false);
343        window_active.set(true);
344        assert_eq!(role.get(), SurfaceRole::Transparent);
345    }
346
347    #[test]
348    fn filled_variant_rests_on_container_surface() {
349        let selected = Signal::new(false);
350        let pressed = Signal::new(false);
351        let hovered = Signal::new(false);
352        let window_active = Signal::new(true);
353        let role = tile_bg_signal(
354            &selected,
355            &pressed,
356            &hovered,
357            &window_active,
358            RadioTileVariant::Filled,
359        );
360        assert_eq!(role.get(), SurfaceRole::Container);
361    }
362}