1use crate::core::alignment;
60use crate::core::border::{self, Border};
61use crate::core::keyboard;
62use crate::core::keyboard::key;
63use crate::core::layout;
64use crate::core::mouse;
65use crate::core::renderer;
66use crate::core::text;
67use crate::core::theme::palette;
68use crate::core::touch;
69use crate::core::widget;
70use crate::core::widget::operation::accessible::{Accessible, Role};
71use crate::core::widget::operation::focusable::Focusable;
72use crate::core::widget::tree::{self, Tree};
73use crate::core::window;
74use crate::core::{
75 Background, Color, Element, Event, Layout, Length, Pixels, Rectangle, Shadow, Shell, Size,
76 Theme, Widget,
77};
78
79pub struct Radio<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer>
138where
139 Theme: Catalog,
140 Renderer: text::Renderer,
141{
142 is_selected: bool,
143 on_click: Message,
144 label: String,
145 width: Length,
146 size: f32,
147 spacing: f32,
148 text_size: Option<Pixels>,
149 line_height: text::LineHeight,
150 shaping: text::Shaping,
151 wrapping: text::Wrapping,
152 font: Option<Renderer::Font>,
153 class: Theme::Class<'a>,
154 last_status: Option<Status>,
155}
156
157impl<'a, Message, Theme, Renderer> Radio<'a, Message, Theme, Renderer>
158where
159 Message: Clone,
160 Theme: Catalog,
161 Renderer: text::Renderer,
162{
163 pub const DEFAULT_SIZE: f32 = 16.0;
165
166 pub const DEFAULT_SPACING: f32 = 8.0;
168
169 pub fn new<F, V>(label: impl Into<String>, value: V, selected: Option<V>, f: F) -> Self
178 where
179 V: Eq + Copy,
180 F: FnOnce(V) -> Message,
181 {
182 Radio {
183 is_selected: Some(value) == selected,
184 on_click: f(value),
185 label: label.into(),
186 width: Length::Shrink,
187 size: Self::DEFAULT_SIZE,
188 spacing: Self::DEFAULT_SPACING,
189 text_size: None,
190 line_height: text::LineHeight::default(),
191 shaping: text::Shaping::default(),
192 wrapping: text::Wrapping::default(),
193 font: None,
194 class: Theme::default(),
195 last_status: None,
196 }
197 }
198
199 pub fn size(mut self, size: impl Into<Pixels>) -> Self {
201 self.size = size.into().0;
202 self
203 }
204
205 pub fn width(mut self, width: impl Into<Length>) -> Self {
207 self.width = width.into();
208 self
209 }
210
211 pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
213 self.spacing = spacing.into().0;
214 self
215 }
216
217 pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
219 self.text_size = Some(text_size.into());
220 self
221 }
222
223 pub fn line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
225 self.line_height = line_height.into();
226 self
227 }
228
229 pub fn shaping(mut self, shaping: text::Shaping) -> Self {
231 self.shaping = shaping;
232 self
233 }
234
235 pub fn wrapping(mut self, wrapping: text::Wrapping) -> Self {
237 self.wrapping = wrapping;
238 self
239 }
240
241 pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
243 self.font = Some(font.into());
244 self
245 }
246
247 #[must_use]
249 pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self
250 where
251 Theme::Class<'a>: From<StyleFn<'a, Theme>>,
252 {
253 self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
254 self
255 }
256
257 #[cfg(feature = "advanced")]
259 #[must_use]
260 pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
261 self.class = class.into();
262 self
263 }
264}
265
266#[derive(Debug, Clone, Default)]
267struct State<P: text::Paragraph> {
268 is_focused: bool,
269 focus_visible: bool,
270 label: widget::text::State<P>,
271}
272
273impl<P: text::Paragraph> Focusable for State<P> {
274 fn is_focused(&self) -> bool {
275 self.is_focused
276 }
277
278 fn focus(&mut self) {
279 self.is_focused = true;
280 self.focus_visible = true;
281 }
282
283 fn unfocus(&mut self) {
284 self.is_focused = false;
285 self.focus_visible = false;
286 }
287}
288
289impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
290 for Radio<'_, Message, Theme, Renderer>
291where
292 Message: Clone,
293 Theme: Catalog,
294 Renderer: text::Renderer,
295{
296 fn tag(&self) -> tree::Tag {
297 tree::Tag::of::<State<Renderer::Paragraph>>()
298 }
299
300 fn state(&self) -> tree::State {
301 tree::State::new(State::<Renderer::Paragraph>::default())
302 }
303
304 fn size(&self) -> Size<Length> {
305 Size {
306 width: self.width,
307 height: Length::Shrink,
308 }
309 }
310
311 fn layout(
312 &mut self,
313 tree: &mut Tree,
314 renderer: &Renderer,
315 limits: &layout::Limits,
316 ) -> layout::Node {
317 layout::next_to_each_other(
318 &limits.width(self.width),
319 self.spacing,
320 |_| layout::Node::new(Size::new(self.size, self.size)),
321 |limits| {
322 let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
323
324 widget::text::layout(
325 &mut state.label,
326 renderer,
327 limits,
328 &self.label,
329 widget::text::Format {
330 width: self.width,
331 height: Length::Shrink,
332 line_height: self.line_height,
333 size: self.text_size,
334 font: self.font,
335 align_x: text::Alignment::Default,
336 align_y: alignment::Vertical::Top,
337 shaping: self.shaping,
338 wrapping: self.wrapping,
339 ellipsis: text::Ellipsis::default(),
340 },
341 )
342 },
343 )
344 }
345
346 fn operate(
347 &mut self,
348 tree: &mut Tree,
349 layout: Layout<'_>,
350 _renderer: &Renderer,
351 operation: &mut dyn widget::Operation,
352 ) {
353 let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
354
355 operation.accessible(
356 None,
357 layout.bounds(),
358 &Accessible {
359 role: Role::RadioButton,
360 label: Some(&self.label),
361 selected: Some(self.is_selected),
362 ..Accessible::default()
363 },
364 );
365
366 operation.focusable(None, layout.bounds(), state);
367 }
368
369 fn update(
370 &mut self,
371 tree: &mut Tree,
372 event: &Event,
373 layout: Layout<'_>,
374 cursor: mouse::Cursor,
375 _renderer: &Renderer,
376 shell: &mut Shell<'_, Message>,
377 _viewport: &Rectangle,
378 ) {
379 match event {
380 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
381 | Event::Touch(touch::Event::FingerPressed { .. }) => {
382 let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
383
384 if cursor.is_over(layout.bounds()) {
385 state.is_focused = true;
386 state.focus_visible = false;
387
388 shell.publish(self.on_click.clone());
389 shell.capture_event();
390 } else {
391 state.is_focused = false;
392 state.focus_visible = false;
393 }
394 }
395 Event::Keyboard(keyboard::Event::KeyPressed {
396 key: keyboard::Key::Named(key::Named::Space),
397 ..
398 }) => {
399 let state = tree.state.downcast_ref::<State<Renderer::Paragraph>>();
400
401 if state.is_focused {
402 shell.publish(self.on_click.clone());
403 shell.capture_event();
404 }
405 }
406 Event::Keyboard(keyboard::Event::KeyPressed {
407 key: keyboard::Key::Named(key::Named::Escape),
408 ..
409 }) => {
410 let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
411 if state.is_focused {
412 state.is_focused = false;
413 state.focus_visible = false;
414 shell.capture_event();
415 }
416 }
417 _ => {}
418 }
419
420 let current_status = {
421 let state = tree.state.downcast_ref::<State<Renderer::Paragraph>>();
422 let is_mouse_over = cursor.is_over(layout.bounds());
423 let is_selected = self.is_selected;
424
425 if state.focus_visible {
426 Status::Focused { is_selected }
427 } else if is_mouse_over {
428 Status::Hovered { is_selected }
429 } else {
430 Status::Active { is_selected }
431 }
432 };
433
434 if let Event::Window(window::Event::RedrawRequested(_now)) = event {
435 self.last_status = Some(current_status);
436 } else if self
437 .last_status
438 .is_some_and(|last_status| last_status != current_status)
439 {
440 shell.request_redraw();
441 }
442 }
443
444 fn mouse_interaction(
445 &self,
446 _tree: &Tree,
447 layout: Layout<'_>,
448 cursor: mouse::Cursor,
449 _viewport: &Rectangle,
450 _renderer: &Renderer,
451 ) -> mouse::Interaction {
452 if cursor.is_over(layout.bounds()) {
453 mouse::Interaction::Pointer
454 } else {
455 mouse::Interaction::default()
456 }
457 }
458
459 fn draw(
460 &self,
461 tree: &Tree,
462 renderer: &mut Renderer,
463 theme: &Theme,
464 defaults: &renderer::Style,
465 layout: Layout<'_>,
466 _cursor: mouse::Cursor,
467 viewport: &Rectangle,
468 ) {
469 let mut children = layout.children();
470
471 let style = theme.style(
472 &self.class,
473 self.last_status.unwrap_or(Status::Active {
474 is_selected: self.is_selected,
475 }),
476 );
477
478 {
479 let layout = children.next().unwrap();
480 let bounds = layout.bounds();
481
482 let size = bounds.width;
483 let dot_size = size / 2.0;
484
485 renderer.fill_quad(
486 renderer::Quad {
487 bounds,
488 border: Border {
489 radius: (size / 2.0).into(),
490 width: style.border_width,
491 color: style.border_color,
492 },
493 shadow: style.shadow,
494 ..renderer::Quad::default()
495 },
496 style.background,
497 );
498
499 if self.is_selected {
500 renderer.fill_quad(
501 renderer::Quad {
502 bounds: Rectangle {
503 x: bounds.x + dot_size / 2.0,
504 y: bounds.y + dot_size / 2.0,
505 width: bounds.width - dot_size,
506 height: bounds.height - dot_size,
507 },
508 border: border::rounded(dot_size / 2.0),
509 ..renderer::Quad::default()
510 },
511 style.dot_color,
512 );
513 }
514 }
515
516 {
517 let label_layout = children.next().unwrap();
518 let state: &State<Renderer::Paragraph> = tree.state.downcast_ref();
519
520 crate::text::draw(
521 renderer,
522 defaults,
523 label_layout.bounds(),
524 state.label.raw(),
525 crate::text::Style {
526 color: style.text_color,
527 },
528 viewport,
529 );
530 }
531 }
532}
533
534impl<'a, Message, Theme, Renderer> From<Radio<'a, Message, Theme, Renderer>>
535 for Element<'a, Message, Theme, Renderer>
536where
537 Message: 'a + Clone,
538 Theme: 'a + Catalog,
539 Renderer: 'a + text::Renderer,
540{
541 fn from(radio: Radio<'a, Message, Theme, Renderer>) -> Element<'a, Message, Theme, Renderer> {
542 Element::new(radio)
543 }
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub enum Status {
549 Active {
551 is_selected: bool,
553 },
554 Hovered {
556 is_selected: bool,
558 },
559 Focused {
561 is_selected: bool,
563 },
564}
565
566#[derive(Debug, Clone, Copy, PartialEq)]
568pub struct Style {
569 pub background: Background,
571 pub dot_color: Color,
573 pub border_width: f32,
575 pub border_color: Color,
577 pub text_color: Option<Color>,
579 pub shadow: Shadow,
581}
582
583pub trait Catalog {
585 type Class<'a>;
587
588 fn default<'a>() -> Self::Class<'a>;
590
591 fn style(&self, class: &Self::Class<'_>, status: Status) -> Style;
593}
594
595pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme, Status) -> Style + 'a>;
597
598impl Catalog for Theme {
599 type Class<'a> = StyleFn<'a, Self>;
600
601 fn default<'a>() -> Self::Class<'a> {
602 Box::new(default)
603 }
604
605 fn style(&self, class: &Self::Class<'_>, status: Status) -> Style {
606 class(self, status)
607 }
608}
609
610pub fn default(theme: &Theme, status: Status) -> Style {
612 let palette = theme.palette();
613
614 let active = Style {
615 background: Color::TRANSPARENT.into(),
616 dot_color: palette.primary.strong.color,
617 border_width: 1.0,
618 border_color: palette.primary.strong.color,
619 text_color: None,
620 shadow: Shadow::default(),
621 };
622
623 match status {
624 Status::Active { .. } => active,
625 Status::Hovered { .. } => Style {
626 dot_color: palette.primary.strong.color,
627 background: palette.primary.weak.color.into(),
628 ..active
629 },
630 Status::Focused { .. } => {
631 let page_bg = palette.background.base.color;
632 Style {
633 border_color: palette::focus_border_color(
634 Color::TRANSPARENT,
635 palette.primary.strong.color,
636 page_bg,
637 ),
638 border_width: 2.0,
639 shadow: palette::focus_shadow(palette.primary.strong.color, page_bg),
640 ..active
641 }
642 }
643 }
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649 use crate::core::widget::operation::focusable::Focusable;
650
651 type TestState = State<()>;
652
653 #[test]
654 fn focusable_trait() {
655 let mut state = TestState::default();
656 assert!(!state.is_focused());
657 assert!(!state.focus_visible);
658 state.focus();
659 assert!(state.is_focused());
660 assert!(state.focus_visible);
661 state.unfocus();
662 assert!(!state.is_focused());
663 assert!(!state.focus_visible);
664 }
665}