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>(&self, ui: &mut Ui, inner: impl FnOnce(&mut Ui) -> R) -> (Response, R) {
670 let layout = if self.vertical {
671 egui::Layout::top_down(egui::Align::LEFT)
672 } else {
673 egui::Layout::left_to_right(egui::Align::Center)
674 };
675
676 let prev_spacing = ui.spacing().item_spacing;
677 ui.spacing_mut().item_spacing = Vec2::ZERO;
678
679 let result = ui.with_layout(layout, |ui| {
680 if self.spacing > 0.0 {
681 ui.spacing_mut().item_spacing = if self.vertical {
682 vec2(0.0, self.spacing)
683 } else {
684 vec2(self.spacing, 0.0)
685 };
686 } else {
687 ui.spacing_mut().item_spacing = Vec2::ZERO;
688 }
689 inner(ui)
690 });
691
692 ui.spacing_mut().item_spacing = prev_spacing;
693
694 (result.response, result.inner)
695 }
696
697 pub fn show(
703 self,
704 ui: &mut Ui,
705 items: &[&str],
706 selected: &mut Vec<bool>,
707 ) -> ToggleGroupResponse {
708 let theme = ui.ctx().armas_theme();
709 let mut changed = false;
710
711 selected.resize(items.len(), false);
712 self.load_state(ui, selected);
713
714 let (response, ()) = self.with_group_layout(ui, |ui| {
715 let font_size = self.size.font_size(&theme.typography);
716 let padding_x = self.padding.unwrap_or_else(|| self.size.padding_x());
717 let height = self.size.height();
718
719 let uniform_width = self.item_width.unwrap_or_else(|| {
721 let max_text_width = items
722 .iter()
723 .map(|label| {
724 ui.painter()
725 .layout_no_wrap(
726 label.to_string(),
727 egui::FontId::proportional(font_size),
728 theme.foreground(),
729 )
730 .size()
731 .x
732 })
733 .fold(0.0_f32, f32::max);
734 max_text_width + padding_x * 2.0
735 });
736
737 for (i, label) in items.iter().enumerate() {
738 let is_selected = selected[i];
739
740 let (rect, item_response) = ui.allocate_exact_size(
741 Vec2::new(uniform_width, height),
742 if self.disabled {
743 Sense::hover()
744 } else {
745 Sense::click()
746 },
747 );
748
749 if ui.is_rect_visible(rect) {
750 let text_color = self.draw_item_frame(
751 ui,
752 rect,
753 &item_response,
754 is_selected,
755 i,
756 items.len(),
757 &theme,
758 );
759
760 let text_galley = ui.painter().layout_no_wrap(
761 label.to_string(),
762 egui::FontId::proportional(font_size),
763 text_color,
764 );
765 let text_pos = rect.center() - text_galley.size() / 2.0;
766 ui.painter()
767 .galley(pos2(text_pos.x, text_pos.y), text_galley, text_color);
768 }
769
770 if item_response.clicked() && !self.disabled {
771 self.handle_click(selected, i);
772 changed = true;
773 }
774 }
775 });
776
777 self.save_state(ui, selected);
778
779 ToggleGroupResponse { response, changed }
780 }
781
782 pub fn show_ui(
799 self,
800 ui: &mut Ui,
801 count: usize,
802 selected: &mut Vec<bool>,
803 render_item: impl Fn(usize, &mut Ui, &ContentContext),
804 ) -> ToggleGroupResponse {
805 let theme = ui.ctx().armas_theme();
806 let mut changed = false;
807
808 selected.resize(count, false);
809 self.load_state(ui, selected);
810
811 let height = self.size.height();
812 let padding_x = self.padding.unwrap_or_else(|| self.size.padding_x());
813 let uniform_width = self.item_width.unwrap_or(height);
814
815 let (response, ()) = self.with_group_layout(ui, |ui| {
816 for i in 0..count {
817 let is_selected = selected[i];
818
819 let (rect, item_response) = ui.allocate_exact_size(
820 Vec2::new(uniform_width, height),
821 if self.disabled {
822 Sense::hover()
823 } else {
824 Sense::click()
825 },
826 );
827
828 if ui.is_rect_visible(rect) {
829 let text_color = self.draw_item_frame(
830 ui,
831 rect,
832 &item_response,
833 is_selected,
834 i,
835 count,
836 &theme,
837 );
838
839 let content_rect = rect.shrink2(Vec2::new(padding_x, 0.0));
840 let mut child_ui = ui.new_child(
841 egui::UiBuilder::new()
842 .max_rect(content_rect)
843 .layout(egui::Layout::left_to_right(egui::Align::Center)),
844 );
845 child_ui.style_mut().visuals.override_text_color = Some(text_color);
846
847 let ctx = ContentContext {
848 color: text_color,
849 font_size: self.size.font_size(&theme.typography),
850 is_active: is_selected,
851 };
852 render_item(i, &mut child_ui, &ctx);
853 }
854
855 if item_response.clicked() && !self.disabled {
856 self.handle_click(selected, i);
857 changed = true;
858 }
859 }
860 });
861
862 self.save_state(ui, selected);
863
864 ToggleGroupResponse { response, changed }
865 }
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871
872 #[test]
873 fn test_toggle_creation() {
874 let toggle = Toggle::new("Bold");
875 assert_eq!(toggle.label, "Bold");
876 assert_eq!(toggle.variant, ToggleVariant::Default);
877 assert_eq!(toggle.size, ToggleSize::Default);
878 assert!(!toggle.disabled);
879 }
880
881 #[test]
882 fn test_toggle_builder() {
883 let toggle = Toggle::new("Bold")
884 .variant(ToggleVariant::Outline)
885 .size(ToggleSize::Lg)
886 .disabled(true);
887
888 assert_eq!(toggle.variant, ToggleVariant::Outline);
889 assert_eq!(toggle.size, ToggleSize::Lg);
890 assert!(toggle.disabled);
891 }
892
893 #[test]
894 fn test_toggle_size_heights() {
895 assert_eq!(ToggleSize::Sm.height(), 28.0);
896 assert_eq!(ToggleSize::Default.height(), 32.0);
897 assert_eq!(ToggleSize::Lg.height(), 36.0);
898 }
899
900 #[test]
901 fn test_toggle_empty_label() {
902 let toggle = Toggle::new("");
903 assert_eq!(toggle.label, "");
904 assert!(toggle.custom_content_width.is_none());
905 }
906
907 #[test]
908 fn test_toggle_content_width() {
909 let toggle = Toggle::new("").content_width(80.0);
910 assert_eq!(toggle.custom_content_width, Some(80.0));
911 }
912
913 #[test]
914 fn test_toggle_group_creation() {
915 let group = ToggleGroup::new(ToggleGroupType::Single)
916 .variant(ToggleGroupVariant::Outline)
917 .size(ToggleGroupSize::Sm)
918 .spacing(4.0)
919 .vertical(true)
920 .disabled(true);
921
922 assert_eq!(group.group_type, ToggleGroupType::Single);
923 assert_eq!(group.variant, ToggleGroupVariant::Outline);
924 assert_eq!(group.size, ToggleGroupSize::Sm);
925 assert_eq!(group.spacing, 4.0);
926 assert!(group.vertical);
927 assert!(group.disabled);
928 }
929
930 #[test]
931 fn test_toggle_group_size_heights() {
932 assert_eq!(ToggleGroupSize::Sm.height(), 28.0);
933 assert_eq!(ToggleGroupSize::Default.height(), 32.0);
934 assert_eq!(ToggleGroupSize::Lg.height(), 36.0);
935 }
936
937 #[test]
938 fn test_toggle_group_defaults() {
939 let group = ToggleGroup::new(ToggleGroupType::Multiple);
940 assert_eq!(group.group_type, ToggleGroupType::Multiple);
941 assert_eq!(group.variant, ToggleGroupVariant::Default);
942 assert_eq!(group.size, ToggleGroupSize::Default);
943 assert_eq!(group.spacing, 0.0);
944 assert!(!group.vertical);
945 assert!(!group.disabled);
946 assert!(group.item_width.is_none());
947 }
948
949 #[test]
950 fn test_toggle_group_item_width() {
951 let group = ToggleGroup::new(ToggleGroupType::Single).item_width(60.0);
952 assert_eq!(group.item_width, Some(60.0));
953 }
954}