1use crate::core::alignment;
10use crate::core::border::{self, Border};
11use crate::core::keyboard;
12use crate::core::keyboard::key;
13use crate::core::layout;
14use crate::core::mouse;
15use crate::core::renderer;
16use crate::core::text;
17use crate::core::touch;
18use crate::core::widget;
19use crate::core::widget::operation::accessible::{Accessible, Role};
20use crate::core::widget::operation::focusable::Focusable;
21use crate::core::widget::tree::{self, Tree};
22use crate::core::window;
23use crate::core::{Element, Event, Layout, Length, Pixels, Rectangle, Shell, Size, Widget};
24use crate::radio;
25
26#[allow(missing_debug_implementations)]
32pub struct RadioGroup<'a, V, Message, Theme = crate::Theme, Renderer = crate::Renderer>
33where
34 V: Copy + Eq,
35 Theme: radio::Catalog,
36 Renderer: text::Renderer,
37{
38 options: Vec<(String, V)>,
39 selected: Option<V>,
40 on_select: Box<dyn Fn(V) -> Message + 'a>,
41 size: f32,
42 spacing: f32,
43 option_spacing: f32,
44 text_size: Option<Pixels>,
45 line_height: text::LineHeight,
46 shaping: text::Shaping,
47 wrapping: text::Wrapping,
48 font: Option<Renderer::Font>,
49 class: Theme::Class<'a>,
50 width: Length,
51}
52
53impl<'a, V, Message, Theme, Renderer> RadioGroup<'a, V, Message, Theme, Renderer>
54where
55 V: Copy + Eq,
56 Theme: radio::Catalog,
57 Renderer: text::Renderer,
58{
59 pub const DEFAULT_OPTION_SPACING: f32 = 6.0;
61
62 pub fn new<F>(
69 options: impl IntoIterator<Item = (impl Into<String>, V)>,
70 selected: Option<V>,
71 on_select: F,
72 ) -> Self
73 where
74 F: Fn(V) -> Message + 'a,
75 {
76 RadioGroup {
77 options: options
78 .into_iter()
79 .map(|(label, value)| (label.into(), value))
80 .collect(),
81 selected,
82 on_select: Box::new(on_select),
83 size: 16.0,
84 spacing: 8.0,
85 option_spacing: Self::DEFAULT_OPTION_SPACING,
86 text_size: None,
87 line_height: text::LineHeight::default(),
88 shaping: text::Shaping::default(),
89 wrapping: text::Wrapping::default(),
90 font: None,
91 class: Theme::default(),
92 width: Length::Shrink,
93 }
94 }
95
96 pub fn size(mut self, size: impl Into<Pixels>) -> Self {
98 self.size = size.into().0;
99 self
100 }
101
102 pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
104 self.spacing = spacing.into().0;
105 self
106 }
107
108 pub fn option_spacing(mut self, spacing: impl Into<Pixels>) -> Self {
110 self.option_spacing = spacing.into().0;
111 self
112 }
113
114 pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
116 self.text_size = Some(text_size.into());
117 self
118 }
119
120 pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
122 self.font = Some(font.into());
123 self
124 }
125
126 pub fn width(mut self, width: impl Into<Length>) -> Self {
128 self.width = width.into();
129 self
130 }
131
132 #[must_use]
134 pub fn style(mut self, style: impl Fn(&Theme, radio::Status) -> radio::Style + 'a) -> Self
135 where
136 Theme::Class<'a>: From<radio::StyleFn<'a, Theme>>,
137 {
138 self.class = (Box::new(style) as radio::StyleFn<'a, Theme>).into();
139 self
140 }
141
142 #[cfg(feature = "advanced")]
144 #[must_use]
145 pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
146 self.class = class.into();
147 self
148 }
149}
150
151#[derive(Debug, Clone, Default)]
152struct State<P: text::Paragraph> {
153 active_index: usize,
154 is_focused: bool,
155 focus_visible: bool,
156 labels: Vec<widget::text::State<P>>,
157}
158
159impl<P: text::Paragraph> Focusable for State<P> {
160 fn is_focused(&self) -> bool {
161 self.is_focused
162 }
163
164 fn focus(&mut self) {
165 self.is_focused = true;
166 self.focus_visible = true;
167 }
168
169 fn unfocus(&mut self) {
170 self.is_focused = false;
171 self.focus_visible = false;
172 }
173}
174
175impl<V, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
176 for RadioGroup<'_, V, Message, Theme, Renderer>
177where
178 V: Copy + Eq,
179 Theme: radio::Catalog,
180 Renderer: text::Renderer,
181{
182 fn tag(&self) -> tree::Tag {
183 tree::Tag::of::<State<Renderer::Paragraph>>()
184 }
185
186 fn state(&self) -> tree::State {
187 tree::State::new(State::<Renderer::Paragraph>::default())
188 }
189
190 fn size(&self) -> Size<Length> {
191 Size {
192 width: self.width,
193 height: Length::Shrink,
194 }
195 }
196
197 fn layout(
198 &mut self,
199 tree: &mut Tree,
200 renderer: &Renderer,
201 limits: &layout::Limits,
202 ) -> layout::Node {
203 let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
204
205 state
207 .labels
208 .resize_with(self.options.len(), Default::default);
209
210 let limits = limits.width(self.width);
211 let mut children = Vec::with_capacity(self.options.len());
212 let mut total_height: f32 = 0.0;
213 let mut max_width: f32 = 0.0;
214
215 for (i, (label, _)) in self.options.iter().enumerate() {
216 let node = layout::next_to_each_other(
217 &limits,
218 self.spacing,
219 |_| layout::Node::new(Size::new(self.size, self.size)),
220 |limits| {
221 widget::text::layout(
222 &mut state.labels[i],
223 renderer,
224 limits,
225 label,
226 widget::text::Format {
227 width: self.width,
228 height: Length::Shrink,
229 line_height: self.line_height,
230 size: self.text_size,
231 font: self.font,
232 align_x: text::Alignment::Default,
233 align_y: alignment::Vertical::Top,
234 shaping: self.shaping,
235 wrapping: self.wrapping,
236 ellipsis: text::Ellipsis::default(),
237 },
238 )
239 },
240 );
241
242 let node_size = node.size();
243
244 if i > 0 {
245 total_height += self.option_spacing;
246 }
247
248 let node = node.move_to((0.0, total_height));
249 total_height += node_size.height;
250 max_width = max_width.max(node_size.width);
251
252 children.push(node);
253 }
254
255 let size = limits.resolve(
256 self.width,
257 Length::Shrink,
258 Size::new(max_width, total_height),
259 );
260
261 layout::Node::with_children(size, children)
262 }
263
264 fn operate(
265 &mut self,
266 tree: &mut Tree,
267 layout: Layout<'_>,
268 _renderer: &Renderer,
269 operation: &mut dyn widget::Operation,
270 ) {
271 let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
272 let total = self.options.len();
273
274 operation.accessible(
276 None,
277 layout.bounds(),
278 &Accessible {
279 role: Role::Group,
280 ..Accessible::default()
281 },
282 );
283
284 operation.container(None, layout.bounds());
285 operation.traverse(&mut |operation| {
286 for (i, ((label, _), child_layout)) in
287 self.options.iter().zip(layout.children()).enumerate()
288 {
289 operation.accessible(
290 None,
291 child_layout.bounds(),
292 &Accessible {
293 role: Role::RadioButton,
294 label: Some(label),
295 selected: Some(self.selected.is_some_and(|s| s == self.options[i].1)),
296 position_in_set: Some(i + 1),
297 size_of_set: Some(total),
298 ..Accessible::default()
299 },
300 );
301
302 let mut label_children = child_layout.children();
304 let _circle = label_children.next();
305 if let Some(text_layout) = label_children.next() {
306 operation.text(None, text_layout.bounds(), label);
307 }
308 }
309 });
310
311 if total > 0 {
312 operation.focusable(None, layout.bounds(), state);
313 } else {
314 state.unfocus();
315 }
316 }
317
318 fn update(
319 &mut self,
320 tree: &mut Tree,
321 event: &Event,
322 layout: Layout<'_>,
323 cursor: mouse::Cursor,
324 _renderer: &Renderer,
325 shell: &mut Shell<'_, Message>,
326 _viewport: &Rectangle,
327 ) {
328 let total = self.options.len();
329
330 if total == 0 {
331 return;
332 }
333
334 let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
335
336 if let Some(selected) = self.selected
338 && let Some(idx) = self.options.iter().position(|(_, v)| *v == selected)
339 {
340 state.active_index = idx;
341 }
342
343 match event {
344 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
345 | Event::Touch(touch::Event::FingerPressed { .. }) => {
346 for (i, child_layout) in layout.children().enumerate() {
347 if cursor.is_over(child_layout.bounds()) {
348 state.active_index = i;
349 state.is_focused = true;
350 state.focus_visible = false;
351
352 shell.publish((self.on_select)(self.options[i].1));
353 shell.capture_event();
354 return;
355 }
356 }
357
358 if cursor.is_over(layout.bounds()) {
360 } else {
362 state.is_focused = false;
363 state.focus_visible = false;
364 }
365 }
366 Event::Keyboard(keyboard::Event::KeyPressed {
367 key: keyboard::Key::Named(key::Named::ArrowDown | key::Named::ArrowRight),
368 ..
369 }) => {
370 if state.is_focused {
371 state.active_index = (state.active_index + 1) % total;
372 shell.publish((self.on_select)(self.options[state.active_index].1));
373 shell.capture_event();
374 }
375 }
376 Event::Keyboard(keyboard::Event::KeyPressed {
377 key: keyboard::Key::Named(key::Named::ArrowUp | key::Named::ArrowLeft),
378 ..
379 }) => {
380 if state.is_focused {
381 state.active_index = (state.active_index + total - 1) % total;
382 shell.publish((self.on_select)(self.options[state.active_index].1));
383 shell.capture_event();
384 }
385 }
386 Event::Keyboard(keyboard::Event::KeyPressed {
387 key: keyboard::Key::Named(key::Named::Space),
388 ..
389 }) => {
390 if state.is_focused {
391 shell.publish((self.on_select)(self.options[state.active_index].1));
392 shell.capture_event();
393 }
394 }
395 Event::Keyboard(keyboard::Event::KeyPressed {
396 key: keyboard::Key::Named(key::Named::Escape),
397 ..
398 }) => {
399 if state.is_focused {
400 state.is_focused = false;
401 state.focus_visible = false;
402 shell.capture_event();
403 }
404 }
405 _ => {}
406 }
407
408 if let Event::Window(window::Event::RedrawRequested(_)) = event {
410 } else {
412 shell.request_redraw();
414 }
415 }
416
417 fn mouse_interaction(
418 &self,
419 _tree: &Tree,
420 layout: Layout<'_>,
421 cursor: mouse::Cursor,
422 _viewport: &Rectangle,
423 _renderer: &Renderer,
424 ) -> mouse::Interaction {
425 for child_layout in layout.children() {
426 if cursor.is_over(child_layout.bounds()) {
427 return mouse::Interaction::Pointer;
428 }
429 }
430
431 mouse::Interaction::default()
432 }
433
434 fn draw(
435 &self,
436 tree: &Tree,
437 renderer: &mut Renderer,
438 theme: &Theme,
439 defaults: &renderer::Style,
440 layout: Layout<'_>,
441 cursor: mouse::Cursor,
442 viewport: &Rectangle,
443 ) {
444 let state = tree.state.downcast_ref::<State<Renderer::Paragraph>>();
445
446 for (i, ((_label, value), option_layout)) in
447 self.options.iter().zip(layout.children()).enumerate()
448 {
449 let is_selected = self.selected.is_some_and(|s| s == *value);
450 let is_active = i == state.active_index;
451 let is_mouse_over = cursor.is_over(option_layout.bounds());
452
453 let status = if is_active && state.focus_visible {
454 radio::Status::Focused { is_selected }
455 } else if is_mouse_over {
456 radio::Status::Hovered { is_selected }
457 } else {
458 radio::Status::Active { is_selected }
459 };
460
461 let style = theme.style(&self.class, status);
462
463 let mut children = option_layout.children();
464
465 {
467 let circle_layout = children.next().unwrap();
468 let bounds = circle_layout.bounds();
469 let size = bounds.width;
470 let dot_size = size / 2.0;
471
472 renderer.fill_quad(
473 renderer::Quad {
474 bounds,
475 border: Border {
476 radius: (size / 2.0).into(),
477 width: style.border_width,
478 color: style.border_color,
479 },
480 ..renderer::Quad::default()
481 },
482 style.background,
483 );
484
485 if is_selected {
486 renderer.fill_quad(
487 renderer::Quad {
488 bounds: Rectangle {
489 x: bounds.x + dot_size / 2.0,
490 y: bounds.y + dot_size / 2.0,
491 width: bounds.width - dot_size,
492 height: bounds.height - dot_size,
493 },
494 border: border::rounded(dot_size / 2.0),
495 ..renderer::Quad::default()
496 },
497 style.dot_color,
498 );
499 }
500 }
501
502 {
504 let label_layout = children.next().unwrap();
505
506 crate::text::draw(
507 renderer,
508 defaults,
509 label_layout.bounds(),
510 state.labels[i].raw(),
511 crate::text::Style {
512 color: style.text_color,
513 },
514 viewport,
515 );
516 }
517 }
518 }
519}
520
521impl<'a, V, Message, Theme, Renderer> From<RadioGroup<'a, V, Message, Theme, Renderer>>
522 for Element<'a, Message, Theme, Renderer>
523where
524 V: 'a + Copy + Eq,
525 Message: 'a,
526 Theme: 'a + radio::Catalog,
527 Renderer: 'a + text::Renderer,
528{
529 fn from(
530 radio_group: RadioGroup<'a, V, Message, Theme, Renderer>,
531 ) -> Element<'a, Message, Theme, Renderer> {
532 Element::new(radio_group)
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use crate::core::widget::operation::focusable::Focusable;
540
541 type TestState = State<()>;
542
543 #[test]
544 fn focusable_trait() {
545 let mut state = TestState::default();
546 assert!(!state.is_focused());
547 assert!(!state.focus_visible);
548 state.focus();
549 assert!(state.is_focused());
550 assert!(state.focus_visible);
551 state.unfocus();
552 assert!(!state.is_focused());
553 assert!(!state.focus_visible);
554 }
555
556 #[test]
557 fn default_state_starts_at_zero() {
558 let state = TestState::default();
559 assert_eq!(state.active_index, 0);
560 assert!(!state.is_focused());
561 }
562}