Skip to main content

armas_basic/components/
switch.rs

1//! Switch Component
2//!
3//! Animated toggle switch styled like shadcn/ui Switch.
4//! Provides smooth spring animations and supports:
5//! - Multiple sizes
6//! - Labels and descriptions
7//! - Disabled state
8
9use crate::animation::SpringAnimation;
10use crate::ext::ArmasContextExt;
11use crate::Theme;
12use egui::{pos2, vec2, Color32, CornerRadius, Response, Sense, Stroke, Ui, Vec2};
13
14// shadcn Switch dimensions
15const SWITCH_WIDTH: f32 = 44.0; // w-11
16const SWITCH_HEIGHT: f32 = 24.0; // h-6
17const SWITCH_THUMB_SIZE: f32 = 20.0; // h-5 w-5
18
19/// Switch size
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum SwitchSize {
22    /// Small switch
23    Small,
24    /// Medium switch (default)
25    Medium,
26    /// Large switch
27    Large,
28}
29
30impl SwitchSize {
31    const fn dimensions(self) -> (f32, f32) {
32        match self {
33            Self::Small => (36.0, 20.0),
34            Self::Medium => (SWITCH_WIDTH, SWITCH_HEIGHT),
35            Self::Large => (52.0, 28.0),
36        }
37    }
38
39    const fn thumb_size(self) -> f32 {
40        match self {
41            Self::Small => 16.0,
42            Self::Medium => SWITCH_THUMB_SIZE,
43            Self::Large => 24.0,
44        }
45    }
46}
47
48/// Animated switch component (shadcn/ui Switch)
49///
50/// # Example
51///
52/// ```rust,no_run
53/// # use egui::Ui;
54/// # fn example(ui: &mut Ui) {
55/// use armas_basic::components::Switch;
56///
57/// let mut checked = false;
58/// let mut switch = Switch::new().label("Dark mode");
59/// switch.show(ui, &mut checked);
60/// # }
61/// ```
62pub struct Switch {
63    id: Option<egui::Id>,
64    size: SwitchSize,
65    label: Option<String>,
66    description: Option<String>,
67    disabled: bool,
68    toggle_spring: SpringAnimation,
69}
70
71impl Switch {
72    /// Create a new switch
73    #[must_use]
74    pub const fn new() -> Self {
75        Self {
76            id: None,
77            size: SwitchSize::Medium,
78            label: None,
79            description: None,
80            disabled: false,
81            toggle_spring: SpringAnimation::new(0.0, 0.0).params(800.0, 30.0),
82        }
83    }
84
85    /// Set ID for state persistence
86    #[must_use]
87    pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
88        self.id = Some(id.into());
89        self
90    }
91
92    /// Set the size
93    #[must_use]
94    pub const fn size(mut self, size: SwitchSize) -> Self {
95        self.size = size;
96        self
97    }
98
99    /// Set a label
100    #[must_use]
101    pub fn label(mut self, label: impl Into<String>) -> Self {
102        self.label = Some(label.into());
103        self
104    }
105
106    /// Set a description
107    #[must_use]
108    pub fn description(mut self, description: impl Into<String>) -> Self {
109        self.description = Some(description.into());
110        self
111    }
112
113    /// Set disabled state
114    #[must_use]
115    pub const fn disabled(mut self, disabled: bool) -> Self {
116        self.disabled = disabled;
117        self
118    }
119
120    /// Show the switch and return whether it changed
121    pub fn show(&mut self, ui: &mut Ui, checked: &mut bool) -> SwitchResponse {
122        let theme = ui.ctx().armas_theme();
123
124        // Load state from memory if ID is set
125        if let Some(id) = self.id {
126            let state_id = id.with("switch_state");
127            let (stored_checked, stored_anim): (bool, f32) = ui.ctx().data_mut(|d| {
128                d.get_temp(state_id)
129                    .unwrap_or((*checked, if *checked { 1.0 } else { 0.0 }))
130            });
131            *checked = stored_checked;
132            self.toggle_spring.value = stored_anim;
133        }
134
135        let old_checked = *checked;
136
137        // Update spring animation
138        let target = if *checked { 1.0 } else { 0.0 };
139        self.toggle_spring.set_target(target);
140        let dt = ui.input(|i| i.stable_dt);
141        self.toggle_spring.update(dt);
142
143        if !self.toggle_spring.is_settled(0.001, 0.001) {
144            ui.ctx().request_repaint();
145        }
146
147        let response = ui
148            .horizontal(|ui| {
149                let (width, height) = self.size.dimensions();
150                let (rect, mut response) = ui.allocate_exact_size(
151                    Vec2::new(width, height),
152                    if self.disabled {
153                        Sense::hover()
154                    } else {
155                        Sense::click()
156                    },
157                );
158
159                if ui.is_rect_visible(rect) {
160                    self.draw(ui, rect, *checked, &theme);
161                }
162
163                if response.clicked() && !self.disabled {
164                    *checked = !*checked;
165                    response.mark_changed();
166                }
167
168                // Label and description
169                if self.label.is_some() || self.description.is_some() {
170                    ui.add_space(theme.spacing.sm);
171                    ui.vertical(|ui| {
172                        ui.spacing_mut().item_spacing.y = theme.spacing.xs;
173                        if let Some(label) = &self.label {
174                            let color = if self.disabled {
175                                theme.muted_foreground().linear_multiply(0.5)
176                            } else {
177                                theme.foreground()
178                            };
179                            ui.label(
180                                egui::RichText::new(label)
181                                    .size(theme.typography.base)
182                                    .color(color),
183                            );
184                        }
185                        if let Some(desc) = &self.description {
186                            ui.label(
187                                egui::RichText::new(desc)
188                                    .size(theme.typography.sm)
189                                    .color(theme.muted_foreground()),
190                            );
191                        }
192                    });
193                }
194
195                response
196            })
197            .inner;
198
199        // Save state
200        if let Some(id) = self.id {
201            let state_id = id.with("switch_state");
202            ui.ctx().data_mut(|d| {
203                d.insert_temp(state_id, (*checked, self.toggle_spring.value));
204            });
205        }
206
207        SwitchResponse {
208            response,
209            changed: old_checked != *checked,
210        }
211    }
212
213    fn draw(&self, ui: &mut Ui, rect: egui::Rect, checked: bool, theme: &Theme) {
214        let painter = ui.painter();
215        let t = self.toggle_spring.value;
216
217        // Background track
218        let bg_color = if self.disabled {
219            theme.muted().gamma_multiply(0.5)
220        } else if checked {
221            theme.primary()
222        } else {
223            theme.input()
224        };
225
226        let track_radius = rect.height() / 2.0;
227        painter.rect_filled(rect, CornerRadius::same(track_radius as u8), bg_color);
228
229        // Focus ring on hover
230        let response = ui.interact(rect, ui.id().with("switch_hover"), Sense::hover());
231        if response.hovered() && !self.disabled {
232            painter.rect_stroke(
233                rect.expand(2.0),
234                CornerRadius::same((track_radius + 2.0) as u8),
235                Stroke::new(2.0, theme.ring()),
236                egui::StrokeKind::Outside,
237            );
238        }
239
240        // Thumb
241        let thumb_size = self.size.thumb_size();
242        let thumb_radius = thumb_size / 2.0;
243        let thumb_padding = 2.0;
244        let thumb_travel = rect.width() - thumb_size - thumb_padding * 2.0;
245        let thumb_x = rect.min.x + thumb_padding + thumb_radius + thumb_travel * t;
246        let thumb_center = pos2(thumb_x, rect.center().y);
247
248        let thumb_color = if self.disabled {
249            theme.muted_foreground()
250        } else {
251            theme.background()
252        };
253
254        // Shadow under thumb
255        if !self.disabled {
256            painter.circle_filled(
257                thumb_center + vec2(0.0, 1.0),
258                thumb_radius,
259                Color32::from_rgba_unmultiplied(0, 0, 0, 20),
260            );
261        }
262
263        painter.circle_filled(thumb_center, thumb_radius, thumb_color);
264    }
265}
266
267impl Default for Switch {
268    fn default() -> Self {
269        Self::new()
270    }
271}
272
273/// Response from switch interaction
274pub struct SwitchResponse {
275    /// The underlying egui response
276    pub response: Response,
277    /// Whether the switch state changed
278    pub changed: bool,
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_switch_creation() {
287        let switch = Switch::new();
288        assert_eq!(switch.size, SwitchSize::Medium);
289        assert!(!switch.disabled);
290    }
291
292    #[test]
293    fn test_switch_builder() {
294        let switch = Switch::new()
295            .size(SwitchSize::Large)
296            .label("Enable feature")
297            .disabled(true);
298
299        assert_eq!(switch.size, SwitchSize::Large);
300        assert_eq!(switch.label, Some("Enable feature".to_string()));
301        assert!(switch.disabled);
302    }
303
304    #[test]
305    fn test_switch_size_dimensions() {
306        assert_eq!(SwitchSize::Small.dimensions(), (36.0, 20.0));
307        assert_eq!(SwitchSize::Medium.dimensions(), (44.0, 24.0));
308        assert_eq!(SwitchSize::Large.dimensions(), (52.0, 28.0));
309    }
310}