Skip to main content

zest_widget/widget/
radio.rs

1//! Radio button: an outer circle with an inner dot when selected, plus an
2//! optional trailing label.
3//!
4//! Immediate-mode: the host owns the selected state and rebuilds the widget
5//! each frame, passing whether *this* button is selected to
6//! [`RadioButton::new`]. A tap emits the [`on_select`](RadioButton::on_select)
7//! message. **Exclusivity is the host's responsibility**: render one
8//! `RadioButton` per option, set `selected = (option == current)`, and in the
9//! handler for each button's message store that option as the new current.
10//! The widget itself knows nothing about its siblings.
11//!
12//! Click semantics mirror [`Button`](crate::Button): the press registers on
13//! `TouchPhase::Down` and the message fires on `TouchPhase::Up` if the press
14//! is still active. The runtime rehydrates the pressed flag each frame via
15//! [`mark_pressed`](Widget::mark_pressed).
16//!
17//! Colors come from the theme's accent [`Component`](zest_theme::Component):
18//! the inner dot uses `accent.base`, the outer ring uses `accent.border`,
19//! and the label uses `background.on_base`.
20
21use super::Widget;
22use alloc::string::String;
23use core::marker::PhantomData;
24use embedded_graphics::{
25    pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
26};
27use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
28use zest_theme::Theme;
29
30/// Outer circle diameter in pixels.
31const CIRCLE_SIZE: u32 = 20;
32/// Gap between the circle and the label in pixels.
33const LABEL_GAP: u32 = 6;
34
35/// Radio button. Host owns selection and exclusivity; a tap emits
36/// [`on_select`](RadioButton::on_select).
37pub struct RadioButton<C: PixelColor, M: Clone> {
38    rect: Rectangle,
39    selected: bool,
40    label: Option<String>,
41    id: Option<WidgetId>,
42    on_select: Option<M>,
43    focused: bool,
44    pressed: bool,
45    width: Length,
46    height: Length,
47    _color: PhantomData<C>,
48}
49
50impl<C: PixelColor, M: Clone> RadioButton<C, M> {
51    /// New radio button reflecting `selected`. Position and size are
52    /// assigned by the parent container via `arrange`.
53    pub fn new(selected: bool) -> Self {
54        Self {
55            rect: Rectangle::zero(),
56            selected,
57            label: None,
58            id: None,
59            on_select: None,
60            focused: false,
61            pressed: false,
62            width: Length::Shrink,
63            height: Length::Fixed(CIRCLE_SIZE),
64            _color: PhantomData,
65        }
66    }
67
68    /// Trailing label drawn to the right of the circle.
69    #[must_use]
70    pub fn label(mut self, label: impl Into<String>) -> Self {
71        self.label = Some(label.into());
72        self
73    }
74
75    /// Message emitted on tap. Without it the button is disabled
76    /// and ignores touches.
77    #[must_use]
78    pub fn on_select(mut self, msg: M) -> Self {
79        self.on_select = Some(msg);
80        self
81    }
82
83    /// Set a stable id so this radio button can participate in focus traversal.
84    #[must_use]
85    pub fn id(mut self, id: WidgetId) -> Self {
86        self.id = Some(id);
87        self
88    }
89
90    /// Width sizing intent.
91    #[must_use]
92    pub fn width(mut self, width: impl Into<Length>) -> Self {
93        self.width = width.into();
94        self
95    }
96
97    /// Height sizing intent.
98    #[must_use]
99    pub fn height(mut self, height: impl Into<Length>) -> Self {
100        self.height = height.into();
101        self
102    }
103
104    /// True iff a select message is bound. Disabled buttons render dimmed
105    /// and ignore touches.
106    pub fn is_enabled(&self) -> bool {
107        self.on_select.is_some()
108    }
109
110    fn intrinsic(&self) -> Size {
111        let label_w = self
112            .label
113            .as_ref()
114            .map_or(0, |l| LABEL_GAP + l.chars().count() as u32 * 8);
115        Size::new(CIRCLE_SIZE + label_w, CIRCLE_SIZE)
116    }
117
118    /// Center of the outer circle, vertically centered within `rect`.
119    fn circle_center(&self) -> Point {
120        let r = CIRCLE_SIZE as i32 / 2;
121        Point::new(
122            self.rect.top_left.x + r,
123            self.rect.top_left.y + self.rect.size.height as i32 / 2,
124        )
125    }
126
127    fn hit_test(&self, point: Point) -> bool {
128        let tl = self.rect.top_left;
129        let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
130        point.x >= tl.x && point.x < br.x && point.y >= tl.y && point.y < br.y
131    }
132}
133
134impl<C: PixelColor, M: Clone> Widget<C, M> for RadioButton<C, M> {
135    fn measure(&mut self, constraints: Constraints) -> Size {
136        let intrinsic = self.intrinsic();
137        let w = self.width.resolve(intrinsic.width, constraints.max.width);
138        let h = self
139            .height
140            .resolve(intrinsic.height, constraints.max.height);
141        constraints.clamp(Size::new(w, h))
142    }
143
144    fn preferred_size(&self) -> (Length, Length) {
145        (self.width, self.height)
146    }
147
148    fn arrange(&mut self, rect: Rectangle) {
149        self.rect = rect;
150    }
151
152    fn rect(&self) -> Rectangle {
153        self.rect
154    }
155
156    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
157        if !self.is_enabled() || !self.hit_test(point) {
158            if matches!(phase, TouchPhase::Up | TouchPhase::Moved) {
159                self.pressed = false;
160            }
161            return None;
162        }
163        match phase {
164            TouchPhase::Down => {
165                self.pressed = true;
166                None
167            }
168            TouchPhase::Up => {
169                if self.pressed {
170                    self.pressed = false;
171                    self.on_select.clone()
172                } else {
173                    None
174                }
175            }
176            TouchPhase::Moved => None,
177        }
178    }
179
180    fn mark_pressed(&mut self, point: Point) {
181        if self.is_enabled() && self.hit_test(point) {
182            self.pressed = true;
183        }
184    }
185
186    fn widget_id(&self) -> Option<WidgetId> {
187        self.id
188    }
189
190    fn is_focusable(&self) -> bool {
191        self.id.is_some() && self.is_enabled()
192    }
193
194    fn handle_action(&mut self, action: UiAction) -> Option<M> {
195        if !self.is_enabled() {
196            return None;
197        }
198
199        match action {
200            UiAction::Activate => self.on_select.clone(),
201            _ => None,
202        }
203    }
204
205    fn sync_focus(&mut self, focused: Option<WidgetId>) {
206        self.focused = self.id.is_some() && self.id == focused;
207    }
208
209    fn focus_at(&self, point: Point) -> Option<WidgetId> {
210        if self.is_focusable() && self.hit_test(point) {
211            self.id
212        } else {
213            None
214        }
215    }
216
217    fn draw<'t>(
218        &self,
219        renderer: &mut dyn Renderer<C>,
220        theme: &Theme<'t, C>,
221    ) -> Result<(), RenderError> {
222        let accent = &theme.accent;
223        let center = self.circle_center();
224        let outer = CIRCLE_SIZE / 2;
225        let border = if self.focused {
226            accent.base
227        } else {
228            accent.border
229        };
230
231        // Outer ring: fill border color, then punch out the interior so a
232        // ring remains (the renderer has no stroke_circle primitive).
233        renderer.fill_circle(center, outer, border)?;
234        let interior_color = if self.pressed {
235            accent.pressed
236        } else {
237            theme.background.base
238        };
239        renderer.fill_circle(center, outer.saturating_sub(2), interior_color)?;
240
241        // Inner dot when selected.
242        if self.selected {
243            let dot = if self.is_enabled() {
244                accent.base
245            } else {
246                theme.background.divider
247            };
248            renderer.fill_circle(center, outer.saturating_sub(6), dot)?;
249        }
250
251        if let Some(label) = &self.label {
252            let font = theme.default_font();
253            let text_x = self.rect.top_left.x + CIRCLE_SIZE as i32 + LABEL_GAP as i32;
254            let center_y = self.rect.top_left.y
255                + self.rect.size.height as i32 / 2
256                + font.character_size.height as i32 / 3;
257            let color = if !self.is_enabled() {
258                theme.palette.neutral_2
259            } else if self.focused {
260                theme.accent.base
261            } else {
262                theme.background.on_base
263            };
264            renderer.draw_text(
265                label,
266                Point::new(text_x, center_y),
267                font,
268                color,
269                Alignment::Left,
270            )?;
271        }
272
273        Ok(())
274    }
275}