Skip to main content

frust_widgets/
radio.rs

1//! The `Radio` interactive widget: a labelled circular indicator
2//! that reports a requested selection but is **not** its own source of truth.
3//!
4//! [`radio`] produces a [`RadioView`] carrying the current `selected` value
5//! and a label; [`RadioView::on_select`] attaches an `on_select(state)`
6//! closure fired on release inside its bounds. Unlike [`crate::checkbox`]'s
7//! `on_toggle`, `on_select` carries no value argument — a radio's release
8//! always means "select me", and the app-level callback is the one that knows
9//! *which* radio it is (e.g. `radio(freq == Daily, "Daily")
10//! .on_select(|s| s.freq = Freq::Daily)`). The widget never flips its own
11//! `selected` field; the app mutates its state in the callback and the next
12//! rebuild feeds the confirmed selection back in (the masonry rule:
13//! application state is the source of truth).
14//!
15//! **No group container in v1.** A radio group is app-level: a column of
16//! [`radio`] views sharing one piece of app state that records which is
17//! selected (see the module doc example above).
18//! There is no `RadioGroup` widget here — compare
19//! [`crate::material::button_group`], whose `Role::RadioGroup` semantics
20//! container is a *different*, already-shipped single-select control.
21
22use std::rc::Rc;
23
24use frust_core::accesskit::{Action, Role};
25use frust_core::{
26    BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent, LayoutCtx,
27    PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
28};
29use frust_scene::arc_path;
30use frust_theme::Theme;
31use kurbo::{Point, Size};
32use peniko::{Brush, Color};
33
34use crate::authoring::presses;
35use crate::text;
36
37/// Outer diameter of the radio ring, in logical px (matches
38/// [`crate::checkbox`]'s box side so a mixed column of checkboxes/radios
39/// lines up).
40const DIAMETER: f64 = 20.0;
41/// Gap between the ring and its label, in logical px.
42const GAP: f64 = 8.0;
43/// Stroke width of the outer ring.
44const RING_STROKE: f64 = 2.0;
45/// Radius of the filled inner dot, painted only when selected.
46const DOT_RADIUS: f64 = 5.0;
47/// Unselected ring stroke color (unthemed fallback; a theme resolves this
48/// from `colors.outline`).
49const RING_OFF: Color = Color::from_rgb8(0xE5, 0xE7, 0xEB);
50/// Selected ring stroke + inner dot fill color (unthemed fallback; a theme
51/// resolves this from `colors.primary`). A filled dot inside a same-colored
52/// ring is the correct M3 shape — the ring is stroked over a transparent
53/// interior, not filled, so there is no primary-colored surface underneath
54/// the dot that would call for an `on_primary`-contrast fill the way
55/// [`crate::checkbox`]'s check mark needs one.
56const RING_ON: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
57
58/// The resolved radio ring/dot color for the given `selected` state. Themed:
59/// `outline`/`primary`. Unthemed: [`RING_OFF`]/[`RING_ON`] exactly, so a
60/// pre-theme app renders unchanged.
61fn resolve_color(theme: Option<&Theme>, selected: bool) -> Color {
62    match theme {
63        Some(theme) => {
64            let scheme = theme.scheme();
65            if selected {
66                scheme.primary
67            } else {
68                scheme.outline
69            }
70        }
71        None => {
72            if selected {
73                RING_ON
74            } else {
75                RING_OFF
76            }
77        }
78    }
79}
80
81/// A view-held, typed select callback (erased on build).
82type OnSelect<State> = Rc<dyn Fn(&mut State)>;
83
84/// A declarative radio button. See the [module docs](self).
85pub struct RadioView<State: 'static> {
86    selected: bool,
87    label: String,
88    on_select: Option<OnSelect<State>>,
89}
90
91/// Create a radio reflecting `selected`, labelled `label`. Attach a callback
92/// with [`RadioView::on_select`]; a radio with none is inert (still paints
93/// and captures the press, but never fires) — the same optional-callback
94/// shape a design system's own list-item row uses.
95pub fn radio<State: 'static>(selected: bool, label: impl Into<String>) -> RadioView<State> {
96    RadioView {
97        selected,
98        label: label.into(),
99        on_select: None,
100    }
101}
102
103/// PascalCase alias for [`radio`].
104#[allow(non_snake_case)]
105pub fn Radio<State: 'static>(selected: bool, label: impl Into<String>) -> RadioView<State> {
106    radio(selected, label)
107}
108
109impl<State: 'static> RadioView<State> {
110    /// Fire `on_select(state)` on release inside this radio's bounds. Never
111    /// self-mutates `selected` — see the [module docs](self).
112    pub fn on_select<F: Fn(&mut State) + 'static>(mut self, on_select: F) -> Self {
113        self.on_select = Some(Rc::new(on_select));
114        self
115    }
116}
117
118/// The retained widget for a [`RadioView`].
119pub struct RadioWidget {
120    selected: bool,
121    label: ChildPod,
122    /// The label text, retained for the semantics node's accessible name (a
123    /// radio is a single a11y node reading its name from here rather than
124    /// recursing into the inner `TextWidget`).
125    label_text: String,
126    /// The pressed *visual* state; follows the cursor in/out while captured.
127    pressed: bool,
128    /// Armed by a `Down` (alongside `capture_pointer`), cleared on `Up`/`Cancel`.
129    /// Gates all `Move`/`Up` handling so a hover `Move` never latches `pressed`
130    /// or fires `on_select` without a preceding press.
131    captured: bool,
132    on_select: Option<crate::authoring::ErasedCallback>,
133}
134
135fn inside(pos: Point, size: Size) -> bool {
136    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
137}
138
139impl<State: 'static> View<State> for RadioView<State> {
140    type Element = RadioWidget;
141
142    fn build(&self, ctx: &mut BuildCtx<'_>) -> RadioWidget {
143        let label_view = any::<State, _>(text(self.label.clone()));
144        RadioWidget {
145            selected: self.selected,
146            label: crate::authoring::build_child(&label_view, ctx),
147            label_text: self.label.clone(),
148            pressed: false,
149            captured: false,
150            on_select: self
151                .on_select
152                .as_ref()
153                .map(crate::authoring::erase_callback),
154        }
155    }
156
157    fn rebuild(
158        &self,
159        prev: &Self,
160        element: &mut RadioWidget,
161        ctx: &mut BuildCtx<'_>,
162    ) -> ChangeFlags {
163        // Closures are not comparable — always reinstall the adapter.
164        element.on_select = self
165            .on_select
166            .as_ref()
167            .map(crate::authoring::erase_callback);
168        let mut flags = ChangeFlags::NONE;
169        if prev.selected != self.selected {
170            // The app is the source of truth: adopt the new value on rebuild.
171            element.selected = self.selected;
172            flags |= ChangeFlags::PAINT;
173        }
174        if prev.label != self.label {
175            element.label_text = self.label.clone();
176            let prev_view = any::<State, _>(text(prev.label.clone()));
177            let next_view = any::<State, _>(text(self.label.clone()));
178            flags |=
179                crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
180        }
181        flags
182    }
183
184    fn teardown(&self, element: &mut RadioWidget, ctx: &mut BuildCtx<'_>) {
185        let label_view = any::<State, _>(text(self.label.clone()));
186        crate::authoring::teardown_child(&label_view, &mut element.label, ctx);
187    }
188}
189
190impl Widget for RadioWidget {
191    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
192        let label_max = Size::new((bc.max().width - DIAMETER - GAP).max(0.0), bc.max().height);
193        let label_size = self
194            .label
195            .layout_child(ctx, &BoxConstraints::loose(label_max));
196        let height = label_size.height.max(DIAMETER);
197        // Vertically centre the label against the ring.
198        self.label.set_origin(Point::new(
199            DIAMETER + GAP,
200            (height - label_size.height) / 2.0,
201        ));
202        bc.constrain(Size::new(DIAMETER + GAP + label_size.width, height))
203    }
204
205    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
206        let color = resolve_color(Theme::from_paint_ctx(ctx), self.selected);
207        let brush = Brush::Solid(color);
208        let center = Point::new(
209            ctx.origin().x + DIAMETER / 2.0,
210            ctx.origin().y + ctx.size().height / 2.0,
211        );
212        let ring_radius = (DIAMETER - RING_STROKE) / 2.0;
213        let ring_path = arc_path(center, ring_radius, 0.0, std::f64::consts::TAU);
214        scene.stroke_path(Point::ZERO, &ring_path, RING_STROKE, &brush);
215        if self.selected {
216            let dot_path = arc_path(center, DOT_RADIUS, 0.0, std::f64::consts::TAU);
217            scene.fill_path(Point::ZERO, &dot_path, &brush);
218        }
219        self.label.paint_child(ctx, scene);
220    }
221
222    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
223        let InputEvent::Pointer(p) = event else {
224            return EventResult::Ignored;
225        };
226        match p.phase {
227            PointerPhase::Down => {
228                if !presses(p) {
229                    return EventResult::Ignored;
230                }
231                self.pressed = true;
232                self.captured = true;
233                ctx.capture_pointer();
234                ctx.request_redraw();
235                EventResult::Handled
236            }
237            PointerPhase::Move => {
238                // Only a press we armed on `Down` tracks the cursor; a hover
239                // `Move` (no prior press) is not ours.
240                if !self.captured {
241                    return EventResult::Ignored;
242                }
243                self.pressed = inside(p.position, ctx.size());
244                ctx.request_redraw();
245                EventResult::Handled
246            }
247            PointerPhase::Up => {
248                if !self.captured {
249                    return EventResult::Ignored;
250                }
251                if inside(p.position, ctx.size())
252                    && let Some(on_select) = self.on_select.as_mut()
253                {
254                    // Fire unconditionally: a release always requests
255                    // "select me", never self-mutating `selected`.
256                    (on_select)(ctx);
257                }
258                self.pressed = false;
259                self.captured = false;
260                ctx.request_redraw();
261                EventResult::Handled
262            }
263            PointerPhase::Cancel => {
264                if !self.captured {
265                    return EventResult::Ignored;
266                }
267                self.pressed = false;
268                self.captured = false;
269                ctx.request_redraw();
270                EventResult::Handled
271            }
272        }
273    }
274
275    fn semantics(&self, ctx: &mut SemanticsCtx) {
276        // A single RadioButton node carrying its selected state and label; it
277        // fires a Click to select.
278        ctx.push_node(Role::RadioButton, |node| {
279            node.set_label(self.label_text.as_str());
280            node.set_selected(self.selected);
281            node.add_action(Action::Click);
282        });
283    }
284
285    crate::authoring::visit_children!(label);
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use std::any::Any;
292
293    #[derive(Default)]
294    struct SelectState {
295        selects: u32,
296    }
297
298    fn widget(selected: bool) -> RadioWidget {
299        let view = radio::<SelectState>(selected, "daily").on_select(|s: &mut SelectState| {
300            s.selects += 1;
301        });
302        let mut counter = 0u64;
303        View::<SelectState>::build(&view, &mut BuildCtx::new(&mut counter))
304    }
305
306    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
307        InputEvent::Pointer(frust_core::PointerEvent {
308            phase,
309            position: Point::new(x, y),
310            button: frust_core::PointerButton::Primary,
311        })
312    }
313
314    fn dispatch(w: &mut RadioWidget, state: &mut SelectState, event: &InputEvent) {
315        let state_any: &mut dyn Any = state;
316        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
317        w.event(&mut ctx, event);
318    }
319
320    #[test]
321    fn unselected_fires_on_select_and_does_not_self_mutate() {
322        let mut w = widget(false);
323        let mut state = SelectState::default();
324        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
325        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
326        assert_eq!(state.selects, 1);
327        assert!(!w.selected, "radio must not mutate its own selected flag");
328    }
329
330    #[test]
331    fn already_selected_still_fires() {
332        let mut w = widget(true);
333        let mut state = SelectState::default();
334        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
335        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
336        assert_eq!(state.selects, 1);
337        assert!(w.selected, "still selected until the app rebuilds it");
338    }
339
340    #[test]
341    fn up_outside_does_not_fire() {
342        let mut w = widget(false);
343        let mut state = SelectState::default();
344        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
345        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
346        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
347        assert_eq!(state.selects, 0);
348    }
349
350    #[test]
351    fn hover_move_without_down_is_ignored_noop() {
352        let mut w = widget(false);
353        let mut state = SelectState::default();
354        let state_any: &mut dyn Any = &mut state;
355        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
356        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
357        assert!(matches!(result, EventResult::Ignored));
358        assert!(!w.pressed, "hover must not press");
359        assert!(!ctx.needs_redraw(), "hover must not request a redraw");
360        assert_eq!(state.selects, 0);
361    }
362
363    #[test]
364    fn up_without_down_does_not_fire() {
365        let mut w = widget(false);
366        let mut state = SelectState::default();
367        let state_any: &mut dyn Any = &mut state;
368        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
369        let result = w.event(&mut ctx, &ev(PointerPhase::Up, 5.0, 12.0));
370        assert!(matches!(result, EventResult::Ignored));
371        assert_eq!(state.selects, 0);
372    }
373
374    #[test]
375    fn cancel_clears_armed_state() {
376        let mut w = widget(false);
377        let mut state = SelectState::default();
378        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
379        assert!(w.captured);
380        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
381        assert!(!w.captured, "Cancel disarms the press");
382        // A follow-up hover Up must not fire.
383        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
384        assert_eq!(state.selects, 0);
385    }
386
387    /// Records the ring stroke and dot fill colors.
388    #[derive(Default)]
389    struct RingRecorder {
390        strokes: Vec<Color>,
391        fills: Vec<Color>,
392    }
393
394    impl PaintScene for RingRecorder {
395        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
396        fn draw_text(&mut self, _o: Point, _t: &str) {}
397        fn stroke_path(&mut self, _o: Point, _path: &kurbo::BezPath, _width: f64, brush: &Brush) {
398            if let Brush::Solid(c) = brush {
399                self.strokes.push(*c);
400            }
401        }
402        fn fill_path(&mut self, _o: Point, _path: &kurbo::BezPath, brush: &Brush) {
403            if let Brush::Solid(c) = brush {
404                self.fills.push(*c);
405            }
406        }
407    }
408
409    fn paint_rec(w: &mut RadioWidget, theme: Option<&Theme>) -> RingRecorder {
410        let mut rec = RingRecorder::default();
411        let mut ctx = match theme {
412            Some(t) => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)).with_theme(t),
413            None => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)),
414        };
415        w.paint(&mut ctx, &mut rec);
416        rec
417    }
418
419    #[test]
420    fn unthemed_paint_uses_fallback_constants() {
421        let mut off = widget(false);
422        let rec = paint_rec(&mut off, None);
423        assert_eq!(rec.strokes, vec![RING_OFF]);
424        assert!(rec.fills.is_empty(), "unselected radio paints no dot");
425        let mut on = widget(true);
426        let rec = paint_rec(&mut on, None);
427        assert_eq!(rec.strokes, vec![RING_ON]);
428        assert_eq!(rec.fills, vec![RING_ON]);
429    }
430
431    #[test]
432    fn themed_paint_resolves_roles() {
433        let theme = Theme::neutral();
434        let scheme = theme.scheme();
435        let mut off = widget(false);
436        assert_eq!(
437            paint_rec(&mut off, Some(&theme)).strokes,
438            vec![scheme.outline],
439            "unselected ring uses the outline role"
440        );
441        let mut on = widget(true);
442        let rec = paint_rec(&mut on, Some(&theme));
443        assert_eq!(
444            rec.strokes,
445            vec![scheme.primary],
446            "selected ring uses primary"
447        );
448        assert_eq!(rec.fills, vec![scheme.primary], "dot uses primary");
449    }
450
451    #[test]
452    fn rebuild_adopts_new_selected_value() {
453        let mut counter = 0u64;
454        let prev = radio::<SelectState>(false, "daily");
455        let mut w = View::<SelectState>::build(&prev, &mut BuildCtx::new(&mut counter));
456        assert!(!w.selected);
457        let next = radio::<SelectState>(true, "daily");
458        let flags =
459            View::<SelectState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
460        assert!(w.selected);
461        assert!(flags.needs_paint());
462    }
463
464    #[test]
465    fn semantics_reports_role_label_selected_and_bounds() {
466        fn logic(_s: &mut ()) -> RadioView<()> {
467            radio::<()>(true, "daily")
468        }
469        let mut root: frust_core::RenderRoot<(), RadioView<()>> = frust_core::RenderRoot::new();
470        let mut state = ();
471        root.rebuild(&mut logic, &mut state);
472        let mut tcx = frust_text::TextContext::new();
473        root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
474        let update = root.semantics();
475        let (_, node) = update
476            .nodes
477            .iter()
478            .find(|(_, n)| n.role() == Role::RadioButton)
479            .expect("radio contributes a Role::RadioButton node");
480        assert_eq!(node.label(), Some("daily"));
481        assert_eq!(node.is_selected(), Some(true));
482        let bounds = node.bounds().expect("radio node has bounds");
483        assert_eq!((bounds.x0, bounds.y0), (0.0, 0.0));
484    }
485}