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        // Without an ID, the spring resets to 0.0 every frame (no stored state).
138        // Snap it to match `checked` so it only animates on actual toggles.
139        if self.id.is_none() {
140            self.toggle_spring.value = if *checked { 1.0 } else { 0.0 };
141        }
142
143        let target = if *checked { 1.0 } else { 0.0 };
144        self.toggle_spring.set_target(target);
145        let dt = ui.input(|i| i.stable_dt);
146        self.toggle_spring.update(dt);
147
148        if !self.toggle_spring.is_settled(0.001, 0.001) {
149            ui.ctx().request_repaint();
150        }
151
152        let response = ui
153            .horizontal(|ui| {
154                let (width, height) = self.size.dimensions();
155                let (rect, mut response) = ui.allocate_exact_size(
156                    Vec2::new(width, height),
157                    if self.disabled {
158                        Sense::hover()
159                    } else {
160                        Sense::click()
161                    },
162                );
163
164                if ui.is_rect_visible(rect) {
165                    self.draw(ui, rect, *checked, &theme);
166                }
167
168                if response.clicked() && !self.disabled {
169                    *checked = !*checked;
170                    response.mark_changed();
171                }
172
173                // Label and description
174                if self.label.is_some() || self.description.is_some() {
175                    ui.add_space(theme.spacing.sm);
176                    ui.vertical(|ui| {
177                        ui.spacing_mut().item_spacing.y = theme.spacing.xs;
178                        if let Some(label) = &self.label {
179                            let color = if self.disabled {
180                                theme.muted_foreground().linear_multiply(0.5)
181                            } else {
182                                theme.foreground()
183                            };
184                            ui.label(
185                                egui::RichText::new(label)
186                                    .size(theme.typography.base)
187                                    .color(color),
188                            );
189                        }
190                        if let Some(desc) = &self.description {
191                            ui.label(
192                                egui::RichText::new(desc)
193                                    .size(theme.typography.sm)
194                                    .color(theme.muted_foreground()),
195                            );
196                        }
197                    });
198                }
199
200                response
201            })
202            .inner;
203
204        // Save state
205        if let Some(id) = self.id {
206            let state_id = id.with("switch_state");
207            ui.ctx().data_mut(|d| {
208                d.insert_temp(state_id, (*checked, self.toggle_spring.value));
209            });
210        }
211
212        SwitchResponse {
213            response,
214            changed: old_checked != *checked,
215        }
216    }
217
218    fn draw(&self, ui: &mut Ui, rect: egui::Rect, checked: bool, theme: &Theme) {
219        let painter = ui.painter();
220        let t = self.toggle_spring.value;
221
222        // Background track
223        let bg_color = if self.disabled {
224            theme.muted().gamma_multiply(0.5)
225        } else if checked {
226            theme.primary()
227        } else {
228            theme.input()
229        };
230
231        let track_radius = rect.height() / 2.0;
232        painter.rect_filled(rect, CornerRadius::same(track_radius as u8), bg_color);
233
234        // Focus ring on hover
235        let response = ui.interact(rect, ui.id().with("switch_hover"), Sense::hover());
236        if response.hovered() && !self.disabled {
237            painter.rect_stroke(
238                rect.expand(2.0),
239                CornerRadius::same((track_radius + 2.0) as u8),
240                Stroke::new(2.0, theme.ring()),
241                egui::StrokeKind::Outside,
242            );
243        }
244
245        // Thumb
246        let thumb_size = self.size.thumb_size();
247        let thumb_radius = thumb_size / 2.0;
248        let thumb_padding = 2.0;
249        let thumb_travel = rect.width() - thumb_size - thumb_padding * 2.0;
250        let thumb_x = rect.min.x + thumb_padding + thumb_radius + thumb_travel * t;
251        let thumb_center = pos2(thumb_x, rect.center().y);
252
253        let thumb_color = if self.disabled {
254            theme.muted_foreground()
255        } else {
256            theme.foreground()
257        };
258
259        // Shadow under thumb
260        if !self.disabled {
261            painter.circle_filled(
262                thumb_center + vec2(0.0, 1.0),
263                thumb_radius,
264                Color32::from_rgba_unmultiplied(0, 0, 0, 20),
265            );
266        }
267
268        painter.circle_filled(thumb_center, thumb_radius, thumb_color);
269    }
270}
271
272impl Default for Switch {
273    fn default() -> Self {
274        Self::new()
275    }
276}
277
278/// Response from switch interaction
279pub struct SwitchResponse {
280    /// The underlying egui response
281    pub response: Response,
282    /// Whether the switch state changed
283    pub changed: bool,
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_switch_creation() {
292        let switch = Switch::new();
293        assert_eq!(switch.size, SwitchSize::Medium);
294        assert!(!switch.disabled);
295    }
296
297    #[test]
298    fn test_switch_builder() {
299        let switch = Switch::new()
300            .size(SwitchSize::Large)
301            .label("Enable feature")
302            .disabled(true);
303
304        assert_eq!(switch.size, SwitchSize::Large);
305        assert_eq!(switch.label, Some("Enable feature".to_string()));
306        assert!(switch.disabled);
307    }
308
309    #[test]
310    fn test_switch_size_dimensions() {
311        assert_eq!(SwitchSize::Small.dimensions(), (36.0, 20.0));
312        assert_eq!(SwitchSize::Medium.dimensions(), (44.0, 24.0));
313        assert_eq!(SwitchSize::Large.dimensions(), (52.0, 28.0));
314    }
315}