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