Skip to main content

sequoia_elements/
radio.rs

1use std::rc::Rc;
2
3use sequoia_core::{Color, Edges, Length, Point, Size};
4use sequoia_input::{CursorIcon, Event, EventPhase, Key, PointerButton, PointerEvent};
5use sequoia_layout::{Align, LayoutCtx, LayoutStyle};
6use sequoia_reactive::Signal;
7use sequoia_style::RadioStyle;
8use sequoia_view::{
9    Accessible, AccessibleRole, AnyView, Column, EventCtx, PaintCtx, Row, Text, View,
10};
11
12const DIAMETER: f32 = 16.0;
13const BORDER_W: f32 = 1.5;
14const GAP: f32 = 8.0;
15const DOT_RATIO: f32 = 0.52;
16const DEFAULT_FONT_SIZE: f32 = 14.0;
17
18pub struct RadioButton {
19    selected: Signal<usize>,
20    value: usize,
21    label: Option<String>,
22    style: Option<RadioStyle>,
23    background: Option<Color>,
24    checked_background: Option<Color>,
25    border: Option<Color>,
26    checked_border: Option<Color>,
27    dot: Option<Color>,
28    text_color: Option<Color>,
29    font_size: Option<f32>,
30    diameter: Option<f32>,
31    border_width: Option<f32>,
32    disabled: bool,
33    on_select: Option<Rc<dyn Fn(usize)>>,
34    margin: Edges,
35}
36
37impl RadioButton {
38    pub fn new(selected: Signal<usize>, value: usize) -> Self {
39        Self {
40            selected,
41            value,
42            label: None,
43            style: None,
44            background: None,
45            checked_background: None,
46            border: None,
47            checked_border: None,
48            dot: None,
49            text_color: None,
50            font_size: None,
51            diameter: None,
52            border_width: None,
53            disabled: false,
54            on_select: None,
55            margin: Edges::ZERO,
56        }
57    }
58
59    pub fn label(mut self, label: impl Into<String>) -> Self {
60        self.label = Some(label.into());
61        self
62    }
63
64    pub fn style(mut self, style: RadioStyle) -> Self {
65        self.style = Some(style);
66        self
67    }
68
69    pub fn background(mut self, color: Color) -> Self {
70        self.background = Some(color);
71        self
72    }
73
74    pub fn checked_background(mut self, color: Color) -> Self {
75        self.checked_background = Some(color);
76        self
77    }
78
79    pub fn border_color(mut self, color: Color) -> Self {
80        self.border = Some(color);
81        self
82    }
83
84    pub fn checked_border(mut self, color: Color) -> Self {
85        self.checked_border = Some(color);
86        self
87    }
88
89    pub fn dot_color(mut self, color: Color) -> Self {
90        self.dot = Some(color);
91        self
92    }
93
94    pub fn text_color(mut self, color: Color) -> Self {
95        self.text_color = Some(color);
96        self
97    }
98
99    pub fn font_size(mut self, size: f32) -> Self {
100        self.font_size = Some(size);
101        self
102    }
103
104    pub fn diameter(mut self, size: f32) -> Self {
105        self.diameter = Some(size);
106        self
107    }
108
109    pub fn border_width(mut self, width: f32) -> Self {
110        self.border_width = Some(width);
111        self
112    }
113
114    pub fn disabled(mut self, disabled: bool) -> Self {
115        self.disabled = disabled;
116        self
117    }
118
119    pub fn on_select(mut self, f: impl Fn(usize) + 'static) -> Self {
120        self.on_select = Some(Rc::new(f));
121        self
122    }
123
124    pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
125        let m = margin.into();
126        self.margin = Edges::only(
127            sanitize_margin(m.left),
128            sanitize_margin(m.top),
129            sanitize_margin(m.right),
130            sanitize_margin(m.bottom),
131        );
132        self
133    }
134
135    pub fn margin_xy(self, x: f32, y: f32) -> Self {
136        self.margin(Edges::xy(x, y))
137    }
138
139    pub fn margin_x(mut self, x: f32) -> Self {
140        let x = sanitize_margin(x);
141        self.margin.left = x;
142        self.margin.right = x;
143        self
144    }
145
146    pub fn margin_y(mut self, y: f32) -> Self {
147        let y = sanitize_margin(y);
148        self.margin.top = y;
149        self.margin.bottom = y;
150        self
151    }
152
153    pub fn margin_left(mut self, v: f32) -> Self {
154        self.margin.left = sanitize_margin(v);
155        self
156    }
157
158    pub fn margin_right(mut self, v: f32) -> Self {
159        self.margin.right = sanitize_margin(v);
160        self
161    }
162
163    pub fn margin_top(mut self, v: f32) -> Self {
164        self.margin.top = sanitize_margin(v);
165        self
166    }
167
168    pub fn margin_bottom(mut self, v: f32) -> Self {
169        self.margin.bottom = sanitize_margin(v);
170        self
171    }
172
173    pub fn value(&self) -> usize {
174        self.value
175    }
176
177    pub fn selected_signal(&self) -> Signal<usize> {
178        self.selected.clone()
179    }
180
181    pub fn is_selected(&self) -> bool {
182        self.selected.get_untracked() == self.value
183    }
184
185    fn select(&self) -> bool {
186        if self.disabled || self.is_selected() {
187            return false;
188        }
189        self.selected.set(self.value);
190        if let Some(f) = &self.on_select {
191            f(self.value);
192        }
193        true
194    }
195
196    fn resolved_diameter(&self, style: RadioStyle) -> f32 {
197        self.diameter
198            .or(style.diameter)
199            .unwrap_or(DIAMETER)
200            .max(8.0)
201    }
202
203    fn resolved_border_width(&self, radius: f32) -> f32 {
204        self.border_width.unwrap_or(BORDER_W).max(0.0).min(radius)
205    }
206
207    fn resolved_font_size(&self, style: RadioStyle) -> f32 {
208        self.font_size
209            .or(style.font_size)
210            .unwrap_or(DEFAULT_FONT_SIZE)
211    }
212}
213
214fn sanitize_margin(v: f32) -> f32 {
215    if v.is_finite() { v.max(0.0) } else { 0.0 }
216}
217
218impl View for RadioButton {
219    fn accessibility(&self) -> Option<Accessible> {
220        Some(Accessible::new(
221            AccessibleRole::Radio,
222            self.label
223                .clone()
224                .unwrap_or_else(|| "przycisk radiowy".to_string()),
225        ))
226    }
227
228    fn body(&self) -> impl View + 'static {
229        sequoia_view::Unit
230    }
231
232    fn is_leaf(&self) -> bool {
233        true
234    }
235
236    fn focusable(&self) -> bool {
237        !self.disabled
238    }
239
240    fn has_visual_state(&self) -> bool {
241        true
242    }
243
244    fn cursor_icon(&self, _state: sequoia_style::VisualState) -> CursorIcon {
245        if self.disabled {
246            CursorIcon::Default
247        } else {
248            CursorIcon::Pointer
249        }
250    }
251
252    fn layout_style(&self) -> LayoutStyle {
253        LayoutStyle {
254            width: Length::Fit,
255            height: Length::Fit,
256            margin: self.margin,
257            ..LayoutStyle::default()
258        }
259    }
260
261    fn intrinsic_size(&self, ctx: &mut LayoutCtx) -> Option<Size> {
262        let style = self.style.unwrap_or_default();
263        let diameter = self.resolved_diameter(style);
264        let font_size = self.resolved_font_size(style);
265        let label_w = self
266            .label
267            .as_deref()
268            .map(|label| ctx.text_width(label, font_size))
269            .unwrap_or(0.0);
270        let gap = if self.label.is_some() { GAP } else { 0.0 };
271        let text_h = if self.label.is_some() {
272            LayoutCtx::text_line_height(font_size)
273        } else {
274            0.0
275        };
276        Some(Size::new(diameter + gap + label_w, diameter.max(text_h)))
277    }
278
279    fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
280        if self.disabled || ctx.phase != EventPhase::Target {
281            return;
282        }
283        if let Event::Pointer(PointerEvent::Up {
284            button: PointerButton::Left,
285            ..
286        }) = event
287        {
288            if ctx.state.pressed && ctx.hover_self && self.select() {
289                ctx.handled();
290            }
291            return;
292        }
293        if let Event::Key(key) = event
294            && ctx.state.focused
295            && (key.key == Key::ENTER || key.key == Key::SPACE)
296        {
297            self.select();
298            ctx.handled();
299        }
300    }
301
302    fn paint_self(&self, ctx: &mut PaintCtx) {
303        let theme = ctx.theme();
304        let style = self.style.unwrap_or_default();
305        let selected = self.is_selected();
306        let diameter = self.resolved_diameter(style);
307        let radius = diameter * 0.5;
308        let border_w = self.resolved_border_width(radius);
309        let center = Point::new(
310            ctx.rect.left() + radius,
311            ctx.rect.top() + ctx.rect.size.height * 0.5,
312        );
313
314        let background = self
315            .background
316            .or(style.background)
317            .unwrap_or(theme.surface);
318        let checked_background = self
319            .checked_background
320            .or(style.checked)
321            .unwrap_or(theme.surface);
322        let border = if self.disabled {
323            theme.disabled
324        } else if selected {
325            self.checked_border
326                .or(style.checked_border)
327                .or(self.border)
328                .or(style.border)
329                .unwrap_or(theme.accent)
330        } else {
331            self.border.or(style.border).unwrap_or(theme.border)
332        };
333        let fill = if selected {
334            checked_background
335        } else {
336            background
337        };
338        let dot = self.dot.or(style.dot).unwrap_or(theme.accent);
339
340        if border_w > 0.0 {
341            ctx.disk(center, radius, border);
342            ctx.disk(center, (radius - border_w).max(0.0), fill);
343        } else {
344            ctx.disk(center, radius, fill);
345        }
346        let inner = (radius - border_w).max(0.0);
347        if selected && inner > 0.5 {
348            ctx.disk(center, inner * DOT_RATIO, dot);
349        }
350
351        if let Some(label) = &self.label {
352            let font_size = self.resolved_font_size(style);
353            let color = if self.disabled {
354                theme.disabled
355            } else {
356                self.text_color.or(style.text).unwrap_or(theme.text)
357            };
358            let x = ctx.rect.left() + diameter + GAP;
359            let y = ctx.rect.top() + ctx.rect.size.height * 0.5 + font_size * 0.35;
360            ctx.draw_text(label, (x, y), font_size, color);
361        }
362    }
363}
364
365pub struct RadioGroup {
366    selected: Signal<usize>,
367    options: Vec<String>,
368    label: String,
369    style: Option<RadioStyle>,
370    gap: f32,
371    horizontal: bool,
372    disabled: bool,
373    on_change: Option<Rc<dyn Fn(usize)>>,
374    margin: Edges,
375}
376
377impl RadioGroup {
378    pub fn new(
379        selected: Signal<usize>,
380        options: impl IntoIterator<Item = impl Into<String>>,
381    ) -> Self {
382        Self {
383            selected,
384            options: options.into_iter().map(Into::into).collect(),
385            label: String::new(),
386            style: None,
387            gap: GAP,
388            horizontal: false,
389            disabled: false,
390            on_change: None,
391            margin: Edges::ZERO,
392        }
393    }
394
395    pub fn option(mut self, label: impl Into<String>) -> Self {
396        self.options.push(label.into());
397        self
398    }
399
400    pub fn label(mut self, label: impl Into<String>) -> Self {
401        self.label = label.into();
402        self
403    }
404
405    pub fn style(mut self, style: RadioStyle) -> Self {
406        self.style = Some(style);
407        self
408    }
409
410    pub fn gap(mut self, gap: f32) -> Self {
411        self.gap = if gap.is_finite() { gap.max(0.0) } else { 0.0 };
412        self
413    }
414
415    pub fn horizontal(mut self) -> Self {
416        self.horizontal = true;
417        self
418    }
419
420    pub fn vertical(mut self) -> Self {
421        self.horizontal = false;
422        self
423    }
424
425    pub fn disabled(mut self, disabled: bool) -> Self {
426        self.disabled = disabled;
427        self
428    }
429
430    pub fn on_change(mut self, f: impl Fn(usize) + 'static) -> Self {
431        self.on_change = Some(Rc::new(f));
432        self
433    }
434
435    pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
436        let m = margin.into();
437        self.margin = Edges::only(
438            sanitize_margin(m.left),
439            sanitize_margin(m.top),
440            sanitize_margin(m.right),
441            sanitize_margin(m.bottom),
442        );
443        self
444    }
445
446    pub fn selected_signal(&self) -> Signal<usize> {
447        self.selected.clone()
448    }
449
450    pub fn selected_index(&self) -> usize {
451        self.selected.get_untracked()
452    }
453
454    pub fn options(&self) -> &[String] {
455        &self.options
456    }
457
458    fn select(&self, index: usize) -> bool {
459        if self.disabled || index >= self.options.len() || self.selected.get_untracked() == index {
460            return false;
461        }
462        self.selected.set(index);
463        if let Some(f) = &self.on_change {
464            f(index);
465        }
466        true
467    }
468
469    fn radio(&self, index: usize, label: &str) -> RadioButton {
470        let mut radio = RadioButton::new(self.selected.clone(), index).label(label.to_string());
471        if let Some(style) = self.style {
472            radio = radio.style(style);
473        }
474        if self.disabled {
475            radio = radio.disabled(true);
476        }
477        if let Some(f) = &self.on_change {
478            let f = f.clone();
479            radio = radio.on_select(move |index| f(index));
480        }
481        radio
482    }
483}
484
485impl View for RadioGroup {
486    fn accessibility(&self) -> Option<Accessible> {
487        Some(Accessible::new(
488            AccessibleRole::RadioGroup,
489            if self.label.is_empty() {
490                "grupa wyboru".to_string()
491            } else {
492                self.label.clone()
493            },
494        ))
495    }
496
497    fn body(&self) -> impl View + 'static {
498        let inner = if self.horizontal {
499            let mut row = Row::new().gap(self.gap).align_items(Align::Center);
500            for (index, option) in self.options.iter().enumerate() {
501                row = row.child(self.radio(index, option));
502            }
503            AnyView::new(row)
504        } else {
505            let mut column = Column::new().gap(self.gap);
506            for (index, option) in self.options.iter().enumerate() {
507                column = column.child(self.radio(index, option));
508            }
509            AnyView::new(column)
510        };
511
512        if self.label.is_empty() {
513            return inner;
514        }
515
516        let style = self.style.unwrap_or_default();
517        let mut label = Text::new(self.label.clone())
518            .size(style.font_size.unwrap_or(DEFAULT_FONT_SIZE))
519            .bold();
520        if let Some(color) = style.text {
521            label = label.color(color);
522        }
523        AnyView::new(Column::new().gap(6.0).child(label).child(inner))
524    }
525
526    fn layout_style(&self) -> LayoutStyle {
527        LayoutStyle {
528            width: Length::Fit,
529            height: Length::Fit,
530            margin: self.margin,
531            ..LayoutStyle::default()
532        }
533    }
534
535    fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
536        if self.disabled || self.options.is_empty() || ctx.phase != EventPhase::Capture {
537            return;
538        }
539        let Event::Key(key) = event else { return };
540        let len = self.options.len();
541        let selected = self.selected.get_untracked().min(len - 1);
542        let next = match key.key {
543            Key::ARROW_DOWN | Key::ARROW_RIGHT => (selected + 1) % len,
544            Key::ARROW_UP | Key::ARROW_LEFT => (selected + len - 1) % len,
545            Key::HOME => 0,
546            Key::END => len - 1,
547            _ => return,
548        };
549        self.select(next);
550        ctx.handled();
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use std::cell::{Cell, RefCell};
557
558    use super::*;
559
560    #[test]
561    fn radio_select_updates_signal_and_fires_once() {
562        let selected = Signal::new(1usize);
563        let calls = Rc::new(Cell::new(0));
564        let counter = calls.clone();
565        let radio = RadioButton::new(selected.clone(), 0).on_select(move |index| {
566            assert_eq!(index, 0);
567            counter.set(counter.get() + 1);
568        });
569
570        assert!(radio.select());
571        assert_eq!(selected.get_untracked(), 0);
572        assert_eq!(calls.get(), 1);
573        assert!(!radio.select());
574        assert_eq!(calls.get(), 1);
575    }
576
577    #[test]
578    fn disabled_radio_ignores_select() {
579        let selected = Signal::new(0usize);
580        let radio = RadioButton::new(selected.clone(), 1).disabled(true);
581
582        assert!(!radio.select());
583        assert_eq!(selected.get_untracked(), 0);
584    }
585
586    #[test]
587    fn radio_reports_fit_layout_and_value() {
588        let radio = RadioButton::new(Signal::new(0usize), 2).margin_xy(3.0, 4.0);
589        let style = radio.layout_style();
590        assert_eq!(style.width, Length::Fit);
591        assert_eq!(style.height, Length::Fit);
592        assert_eq!(style.margin, Edges::xy(3.0, 4.0));
593        assert_eq!(radio.value(), 2);
594        assert!(!radio.is_selected());
595    }
596
597    #[test]
598    fn group_collects_options_and_selects() {
599        let selected = Signal::new(0usize);
600        let calls = Rc::new(RefCell::new(Vec::new()));
601        let log = calls.clone();
602        let group = RadioGroup::new(selected.clone(), ["A", "B"])
603            .option("C")
604            .on_change(move |index| log.borrow_mut().push(index));
605
606        assert_eq!(group.options(), ["A", "B", "C"]);
607        assert!(group.select(2));
608        assert_eq!(selected.get_untracked(), 2);
609        assert_eq!(*calls.borrow(), vec![2]);
610        assert!(!group.select(2));
611        assert!(!group.select(3));
612    }
613
614    #[test]
615    fn disabled_group_ignores_select() {
616        let selected = Signal::new(0usize);
617        let group = RadioGroup::new(selected.clone(), ["A", "B"]).disabled(true);
618
619        assert!(!group.select(1));
620        assert_eq!(selected.get_untracked(), 0);
621    }
622
623    #[test]
624    fn group_direction_and_gap_are_sanitized() {
625        let group = RadioGroup::new(Signal::new(0usize), ["A"])
626            .horizontal()
627            .gap(f32::NAN);
628        assert!(group.horizontal);
629        assert_eq!(group.gap, 0.0);
630        assert!(!group.vertical().horizontal);
631    }
632}