1use crate::get_global_color;
2use eframe::egui::{self, Color32, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2, Widget, FontId};
3
4#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
20pub struct MaterialRadio<'a, T: PartialEq + Clone> {
21 selected: &'a mut Option<T>,
23 value: T,
25 text: String,
27 enabled: bool,
29 toggleable: bool,
31 fill_color: Option<Color32>,
33 overlay_color: Option<Color32>,
35 background_color: Option<Color32>,
37 inner_radius: Option<f32>,
39 splash_radius: Option<f32>,
41}
42
43#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
61pub struct MaterialRadioGroup<'a, T: PartialEq + Clone> {
62 selected: &'a mut Option<T>,
64 options: Vec<RadioOption<T>>,
66 enabled: bool,
68 toggleable: bool,
70}
71
72pub struct RadioOption<T: PartialEq + Clone> {
74 text: String,
76 value: T,
78}
79
80impl<'a, T: PartialEq + Clone> MaterialRadio<'a, T> {
81 pub fn new(selected: &'a mut Option<T>, value: T, text: impl Into<String>) -> Self {
96 Self {
97 selected,
98 value,
99 text: text.into(),
100 enabled: true,
101 toggleable: false,
102 fill_color: None,
103 overlay_color: None,
104 background_color: None,
105 inner_radius: None,
106 splash_radius: None,
107 }
108 }
109
110 pub fn enabled(mut self, enabled: bool) -> Self {
124 self.enabled = enabled;
125 self
126 }
127
128 pub fn toggleable(mut self, toggleable: bool) -> Self {
132 self.toggleable = toggleable;
133 self
134 }
135
136 pub fn fill_color(mut self, color: Color32) -> Self {
138 self.fill_color = Some(color);
139 self
140 }
141
142 pub fn overlay_color(mut self, color: Color32) -> Self {
144 self.overlay_color = Some(color);
145 self
146 }
147
148 pub fn background_color(mut self, color: Color32) -> Self {
150 self.background_color = Some(color);
151 self
152 }
153
154 pub fn inner_radius(mut self, radius: f32) -> Self {
156 self.inner_radius = Some(radius);
157 self
158 }
159
160 pub fn splash_radius(mut self, radius: f32) -> Self {
162 self.splash_radius = Some(radius);
163 self
164 }
165}
166
167impl<'a, T: PartialEq + Clone> Widget for MaterialRadio<'a, T> {
168 fn ui(self, ui: &mut Ui) -> Response {
169 let desired_size = Vec2::new(ui.available_width().min(300.0), 24.0);
170
171 let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
172
173 let is_selected = self.selected.as_ref().map_or(false, |s| s == &self.value);
174
175 if response.clicked() && self.enabled {
176 if self.toggleable && is_selected {
177 *self.selected = None;
179 } else {
180 *self.selected = Some(self.value.clone());
182 }
183 response.mark_changed();
184 }
185
186 let primary_color = self.fill_color.unwrap_or_else(|| get_global_color("primary"));
188 let on_surface = get_global_color("onSurface");
189 let on_surface_variant = get_global_color("onSurfaceVariant");
190 let outline = get_global_color("outline");
191
192 let radio_size = 20.0;
193 let radio_rect = Rect::from_min_size(
194 Pos2::new(rect.min.x, rect.center().y - radio_size / 2.0),
195 Vec2::splat(radio_size),
196 );
197
198 let (border_color, fill_color, inner_color) = if !self.enabled {
199 let disabled_color = get_global_color("onSurfaceVariant").linear_multiply(0.38);
200 (disabled_color, Color32::TRANSPARENT, disabled_color)
201 } else if is_selected {
202 (primary_color, self.background_color.unwrap_or(Color32::TRANSPARENT), primary_color)
203 } else if response.hovered() {
204 let hover_overlay = self.overlay_color.unwrap_or_else(||
205 Color32::from_rgba_premultiplied(
206 on_surface.r(),
207 on_surface.g(),
208 on_surface.b(),
209 20,
210 )
211 );
212 (
213 outline,
214 hover_overlay,
215 on_surface_variant,
216 )
217 } else {
218 (outline, self.background_color.unwrap_or(Color32::TRANSPARENT), on_surface_variant)
219 };
220
221 if fill_color != Color32::TRANSPARENT {
223 ui.painter()
224 .circle_filled(radio_rect.center(), radio_size / 2.0 + 8.0, fill_color);
225 }
226
227 ui.painter().circle_stroke(
229 radio_rect.center(),
230 radio_size / 2.0,
231 Stroke::new(2.0, border_color),
232 );
233
234 if is_selected {
236 let inner_radius = self.inner_radius.unwrap_or(radio_size / 4.0);
237 ui.painter()
238 .circle_filled(radio_rect.center(), inner_radius, inner_color);
239 }
240
241 if !self.text.is_empty() {
243 let text_pos = Pos2::new(radio_rect.max.x + 8.0, rect.center().y);
244
245 let text_color = if self.enabled {
246 on_surface
247 } else {
248 get_global_color("onSurfaceVariant").linear_multiply(0.38)
249 };
250
251 ui.painter().text(
252 text_pos,
253 egui::Align2::LEFT_CENTER,
254 &self.text,
255 egui::FontId::default(),
256 text_color,
257 );
258 }
259
260 if response.hovered() && self.enabled {
262 let ripple_color = self.overlay_color.unwrap_or_else(|| {
263 if is_selected {
264 Color32::from_rgba_premultiplied(
265 primary_color.r(),
266 primary_color.g(),
267 primary_color.b(),
268 20,
269 )
270 } else {
271 Color32::from_rgba_premultiplied(on_surface.r(), on_surface.g(), on_surface.b(), 20)
272 }
273 });
274
275 let ripple_radius = self.splash_radius.unwrap_or(radio_size / 2.0 + 12.0);
276 ui.painter()
277 .circle_filled(radio_rect.center(), ripple_radius, ripple_color);
278 }
279
280 response
281 }
282}
283
284impl<'a, T: PartialEq + Clone> MaterialRadioGroup<'a, T> {
285 pub fn new(selected: &'a mut Option<T>) -> Self {
298 Self {
299 selected,
300 options: Vec::new(),
301 enabled: true,
302 toggleable: false,
303 }
304 }
305
306 pub fn option(mut self, value: T, text: impl Into<String>) -> Self {
322 self.options.push(RadioOption {
323 text: text.into(),
324 value,
325 });
326 self
327 }
328
329 pub fn enabled(mut self, enabled: bool) -> Self {
344 self.enabled = enabled;
345 self
346 }
347
348 pub fn toggleable(mut self, toggleable: bool) -> Self {
350 self.toggleable = toggleable;
351 self
352 }
353}
354
355impl<'a, T: PartialEq + Clone> Widget for MaterialRadioGroup<'a, T> {
356 fn ui(self, ui: &mut Ui) -> Response {
357 let mut group_response = None;
358
359 ui.vertical(|ui| {
360 for option in self.options {
361 let radio = MaterialRadio::new(self.selected, option.value, option.text)
362 .enabled(self.enabled)
363 .toggleable(self.toggleable);
364
365 let response = ui.add(radio);
366
367 if group_response.is_none() {
368 group_response = Some(response);
369 } else if let Some(ref mut group_resp) = group_response {
370 *group_resp = group_resp.union(response);
371 }
372 }
373 });
374
375 group_response.unwrap_or_else(|| {
376 let (_rect, response) = ui.allocate_exact_size(Vec2::ZERO, Sense::hover());
377 response
378 })
379 }
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384pub enum ListTileControlAffinity {
385 Leading,
387 Trailing,
389}
390
391#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
410pub struct RadioListTile<'a, T: PartialEq + Clone> {
411 selected: &'a mut Option<T>,
413 value: T,
415 title: Option<String>,
417 subtitle: Option<String>,
419 enabled: bool,
421 toggleable: bool,
423 control_affinity: ListTileControlAffinity,
425 dense: bool,
427 fill_color: Option<Color32>,
429 tile_color: Option<Color32>,
431 selected_tile_color: Option<Color32>,
433}
434
435impl<'a, T: PartialEq + Clone> RadioListTile<'a, T> {
436 pub fn new(selected: &'a mut Option<T>, value: T) -> Self {
442 Self {
443 selected,
444 value,
445 title: None,
446 subtitle: None,
447 enabled: true,
448 toggleable: false,
449 control_affinity: ListTileControlAffinity::Leading,
450 dense: false,
451 fill_color: None,
452 tile_color: None,
453 selected_tile_color: None,
454 }
455 }
456
457 pub fn title(mut self, title: impl Into<String>) -> Self {
459 self.title = Some(title.into());
460 self
461 }
462
463 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
465 self.subtitle = Some(subtitle.into());
466 self
467 }
468
469 pub fn enabled(mut self, enabled: bool) -> Self {
471 self.enabled = enabled;
472 self
473 }
474
475 pub fn toggleable(mut self, toggleable: bool) -> Self {
477 self.toggleable = toggleable;
478 self
479 }
480
481 pub fn control_affinity(mut self, affinity: ListTileControlAffinity) -> Self {
483 self.control_affinity = affinity;
484 self
485 }
486
487 pub fn dense(mut self, dense: bool) -> Self {
489 self.dense = dense;
490 self
491 }
492
493 pub fn fill_color(mut self, color: Color32) -> Self {
495 self.fill_color = Some(color);
496 self
497 }
498
499 pub fn tile_color(mut self, color: Color32) -> Self {
501 self.tile_color = Some(color);
502 self
503 }
504
505 pub fn selected_tile_color(mut self, color: Color32) -> Self {
507 self.selected_tile_color = Some(color);
508 self
509 }
510}
511
512impl<'a, T: PartialEq + Clone> Widget for RadioListTile<'a, T> {
513 fn ui(self, ui: &mut Ui) -> Response {
514 let is_selected = self.selected.as_ref().map_or(false, |s| s == &self.value);
515
516 let height = if self.dense {
518 if self.subtitle.is_some() { 48.0 } else { 40.0 }
519 } else {
520 if self.subtitle.is_some() { 64.0 } else { 48.0 }
521 };
522
523 let available_width = ui.available_width();
524 let desired_size = Vec2::new(available_width, height);
525
526 let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
527
528 if response.clicked() && self.enabled {
530 if self.toggleable && is_selected {
531 *self.selected = None;
532 } else {
533 *self.selected = Some(self.value.clone());
534 }
535 response.mark_changed();
536 }
537
538 let on_surface = get_global_color("onSurface");
540 let on_surface_variant = get_global_color("onSurfaceVariant");
541 let surface_variant = get_global_color("surfaceVariant");
542
543 let bg_color = if is_selected {
545 self.selected_tile_color.unwrap_or_else(||
546 surface_variant.linear_multiply(0.5)
547 )
548 } else if response.hovered() && self.enabled {
549 self.tile_color.unwrap_or_else(||
550 Color32::from_rgba_premultiplied(
551 on_surface.r(),
552 on_surface.g(),
553 on_surface.b(),
554 10,
555 )
556 )
557 } else {
558 self.tile_color.unwrap_or(Color32::TRANSPARENT)
559 };
560
561 if bg_color != Color32::TRANSPARENT {
562 ui.painter().rect_filled(rect, 4.0, bg_color);
563 }
564
565 let radio_size = 20.0;
567 let padding = 16.0;
568 let gap = 16.0;
569
570 let (radio_x, text_x) = match self.control_affinity {
572 ListTileControlAffinity::Leading => {
573 let radio_x = rect.min.x + padding + radio_size / 2.0;
574 let text_x = radio_x + radio_size / 2.0 + gap;
575 (radio_x, text_x)
576 }
577 ListTileControlAffinity::Trailing => {
578 let radio_x = rect.max.x - padding - radio_size / 2.0;
579 let text_x = rect.min.x + padding;
580 (radio_x, text_x)
581 }
582 };
583
584 let radio_center = Pos2::new(radio_x, rect.center().y);
585
586 let primary_color = self.fill_color.unwrap_or_else(|| get_global_color("primary"));
588 let outline = get_global_color("outline");
589
590 let (border_color, inner_color) = if !self.enabled {
591 let disabled_color = on_surface_variant.linear_multiply(0.38);
592 (disabled_color, disabled_color)
593 } else if is_selected {
594 (primary_color, primary_color)
595 } else {
596 (outline, outline)
597 };
598
599 ui.painter().circle_stroke(
601 radio_center,
602 radio_size / 2.0,
603 Stroke::new(2.0, border_color),
604 );
605
606 if is_selected {
608 ui.painter().circle_filled(radio_center, radio_size / 4.0, inner_color);
609 }
610
611 let text_color = if self.enabled {
613 on_surface
614 } else {
615 on_surface_variant.linear_multiply(0.38)
616 };
617
618 let text_rect_width = match self.control_affinity {
619 ListTileControlAffinity::Leading => rect.max.x - text_x - padding,
620 ListTileControlAffinity::Trailing => radio_x - radio_size / 2.0 - gap - text_x,
621 };
622
623 if let Some(title) = &self.title {
624 let title_y = if self.subtitle.is_some() {
625 rect.min.y + height * 0.35
626 } else {
627 rect.center().y
628 };
629
630 let title_font = if self.dense {
631 FontId::proportional(14.0)
632 } else {
633 FontId::proportional(16.0)
634 };
635
636 ui.painter().text(
637 Pos2::new(text_x, title_y),
638 egui::Align2::LEFT_CENTER,
639 title,
640 title_font,
641 text_color,
642 );
643 }
644
645 if let Some(subtitle) = &self.subtitle {
646 let subtitle_y = rect.min.y + height * 0.65;
647 let subtitle_font = FontId::proportional(if self.dense { 12.0 } else { 14.0 });
648
649 ui.painter().text(
650 Pos2::new(text_x, subtitle_y),
651 egui::Align2::LEFT_CENTER,
652 subtitle,
653 subtitle_font,
654 on_surface_variant,
655 );
656 }
657
658 response
659 }
660}
661
662pub fn radio<'a, T: PartialEq + Clone>(
680 selected: &'a mut Option<T>,
681 value: T,
682 text: impl Into<String>,
683) -> MaterialRadio<'a, T> {
684 MaterialRadio::new(selected, value, text)
685}
686
687pub fn radio_group<'a, T: PartialEq + Clone>(selected: &'a mut Option<T>) -> MaterialRadioGroup<'a, T> {
704 MaterialRadioGroup::new(selected)
705}
706
707pub fn radio_list_tile<'a, T: PartialEq + Clone>(
725 selected: &'a mut Option<T>,
726 value: T,
727) -> RadioListTile<'a, T> {
728 RadioListTile::new(selected, value)
729}