Skip to main content

jag_ui/elements/
radio.rs

1//! Radio button element with circle, dot, and optional label.
2
3use jag_draw::{Brush, Color, ColorLinPremul, Rect};
4use jag_surface::Canvas;
5
6use crate::event::{
7    ElementState, EventHandler, EventResult, KeyCode, KeyboardEvent, MouseButton, MouseClickEvent,
8    MouseMoveEvent, ScrollEvent,
9};
10use crate::focus::FocusId;
11
12use super::Element;
13
14/// A radio button rendered as a circle with an inner dot when selected.
15pub struct Radio {
16    /// Center of the radio circle in logical coordinates.
17    pub center: [f32; 2],
18    /// Outer radius of the radio circle.
19    pub radius: f32,
20    /// Whether this radio button is currently selected.
21    pub selected: bool,
22    /// Optional label rendered to the right of the circle.
23    pub label: Option<String>,
24    /// Label font size in logical pixels.
25    pub label_size: f32,
26    /// Label text color.
27    pub label_color: ColorLinPremul,
28    /// Fill color of the outer circle.
29    pub bg: ColorLinPremul,
30    /// Border color of the outer circle.
31    pub border_color: ColorLinPremul,
32    /// Border width of the outer circle.
33    pub border_width: f32,
34    /// Color of the inner dot when selected.
35    pub dot_color: ColorLinPremul,
36    /// Whether this radio button is focused.
37    pub focused: bool,
38    /// Validation error message displayed below the radio.
39    pub validation_error: Option<String>,
40    /// Focus identifier.
41    pub focus_id: FocusId,
42}
43
44impl Radio {
45    /// Create a radio button with sensible defaults (unselected, no label).
46    pub fn new() -> Self {
47        Self {
48            center: [9.0, 9.0],
49            radius: 9.0,
50            selected: false,
51            label: None,
52            label_size: 14.0,
53            label_color: ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
54            bg: ColorLinPremul::from_srgba_u8([40, 40, 40, 255]),
55            border_color: ColorLinPremul::from_srgba_u8([80, 80, 80, 255]),
56            border_width: 1.0,
57            dot_color: ColorLinPremul::from_srgba_u8([59, 130, 246, 255]),
58            focused: false,
59            validation_error: None,
60            focus_id: FocusId(0),
61        }
62    }
63
64    /// Select this radio button.
65    pub fn select(&mut self) {
66        self.selected = true;
67    }
68
69    /// Deselect this radio button.
70    pub fn deselect(&mut self) {
71        self.selected = false;
72    }
73
74    /// Hit-test the radio circle.
75    pub fn hit_test_circle(&self, x: f32, y: f32) -> bool {
76        let dx = x - self.center[0];
77        let dy = y - self.center[1];
78        dx * dx + dy * dy <= self.radius * self.radius
79    }
80
81    /// Hit-test the label area (if a label exists).
82    pub fn hit_test_label(&self, x: f32, y: f32) -> bool {
83        if let Some(label) = &self.label {
84            let label_x = self.center[0] + self.radius + 8.0;
85            let char_width = self.label_size * 0.5;
86            let label_width = label.len() as f32 * char_width;
87            let clickable_height = (self.radius * 2.0).max(self.label_size * 1.2);
88
89            x >= label_x
90                && x <= label_x + label_width
91                && y >= self.center[1] - clickable_height / 2.0
92                && y <= self.center[1] + clickable_height / 2.0
93        } else {
94            false
95        }
96    }
97}
98
99impl Default for Radio {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Element trait
107// ---------------------------------------------------------------------------
108
109impl Element for Radio {
110    fn rect(&self) -> Rect {
111        let d = self.radius * 2.0;
112        Rect {
113            x: self.center[0] - self.radius,
114            y: self.center[1] - self.radius,
115            w: d,
116            h: d,
117        }
118    }
119
120    fn set_rect(&mut self, rect: Rect) {
121        self.center = [rect.x + rect.w * 0.5, rect.y + rect.h * 0.5];
122        self.radius = rect.w.min(rect.h) * 0.5;
123    }
124
125    fn render(&self, canvas: &mut Canvas, z: i32) {
126        // Background circle
127        canvas.ellipse(
128            self.center,
129            [self.radius, self.radius],
130            Brush::Solid(self.bg),
131            z,
132        );
133
134        // Border
135        if self.border_width > 0.0 {
136            let has_error = self.validation_error.is_some();
137            let border_color = if has_error {
138                Color::rgba(220, 38, 38, 255)
139            } else {
140                self.border_color
141            };
142            let border_width = if has_error {
143                self.border_width.max(2.0)
144            } else {
145                self.border_width
146            };
147            jag_surface::shapes::draw_ellipse(
148                canvas,
149                self.center,
150                [self.radius, self.radius],
151                None,
152                Some(border_width),
153                Some(Brush::Solid(border_color)),
154                z + 1,
155            );
156        }
157
158        // Selected inner dot
159        if self.selected {
160            let inner = self.radius * 0.6;
161            canvas.ellipse(
162                self.center,
163                [inner, inner],
164                Brush::Solid(self.dot_color),
165                z + 2,
166            );
167        }
168
169        // Focus ring
170        if self.focused {
171            let focus_radius = self.radius + 2.0;
172            jag_surface::shapes::draw_ellipse(
173                canvas,
174                self.center,
175                [focus_radius, focus_radius],
176                None,
177                Some(2.0),
178                Some(Brush::Solid(Color::rgba(63, 130, 246, 255))),
179                z + 3,
180            );
181        }
182
183        // Label
184        if let Some(text) = &self.label {
185            let pos = [
186                self.center[0] + self.radius + 8.0,
187                self.center[1] + self.label_size * 0.35,
188            ];
189            canvas.draw_text_run_weighted(
190                pos,
191                text.clone(),
192                self.label_size,
193                400.0,
194                self.label_color,
195                z + 3,
196            );
197        }
198
199        // Validation error
200        if let Some(ref error_msg) = self.validation_error {
201            let error_size = (self.label_size * 0.85).max(12.0);
202            let baseline_offset = error_size * 0.8;
203            let top_gap = 3.0;
204            let control_height = self.radius * 2.0;
205            let error_y = self.center[1] + control_height * 0.5 + top_gap + baseline_offset;
206            let error_x = self.center[0] - self.radius;
207            let error_color = ColorLinPremul::from_srgba_u8([220, 38, 38, 255]);
208
209            canvas.draw_text_run_weighted(
210                [error_x, error_y],
211                error_msg.clone(),
212                error_size,
213                400.0,
214                error_color,
215                z + 4,
216            );
217        }
218    }
219
220    fn focus_id(&self) -> Option<FocusId> {
221        Some(self.focus_id)
222    }
223}
224
225// ---------------------------------------------------------------------------
226// EventHandler trait
227// ---------------------------------------------------------------------------
228
229impl EventHandler for Radio {
230    fn handle_mouse_click(&mut self, event: &MouseClickEvent) -> EventResult {
231        if event.button != MouseButton::Left || event.state != ElementState::Pressed {
232            return EventResult::Ignored;
233        }
234        if self.hit_test_circle(event.x, event.y) || self.hit_test_label(event.x, event.y) {
235            self.select();
236            EventResult::Handled
237        } else {
238            EventResult::Ignored
239        }
240    }
241
242    fn handle_keyboard(&mut self, event: &KeyboardEvent) -> EventResult {
243        if event.state != ElementState::Pressed || !self.focused {
244            return EventResult::Ignored;
245        }
246        match event.key {
247            KeyCode::Space | KeyCode::Enter => {
248                self.select();
249                EventResult::Handled
250            }
251            _ => EventResult::Ignored,
252        }
253    }
254
255    fn handle_mouse_move(&mut self, _event: &MouseMoveEvent) -> EventResult {
256        EventResult::Ignored
257    }
258
259    fn handle_scroll(&mut self, _event: &ScrollEvent) -> EventResult {
260        EventResult::Ignored
261    }
262
263    fn is_focused(&self) -> bool {
264        self.focused
265    }
266
267    fn set_focused(&mut self, focused: bool) {
268        self.focused = focused;
269    }
270
271    fn contains_point(&self, x: f32, y: f32) -> bool {
272        self.hit_test_circle(x, y) || self.hit_test_label(x, y)
273    }
274}
275
276// ---------------------------------------------------------------------------
277// Tests
278// ---------------------------------------------------------------------------
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn radio_new_defaults() {
286        let r = Radio::new();
287        assert!(!r.selected);
288        assert!(!r.focused);
289        assert!(r.label.is_none());
290    }
291
292    #[test]
293    fn radio_select_deselect() {
294        let mut r = Radio::new();
295        assert!(!r.selected);
296        r.select();
297        assert!(r.selected);
298        r.deselect();
299        assert!(!r.selected);
300    }
301
302    #[test]
303    fn radio_hit_test_circle() {
304        let mut r = Radio::new();
305        r.center = [50.0, 50.0];
306        r.radius = 10.0;
307        assert!(r.hit_test_circle(50.0, 50.0)); // center
308        assert!(r.hit_test_circle(55.0, 50.0)); // inside
309        assert!(!r.hit_test_circle(70.0, 50.0)); // outside
310    }
311
312    #[test]
313    fn radio_hit_test_label() {
314        let mut r = Radio::new();
315        r.center = [50.0, 50.0];
316        r.radius = 10.0;
317        r.label = Some("Option A".to_string());
318        // label starts at x = 50 + 10 + 8 = 68
319        assert!(r.hit_test_label(70.0, 50.0));
320        assert!(!r.hit_test_label(40.0, 50.0));
321    }
322
323    #[test]
324    fn radio_focus() {
325        let mut r = Radio::new();
326        assert!(!r.is_focused());
327        r.set_focused(true);
328        assert!(r.is_focused());
329    }
330
331    #[test]
332    fn radio_keyboard_select() {
333        let mut r = Radio::new();
334        r.focused = true;
335        let evt = KeyboardEvent {
336            key: KeyCode::Space,
337            state: ElementState::Pressed,
338            modifiers: Default::default(),
339            text: None,
340        };
341        assert!(!r.selected);
342        assert_eq!(r.handle_keyboard(&evt), EventResult::Handled);
343        assert!(r.selected);
344    }
345}