1use super::content::ContentContext;
7use crate::ext::ArmasContextExt;
8use egui::{pos2, vec2, Color32, CornerRadius, Response, Sense, Stroke, Ui, Vec2};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum ToggleVariant {
17 #[default]
19 Default,
20 Outline,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum ToggleSize {
27 Sm,
29 #[default]
31 Default,
32 Lg,
34}
35
36impl ToggleSize {
37 const fn height(self) -> f32 {
38 match self {
39 Self::Sm => 28.0,
40 Self::Default => 32.0,
41 Self::Lg => 36.0,
42 }
43 }
44
45 const fn font_size(self, typo: &crate::theme::Typography) -> f32 {
46 match self {
47 Self::Sm => typo.sm,
48 Self::Default | Self::Lg => typo.base,
49 }
50 }
51
52 const fn padding_x(self) -> f32 {
53 match self {
54 Self::Sm => 8.0,
55 Self::Default => 10.0,
56 Self::Lg => 12.0,
57 }
58 }
59
60 const fn corner_radius(self) -> f32 {
61 match self {
62 Self::Sm => 5.0,
63 Self::Default | Self::Lg => 6.0,
64 }
65 }
66}
67
68pub struct ToggleResponse {
70 pub response: Response,
72 pub changed: bool,
74}
75
76pub struct Toggle {
87 id: Option<egui::Id>,
88 label: String,
89 variant: ToggleVariant,
90 size: ToggleSize,
91 disabled: bool,
92 custom_content_width: Option<f32>,
93}
94
95impl Toggle {
96 #[must_use]
98 pub fn new(label: impl Into<String>) -> Self {
99 Self {
100 id: None,
101 label: label.into(),
102 variant: ToggleVariant::Default,
103 size: ToggleSize::Default,
104 disabled: false,
105 custom_content_width: None,
106 }
107 }
108
109 #[must_use]
111 pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
112 self.id = Some(id.into());
113 self
114 }
115
116 #[must_use]
118 pub const fn variant(mut self, variant: ToggleVariant) -> Self {
119 self.variant = variant;
120 self
121 }
122
123 #[must_use]
125 pub const fn size(mut self, size: ToggleSize) -> Self {
126 self.size = size;
127 self
128 }
129
130 #[must_use]
132 pub const fn disabled(mut self, disabled: bool) -> Self {
133 self.disabled = disabled;
134 self
135 }
136
137 #[must_use]
142 pub const fn content_width(mut self, width: f32) -> Self {
143 self.custom_content_width = Some(width);
144 self
145 }
146
147 fn load_state(&self, ui: &Ui, pressed: &mut bool) {
149 if let Some(id) = self.id {
150 let state_id = id.with("toggle_state");
151 let stored: bool = ui
152 .ctx()
153 .data_mut(|d| d.get_temp(state_id).unwrap_or(*pressed));
154 *pressed = stored;
155 }
156 }
157
158 fn save_state(&self, ui: &Ui, pressed: bool) {
160 if let Some(id) = self.id {
161 let state_id = id.with("toggle_state");
162 ui.ctx().data_mut(|d| d.insert_temp(state_id, pressed));
163 }
164 }
165
166 fn draw_frame(
169 &self,
170 ui: &Ui,
171 rect: egui::Rect,
172 response: &Response,
173 pressed: bool,
174 theme: &crate::Theme,
175 ) -> Color32 {
176 let painter = ui.painter();
177 let hovered = response.hovered() && !self.disabled;
178 let item_radius = self.size.corner_radius();
179 let corner_radius = CornerRadius::same(item_radius as u8);
180
181 let bg_color = if self.disabled {
182 Color32::TRANSPARENT
183 } else if pressed || hovered {
184 theme.muted()
185 } else {
186 Color32::TRANSPARENT
187 };
188
189 painter.rect_filled(rect, corner_radius, bg_color);
190
191 if self.variant == ToggleVariant::Outline {
192 let border_color = if self.disabled {
193 theme.border().linear_multiply(0.5)
194 } else {
195 theme.input()
196 };
197 painter.rect_stroke(
198 rect,
199 corner_radius,
200 Stroke::new(1.0, border_color),
201 egui::StrokeKind::Inside,
202 );
203 }
204
205 if response.has_focus() && !self.disabled {
207 painter.rect_stroke(
208 rect.expand(2.0),
209 corner_radius,
210 Stroke::new(2.0, theme.ring()),
211 egui::StrokeKind::Outside,
212 );
213 }
214
215 if self.disabled {
217 theme.muted_foreground().linear_multiply(0.5)
218 } else if pressed {
219 theme.foreground()
220 } else {
221 theme.muted_foreground()
222 }
223 }
224
225 pub fn show(self, ui: &mut Ui, pressed: &mut bool) -> ToggleResponse {
229 let theme = ui.ctx().armas_theme();
230
231 self.load_state(ui, pressed);
232 let old_pressed = *pressed;
233
234 let height = self.size.height();
235 let font_size = self.size.font_size(&theme.typography);
236 let padding_x = self.size.padding_x();
237
238 let text_galley = ui.painter().layout_no_wrap(
240 self.label.clone(),
241 egui::FontId::proportional(font_size),
242 theme.foreground(),
243 );
244 let item_width = text_galley.size().x + padding_x * 2.0;
245
246 let (rect, response) = ui.allocate_exact_size(
247 Vec2::new(item_width, height),
248 if self.disabled {
249 Sense::hover()
250 } else {
251 Sense::click()
252 },
253 );
254
255 if response.clicked() && !self.disabled {
256 *pressed = !*pressed;
257 }
258
259 if ui.is_rect_visible(rect) {
260 let text_color = self.draw_frame(ui, rect, &response, *pressed, &theme);
261
262 let text_galley = ui.painter().layout_no_wrap(
263 self.label.clone(),
264 egui::FontId::proportional(font_size),
265 text_color,
266 );
267 let text_pos = rect.center() - text_galley.size() / 2.0;
268 ui.painter()
269 .galley(pos2(text_pos.x, text_pos.y), text_galley, text_color);
270 }
271
272 let changed = old_pressed != *pressed;
273 self.save_state(ui, *pressed);
274
275 ToggleResponse { response, changed }
276 }
277
278 pub fn show_ui(
294 self,
295 ui: &mut Ui,
296 pressed: &mut bool,
297 content: impl FnOnce(&mut Ui, &ContentContext),
298 ) -> ToggleResponse {
299 let theme = ui.ctx().armas_theme();
300
301 self.load_state(ui, pressed);
302 let old_pressed = *pressed;
303
304 let height = self.size.height();
305 let padding_x = self.size.padding_x();
306
307 let inner_width = self
309 .custom_content_width
310 .unwrap_or(height - padding_x * 2.0);
311 let item_width = inner_width + padding_x * 2.0;
312
313 let (rect, response) = ui.allocate_exact_size(
314 Vec2::new(item_width, height),
315 if self.disabled {
316 Sense::hover()
317 } else {
318 Sense::click()
319 },
320 );
321
322 if response.clicked() && !self.disabled {
323 *pressed = !*pressed;
324 }
325
326 if ui.is_rect_visible(rect) {
327 let text_color = self.draw_frame(ui, rect, &response, *pressed, &theme);
328
329 let content_rect = rect.shrink2(Vec2::new(padding_x, 0.0));
330 let mut child_ui = ui.new_child(
331 egui::UiBuilder::new()
332 .max_rect(content_rect)
333 .layout(egui::Layout::left_to_right(egui::Align::Center)),
334 );
335 child_ui.style_mut().visuals.override_text_color = Some(text_color);
336
337 let ctx = ContentContext {
338 color: text_color,
339 font_size: self.size.font_size(&theme.typography),
340 is_active: *pressed,
341 };
342 content(&mut child_ui, &ctx);
343 }
344
345 let changed = old_pressed != *pressed;
346 self.save_state(ui, *pressed);
347
348 ToggleResponse { response, changed }
349 }
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358pub enum ToggleGroupType {
359 Single,
361 Multiple,
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
367pub enum ToggleGroupVariant {
368 #[default]
370 Default,
371 Outline,
373}
374
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
377pub enum ToggleGroupSize {
378 Sm,
380 #[default]
382 Default,
383 Lg,
385}
386
387impl ToggleGroupSize {
388 const fn height(self) -> f32 {
389 match self {
390 Self::Sm => 28.0,
391 Self::Default => 32.0,
392 Self::Lg => 36.0,
393 }
394 }
395
396 const fn font_size(self, typo: &crate::theme::Typography) -> f32 {
397 match self {
398 Self::Sm => typo.sm,
399 Self::Default | Self::Lg => typo.base,
400 }
401 }
402
403 const fn padding_x(self) -> f32 {
404 match self {
405 Self::Sm => 6.0,
406 Self::Default => 8.0,
407 Self::Lg => 10.0,
408 }
409 }
410
411 const fn corner_radius(self) -> f32 {
412 match self {
413 Self::Sm => 5.0,
414 Self::Default | Self::Lg => 6.0,
415 }
416 }
417}
418
419pub struct ToggleGroupResponse {
421 pub response: Response,
423 pub changed: bool,
425}
426
427pub struct ToggleGroup {
446 id: Option<egui::Id>,
447 group_type: ToggleGroupType,
448 variant: ToggleGroupVariant,
449 size: ToggleGroupSize,
450 spacing: f32,
451 padding: Option<f32>,
452 vertical: bool,
453 disabled: bool,
454 item_width: Option<f32>,
455}
456
457impl ToggleGroup {
458 #[must_use]
460 pub const fn new(group_type: ToggleGroupType) -> Self {
461 Self {
462 id: None,
463 group_type,
464 variant: ToggleGroupVariant::Default,
465 size: ToggleGroupSize::Default,
466 spacing: 0.0,
467 padding: None,
468 vertical: false,
469 disabled: false,
470 item_width: None,
471 }
472 }
473
474 #[must_use]
476 pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
477 self.id = Some(id.into());
478 self
479 }
480
481 #[must_use]
483 pub const fn variant(mut self, variant: ToggleGroupVariant) -> Self {
484 self.variant = variant;
485 self
486 }
487
488 #[must_use]
490 pub const fn size(mut self, size: ToggleGroupSize) -> Self {
491 self.size = size;
492 self
493 }
494
495 #[must_use]
497 pub const fn spacing(mut self, spacing: f32) -> Self {
498 self.spacing = spacing;
499 self
500 }
501
502 #[must_use]
505 pub const fn padding(mut self, padding: f32) -> Self {
506 self.padding = Some(padding);
507 self
508 }
509
510 #[must_use]
512 pub const fn vertical(mut self, vertical: bool) -> Self {
513 self.vertical = vertical;
514 self
515 }
516
517 #[must_use]
519 pub const fn disabled(mut self, disabled: bool) -> Self {
520 self.disabled = disabled;
521 self
522 }
523
524 #[must_use]
529 pub const fn item_width(mut self, width: f32) -> Self {
530 self.item_width = Some(width);
531 self
532 }
533
534 fn load_state(&self, ui: &Ui, selected: &mut Vec<bool>) {
536 if let Some(id) = self.id {
537 let state_id = id.with("toggle_group_state");
538 let stored: Vec<bool> = ui
539 .ctx()
540 .data_mut(|d| d.get_temp(state_id).unwrap_or_else(|| selected.clone()));
541 if stored.len() == selected.len() {
542 *selected = stored;
543 }
544 }
545 }
546
547 fn save_state(&self, ui: &Ui, selected: &[bool]) {
549 if let Some(id) = self.id {
550 let state_id = id.with("toggle_group_state");
551 ui.ctx()
552 .data_mut(|d| d.insert_temp(state_id, selected.to_vec()));
553 }
554 }
555
556 fn handle_click(&self, selected: &mut [bool], index: usize) {
558 match self.group_type {
559 ToggleGroupType::Single => {
560 if selected[index] {
561 selected[index] = false;
562 } else {
563 for s in selected.iter_mut() {
564 *s = false;
565 }
566 selected[index] = true;
567 }
568 }
569 ToggleGroupType::Multiple => {
570 selected[index] = !selected[index];
571 }
572 }
573 }
574
575 fn draw_item_frame(
578 &self,
579 ui: &Ui,
580 rect: egui::Rect,
581 response: &Response,
582 is_selected: bool,
583 index: usize,
584 total: usize,
585 theme: &crate::Theme,
586 ) -> Color32 {
587 let painter = ui.painter();
588 let hovered = response.hovered() && !self.disabled;
589 let item_radius = self.size.corner_radius();
590
591 let corner_radius = if self.spacing > 0.0 {
593 CornerRadius::same(item_radius as u8)
594 } else {
595 let is_first = index == 0;
596 let is_last = index == total - 1;
597
598 if self.vertical {
599 CornerRadius {
600 nw: if is_first { item_radius as u8 } else { 0 },
601 ne: if is_first { item_radius as u8 } else { 0 },
602 sw: if is_last { item_radius as u8 } else { 0 },
603 se: if is_last { item_radius as u8 } else { 0 },
604 }
605 } else {
606 CornerRadius {
607 nw: if is_first { item_radius as u8 } else { 0 },
608 sw: if is_first { item_radius as u8 } else { 0 },
609 ne: if is_last { item_radius as u8 } else { 0 },
610 se: if is_last { item_radius as u8 } else { 0 },
611 }
612 }
613 };
614
615 let bg_color = if self.disabled {
617 Color32::TRANSPARENT
618 } else if is_selected || hovered {
619 theme.muted()
620 } else {
621 Color32::TRANSPARENT
622 };
623 painter.rect_filled(rect, corner_radius, bg_color);
624
625 if self.variant == ToggleGroupVariant::Outline {
627 let border_color = if self.disabled {
628 theme.border().linear_multiply(0.5)
629 } else {
630 theme.input()
631 };
632 painter.rect_stroke(
633 rect,
634 corner_radius,
635 Stroke::new(1.0, border_color),
636 egui::StrokeKind::Inside,
637 );
638 if self.spacing == 0.0 && index > 0 {
639 let divider_stroke = Stroke::new(1.0, border_color);
640 if self.vertical {
641 painter.line_segment([rect.left_top(), rect.right_top()], divider_stroke);
642 } else {
643 painter.line_segment([rect.left_top(), rect.left_bottom()], divider_stroke);
644 }
645 }
646 }
647
648 if response.has_focus() && !self.disabled {
650 painter.rect_stroke(
651 rect.expand(2.0),
652 corner_radius,
653 Stroke::new(2.0, theme.ring()),
654 egui::StrokeKind::Outside,
655 );
656 }
657
658 if self.disabled {
660 theme.muted_foreground().linear_multiply(0.5)
661 } else if is_selected {
662 theme.foreground()
663 } else {
664 theme.muted_foreground()
665 }
666 }
667
668 fn with_group_layout<R>(
675 &self,
676 ui: &mut Ui,
677 count: usize,
678 item_width: f32,
679 inner: impl FnOnce(&mut Ui) -> R,
680 ) -> (Response, R) {
681 let prev_spacing = ui.spacing().item_spacing;
682 ui.spacing_mut().item_spacing = Vec2::ZERO;
683
684 let height = self.size.height();
685
686 let layout = if self.vertical {
687 egui::Layout::top_down(egui::Align::LEFT)
688 } else {
689 egui::Layout::left_to_right(egui::Align::Center)
690 };
691
692 let total_spacing = if self.spacing > 0.0 {
694 self.spacing * (count.saturating_sub(1) as f32)
695 } else {
696 0.0
697 };
698 let total_size = if self.vertical {
699 vec2(item_width, height * count as f32 + total_spacing)
700 } else {
701 vec2(item_width * count as f32 + total_spacing, height)
702 };
703
704 let (group_rect, response) = ui.allocate_exact_size(total_size, Sense::hover());
705
706 let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(group_rect).layout(layout));
707
708 if self.spacing > 0.0 {
709 child_ui.spacing_mut().item_spacing = if self.vertical {
710 vec2(0.0, self.spacing)
711 } else {
712 vec2(self.spacing, 0.0)
713 };
714 } else {
715 child_ui.spacing_mut().item_spacing = Vec2::ZERO;
716 }
717
718 let result = inner(&mut child_ui);
719
720 ui.spacing_mut().item_spacing = prev_spacing;
721
722 (response, result)
723 }
724
725 pub fn show(
731 self,
732 ui: &mut Ui,
733 items: &[&str],
734 selected: &mut Vec<bool>,
735 ) -> ToggleGroupResponse {
736 let theme = ui.ctx().armas_theme();
737 let mut changed = false;
738
739 selected.resize(items.len(), false);
740 self.load_state(ui, selected);
741
742 let font_size = self.size.font_size(&theme.typography);
743 let padding_x = self.padding.unwrap_or_else(|| self.size.padding_x());
744 let height = self.size.height();
745
746 let uniform_width = self.item_width.unwrap_or_else(|| {
748 let max_text_width = items
749 .iter()
750 .map(|label| {
751 ui.painter()
752 .layout_no_wrap(
753 label.to_string(),
754 egui::FontId::proportional(font_size),
755 theme.foreground(),
756 )
757 .size()
758 .x
759 })
760 .fold(0.0_f32, f32::max);
761 max_text_width + padding_x * 2.0
762 });
763
764 let (response, ()) = self.with_group_layout(ui, items.len(), uniform_width, |ui| {
765 for (i, label) in items.iter().enumerate() {
766 let is_selected = selected[i];
767
768 let (rect, item_response) = ui.allocate_exact_size(
769 Vec2::new(uniform_width, height),
770 if self.disabled {
771 Sense::hover()
772 } else {
773 Sense::click()
774 },
775 );
776
777 if ui.is_rect_visible(rect) {
778 let text_color = self.draw_item_frame(
779 ui,
780 rect,
781 &item_response,
782 is_selected,
783 i,
784 items.len(),
785 &theme,
786 );
787
788 let text_galley = ui.painter().layout_no_wrap(
789 label.to_string(),
790 egui::FontId::proportional(font_size),
791 text_color,
792 );
793 let text_pos = rect.center() - text_galley.size() / 2.0;
794 ui.painter()
795 .galley(pos2(text_pos.x, text_pos.y), text_galley, text_color);
796 }
797
798 if item_response.clicked() && !self.disabled {
799 self.handle_click(selected, i);
800 changed = true;
801 }
802 }
803 });
804
805 self.save_state(ui, selected);
806
807 ToggleGroupResponse { response, changed }
808 }
809
810 pub fn show_ui(
827 self,
828 ui: &mut Ui,
829 count: usize,
830 selected: &mut Vec<bool>,
831 render_item: impl Fn(usize, &mut Ui, &ContentContext),
832 ) -> ToggleGroupResponse {
833 let theme = ui.ctx().armas_theme();
834 let mut changed = false;
835
836 selected.resize(count, false);
837 self.load_state(ui, selected);
838
839 let height = self.size.height();
840 let padding_x = self.padding.unwrap_or_else(|| self.size.padding_x());
841 let uniform_width = self.item_width.unwrap_or(height);
842
843 let (response, ()) = self.with_group_layout(ui, count, uniform_width, |ui| {
844 for i in 0..count {
845 let is_selected = selected[i];
846
847 let (rect, item_response) = ui.allocate_exact_size(
848 Vec2::new(uniform_width, height),
849 if self.disabled {
850 Sense::hover()
851 } else {
852 Sense::click()
853 },
854 );
855
856 if ui.is_rect_visible(rect) {
857 let text_color = self.draw_item_frame(
858 ui,
859 rect,
860 &item_response,
861 is_selected,
862 i,
863 count,
864 &theme,
865 );
866
867 let content_rect = rect.shrink2(Vec2::new(padding_x, 0.0));
868 let mut child_ui = ui.new_child(
869 egui::UiBuilder::new()
870 .max_rect(content_rect)
871 .layout(egui::Layout::left_to_right(egui::Align::Center)),
872 );
873 child_ui.style_mut().visuals.override_text_color = Some(text_color);
874
875 let ctx = ContentContext {
876 color: text_color,
877 font_size: self.size.font_size(&theme.typography),
878 is_active: is_selected,
879 };
880 render_item(i, &mut child_ui, &ctx);
881 }
882
883 if item_response.clicked() && !self.disabled {
884 self.handle_click(selected, i);
885 changed = true;
886 }
887 }
888 });
889
890 self.save_state(ui, selected);
891
892 ToggleGroupResponse { response, changed }
893 }
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899
900 #[test]
901 fn test_toggle_creation() {
902 let toggle = Toggle::new("Bold");
903 assert_eq!(toggle.label, "Bold");
904 assert_eq!(toggle.variant, ToggleVariant::Default);
905 assert_eq!(toggle.size, ToggleSize::Default);
906 assert!(!toggle.disabled);
907 }
908
909 #[test]
910 fn test_toggle_builder() {
911 let toggle = Toggle::new("Bold")
912 .variant(ToggleVariant::Outline)
913 .size(ToggleSize::Lg)
914 .disabled(true);
915
916 assert_eq!(toggle.variant, ToggleVariant::Outline);
917 assert_eq!(toggle.size, ToggleSize::Lg);
918 assert!(toggle.disabled);
919 }
920
921 #[test]
922 fn test_toggle_size_heights() {
923 assert_eq!(ToggleSize::Sm.height(), 28.0);
924 assert_eq!(ToggleSize::Default.height(), 32.0);
925 assert_eq!(ToggleSize::Lg.height(), 36.0);
926 }
927
928 #[test]
929 fn test_toggle_empty_label() {
930 let toggle = Toggle::new("");
931 assert_eq!(toggle.label, "");
932 assert!(toggle.custom_content_width.is_none());
933 }
934
935 #[test]
936 fn test_toggle_content_width() {
937 let toggle = Toggle::new("").content_width(80.0);
938 assert_eq!(toggle.custom_content_width, Some(80.0));
939 }
940
941 #[test]
942 fn test_toggle_group_creation() {
943 let group = ToggleGroup::new(ToggleGroupType::Single)
944 .variant(ToggleGroupVariant::Outline)
945 .size(ToggleGroupSize::Sm)
946 .spacing(4.0)
947 .vertical(true)
948 .disabled(true);
949
950 assert_eq!(group.group_type, ToggleGroupType::Single);
951 assert_eq!(group.variant, ToggleGroupVariant::Outline);
952 assert_eq!(group.size, ToggleGroupSize::Sm);
953 assert_eq!(group.spacing, 4.0);
954 assert!(group.vertical);
955 assert!(group.disabled);
956 }
957
958 #[test]
959 fn test_toggle_group_size_heights() {
960 assert_eq!(ToggleGroupSize::Sm.height(), 28.0);
961 assert_eq!(ToggleGroupSize::Default.height(), 32.0);
962 assert_eq!(ToggleGroupSize::Lg.height(), 36.0);
963 }
964
965 #[test]
966 fn test_toggle_group_defaults() {
967 let group = ToggleGroup::new(ToggleGroupType::Multiple);
968 assert_eq!(group.group_type, ToggleGroupType::Multiple);
969 assert_eq!(group.variant, ToggleGroupVariant::Default);
970 assert_eq!(group.size, ToggleGroupSize::Default);
971 assert_eq!(group.spacing, 0.0);
972 assert!(!group.vertical);
973 assert!(!group.disabled);
974 assert!(group.item_width.is_none());
975 }
976
977 #[test]
978 fn test_toggle_group_item_width() {
979 let group = ToggleGroup::new(ToggleGroupType::Single).item_width(60.0);
980 assert_eq!(group.item_width, Some(60.0));
981 }
982}