Skip to main content

rosace_widgets/tree/
radio.rs

1use std::sync::Arc;
2use rosace_core::types::{Point, Size};
3use rosace_render::Color;
4use super::{Widget, LayoutCtx, PaintCtx};
5
6/// A single radio button (ring + filled dot) — brought to the Quality Bar
7/// (matches the `Switch`/`Checkbox` exemplars). Single-select is the app's
8/// job: bind several radios to one `Atom<T>` and compare; distinct behavior
9/// from `Checkbox` (mutually exclusive), so not a duplicate.
10///
11/// - **States** — selected/unselected · hover · pressed (dot dips) ·
12///   focus-visible (ring) · disabled (dimmed, inert).
13/// - **Motion** — the dot *pops in* (scales while the ring recolors); the
14///   hover/press/focus state-layer halo fades on its own channel.
15/// - **Theming** — ring/dot from `outline`→`primary` tokens; overridable.
16/// - **A11y** — `Role::Radio` + selected value + optional label.
17/// - **Interactive-by-identity** — always owns its hit region.
18pub struct Radio {
19    selected: bool,
20    disabled: bool,
21    label: Option<String>,
22    size: f32,
23    /// `None` = read from the active theme's `typography.body_medium`
24    /// (D127 "environment" track — see `Checkbox::resolved_font_size`'s doc
25    /// for the reasoning).
26    font_size: Option<f32>,
27    color: Option<Color>,
28    on_select: Option<Arc<dyn Fn() + Send + Sync>>,
29}
30
31impl Radio {
32    pub fn new(selected: bool) -> Self {
33        Self { selected, disabled: false, label: None, size: 20.0, font_size: None, color: None, on_select: None }
34    }
35    pub fn size(mut self, s: f32) -> Self { self.size = s; self.font_size = Some(s * 0.65); self }
36    pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
37    pub fn label(mut self, l: impl Into<String>) -> Self { self.label = Some(l.into()); self }
38    pub fn disabled(mut self) -> Self { self.disabled = true; self }
39    pub fn disabled_if(mut self, c: bool) -> Self { if c { self.disabled = true; } self }
40    pub fn on_select(mut self, f: impl Fn() + Send + Sync + 'static) -> Self {
41        self.on_select = Some(Arc::new(f)); self
42    }
43
44    fn resolved_font_size(&self, theme: &rosace_theme::ThemeData) -> f32 {
45        self.font_size.unwrap_or(theme.typography.body_medium.size)
46    }
47}
48
49fn with_alpha(c: Color, a: f32) -> Color {
50    Color::rgba(c.r, c.g, c.b, (a.clamp(0.0, 1.0) * 255.0).round() as u8)
51}
52
53impl Widget for Radio {
54    fn layout(&self, ctx: &LayoutCtx) -> Size {
55        let font_size = self.resolved_font_size(ctx.theme);
56        let label_w = self.label.as_ref()
57            .map(|l| l.len() as f32 * font_size * 0.6 + 10.0)
58            .unwrap_or(0.0);
59        Size { width: self.size + label_w, height: self.size.max(font_size * 1.4) }
60    }
61
62    fn paint(&self, ctx: &mut PaintCtx) {
63        let mut sem = super::Semantics::new(rosace_core::Role::Radio)
64            .value(if self.selected { "selected" } else { "not selected" });
65        if let Some(l) = &self.label { sem = sem.label(l); }
66        ctx.semantics(sem);
67        let font_size = self.resolved_font_size(&ctx.theme);
68
69        // Interactive-by-identity: always own the hit region.
70        match (&self.on_select, self.disabled) {
71            (Some(cb), false) => ctx.register_hit(Arc::clone(cb)),
72            _ => ctx.register_hit(Arc::new(|| {})),
73        }
74        let focused = !self.disabled && ctx.focus_node().is_focused();
75        let hovered = !self.disabled && ctx.hovered();
76        let pressed = !self.disabled && ctx.pressed();
77
78        // Channels: 0=select progress, 1=halo, 2=press dip.
79        let t = ctx.animate_channel(0, if self.selected { 1.0 } else { 0.0 }, 0.0);
80        let halo_t = if pressed { 0.16 } else if focused { 0.12 } else if hovered { 0.08 } else { 0.0 };
81        let halo = ctx.animate_channel(1, halo_t, 0.0);
82        let press = ctx.animate_channel(2, if pressed { 1.0 } else { 0.0 }, 0.0);
83
84        let colors = ctx.theme.colors.clone();
85        let accent = self.color.unwrap_or_else(|| ctx.tc(colors.primary));
86        let outline = ctx.tc(colors.outline);
87        let label_color = ctx.tc(colors.on_surface);
88        let dim = if self.disabled { 0.4 } else { 1.0 };
89
90        let bs = self.size;
91        let cx = ctx.rect.origin.x + bs / 2.0;
92        let cy = ctx.rect.origin.y + ctx.rect.size.height / 2.0;
93        let center = Point { x: cx, y: cy };
94
95        // State-layer halo.
96        if halo > 0.001 {
97            ctx.fill_circle(center, bs * 0.5 + 7.0, with_alpha(super::lerp_color(outline, accent, t), halo));
98        }
99
100        // Ring (outline→accent as it selects).
101        let ring = super::lerp_color(outline, accent, t);
102        ctx.fill_arc(center, bs / 2.0 - 1.5, 2.0, 0.0, 360.0, with_alpha(ring, dim));
103
104        // Inner dot: pops in (scale 0→1) and dips slightly on press.
105        if t > 0.01 {
106            let dot_r = (bs / 4.0) * t * (1.0 - press * 0.12);
107            ctx.fill_circle(center, dot_r, with_alpha(accent, dim));
108        }
109
110        // Focus ring.
111        if focused {
112            ctx.fill_arc(center, bs / 2.0 + 3.0, 2.0, 0.0, 360.0, with_alpha(accent, 0.9));
113        }
114
115        // Label.
116        if let Some(label) = &self.label {
117            let line_h = ctx.font.line_height(font_size);
118            let ty = ((ctx.rect.size.height - line_h) / 2.0).max(0.0);
119            ctx.text(label, bs + 10.0, ty, with_alpha(label_color, dim), font_size);
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use rosace_core::types::Rect;
128    use rosace_render::{FontCache, PictureRecorder};
129    use rosace_render::draw_command::DrawCommand;
130    use std::cell::RefCell;
131    use std::rc::Rc;
132    use crate::tree::RenderTree;
133
134    fn paint(selected: bool) -> Vec<DrawCommand> {
135        let font = FontCache::embedded();
136        let mut rec = PictureRecorder::new();
137        let tree = Rc::new(RefCell::new(RenderTree::new()));
138        let mut ctx = PaintCtx::root(
139            &mut rec,
140            Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 20.0, height: 20.0 } },
141            &font, rosace_theme::built_in::dark_theme(), tree,
142        );
143        Radio::new(selected).paint(&mut ctx);
144        rec.finish().commands
145    }
146
147    #[test]
148    #[ignore] // FAMILY_PNG=/path cargo test -p rosace-widgets control_family_showcase -- --ignored --nocapture
149    fn control_family_showcase() {
150        use super::super::app::WidgetApp;
151        use super::super::{Column, Switch, Checkbox, Slider};
152        use crate::EdgeInsets;
153        let out = std::env::var("FAMILY_PNG").unwrap_or_else(|_| "control_family.png".to_string());
154        let panel = |dark: bool| {
155            let col = Column::new().spacing(20.0).padding(EdgeInsets::all(26.0))
156                .child(Switch::new(true))
157                .child(Checkbox::new(true).label("Checkbox"))
158                .child(Slider::new(0.6).width(200.0))
159                .child(Radio::new(true).label("Selected"))
160                .child(Radio::new(false).label("Unselected"));
161            let app = WidgetApp::new(260, 260);
162            if dark { app.dark() } else { app.light() }.render_png(&col)
163        };
164        std::fs::write(&out, panel(true)).unwrap();
165        std::fs::write(out.replace(".png", "_light.png"), panel(false)).unwrap();
166        println!("wrote {out}");
167    }
168
169    #[test]
170    fn selected_draws_an_inner_dot() {
171        assert!(paint(true).iter().any(|c| matches!(c, DrawCommand::FillCircle { .. })),
172            "a selected radio has a filled dot");
173    }
174
175    #[test]
176    fn unselected_has_no_inner_dot_but_still_a_ring() {
177        let cmds = paint(false);
178        assert!(cmds.iter().any(|c| matches!(c, DrawCommand::FillArc { .. })), "the ring is always drawn");
179        assert!(!cmds.iter().any(|c| matches!(c, DrawCommand::FillCircle { .. })),
180            "an unselected radio has no dot (t=0)");
181    }
182}