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}