1use std::cell::Cell;
7use std::rc::Rc;
8use std::sync::Arc;
9
10use crate::draw_ctx::DrawCtx;
11use crate::event::{Event, EventResult, Key, MouseButton};
12use crate::geometry::{Rect, Size};
13use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
14use crate::text::{measure_advance, Font};
15use crate::widget::Widget;
16use crate::widgets::label::Label;
17
18const DOT_R: f64 = 7.0; const GAP: f64 = 8.0;
20const ROW_H: f64 = 22.0;
21const HWRAP_SPACING: f64 = 6.0;
23const LEFT_INSET: f64 = 2.0;
30
31pub struct RadioGroup {
40 bounds: Rect,
41 children: Vec<Box<dyn Widget>>,
46 base: WidgetBase,
47 options: Vec<String>,
48 selected: usize,
49 hovered: Option<usize>,
50 focused: bool,
51 font: Arc<Font>,
52 font_size: f64,
53 on_change: Option<Box<dyn FnMut(usize)>>,
54 selected_cell: Option<Rc<Cell<usize>>>,
57 horizontal: bool,
60 hwrap_items: Vec<(f64, f64, f64, bool, Rect)>,
65}
66
67impl RadioGroup {
68 pub fn new(options: Vec<impl Into<String>>, selected: usize, font: Arc<Font>) -> Self {
69 let font_size = 14.0;
70 let opts: Vec<String> = options.into_iter().map(|s| s.into()).collect();
71 let children: Vec<Box<dyn Widget>> = opts
72 .iter()
73 .map(|text| {
74 Box::new(Label::new(text.as_str(), Arc::clone(&font)).with_font_size(font_size))
75 as Box<dyn Widget>
76 })
77 .collect();
78 Self {
79 bounds: Rect::default(),
80 children,
81 base: WidgetBase::new(),
82 options: opts,
83 selected,
84 hovered: None,
85 focused: false,
86 font,
87 font_size,
88 on_change: None,
89 selected_cell: None,
90 horizontal: false,
91 hwrap_items: Vec::new(),
92 }
93 }
94
95 pub fn with_horizontal_wrap(mut self, on: bool) -> Self {
100 self.horizontal = on;
101 self
102 }
103
104 pub fn with_selected_cell(mut self, cell: Rc<Cell<usize>>) -> Self {
108 let n = self.options.len();
109 let v = cell.get();
110 if n > 0 {
111 self.selected = v.min(n - 1);
112 }
113 self.selected_cell = Some(cell);
114 self
115 }
116
117 pub fn with_font_size(mut self, size: f64) -> Self {
118 self.font_size = size;
119 self.children = self
121 .options
122 .iter()
123 .map(|text| {
124 Box::new(Label::new(text.as_str(), Arc::clone(&self.font)).with_font_size(size))
125 as Box<dyn Widget>
126 })
127 .collect();
128 self
129 }
130
131 pub fn with_margin(mut self, m: Insets) -> Self {
132 self.base.margin = m;
133 self
134 }
135 pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
136 self.base.h_anchor = h;
137 self
138 }
139 pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
140 self.base.v_anchor = v;
141 self
142 }
143 pub fn with_min_size(mut self, s: Size) -> Self {
144 self.base.min_size = s;
145 self
146 }
147 pub fn with_max_size(mut self, s: Size) -> Self {
148 self.base.max_size = s;
149 self
150 }
151
152 pub fn on_change(mut self, cb: impl FnMut(usize) + 'static) -> Self {
153 self.on_change = Some(Box::new(cb));
154 self
155 }
156
157 pub fn selected(&self) -> usize {
158 self.selected
159 }
160
161 pub fn set_selected(&mut self, idx: usize) {
162 if idx < self.options.len() {
163 self.selected = idx;
164 if let Some(cell) = &self.selected_cell {
165 cell.set(idx);
166 }
167 }
168 }
169
170 fn fire(&mut self) {
171 let idx = self.selected;
172 if let Some(cell) = &self.selected_cell {
173 cell.set(idx);
174 }
175 if let Some(cb) = self.on_change.as_mut() {
176 cb(idx);
177 }
178 }
179
180 fn row_center_y(&self, i: usize, total_h: f64) -> f64 {
182 let n = self.options.len();
183 if n == 0 {
184 return total_h * 0.5;
185 }
186 let row_top_y = total_h - (i as f64) * ROW_H;
189 row_top_y - ROW_H * 0.5
190 }
191
192 fn row_for_y(&self, pos_y: f64) -> Option<usize> {
193 let h = self.bounds.height;
194 for i in 0..self.options.len() {
195 let cy = self.row_center_y(i, h);
196 if pos_y >= cy - ROW_H * 0.5 && pos_y < cy + ROW_H * 0.5 {
197 return Some(i);
198 }
199 }
200 None
201 }
202
203 fn compute_hwrap(&self, available_w: f64) -> (Vec<(f64, f64, f64, bool, Rect)>, f64) {
210 let n = self.options.len();
211 if n == 0 {
212 return (Vec::new(), 0.0);
213 }
214 let dot_extent = LEFT_INSET + DOT_R * 2.0;
215 let mut placed: Vec<(usize, f64, f64, f64, bool)> = Vec::with_capacity(n);
217 let mut x = 0.0_f64;
218 let mut row = 0usize;
219 for opt in &self.options {
220 let has_label = !opt.is_empty();
221 let label_w = if has_label {
222 measure_advance(&self.font, opt, self.font_size)
223 } else {
224 0.0
225 };
226 let item_w = dot_extent + if has_label { GAP + label_w } else { 0.0 };
227 if x > 0.0 && x + item_w > available_w {
228 row += 1;
229 x = 0.0;
230 }
231 placed.push((row, x, item_w, label_w, has_label));
232 x += item_w + HWRAP_SPACING;
233 }
234 let rows = row + 1;
235 let h = rows as f64 * ROW_H;
236 let items = placed
238 .into_iter()
239 .map(|(r, x_left, item_w, _label_w, has_label)| {
240 let cy = h - (r as f64) * ROW_H - ROW_H * 0.5;
241 let dot_cx = LEFT_INSET + DOT_R + x_left;
242 let label_x = x_left + dot_extent + GAP;
243 let hit = Rect::new(x_left, cy - ROW_H * 0.5, item_w, ROW_H);
244 (dot_cx, cy, label_x, has_label, hit)
245 })
246 .collect();
247 (items, h)
248 }
249
250 fn hwrap_item_at(&self, pos_x: f64, pos_y: f64) -> Option<usize> {
252 self.hwrap_items.iter().position(|(_, _, _, _, hit)| {
253 pos_x >= hit.x
254 && pos_x < hit.x + hit.width
255 && pos_y >= hit.y
256 && pos_y < hit.y + hit.height
257 })
258 }
259
260 fn layout_horizontal(&mut self, available: Size) -> Size {
263 let (items, h) = self.compute_hwrap(available.width);
264 self.bounds = Rect::new(0.0, 0.0, available.width, h);
265 for (i, child) in self.children.iter_mut().enumerate() {
266 let Some(&(_dot_cx, cy, label_x, has_label, _hit)) = items.get(i) else {
267 continue;
268 };
269 if !has_label {
270 child.set_bounds(Rect::new(label_x, cy, 0.0, 0.0));
272 let _ = child.layout(Size::new(0.0, ROW_H));
273 continue;
274 }
275 let s = child.layout(Size::new((available.width - label_x).max(0.0), ROW_H));
276 let ly = cy - s.height * 0.5;
277 child.set_bounds(Rect::new(label_x, ly, s.width, s.height));
278 }
279 self.hwrap_items = items;
280 Size::new(available.width, h)
281 }
282}
283
284impl Widget for RadioGroup {
285 fn type_name(&self) -> &'static str {
286 "RadioGroup"
287 }
288 fn bounds(&self) -> Rect {
289 self.bounds
290 }
291 fn set_bounds(&mut self, b: Rect) {
292 self.bounds = b;
293 }
294 fn children(&self) -> &[Box<dyn Widget>] {
295 &self.children
296 }
297 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
298 &mut self.children
299 }
300
301 fn is_focusable(&self) -> bool {
302 true
303 }
304
305 fn margin(&self) -> Insets {
306 self.base.margin
307 }
308 fn widget_base(&self) -> Option<&WidgetBase> {
309 Some(&self.base)
310 }
311 fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
312 Some(&mut self.base)
313 }
314 fn h_anchor(&self) -> HAnchor {
315 self.base.h_anchor
316 }
317 fn v_anchor(&self) -> VAnchor {
318 self.base.v_anchor
319 }
320 fn min_size(&self) -> Size {
321 self.base.min_size
322 }
323 fn max_size(&self) -> Size {
324 self.base.max_size
325 }
326
327 fn measure_min_height(&self, available_w: f64) -> f64 {
332 if self.horizontal {
333 let (_, h) = self.compute_hwrap(available_w);
334 return h;
335 }
336 self.options.len() as f64 * ROW_H
337 }
338
339 fn layout(&mut self, available: Size) -> Size {
340 if let Some(cell) = &self.selected_cell {
343 let n = self.options.len();
344 if n > 0 {
345 let v = cell.get().min(n - 1);
346 self.selected = v;
347 }
348 }
349 if self.horizontal {
350 return self.layout_horizontal(available);
351 }
352 let h = self.options.len() as f64 * ROW_H;
353 self.bounds = Rect::new(0.0, 0.0, available.width, h);
354 let circle_extent = LEFT_INSET + DOT_R * 2.0;
357 let label_avail_w = (available.width - circle_extent - GAP).max(0.0);
358 let lx = circle_extent + GAP;
359 for (i, child) in self.children.iter_mut().enumerate() {
360 let s = child.layout(Size::new(label_avail_w, ROW_H));
361 let row_top_y = h - (i as f64) * ROW_H;
365 let cy = row_top_y - ROW_H * 0.5;
366 let ly = cy - s.height * 0.5;
367 child.set_bounds(Rect::new(lx, ly, s.width, s.height));
368 }
369 Size::new(available.width, h)
370 }
371
372 fn paint(&mut self, ctx: &mut dyn DrawCtx) {
373 let v = ctx.visuals();
374 let h = self.bounds.height;
375
376 if self.focused {
380 ctx.set_stroke_color(v.accent_focus);
381 ctx.set_line_width(1.5);
382 ctx.begin_path();
383 ctx.rounded_rect(0.75, 0.75, self.bounds.width - 1.5, h - 1.5, 4.0);
384 ctx.stroke();
385 }
386
387 let text_color = v.text_color;
393 for i in 0..self.options.len() {
394 let (dot_cx, cy) = if self.horizontal {
397 match self.hwrap_items.get(i) {
398 Some(&(cx, cy, _, _, _)) => (cx, cy),
399 None => continue,
400 }
401 } else {
402 (LEFT_INSET + DOT_R, self.row_center_y(i, h))
403 };
404 let checked = i == self.selected;
405 let hovered = self.hovered == Some(i);
406
407 let border = if checked {
408 v.accent
409 } else if hovered {
410 v.widget_bg_hovered
411 } else {
412 v.widget_stroke
413 };
414 let bg = if checked { v.accent } else { v.widget_bg };
415
416 ctx.set_fill_color(bg);
417 ctx.begin_path();
418 ctx.circle(dot_cx, cy, DOT_R);
419 ctx.fill();
420
421 ctx.set_stroke_color(border);
422 ctx.set_line_width(1.5);
423 ctx.begin_path();
424 ctx.circle(dot_cx, cy, DOT_R);
425 ctx.stroke();
426
427 if checked {
430 ctx.set_fill_color(v.widget_bg);
431 ctx.begin_path();
432 ctx.circle(dot_cx, cy, DOT_R * 0.45);
433 ctx.fill();
434 }
435
436 if let Some(child) = self.children.get_mut(i) {
437 child.set_label_color(text_color);
438 }
439 }
440 }
441
442 fn on_event(&mut self, event: &Event) -> EventResult {
443 match event {
444 Event::MouseMove { pos } => {
445 let was = self.hovered;
446 self.hovered = if self.horizontal {
447 self.hwrap_item_at(pos.x, pos.y)
448 } else {
449 self.row_for_y(pos.y)
450 };
451 if was != self.hovered {
452 crate::animation::request_draw();
453 return EventResult::Consumed;
454 }
455 EventResult::Ignored
456 }
457 Event::MouseDown {
458 button: MouseButton::Left,
459 pos,
460 ..
461 } => {
462 let hit = if self.horizontal {
463 self.hwrap_item_at(pos.x, pos.y)
464 } else {
465 self.row_for_y(pos.y)
466 };
467 if let Some(i) = hit {
468 let was = self.selected;
469 self.selected = i;
470 self.fire();
471 if was != i {
472 crate::animation::request_draw();
473 }
474 return EventResult::Consumed;
475 }
476 EventResult::Ignored
477 }
478 Event::KeyDown { key, .. } => {
479 let n = self.options.len();
480 let changed = match key {
481 Key::ArrowUp | Key::ArrowLeft => {
482 if self.selected > 0 {
483 self.selected -= 1;
484 true
485 } else {
486 false
487 }
488 }
489 Key::ArrowDown | Key::ArrowRight => {
490 if self.selected + 1 < n {
491 self.selected += 1;
492 true
493 } else {
494 false
495 }
496 }
497 _ => false,
498 };
499 if changed {
500 self.fire();
501 crate::animation::request_draw();
502 EventResult::Consumed
503 } else {
504 EventResult::Ignored
505 }
506 }
507 Event::FocusGained => {
508 let was = self.focused;
509 self.focused = true;
510 if !was {
511 crate::animation::request_draw();
512 EventResult::Consumed
513 } else {
514 EventResult::Ignored
515 }
516 }
517 Event::FocusLost => {
518 let was = self.focused;
519 self.focused = false;
520 if was {
521 crate::animation::request_draw();
522 EventResult::Consumed
523 } else {
524 EventResult::Ignored
525 }
526 }
527 _ => EventResult::Ignored,
528 }
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use crate::color::Color;
536 use crate::draw_ctx::{FillRule, GlPaint, LinearGradientPaint, RadialGradientPaint};
537 use crate::event::Modifiers;
538 use crate::geometry::Point;
539 use crate::text::TextMetrics;
540 use crate::theme::{current_visuals, set_visuals, Visuals};
541 use agg_rust::comp_op::CompOp;
542 use agg_rust::math_stroke::{LineCap, LineJoin};
543 use agg_rust::trans_affine::TransAffine;
544
545 const FONT_BYTES: &[u8] = include_bytes!("../../../demo/assets/CascadiaCode.ttf");
546
547 fn test_font() -> Arc<Font> {
548 Arc::new(Font::from_slice(FONT_BYTES).expect("font"))
549 }
550
551 struct CircleFillRecorder {
558 transform: TransAffine,
559 stack: Vec<TransAffine>,
560 fill_color: Color,
561 last_was_circle: bool,
562 filled_circles: Vec<Color>,
563 }
564
565 impl CircleFillRecorder {
566 fn new() -> Self {
567 Self {
568 transform: TransAffine::new(),
569 stack: Vec::new(),
570 fill_color: Color::rgba(0.0, 0.0, 0.0, 0.0),
571 last_was_circle: false,
572 filled_circles: Vec::new(),
573 }
574 }
575 }
576
577 impl DrawCtx for CircleFillRecorder {
578 fn set_fill_color(&mut self, color: Color) {
579 self.fill_color = color;
580 }
581 fn set_stroke_color(&mut self, _color: Color) {}
582 fn set_fill_linear_gradient(&mut self, _gradient: LinearGradientPaint) {}
583 fn set_fill_radial_gradient(&mut self, _gradient: RadialGradientPaint) {}
584 fn set_line_width(&mut self, _w: f64) {}
585 fn set_line_join(&mut self, _join: LineJoin) {}
586 fn set_line_cap(&mut self, _cap: LineCap) {}
587 fn set_miter_limit(&mut self, _limit: f64) {}
588 fn set_line_dash(&mut self, _dashes: &[f64], _offset: f64) {}
589 fn set_blend_mode(&mut self, _mode: CompOp) {}
590 fn set_global_alpha(&mut self, _alpha: f64) {}
591 fn set_fill_rule(&mut self, _rule: FillRule) {}
592 fn set_font(&mut self, _font: Arc<Font>) {}
593 fn set_font_size(&mut self, _size: f64) {}
594 fn clip_rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64) {}
595 fn reset_clip(&mut self) {}
596 fn clear(&mut self, _color: Color) {}
597 fn begin_path(&mut self) {
598 self.last_was_circle = false;
599 }
600 fn move_to(&mut self, _x: f64, _y: f64) {
601 self.last_was_circle = false;
602 }
603 fn line_to(&mut self, _x: f64, _y: f64) {
604 self.last_was_circle = false;
605 }
606 fn cubic_to(&mut self, _cx1: f64, _cy1: f64, _cx2: f64, _cy2: f64, _x: f64, _y: f64) {
607 self.last_was_circle = false;
608 }
609 fn quad_to(&mut self, _cx: f64, _cy: f64, _x: f64, _y: f64) {
610 self.last_was_circle = false;
611 }
612 fn arc_to(&mut self, _cx: f64, _cy: f64, _r: f64, _s: f64, _e: f64, _ccw: bool) {
613 self.last_was_circle = false;
614 }
615 fn circle(&mut self, _cx: f64, _cy: f64, _r: f64) {
616 self.last_was_circle = true;
617 }
618 fn rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64) {
619 self.last_was_circle = false;
620 }
621 fn rounded_rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64, _r: f64) {
622 self.last_was_circle = false;
623 }
624 fn close_path(&mut self) {}
625 fn fill(&mut self) {
626 if self.last_was_circle {
627 self.filled_circles.push(self.fill_color);
628 }
629 }
630 fn stroke(&mut self) {}
631 fn fill_and_stroke(&mut self) {}
632 fn draw_triangles_aa(&mut self, _vertices: &[[f32; 3]], _indices: &[u32], _color: Color) {}
633 fn fill_text(&mut self, _text: &str, _x: f64, _y: f64) {}
634 fn fill_text_gsv(&mut self, _text: &str, _x: f64, _y: f64, _size: f64) {}
635 fn measure_text(&self, _text: &str) -> Option<TextMetrics> {
636 Some(TextMetrics {
637 width: 40.0,
638 ascent: 10.0,
639 descent: 3.0,
640 line_height: 16.0,
641 })
642 }
643 fn transform(&self) -> TransAffine {
644 self.transform
645 }
646 fn save(&mut self) {
647 self.stack.push(self.transform);
648 }
649 fn restore(&mut self) {
650 if let Some(t) = self.stack.pop() {
651 self.transform = t;
652 }
653 }
654 fn translate(&mut self, tx: f64, ty: f64) {
655 self.transform
656 .premultiply(&TransAffine::new_translation(tx, ty));
657 }
658 fn rotate(&mut self, radians: f64) {
659 self.transform
660 .premultiply(&TransAffine::new_rotation(radians));
661 }
662 fn scale(&mut self, sx: f64, sy: f64) {
663 self.transform.premultiply(&TransAffine::new_scaling(sx, sy));
664 }
665 fn set_transform(&mut self, m: TransAffine) {
666 self.transform = m;
667 }
668 fn reset_transform(&mut self) {
669 self.transform = TransAffine::new();
670 }
671 fn gl_paint(&mut self, _screen_rect: Rect, _painter: &mut dyn GlPaint) {}
672 }
673
674 fn accent_dot_count(g: &mut RadioGroup) -> usize {
677 let v = current_visuals();
678 let mut ctx = CircleFillRecorder::new();
679 g.paint(&mut ctx);
680 ctx.filled_circles
681 .iter()
682 .filter(|c| **c == v.accent)
683 .count()
684 }
685
686 #[test]
691 fn exactly_one_dot_painted_selected() {
692 set_visuals(Visuals::dark());
693 for sel in 0..3 {
694 let mut g = RadioGroup::new(vec!["First", "Second", "Third"], sel, test_font());
695 g.layout(Size::new(200.0, 0.0));
696 assert_eq!(
697 accent_dot_count(&mut g),
698 1,
699 "vertical group with selected={sel} must paint exactly one accent dot"
700 );
701 }
702 }
703
704 #[test]
707 fn hover_does_not_add_a_second_selected_dot() {
708 set_visuals(Visuals::dark());
709 let mut g = RadioGroup::new(vec!["First", "Second", "Third"], 0, test_font())
710 .with_horizontal_wrap(true);
711 g.layout(Size::new(400.0, 0.0));
712 let (cx, cy, _, _, _) = g.hwrap_items[1];
714 let _ = g.on_event(&Event::MouseMove {
715 pos: Point::new(cx, cy),
716 });
717 assert_eq!(
718 accent_dot_count(&mut g),
719 1,
720 "hovering an unselected option must not paint it as selected"
721 );
722 }
723
724 fn empty_group(n: usize) -> RadioGroup {
725 let opts: Vec<String> = (0..n).map(|_| String::new()).collect();
726 RadioGroup::new(opts, 0, test_font()).with_horizontal_wrap(true)
727 }
728
729 #[test]
730 fn empty_labels_wrap_onto_multiple_rows() {
731 let mut g = empty_group(64);
732 let size = g.layout(Size::new(80.0, 0.0));
734 assert!(size.height > ROW_H, "64 dots at 80px wide must wrap");
735 assert_eq!(g.hwrap_items.len(), 64);
736 let rows = (size.height / ROW_H).round() as usize;
738 assert!(rows > 1, "expected multiple rows, got {rows}");
739 }
740
741 #[test]
742 fn single_wide_row_does_not_wrap() {
743 let mut g = empty_group(4);
744 let size = g.layout(Size::new(2000.0, 0.0));
745 assert_eq!(
746 (size.height / ROW_H).round() as usize,
747 1,
748 "4 dots in 2000px must stay on one row"
749 );
750 }
751
752 #[test]
753 fn click_selects_the_hit_dot() {
754 let mut g = empty_group(8);
755 g.layout(Size::new(2000.0, 0.0)); let (cx, cy, _, _, _) = g.hwrap_items[2];
758 let down = Event::MouseDown {
759 pos: Point::new(cx, cy),
760 button: MouseButton::Left,
761 modifiers: Modifiers::default(),
762 };
763 let r = g.on_event(&down);
764 assert_eq!(r, EventResult::Consumed);
765 assert_eq!(g.selected(), 2);
766 }
767
768 #[test]
769 fn vertical_mode_is_unaffected() {
770 let mut g = RadioGroup::new(vec!["a", "b", "c"], 0, test_font());
771 let size = g.layout(Size::new(200.0, 0.0));
772 assert_eq!(size.height, 3.0 * ROW_H);
773 assert!(g.hwrap_items.is_empty());
774 }
775}