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    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    /// Show the toggle group
698    ///
699    /// `selected` is a bool per item. Will be resized to match `items.len()`.
700    /// - `Single`: clicking an item deselects all others (radio behavior)
701    /// - `Multiple`: each item toggles independently
702    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            // Pre-measure all items to find uniform width
720            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    /// Show the toggle group with custom content for each item.
783    ///
784    /// The closure receives the item index, a `&mut Ui`, and a [`ContentContext`].
785    /// Use [`item_width`](Self::item_width) to set uniform item width.
786    /// If not set, items default to square (height x height).
787    ///
788    /// # Example
789    ///
790    /// ```ignore
791    /// let mut selected = vec![false, false, false];
792    /// ToggleGroup::new(ToggleGroupType::Single)
793    ///     .item_width(40.0)
794    ///     .show_ui(ui, 3, &mut selected, |index, ui, ctx| {
795    ///         // Render icon for item `index` using ctx.color
796    ///     });
797    /// ```
798    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}