Skip to main content

frust_material/
radio.rs

1//! The M3 `Radio` button: a generic value/group-value single-selection
2//! control with an optional label slot, an error flavor, and an inner-dot
3//! scale-in animation.
4//!
5//! Source: `paadevelopments/material_3_expressive` v1.0.8 (MIT, © 2026 Paa
6//! Developments — see `plugins/material/NOTICE`'s root-crate MIT section),
7//! `lib/components/radio_button/{m3e_radio_button.dart,
8//! styles/m3e_radio_theme.dart}`'s `M3ERadio<T>`/`M3ERadioTheme` (retrieved
9//! 2026-08-19). No license changes; the generic-`T` selection model, the
10//! optional label slot, and the dot scale-in are all preserved from the Dart
11//! source — see the sections below for what changed in translation.
12//!
13//! # Naming: plain `radio`/`Radio`, deliberately
14//!
15//! Baseline `frust-widgets` already ships a non-generic `radio`/[`Radio`]
16//! ([`frust::radio`]) taking a plain `bool` `selected`. This module reuses
17//! the same plain names in `frust_material` on purpose — the same
18//! cross-crate naming call `frust_material::switch`/[`super::switch::Switch`]
19//! already made against a baseline widget with no direct counterpart, now
20//! extended to a name baseline *does* have one for. Two catalogs (or a
21//! catalog and the baseline set) legitimately reuse a vocabulary word; an app
22//! picks exactly one by import path (`frust_material::radio` vs.
23//! `frust::radio`), never both in the same call site. This crate's own
24//! `radio` is a strict superset of what the baseline widget can express
25//! (generic `T`, group-value selection, optional label, error flavor,
26//! animation) — recorded here as the deliberate decision, not an oversight.
27//!
28//! # Generic selection model
29//!
30//! [`radio`] takes an owned `value: T` and `group_value: T` (`T: PartialEq +
31//! Clone + 'static`, matching the Dart source's `T`/`T?` pair) and computes
32//! `selected = value == group_value` once, at `View::build`/`rebuild` — the
33//! same shape `M3ERadio<T>`'s `_selected` getter uses. [`RadioView::on_changed`]
34//! attaches a single `Fn(&mut State, T) + 'static` callback shared by every
35//! member of a group (mirroring Dart's `ValueChanged<T>`): a release inside
36//! bounds fires `on_changed(state, value.clone())` — reporting *this* radio's
37//! own value, not a derived index — and never self-mutates. `T` is erased out
38//! of the retained [`RadioWidget`] entirely: `on_changed` is bound with
39//! `value` already captured into a plain [`frust::authoring::ErasedCallback`]
40//! at build/rebuild time, so the widget itself carries no generic parameter
41//! (the same type-erase-to-avoid-a-generic idiom `docs/CODE_STANDARDS.md`'s
42//! Language Idioms describes for a downstream-crate dependency, applied here
43//! to a generic instead).
44//!
45//! # Enabled/disabled
46//!
47//! Mirroring the Dart source's `_enabled => onChanged != null` (and
48//! `M3ETappable`'s `_isInteractive = enabled && onTap != null`, which for
49//! `M3ERadio` reduces to the same condition since it never sets `enabled`
50//! itself): a radio built with no [`RadioView::on_changed`] call is
51//! **disabled** — it paints its ring/dot dimmed to
52//! [`crate::interaction::DISABLED_CONTENT_OPACITY`] (also the Dart source's
53//! own `disabledOpacity: 0.38`, so the values already agree) over `on_surface`
54//! and ignores every pointer event outright, never entering a pressed/captured
55//! state. A disabled radio still tracks `selected` (and animates its dot)
56//! when the *group's* value changes out from under it via a sibling's own
57//! `on_changed` — the Dart source's `AnimatedScale` is keyed on `_selected`
58//! alone, independent of `_enabled`, and this port preserves that. The one
59//! documented simplification: the optional label's text color does not
60//! itself dim when disabled (the Dart source multiplies `onSurface`'s alpha
61//! for the label style) — this crate's `frust::text` has no themed-role
62//! variant carrying a fixed alpha multiplier, only a plain
63//! [`frust::authoring::ThemeTextColor`] role or an explicit
64//! [`frust::authoring::AnyView`]-time [`Color`] override neither `build` nor
65//! `rebuild` can theme-resolve (see `docs/CODE_STANDARDS.md`'s Theming
66//! conventions: only `LayoutCtx`/`PaintCtx` thread a theme). The primary
67//! disabled signal — the ring/dot dimming — carries the state correctly; a
68//! themed-role-at-reduced-alpha text API is future work, not something this
69//! module can reach for.
70//!
71//! # Dot scale-in: duration + curve, not a spring
72//!
73//! The reference's `AnimatedScale(scale: _selected ? 1 : 0, duration:
74//! M3EMotion.short4, curve: M3EMotion.emphasizedDecelerate)` is a
75//! **duration-driven**, not spring-driven, implicit animation — unlike this
76//! crate's other toggle-style controls ([`super::switch`], `NavigationBar`'s
77//! selection pill), which all animate via
78//! [`frust::AnimationController::fling`] against a [`frust::SpringDesc`].
79//! This module instead drives [`RadioWidget::anim`] with
80//! [`frust::AnimationController::forward`]/[`frust::AnimationController::reverse`]
81//! against a fixed duration+curve built once from
82//! [`crate::tokens::MaterialMotion::SHORT_4`]/
83//! [`crate::tokens::MaterialMotion::EMPHASIZED_DECELERATE`] — both compile-time
84//! crate constants, not a per-instance `Theme` lookup, so (unlike `switch`'s
85//! themed spring, which must wait for `paint` to have a theme in scope) the
86//! motion can start directly in `rebuild` the moment `selected` actually
87//! changes. Flutter's `AnimatedScale` applies the *same* curve shape whether
88//! animating toward `1` or back toward `0` (not a mirrored/reversed curve);
89//! [`frust::AnimationController`]'s own duration-driven interpolation
90//! (`value = start + (target - start) * curve.transform(elapsed / duration)`)
91//! has the identical property, so `forward`/`reverse` reproduce the Dart
92//! source's both-directions animated deselect exactly — a verified fact, not
93//! an assumption (`crates/frust-core/src/anim.rs`'s `Drive::Duration` arm).
94//!
95//! An initially-selected radio never plays a pop-in on first mount (the same
96//! contract Flutter's `ImplicitlyAnimatedWidget` gives `AnimatedScale`, and
97//! this crate's own [`super::navbar`] indicator pill and [`super::switch`]
98//! thumb already establish for their own first-mount snap): [`View::build`]
99//! settles [`RadioWidget::anim`] to `1.0` with two `advance` calls — the
100//! first only seeds the controller's clock (a documented no-op per
101//! [`frust::AnimationController::advance`]), the second covers the whole
102//! `SHORT_4` span in one step, so no intermediate frame is ever painted.
103//!
104//! # Haptics: fires the hook, with the reference's own choice — none
105//!
106//! [`crate::interaction::MaterialHaptics`] is fired on every successful
107//! select — but the Dart source's own `M3ETappable(haptic: ...)` parameter is
108//! never passed by `M3ERadio` (verified: neither the widget nor any example
109//! usage in the reference package overrides it), so it takes
110//! `M3ETappable`'s own default, `M3EHapticFeedback.none`. This module
111//! preserves that choice exactly — [`crate::interaction::HapticSignal::None`]
112//! is documented as an explicit no-op, identical to firing nothing at all —
113//! while still wiring the call site through the shared hook (this crate's
114//! first real [`MaterialHaptics::fire`] consumer), so a future upstream
115//! change to `M3ERadio`'s own haptic wiring has exactly one call site to
116//! update here.
117
118use std::rc::Rc;
119
120use frust::authoring::{Action, Role};
121use frust::authoring::{
122    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
123    LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, ThemeTextColor, ThemeTextType,
124    TypedArgCallback, View, Widget, any,
125};
126use frust::{AnimationController, FrameTime, Theme};
127use kurbo::{Circle, Point, Rect, Shape, Size};
128use peniko::{Brush, Color};
129
130use super::press::presses;
131use super::state_layer::StateLayer;
132use crate::interaction::{DISABLED_CONTENT_OPACITY, HapticSignal, MaterialHaptics};
133use crate::tokens::MaterialMotion;
134
135/// The control's own tap-target/state-layer diameter, in logical px
136/// (`M3ERadioTheme.hitSize`) — also the width the control column reserves in
137/// layout, wider than the visible ring so the state layer can extend past it.
138const HIT_SIZE: f64 = 40.0;
139/// The visible outer ring's diameter, in logical px (`M3ERadioTheme.ringSize`).
140const RING_SIZE: f64 = 20.0;
141/// The inner dot's fully-scaled-in diameter, in logical px
142/// (`M3ERadioTheme.dotSize`).
143const DOT_SIZE: f64 = 10.0;
144/// The ring's stroke width, in logical px (`M3ERadioTheme.borderWidth`).
145const BORDER_WIDTH: f64 = 2.0;
146/// Gap between the control and an optional label, in logical px
147/// (`M3ERadioTheme.labelGap`).
148const LABEL_GAP: f64 = 8.0;
149/// Flattening tolerance for the ring's stroked-circle path — a
150/// visually-lossless value for on-screen radii, matching
151/// [`super::card`]'s `STROKE_TOLERANCE` precedent (the same primitive-choice
152/// rationale: no stroked-circle primitive exists on
153/// [`frust::authoring::PaintScene`], only `stroke_path` over a `kurbo`
154/// [`kurbo::Shape`]).
155const STROKE_TOLERANCE: f64 = 0.1;
156
157/// Unthemed fallback selected-ring/dot color (a theme resolves this from
158/// `colors.primary`).
159const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
160/// Unthemed fallback unselected-ring color (a theme resolves this from
161/// `colors.on_surface_variant`).
162const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
163/// Unthemed fallback state-layer base color, both states share the ring's
164/// selected color only when selected (a theme resolves this from
165/// `colors.on_surface`).
166const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
167/// Unthemed fallback error-flavor ring/dot color (a theme resolves this from
168/// `colors.error`).
169const ERROR: Color = Color::from_rgb8(0xB3, 0x26, 0x1E);
170
171/// Return `color` with its alpha channel replaced by `alpha` (mirrors
172/// [`super::state_layer`]'s helper of the same shape).
173fn with_alpha(color: Color, alpha: f32) -> Color {
174    let c = color.components;
175    Color::new([c[0], c[1], c[2], alpha])
176}
177
178/// The resolved `(ring_color, state_layer_color)` pair.
179///
180/// Ring/dot (`M3ERadioTheme.color`): disabled wins over everything —
181/// `on_surface` at [`DISABLED_CONTENT_OPACITY`]; else error wins over
182/// selection — `colors.error`; else `primary` if selected, `on_surface_variant`
183/// otherwise. State layer (`M3ERadioTheme.stateLayerColor`): `primary` if
184/// selected, `on_surface` otherwise — **not** conditioned on `error`, exactly
185/// mirroring the Dart source (the state-layer overlay tints the same either
186/// way; only the ring/dot recolor for the error flavor).
187fn resolve_colors(
188    theme: Option<&Theme>,
189    selected: bool,
190    error: bool,
191    enabled: bool,
192) -> (Color, Color) {
193    match theme {
194        Some(theme) => {
195            let scheme = theme.scheme();
196            let ring = if !enabled {
197                with_alpha(scheme.on_surface, DISABLED_CONTENT_OPACITY)
198            } else if error {
199                scheme.error
200            } else if selected {
201                scheme.primary
202            } else {
203                scheme.on_surface_variant
204            };
205            let state_layer = if selected {
206                scheme.primary
207            } else {
208                scheme.on_surface
209            };
210            (ring, state_layer)
211        }
212        None => {
213            let ring = if !enabled {
214                with_alpha(ON_SURFACE, DISABLED_CONTENT_OPACITY)
215            } else if error {
216                ERROR
217            } else if selected {
218                PRIMARY
219            } else {
220                ON_SURFACE_VARIANT
221            };
222            let state_layer = if selected { PRIMARY } else { ON_SURFACE };
223            (ring, state_layer)
224        }
225    }
226}
227
228/// Whether a widget-local `pos` lies within a `size`-sized box anchored at
229/// the origin (mirrors [`super::switch`]'s helper of the same shape).
230fn inside(pos: Point, size: Size) -> bool {
231    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
232}
233
234/// Build the optional label's type-erased child view, themed `on_surface`
235/// (`frust::text`'s default body style), its family following the live
236/// theme's `bodyLarge` role — see the module docs' Enabled/disabled section
237/// for why this doesn't dim when the radio is disabled.
238fn label_view<State: 'static>(label: String) -> AnyView<State> {
239    any::<State, _>(
240        frust::text(label)
241            .themed_role(ThemeTextColor::OnSurface)
242            .themed_family(ThemeTextType::BodyLarge),
243    )
244}
245
246/// Erase a view-held, generic `Fn(&mut State, T)` group handler into a plain
247/// [`frust::authoring::ErasedCallback`] with `value` already bound — see the
248/// module docs' Generic selection model section for why this is what lets
249/// [`RadioWidget`] carry no generic parameter of its own.
250fn bind_on_changed<State: 'static, T: Clone + 'static>(
251    on_changed: &TypedArgCallback<State, T>,
252    value: T,
253) -> frust::authoring::ErasedCallback {
254    let on_changed = on_changed.clone();
255    Box::new(move |ctx: &mut EventCtx| {
256        let state = ctx.state_mut::<State>();
257        on_changed(state, value.clone());
258    })
259}
260
261/// A declarative M3 radio button. See the [module docs](self).
262pub struct RadioView<State: 'static, T: PartialEq + Clone + 'static> {
263    value: T,
264    group_value: T,
265    on_changed: Option<TypedArgCallback<State, T>>,
266    label: Option<String>,
267    error: bool,
268}
269
270/// Create a radio for `value`, reflecting `selected = value == group_value`.
271/// Attach a shared group handler with [`RadioView::on_changed`]; a radio with
272/// none is **disabled** — see the [module docs](self)' Enabled/disabled
273/// section.
274pub fn radio<State: 'static, T: PartialEq + Clone + 'static>(
275    value: T,
276    group_value: T,
277) -> RadioView<State, T> {
278    RadioView {
279        value,
280        group_value,
281        on_changed: None,
282        label: None,
283        error: false,
284    }
285}
286
287/// PascalCase alias for [`radio`].
288#[allow(non_snake_case)]
289pub fn Radio<State: 'static, T: PartialEq + Clone + 'static>(
290    value: T,
291    group_value: T,
292) -> RadioView<State, T> {
293    radio(value, group_value)
294}
295
296impl<State: 'static, T: PartialEq + Clone + 'static> RadioView<State, T> {
297    /// Fire `on_changed(state, value.clone())` on release inside this
298    /// radio's bounds (whole control **and** an attached [`Self::label`]) —
299    /// unconditionally, even if already selected, mirroring the Dart
300    /// source's `onTap`. Never self-mutates. Omitting this call leaves the
301    /// radio disabled — see the [module docs](self).
302    pub fn on_changed<F: Fn(&mut State, T) + 'static>(mut self, on_changed: F) -> Self {
303        self.on_changed = Some(Rc::new(on_changed));
304        self
305    }
306
307    /// Attach an optional text label beside the control; tapping it also
308    /// selects (the whole control+label bounds is one press target).
309    pub fn label(mut self, label: impl Into<String>) -> Self {
310        self.label = Some(label.into());
311        self
312    }
313
314    /// Set the error flavor: recolors the ring/dot to `colors.error` while
315    /// enabled (a disabled radio ignores this — see [`resolve_colors`]).
316    pub fn error(mut self, error: bool) -> Self {
317        self.error = error;
318        self
319    }
320}
321
322/// The retained widget for a [`RadioView`] — carries no generic parameter;
323/// see the module docs' Generic selection model section.
324pub struct RadioWidget {
325    selected: bool,
326    enabled: bool,
327    error: bool,
328    /// Drives the inner dot's `0.0` (hidden) .. `1.0` (fully scaled in)
329    /// fraction — duration+curve, not a spring; see the module docs.
330    anim: AnimationController,
331    state_layer: StateLayer,
332    label_pod: Option<ChildPod>,
333    /// The label text, retained for the semantics node's accessible name.
334    label_text: Option<String>,
335    /// The pressed *visual* state; follows the cursor in/out while captured.
336    pressed: bool,
337    /// Armed by a `Down`, cleared on `Up`/`Cancel`.
338    captured: bool,
339    on_changed: Option<frust::authoring::ErasedCallback>,
340}
341
342impl<State: 'static, T: PartialEq + Clone + 'static> View<State> for RadioView<State, T> {
343    type Element = RadioWidget;
344
345    fn build(&self, ctx: &mut BuildCtx<'_>) -> RadioWidget {
346        let selected = self.value == self.group_value;
347        let enabled = self.on_changed.is_some();
348
349        let label_pod = self
350            .label
351            .as_ref()
352            .map(|label| frust::authoring::build_child(&label_view::<State>(label.clone()), ctx));
353
354        let mut anim = AnimationController::new(MaterialMotion::SHORT_4)
355            .with_curve(MaterialMotion::EMPHASIZED_DECELERATE);
356        if selected {
357            anim.forward();
358            // Settle instantly on first mount — see the module docs' Dot
359            // scale-in section for why two `advance` calls are needed.
360            anim.advance(FrameTime::ZERO);
361            anim.advance(FrameTime::from_nanos(
362                MaterialMotion::SHORT_4.as_nanos() as u64
363            ));
364        }
365
366        RadioWidget {
367            selected,
368            enabled,
369            error: self.error,
370            anim,
371            state_layer: StateLayer::new(),
372            label_pod,
373            label_text: self.label.clone(),
374            pressed: false,
375            captured: false,
376            on_changed: self
377                .on_changed
378                .as_ref()
379                .map(|cb| bind_on_changed::<State, T>(cb, self.value.clone())),
380        }
381    }
382
383    fn rebuild(
384        &self,
385        prev: &Self,
386        element: &mut RadioWidget,
387        ctx: &mut BuildCtx<'_>,
388    ) -> ChangeFlags {
389        let mut flags = ChangeFlags::NONE;
390
391        // Closures aren't comparable — always reinstall the adapter.
392        element.on_changed = self
393            .on_changed
394            .as_ref()
395            .map(|cb| bind_on_changed::<State, T>(cb, self.value.clone()));
396
397        let selected = self.value == self.group_value;
398        if element.selected != selected {
399            element.selected = selected;
400            // Compile-time duration+curve — no theme needed, so (unlike
401            // `switch`'s themed spring) this can start right here rather
402            // than waiting for `paint`. See the module docs.
403            if selected {
404                element.anim.forward();
405            } else {
406                element.anim.reverse();
407            }
408            flags |= ChangeFlags::PAINT;
409        }
410
411        let enabled = self.on_changed.is_some();
412        if element.enabled != enabled {
413            element.enabled = enabled;
414            if !enabled {
415                // A radio disabled mid-press keeps neither the press nor the
416                // capture it was holding (mirrors
417                // `button::ButtonWidget::rebuild`'s identical clear).
418                element.pressed = false;
419                element.captured = false;
420                element.state_layer.set_pressed(false);
421            }
422            flags |= ChangeFlags::PAINT;
423        }
424
425        if element.error != self.error {
426            element.error = self.error;
427            flags |= ChangeFlags::PAINT;
428        }
429
430        match (&prev.label, &self.label) {
431            (None, None) => {}
432            (Some(p), Some(n)) if p == n => {}
433            (Some(_), Some(n)) => {
434                element.label_text = Some(n.clone());
435                let pod = element.label_pod.as_mut().expect("label pod present");
436                let prev_view = label_view::<State>(prev.label.clone().unwrap_or_default());
437                let next_view = label_view::<State>(n.clone());
438                flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
439            }
440            (None, Some(n)) => {
441                element.label_text = Some(n.clone());
442                element.label_pod = Some(frust::authoring::build_child(
443                    &label_view::<State>(n.clone()),
444                    ctx,
445                ));
446                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
447            }
448            (Some(p), None) => {
449                element.label_text = None;
450                let mut pod = element.label_pod.take().expect("label pod present");
451                let prev_view = label_view::<State>(p.clone());
452                frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
453                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
454            }
455        }
456
457        flags
458    }
459
460    fn teardown(&self, element: &mut RadioWidget, ctx: &mut BuildCtx<'_>) {
461        if let (Some(label), Some(pod)) = (&self.label, element.label_pod.as_mut()) {
462            let label_view = label_view::<State>(label.clone());
463            frust::authoring::teardown_child(&label_view, pod, ctx);
464        }
465    }
466}
467
468impl Widget for RadioWidget {
469    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
470        let Some(label) = self.label_pod.as_mut() else {
471            return bc.constrain(Size::new(HIT_SIZE, HIT_SIZE));
472        };
473        let label_max = Size::new(
474            (bc.max().width - HIT_SIZE - LABEL_GAP).max(0.0),
475            bc.max().height,
476        );
477        let label_size = label.layout_child(ctx, &BoxConstraints::loose(label_max));
478        let height = label_size.height.max(HIT_SIZE);
479        // Vertically centre the label against the control column.
480        label.set_origin(Point::new(
481            HIT_SIZE + LABEL_GAP,
482            (height - label_size.height) / 2.0,
483        ));
484        bc.constrain(Size::new(HIT_SIZE + LABEL_GAP + label_size.width, height))
485    }
486
487    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
488        let theme = Theme::from_paint_ctx(ctx);
489        let (ring_color, state_layer_color) =
490            resolve_colors(theme, self.selected, self.error, self.enabled);
491
492        if self.anim.advance(ctx.frame_time()) {
493            ctx.request_frame();
494        }
495        // Duration+curve motion is already bounded 0.0..=1.0 (see
496        // `AnimationController::value`'s own docs) — no clamping needed.
497        let progress = self.anim.value();
498
499        let o = ctx.origin();
500        let cy = ctx.size().height / 2.0;
501        let center_local = Point::new(HIT_SIZE / 2.0, cy);
502        let center_abs = Point::new(o.x + HIT_SIZE / 2.0, o.y + cy);
503
504        // The pod's hover link is authoritative; re-synced every paint — the
505        // pointer's departure never reaches `event`, so this is what drops
506        // the hover tint the frame it leaves (mirrors `checkbox`/`list_item`'s
507        // identical self-correction). Enabled-gated the same way
508        // `button::core`'s identical self-correction is: a claim latched
509        // before a mid-hover disable must still be dropped here.
510        self.state_layer
511            .set_hovered(self.enabled && ctx.is_hovered());
512
513        self.state_layer.paint(
514            ctx,
515            scene,
516            Rect::from_center_size(center_abs, Size::new(HIT_SIZE, HIT_SIZE)),
517            HIT_SIZE / 2.0,
518            state_layer_color,
519        );
520
521        let ring_radius = (RING_SIZE - BORDER_WIDTH) / 2.0;
522        let ring_path = Circle::new(center_local, ring_radius).to_path(STROKE_TOLERANCE);
523        scene.stroke_path(o, &ring_path, BORDER_WIDTH, &Brush::Solid(ring_color));
524
525        let dot_diam = DOT_SIZE * progress;
526        scene.fill_rounded_rect(
527            Point::new(center_abs.x - dot_diam / 2.0, center_abs.y - dot_diam / 2.0),
528            Size::new(dot_diam, dot_diam),
529            dot_diam / 2.0,
530            ring_color,
531        );
532
533        if let Some(label) = self.label_pod.as_mut() {
534            label.paint_child(ctx, scene);
535        }
536    }
537
538    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
539        let InputEvent::Pointer(p) = event else {
540            return EventResult::Ignored;
541        };
542        // A disabled radio (`M3ETappable`'s `_isInteractive` reducing to
543        // `_enabled` for this widget — see the module docs) ignores every
544        // pointer phase outright, never arming a press.
545        if !self.enabled {
546            return EventResult::Ignored;
547        }
548        match p.phase {
549            PointerPhase::Down => {
550                if !presses(p) {
551                    return EventResult::Ignored;
552                }
553                self.pressed = true;
554                self.captured = true;
555                self.state_layer.set_pressed(true);
556                ctx.capture_pointer();
557                ctx.request_redraw();
558                EventResult::Handled
559            }
560            PointerPhase::Move => {
561                if !self.captured {
562                    // No capture: this is the hover pass — claim whenever
563                    // the pointer is inside, mirroring `list_item`'s
564                    // reference hover-claim implementation (see the module
565                    // docs).
566                    let over = inside(p.position, ctx.size());
567                    if over {
568                        ctx.claim_hover();
569                    }
570                    if self.state_layer.set_hovered(over) {
571                        ctx.request_redraw();
572                    }
573                    return EventResult::Ignored;
574                }
575                let inside_now = inside(p.position, ctx.size());
576                self.pressed = inside_now;
577                self.state_layer.set_pressed(inside_now);
578                ctx.request_redraw();
579                EventResult::Handled
580            }
581            PointerPhase::Up => {
582                if !self.captured {
583                    return EventResult::Ignored;
584                }
585                if inside(p.position, ctx.size())
586                    && let Some(on_changed) = self.on_changed.as_mut()
587                {
588                    // The reference's own choice — see the module docs'
589                    // Haptics section.
590                    MaterialHaptics::fire(HapticSignal::None);
591                    (on_changed)(ctx);
592                }
593                self.pressed = false;
594                self.captured = false;
595                self.state_layer.set_pressed(false);
596                ctx.request_redraw();
597                EventResult::Handled
598            }
599            PointerPhase::Cancel => {
600                if !self.captured {
601                    return EventResult::Ignored;
602                }
603                self.pressed = false;
604                self.captured = false;
605                self.state_layer.set_pressed(false);
606                ctx.request_redraw();
607                EventResult::Handled
608            }
609        }
610    }
611
612    fn semantics(&self, ctx: &mut SemanticsCtx) {
613        ctx.push_node(Role::RadioButton, |node| {
614            if let Some(label) = &self.label_text {
615                node.set_label(label.as_str());
616            }
617            node.set_selected(self.selected);
618            if self.enabled {
619                node.add_action(Action::Click);
620            }
621        });
622    }
623
624    frust::authoring::visit_children!(label_pod);
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use std::any::Any;
631
632    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
633    enum Freq {
634        Daily,
635        Weekly,
636        Monthly,
637    }
638
639    struct GroupState {
640        selected: Freq,
641        changes: u32,
642    }
643
644    fn on_changed(s: &mut GroupState, v: Freq) {
645        s.selected = v;
646        s.changes += 1;
647    }
648
649    fn built(value: Freq, group_value: Freq, enabled: bool) -> RadioWidget {
650        let view = radio::<GroupState, Freq>(value, group_value);
651        let view = if enabled {
652            view.on_changed(on_changed)
653        } else {
654            view
655        };
656        let mut counter = 0u64;
657        View::<GroupState>::build(&view, &mut BuildCtx::new(&mut counter))
658    }
659
660    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
661        InputEvent::Pointer(frust::authoring::PointerEvent {
662            phase,
663            position: Point::new(x, y),
664            button: frust::authoring::PointerButton::Primary,
665        })
666    }
667
668    fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
669        InputEvent::Pointer(frust::authoring::PointerEvent {
670            phase,
671            position: Point::new(x, y),
672            button: frust::authoring::PointerButton::Secondary,
673        })
674    }
675
676    fn dispatch(w: &mut RadioWidget, state: &mut GroupState, event: &InputEvent) {
677        let state_any: &mut dyn Any = state;
678        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
679        w.event(&mut ctx, event);
680    }
681
682    // ---- Acceptance 1: select/deselect across a 3-member group -----------
683
684    #[test]
685    fn select_and_deselect_across_a_three_member_group() {
686        let mut state = GroupState {
687            selected: Freq::Daily,
688            changes: 0,
689        };
690
691        // Round 1: group starts on Daily.
692        let daily = built(Freq::Daily, state.selected, true);
693        let mut weekly = built(Freq::Weekly, state.selected, true);
694        let monthly = built(Freq::Monthly, state.selected, true);
695        assert!(daily.selected);
696        assert!(!weekly.selected);
697        assert!(!monthly.selected);
698
699        // Tap "weekly": fires the shared handler with its own value.
700        dispatch(&mut weekly, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
701        dispatch(&mut weekly, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
702        assert_eq!(state.selected, Freq::Weekly);
703        assert_eq!(state.changes, 1);
704        assert!(
705            !weekly.selected,
706            "must not self-mutate — still false until the app rebuilds"
707        );
708
709        // The app rebuilds all three against the new group value.
710        let daily = built(Freq::Daily, state.selected, true);
711        let weekly = built(Freq::Weekly, state.selected, true);
712        let monthly = built(Freq::Monthly, state.selected, true);
713        assert!(!daily.selected, "Daily deselected");
714        assert!(weekly.selected, "Weekly now selected");
715        assert!(!monthly.selected);
716
717        // Round 2: tap "monthly" next — deselects Weekly, selects Monthly.
718        let mut monthly = monthly;
719        dispatch(&mut monthly, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
720        dispatch(&mut monthly, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
721        assert_eq!(state.selected, Freq::Monthly);
722        assert_eq!(state.changes, 2);
723
724        let daily = built(Freq::Daily, state.selected, true);
725        let weekly = built(Freq::Weekly, state.selected, true);
726        let monthly = built(Freq::Monthly, state.selected, true);
727        assert!(!daily.selected);
728        assert!(!weekly.selected, "Weekly deselected");
729        assert!(monthly.selected, "Monthly now selected");
730    }
731
732    #[test]
733    fn already_selected_still_fires() {
734        let mut w = built(Freq::Daily, Freq::Daily, true);
735        let mut state = GroupState {
736            selected: Freq::Daily,
737            changes: 0,
738        };
739        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
740        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
741        assert_eq!(state.changes, 1);
742        assert!(w.selected, "still selected until the app rebuilds it");
743    }
744
745    #[test]
746    fn up_outside_does_not_fire() {
747        let mut w = built(Freq::Weekly, Freq::Daily, true);
748        let mut state = GroupState {
749            selected: Freq::Daily,
750            changes: 0,
751        };
752        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
753        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
754        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
755        assert_eq!(state.changes, 0);
756    }
757
758    #[test]
759    fn hover_move_without_down_is_ignored_noop() {
760        let mut w = built(Freq::Weekly, Freq::Daily, true);
761        let mut state = GroupState {
762            selected: Freq::Daily,
763            changes: 0,
764        };
765        let state_any: &mut dyn Any = &mut state;
766        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
767        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
768        assert!(matches!(result, EventResult::Ignored));
769        assert!(!w.pressed);
770        assert_eq!(state.changes, 0);
771    }
772
773    #[test]
774    fn hover_move_inside_claims_hover_and_requests_redraw_once() {
775        let mut w = built(Freq::Weekly, Freq::Daily, true);
776        let mut state = GroupState {
777            selected: Freq::Daily,
778            changes: 0,
779        };
780        let state_any: &mut dyn Any = &mut state;
781        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
782        w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
783        assert!(ctx.needs_redraw(), "hover gain must request a redraw");
784        assert_eq!(w.state_layer.opacity(), crate::interaction::HOVER_OPACITY);
785    }
786
787    #[test]
788    fn paint_self_corrects_hover_off_when_the_pod_link_is_gone() {
789        let mut w = built(Freq::Weekly, Freq::Daily, true);
790        let mut state = GroupState {
791            selected: Freq::Daily,
792            changes: 0,
793        };
794        let state_any: &mut dyn Any = &mut state;
795        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
796        w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
797        assert!(w.state_layer.opacity() > 0.0, "hover claimed");
798
799        // `PaintCtx::is_hovered` reads false with no pod link recorded — the
800        // pointer left with no event ever telling this widget so; paint is
801        // what drops the stale flag.
802        let mut paint_ctx = PaintCtx::new(Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
803        let mut rec = Recorder::default();
804        w.paint(&mut paint_ctx, &mut rec);
805        assert_eq!(
806            w.state_layer.opacity(),
807            0.0,
808            "paint self-corrected the flag"
809        );
810    }
811
812    #[test]
813    fn cancel_clears_armed_state() {
814        let mut w = built(Freq::Weekly, Freq::Daily, true);
815        let mut state = GroupState {
816            selected: Freq::Daily,
817            changes: 0,
818        };
819        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
820        assert!(w.captured);
821        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
822        assert!(!w.captured, "Cancel disarms the press");
823        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
824        assert_eq!(state.changes, 0);
825    }
826
827    #[test]
828    fn a_secondary_press_never_presses_captures_or_fires() {
829        let mut w = built(Freq::Weekly, Freq::Daily, true);
830        let mut state = GroupState {
831            selected: Freq::Daily,
832            changes: 0,
833        };
834        dispatch(
835            &mut w,
836            &mut state,
837            &secondary_ev(PointerPhase::Down, 5.0, 12.0),
838        );
839        assert!(!w.pressed);
840        assert!(!w.captured);
841        dispatch(
842            &mut w,
843            &mut state,
844            &secondary_ev(PointerPhase::Up, 5.0, 12.0),
845        );
846        assert_eq!(state.changes, 0);
847
848        // The primary gesture is untouched by the guard.
849        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
850        assert!(w.pressed);
851        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
852        assert_eq!(state.changes, 1);
853    }
854
855    #[test]
856    fn disabled_radio_ignores_every_pointer_phase() {
857        let mut w = built(Freq::Weekly, Freq::Daily, false);
858        assert!(!w.enabled);
859        let mut state = GroupState {
860            selected: Freq::Daily,
861            changes: 0,
862        };
863        let state_any: &mut dyn Any = &mut state;
864        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
865        let down = w.event(&mut ctx, &ev(PointerPhase::Down, 5.0, 12.0));
866        assert!(matches!(down, EventResult::Ignored));
867        assert!(!w.pressed);
868        assert!(!w.captured);
869    }
870
871    // ---- Acceptance 2: dot animation progress at t=0/mid/1 ---------------
872
873    fn paint_rec(w: &mut RadioWidget, theme: Option<&Theme>) -> Recorder {
874        let mut rec = Recorder::default();
875        let mut ctx = match theme {
876            Some(t) => PaintCtx::new(Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE)).with_theme(t),
877            None => PaintCtx::new(Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE)),
878        };
879        w.paint(&mut ctx, &mut rec);
880        rec
881    }
882
883    #[derive(Default)]
884    struct Recorder {
885        /// `fill_rounded_rect` calls, in paint order: the state layer (only
886        /// while active) then the dot.
887        rrects: Vec<(Point, Size, f64, Color)>,
888        /// `stroke_path` calls' solid brush color, in paint order (the ring).
889        strokes: Vec<Color>,
890    }
891
892    impl PaintScene for Recorder {
893        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
894        fn draw_text(&mut self, _o: Point, _t: &str) {}
895        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
896            self.rrects.push((o, s, radius, color));
897        }
898        fn stroke_path(&mut self, _o: Point, _path: &kurbo::BezPath, _width: f64, brush: &Brush) {
899            if let Brush::Solid(c) = brush {
900                self.strokes.push(*c);
901            }
902        }
903    }
904
905    #[test]
906    fn dot_scale_in_progress_observable_at_zero_mid_and_settled() {
907        let mut counter = 0u64;
908        let prev = radio::<GroupState, Freq>(Freq::Daily, Freq::Weekly).on_changed(on_changed);
909        let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
910        assert!(!w.selected);
911        assert_eq!(w.anim.value(), 0.0);
912
913        let next = radio::<GroupState, Freq>(Freq::Daily, Freq::Daily).on_changed(on_changed);
914        let flags =
915            View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
916        assert!(w.selected);
917        assert!(flags.needs_paint());
918        assert!(w.anim.is_animating(), "rebuild started the forward drive");
919
920        // t=0: the first `advance` (inside `paint`) only seeds the clock.
921        let rec0 = paint_rec(&mut w, None);
922        assert_eq!(
923            rec0.rrects[0].1,
924            Size::ZERO,
925            "dot has not started scaling in yet"
926        );
927
928        // t=mid: advance halfway through SHORT_4 directly on the controller
929        // (mirroring `super::switch`'s own test pattern — `PaintCtx::new`
930        // always reads `FrameTime::ZERO`, so a real elapsed span is driven
931        // by hand), then paint again purely to record the result.
932        let half_ns = (MaterialMotion::SHORT_4.as_nanos() / 2) as u64;
933        w.anim.advance(FrameTime::from_nanos(half_ns));
934        let rec_mid = paint_rec(&mut w, None);
935        let mid_diam = rec_mid.rrects[0].1.width;
936        assert!(
937            mid_diam > 0.0 && mid_diam < DOT_SIZE,
938            "dot mid-animation diameter should be strictly between 0 and {DOT_SIZE}, got {mid_diam}"
939        );
940
941        // t=1: advance to (and past) the full SHORT_4 span — settles exactly.
942        let full_ns = MaterialMotion::SHORT_4.as_nanos() as u64;
943        w.anim.advance(FrameTime::from_nanos(full_ns));
944        assert!(!w.anim.is_animating(), "fling settled");
945        let rec_done = paint_rec(&mut w, None);
946        assert_eq!(rec_done.rrects[0].1, Size::new(DOT_SIZE, DOT_SIZE));
947    }
948
949    #[test]
950    fn deselect_also_animates_both_ways() {
951        let mut counter = 0u64;
952        let prev = radio::<GroupState, Freq>(Freq::Daily, Freq::Daily).on_changed(on_changed);
953        let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
954        // Settle to fully selected first (build's own instant-settle path).
955        assert_eq!(w.anim.value(), 1.0);
956
957        let next = radio::<GroupState, Freq>(Freq::Daily, Freq::Weekly).on_changed(on_changed);
958        View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
959        assert!(!w.selected);
960        assert!(w.anim.is_animating(), "rebuild started the reverse drive");
961
962        let full_ns = MaterialMotion::SHORT_4.as_nanos() as u64;
963        w.anim.advance(FrameTime::ZERO); // seed
964        w.anim.advance(FrameTime::from_nanos(full_ns));
965        assert!(!w.anim.is_animating());
966        assert_eq!(w.anim.value(), 0.0);
967        let rec = paint_rec(&mut w, None);
968        assert_eq!(rec.rrects[0].1, Size::ZERO, "dot fully scaled back out");
969    }
970
971    // ---- Acceptance 3: error flavor colors --------------------------------
972
973    #[test]
974    fn error_flavor_recolors_ring_unthemed() {
975        let mut w = built(Freq::Weekly, Freq::Daily, true);
976        w.error = true;
977        let rec = paint_rec(&mut w, None);
978        assert_eq!(rec.strokes, vec![ERROR]);
979    }
980
981    #[test]
982    fn error_flavor_recolors_ring_themed() {
983        let theme = crate::baseline();
984        let scheme = theme.scheme();
985        let mut w = built(Freq::Weekly, Freq::Daily, true);
986        w.error = true;
987        let rec = paint_rec(&mut w, Some(&theme));
988        assert_eq!(rec.strokes, vec![scheme.error]);
989
990        // A selected+error radio still reads `error`, not `primary` — error
991        // wins over selection (matches `M3ERadioTheme.color`).
992        let mut selected = built(Freq::Daily, Freq::Daily, true);
993        selected.error = true;
994        let rec = paint_rec(&mut selected, Some(&theme));
995        assert_eq!(rec.strokes, vec![scheme.error]);
996
997        // The state layer never recolors for error — always primary/on_surface.
998        selected.state_layer.set_pressed(true);
999        let rec = paint_rec(&mut selected, Some(&theme));
1000        assert_eq!(rec.rrects[0].3, with_alpha(scheme.primary, 0.10));
1001    }
1002
1003    #[test]
1004    fn disabled_wins_over_error() {
1005        let theme = crate::baseline();
1006        let scheme = theme.scheme();
1007        let mut w = built(Freq::Weekly, Freq::Daily, false);
1008        w.error = true;
1009        let rec = paint_rec(&mut w, Some(&theme));
1010        assert_eq!(rec.strokes, vec![with_alpha(scheme.on_surface, 0.38)]);
1011    }
1012
1013    // ---- Paint: unthemed/themed role resolution ---------------------------
1014
1015    #[test]
1016    fn unthemed_paint_uses_fallback_constants() {
1017        let mut off = built(Freq::Weekly, Freq::Daily, true);
1018        let rec = paint_rec(&mut off, None);
1019        assert_eq!(rec.strokes, vec![ON_SURFACE_VARIANT]);
1020
1021        let mut on = built(Freq::Daily, Freq::Daily, true);
1022        let rec = paint_rec(&mut on, None);
1023        assert_eq!(rec.strokes, vec![PRIMARY]);
1024        assert_eq!(rec.rrects[0].3, PRIMARY, "the fully-settled dot is PRIMARY");
1025    }
1026
1027    #[test]
1028    fn themed_paint_resolves_roles() {
1029        let theme = crate::baseline();
1030        let scheme = theme.scheme();
1031        let mut off = built(Freq::Weekly, Freq::Daily, true);
1032        let rec = paint_rec(&mut off, Some(&theme));
1033        assert_eq!(rec.strokes, vec![scheme.on_surface_variant]);
1034
1035        let mut on = built(Freq::Daily, Freq::Daily, true);
1036        let rec = paint_rec(&mut on, Some(&theme));
1037        assert_eq!(rec.strokes, vec![scheme.primary]);
1038    }
1039
1040    // ---- rebuild ------------------------------------------------------------
1041
1042    #[test]
1043    fn rebuild_adopts_new_selected_value() {
1044        let mut counter = 0u64;
1045        let prev = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
1046        let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
1047        assert!(!w.selected);
1048        let next = radio::<GroupState, Freq>(Freq::Weekly, Freq::Weekly).on_changed(on_changed);
1049        let flags =
1050            View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1051        assert!(w.selected);
1052        assert!(flags.needs_paint());
1053    }
1054
1055    #[test]
1056    fn rebuild_toggles_enabled_state() {
1057        let mut counter = 0u64;
1058        let prev = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily);
1059        let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
1060        assert!(!w.enabled);
1061        let next = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
1062        let flags =
1063            View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1064        assert!(w.enabled);
1065        assert!(flags.needs_paint());
1066    }
1067
1068    #[test]
1069    fn disabling_mid_press_clears_the_press_and_the_capture() {
1070        let mut counter = 0u64;
1071        let prev = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
1072        let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
1073        w.pressed = true;
1074        w.captured = true;
1075        w.state_layer.set_pressed(true);
1076
1077        let next = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily);
1078        let flags =
1079            View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1080        assert!(!w.pressed);
1081        assert!(!w.captured);
1082        assert_eq!(
1083            w.state_layer.opacity(),
1084            0.0,
1085            "the state-layer's own pressed flag clears too"
1086        );
1087        assert!(flags.needs_paint());
1088    }
1089
1090    // ---- through a real RenderRoot: paint self-correction with a live claim -
1091    //
1092    // Hover is an authoritative read off `PaintCtx`, seeded by the pod chain, so
1093    // a bare `PaintCtx` can never fake a *standing* claim — only a real tree can
1094    // (the pattern `list_item.rs`'s/`button/core.rs`'s own hover harnesses use).
1095
1096    const HOVER_DISABLE_WINDOW: Size = Size::new(200.0, 200.0);
1097
1098    struct HoverDisableHarness {
1099        root: frust_core::RenderRoot<GroupState, RadioView<GroupState, Freq>>,
1100        state: GroupState,
1101        tcx: frust::authoring::text::TextContext,
1102        enabled: bool,
1103    }
1104
1105    impl HoverDisableHarness {
1106        fn new(enabled: bool) -> Self {
1107            let mut h = HoverDisableHarness {
1108                root: frust_core::RenderRoot::new(),
1109                state: GroupState {
1110                    selected: Freq::Daily,
1111                    changes: 0,
1112                },
1113                tcx: frust::authoring::text::TextContext::new(),
1114                enabled,
1115            };
1116            h.sync();
1117            h
1118        }
1119
1120        fn sync(&mut self) {
1121            let enabled = self.enabled;
1122            let mut app = move |_: &mut GroupState| {
1123                let view = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily);
1124                if enabled {
1125                    view.on_changed(on_changed)
1126                } else {
1127                    view
1128                }
1129            };
1130            self.root.rebuild(&mut app, &mut self.state);
1131            self.root
1132                .layout_with_text(HOVER_DISABLE_WINDOW, &mut self.tcx as &mut dyn Any);
1133        }
1134
1135        fn dispatch(&mut self, phase: PointerPhase, x: f64, y: f64) {
1136            self.root.event(&mut self.state, &ev(phase, x, y));
1137        }
1138
1139        fn paint(&mut self) -> Recorder {
1140            let mut rec = Recorder::default();
1141            self.root.paint(&mut rec, frust::FrameTime::ZERO);
1142            rec
1143        }
1144    }
1145
1146    #[test]
1147    fn a_disabled_radio_drops_a_standing_hover_claim_at_paint() {
1148        let mut h = HoverDisableHarness::new(true);
1149
1150        // Claim hover while enabled — the pod's hover link is now standing.
1151        h.dispatch(PointerPhase::Move, 5.0, 12.0);
1152        let rec = h.paint();
1153        assert_eq!(
1154            rec.rrects.len(),
1155            2,
1156            "hovered+enabled radio paints the state-layer overlay plus the dot"
1157        );
1158
1159        // Disable via rebuild — no `Down`/`Up`/`Cancel` runs, so nothing ever
1160        // tells the widget's own event handler to drop the claim; the pointer's
1161        // hover link is still standing (`ctx.is_hovered()` would read `true`)
1162        // when the next paint runs.
1163        h.enabled = false;
1164        h.sync();
1165
1166        let rec = h.paint();
1167        assert_eq!(
1168            rec.rrects.len(),
1169            1,
1170            "paint's self-correction must gate on `enabled`: state_layer.opacity() \
1171             drops to 0.0 (the overlay fill is skipped entirely), so only the dot paints"
1172        );
1173    }
1174
1175    // ---- label: presence/text reconciliation -------------------------------
1176
1177    #[test]
1178    fn label_presence_change_builds_and_tears_down_the_child() {
1179        let mut counter = 0u64;
1180        let prev = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
1181        let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
1182        assert!(w.label_pod.is_none());
1183
1184        let next = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily)
1185            .on_changed(on_changed)
1186            .label("Weekly");
1187        let flags =
1188            View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1189        assert!(w.label_pod.is_some());
1190        assert_eq!(w.label_text.as_deref(), Some("Weekly"));
1191        assert!(flags.needs_layout());
1192
1193        let prev2 = next;
1194        let next2 = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
1195        let flags =
1196            View::<GroupState>::rebuild(&next2, &prev2, &mut w, &mut BuildCtx::new(&mut counter));
1197        assert!(w.label_pod.is_none());
1198        assert!(w.label_text.is_none());
1199        assert!(flags.needs_layout());
1200    }
1201
1202    // ---- semantics ----------------------------------------------------------
1203
1204    #[test]
1205    fn semantics_reports_role_label_selected_and_bounds() {
1206        fn logic(_s: &mut ()) -> RadioView<(), Freq> {
1207            radio::<(), Freq>(Freq::Daily, Freq::Daily)
1208                .on_changed(|_, _| {})
1209                .label("daily")
1210        }
1211        let mut root: frust_core::RenderRoot<(), RadioView<(), Freq>> =
1212            frust_core::RenderRoot::new();
1213        let mut state = ();
1214        root.rebuild(&mut logic, &mut state);
1215        let mut tcx = frust::authoring::text::TextContext::new();
1216        root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1217        let update = root.semantics();
1218        let (_, node) = update
1219            .nodes
1220            .iter()
1221            .find(|(_, n)| n.role() == Role::RadioButton)
1222            .expect("radio contributes a Role::RadioButton node");
1223        assert_eq!(node.label(), Some("daily"));
1224        assert_eq!(node.is_selected(), Some(true));
1225        assert!(node.supports_action(Action::Click));
1226        let bounds = node.bounds().expect("radio node has bounds");
1227        assert_eq!((bounds.x0, bounds.y0), (0.0, 0.0));
1228    }
1229
1230    #[test]
1231    fn semantics_omits_click_action_when_disabled() {
1232        fn logic(_s: &mut ()) -> RadioView<(), Freq> {
1233            radio::<(), Freq>(Freq::Daily, Freq::Weekly)
1234        }
1235        let mut root: frust_core::RenderRoot<(), RadioView<(), Freq>> =
1236            frust_core::RenderRoot::new();
1237        let mut state = ();
1238        root.rebuild(&mut logic, &mut state);
1239        let mut tcx = frust::authoring::text::TextContext::new();
1240        root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1241        let update = root.semantics();
1242        let (_, node) = update
1243            .nodes
1244            .iter()
1245            .find(|(_, n)| n.role() == Role::RadioButton)
1246            .expect("radio contributes a Role::RadioButton node");
1247        assert!(!node.supports_action(Action::Click));
1248        assert_eq!(node.is_selected(), Some(false));
1249    }
1250}