Skip to main content

armas_basic/components/
toggle.rs

1//! Toggle & Toggle Group Components
2//!
3//! Toggle: A single pressable button with on/off state (shadcn/ui Toggle).
4//! Toggle Group: A group of pressable toggle buttons for selection (shadcn/ui Toggle Group).
5
6use super::content::ContentContext;
7use crate::ext::ArmasContextExt;
8use egui::{pos2, vec2, Color32, CornerRadius, Response, Sense, Stroke, Ui, Vec2};
9
10// ============================================================================
11// TOGGLE — shadcn/ui pressable button with on/off state
12// ============================================================================
13
14/// Toggle visual variant
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum ToggleVariant {
17    /// Transparent background, muted bg when pressed
18    #[default]
19    Default,
20    /// Bordered
21    Outline,
22}
23
24/// Toggle size
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum ToggleSize {
27    /// Small: 28px height, 28px min width
28    Sm,
29    /// Default: 32px height, 32px min width
30    #[default]
31    Default,
32    /// Large: 36px height, 36px min width
33    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
68/// Response from toggle interaction
69pub struct ToggleResponse {
70    /// The underlying egui response
71    pub response: Response,
72    /// Whether the toggle state changed this frame
73    pub changed: bool,
74}
75
76/// A pressable button with on/off state (shadcn/ui Toggle)
77///
78/// # Example
79///
80/// ```ignore
81/// let mut pressed = false;
82/// Toggle::new("Bold")
83///     .variant(ToggleVariant::Outline)
84///     .show(ui, &mut pressed);
85/// ```
86pub 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    /// Create a new toggle with the given label
97    #[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    /// Set ID for state persistence
110    #[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    /// Set the visual variant
117    #[must_use]
118    pub const fn variant(mut self, variant: ToggleVariant) -> Self {
119        self.variant = variant;
120        self
121    }
122
123    /// Set the size
124    #[must_use]
125    pub const fn size(mut self, size: ToggleSize) -> Self {
126        self.size = size;
127        self
128    }
129
130    /// Set disabled state
131    #[must_use]
132    pub const fn disabled(mut self, disabled: bool) -> Self {
133        self.disabled = disabled;
134        self
135    }
136
137    /// Set explicit content area width for custom content.
138    ///
139    /// When using [`show_ui`](Self::show_ui), this controls the inner width.
140    /// If not set, defaults to a square layout (width = height).
141    #[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    /// Load toggle state from memory if ID is set, update `pressed`.
148    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    /// Save toggle state to memory if ID is set.
159    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    /// Draw the toggle frame (background, border, focus ring).
167    /// Returns `(text_color, hovered)`.
168    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        // Focus ring
206        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        // Return text color
216        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    /// Show the toggle button
226    ///
227    /// `pressed` tracks whether the toggle is in the on/off state.
228    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        // Measure text to determine width
239        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    /// Show the toggle with custom content instead of a text label.
279    ///
280    /// The closure receives a `&mut Ui` (with override text color set) and a
281    /// [`ContentContext`] with the state-dependent color and font size.
282    ///
283    /// # Example
284    ///
285    /// ```ignore
286    /// let mut pressed = false;
287    /// Toggle::new("")
288    ///     .variant(ToggleVariant::Outline)
289    ///     .show_ui(ui, &mut pressed, |ui, ctx| {
290    ///         // Render an icon using ctx.color
291    ///     });
292    /// ```
293    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        // Width: use content_width if set, otherwise square
308        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// ============================================================================
353// TOGGLE GROUP — shadcn/ui style pressable button group
354// ============================================================================
355
356/// Toggle group selection type
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358pub enum ToggleGroupType {
359    /// Only one item can be selected at a time
360    Single,
361    /// Multiple items can be selected
362    Multiple,
363}
364
365/// Toggle group visual variant
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
367pub enum ToggleGroupVariant {
368    /// Transparent background, muted bg when pressed
369    #[default]
370    Default,
371    /// Bordered items
372    Outline,
373}
374
375/// Toggle group item size
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
377pub enum ToggleGroupSize {
378    /// Small: 28px height
379    Sm,
380    /// Default: 32px height
381    #[default]
382    Default,
383    /// Large: 36px height
384    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
419/// Response from toggle group interaction
420pub struct ToggleGroupResponse {
421    /// The underlying egui response
422    pub response: Response,
423    /// Whether the selection changed this frame
424    pub changed: bool,
425}
426
427/// A group of pressable toggle buttons for selection (shadcn/ui Toggle Group)
428///
429/// Supports single selection (radio-like) or multiple selection (checkbox-like).
430///
431/// # Example
432///
433/// ```ignore
434/// // Single selection — clicking one deselects the others
435/// let mut selected = vec![true, false, false];
436/// ToggleGroup::new(ToggleGroupType::Single)
437///     .variant(ToggleGroupVariant::Outline)
438///     .show(ui, &["Bold", "Italic", "Underline"], &mut selected);
439///
440/// // Multiple selection — each item toggles independently
441/// let mut selected = vec![false, false, false];
442/// ToggleGroup::new(ToggleGroupType::Multiple)
443///     .show(ui, &["Bold", "Italic", "Underline"], &mut selected);
444/// ```
445pub 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    /// Create a new toggle group
459    #[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    /// Set ID for state persistence
475    #[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    /// Set the visual variant
482    #[must_use]
483    pub const fn variant(mut self, variant: ToggleGroupVariant) -> Self {
484        self.variant = variant;
485        self
486    }
487
488    /// Set the size
489    #[must_use]
490    pub const fn size(mut self, size: ToggleGroupSize) -> Self {
491        self.size = size;
492        self
493    }
494
495    /// Set spacing between items (0 = joined, >0 = separated)
496    #[must_use]
497    pub const fn spacing(mut self, spacing: f32) -> Self {
498        self.spacing = spacing;
499        self
500    }
501
502    /// Override horizontal padding around each item's text.
503    /// When set, this takes precedence over the size-based default padding.
504    #[must_use]
505    pub const fn padding(mut self, padding: f32) -> Self {
506        self.padding = Some(padding);
507        self
508    }
509
510    /// Set vertical orientation
511    #[must_use]
512    pub const fn vertical(mut self, vertical: bool) -> Self {
513        self.vertical = vertical;
514        self
515    }
516
517    /// Set disabled state
518    #[must_use]
519    pub const fn disabled(mut self, disabled: bool) -> Self {
520        self.disabled = disabled;
521        self
522    }
523
524    /// Set explicit uniform item width.
525    ///
526    /// Required when using [`show_ui`](Self::show_ui) for proper layout.
527    /// For text-based [`show`](Self::show), items auto-size to the widest label.
528    #[must_use]
529    pub const fn item_width(mut self, width: f32) -> Self {
530        self.item_width = Some(width);
531        self
532    }
533
534    /// Load state from memory if ID is set.
535    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    /// Save state to memory if ID is set.
548    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    /// Handle selection logic when an item is clicked.
557    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    /// Draw the frame (background, border, focus ring) for a single item.
576    /// Returns the text color.
577    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        // Calculate corner rounding based on spacing and position
592        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        // Background
616        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        // Border for outline variant
626        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        // Focus ring
649        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        // Return text color
659        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    /// Set up the group layout, run the inner closure, restore spacing.
669    /// Set up the group layout, run the inner closure, restore spacing.
670    ///
671    /// `item_width` is the per-item width used to pre-allocate the group rect
672    /// so the response height matches the item height (avoids egui expanding
673    /// to fill the parent cross-axis when switching layout direction).
674    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        // Calculate total size so the response rect is tight
693        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    /// Show the toggle group
726    ///
727    /// `selected` is a bool per item. Will be resized to match `items.len()`.
728    /// - `Single`: clicking an item deselects all others (radio behavior)
729    /// - `Multiple`: each item toggles independently
730    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        // Pre-measure all items to find uniform width
747        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    /// Show the toggle group with custom content for each item.
811    ///
812    /// The closure receives the item index, a `&mut Ui`, and a [`ContentContext`].
813    /// Use [`item_width`](Self::item_width) to set uniform item width.
814    /// If not set, items default to square (height x height).
815    ///
816    /// # Example
817    ///
818    /// ```ignore
819    /// let mut selected = vec![false, false, false];
820    /// ToggleGroup::new(ToggleGroupType::Single)
821    ///     .item_width(40.0)
822    ///     .show_ui(ui, 3, &mut selected, |index, ui, ctx| {
823    ///         // Render icon for item `index` using ctx.color
824    ///     });
825    /// ```
826    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}