Skip to main content

egui_material3/
switch.rs

1use crate::get_global_color;
2use egui::{self, Color32, FontId, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, Widget};
3
4/// Material Design switch component following Material Design 3 specifications
5///
6/// Switches toggle the state of a single item on or off. They are the preferred way to
7/// adjust settings on mobile and should be used instead of checkboxes in most cases.
8///
9/// ## Usage Examples
10/// ```rust
11/// # egui::__run_test_ui(|ui| {
12/// let mut wifi_enabled = false;
13/// let mut bluetooth_enabled = true;
14///
15/// // Basic switch
16/// ui.add(MaterialSwitch::new(&mut wifi_enabled));
17///
18/// // Switch with label text
19/// ui.add(MaterialSwitch::new(&mut bluetooth_enabled)
20///     .text("Enable Bluetooth"));
21///
22/// // Disabled switch
23/// let mut disabled_option = false;
24/// ui.add(MaterialSwitch::new(&mut disabled_option)
25///     .text("Unavailable option")
26///     .enabled(false));
27/// # });
28/// ```
29///
30/// ## Material Design Spec (Material 3)
31/// - Track width: 52dp, height: 32dp
32/// - Thumb diameter: 24dp when on, 16dp when off, 28dp when pressed
33/// - Corner radius: 16dp (fully rounded)
34/// - Touch target: 48x48dp minimum
35/// - Colors: Primary when on, surfaceContainerHighest when off
36/// - Track outline: 2dp when off, transparent when on
37/// - Icons: 16dp, displayed on thumb
38/// - Animation: 300ms cubic-bezier transition
39pub struct MaterialSwitch<'a> {
40    /// Mutable reference to the switch state (on/off)
41    selected: &'a mut bool,
42    /// Optional text label displayed next to the switch
43    text: Option<String>,
44    /// Whether the switch is interactive (enabled/disabled)
45    enabled: bool,
46    /// Optional icon displayed on thumb when selected
47    selected_icon: Option<char>,
48    /// Optional icon displayed on thumb when unselected
49    unselected_icon: Option<char>,
50    /// Whether to show track outline (Material 3: true, Material 2: false)
51    show_track_outline: bool,
52}
53
54impl<'a> MaterialSwitch<'a> {
55    /// Create a new Material Design switch
56    ///
57    /// ## Parameters
58    /// - `selected`: Mutable reference to boolean state representing on/off
59    ///
60    /// ## Returns
61    /// A new MaterialSwitch instance with default settings
62    pub fn new(selected: &'a mut bool) -> Self {
63        Self {
64            selected,
65            text: None,
66            enabled: true,
67            selected_icon: None,
68            unselected_icon: None,
69            show_track_outline: true, // Material 3 default
70        }
71    }
72
73    /// Set optional text label for the switch
74    ///
75    /// The text will be displayed to the right of the switch component.
76    ///
77    /// ## Parameters
78    /// - `text`: Label text to display next to the switch
79    pub fn text(mut self, text: impl Into<String>) -> Self {
80        self.text = Some(text.into());
81        self
82    }
83
84    /// Set whether the switch is enabled or disabled
85    ///
86    /// Disabled switches cannot be interacted with and are visually dimmed.
87    ///
88    /// ## Parameters
89    /// - `enabled`: True for interactive, false for disabled
90    pub fn enabled(mut self, enabled: bool) -> Self {
91        self.enabled = enabled;
92        self
93    }
94
95    /// Set an icon to display on the thumb when the switch is selected (on)
96    ///
97    /// ## Parameters
98    /// - `icon`: Character representing a Material icon to display on selected thumb
99    pub fn selected_icon(mut self, icon: char) -> Self {
100        self.selected_icon = Some(icon);
101        self
102    }
103
104    /// Set an icon to display on the thumb when the switch is unselected (off)
105    ///
106    /// ## Parameters
107    /// - `icon`: Character representing a Material icon to display on unselected thumb
108    pub fn unselected_icon(mut self, icon: char) -> Self {
109        self.unselected_icon = Some(icon);
110        self
111    }
112
113    /// Set icons for both selected and unselected states
114    ///
115    /// ## Parameters
116    /// - `selected`: Character representing a Material icon to display when on
117    /// - `unselected`: Character representing a Material icon to display when off
118    pub fn with_icons(mut self, selected: char, unselected: char) -> Self {
119        self.selected_icon = Some(selected);
120        self.unselected_icon = Some(unselected);
121        self
122    }
123
124    /// Set whether to show track outline (Material 3 style)
125    ///
126    /// ## Parameters
127    /// - `show`: True to show outline when off, false for no outline
128    pub fn show_track_outline(mut self, show: bool) -> Self {
129        self.show_track_outline = show;
130        self
131    }
132}
133
134impl<'a> Widget for MaterialSwitch<'a> {
135    fn ui(self, ui: &mut Ui) -> Response {
136        // Material 3 specifications
137        let switch_width = 52.0;
138        let switch_height = 32.0;
139        let track_height = 32.0;
140
141        let desired_size = if let Some(ref text) = self.text {
142            let text_width = ui.painter().layout_no_wrap(
143                text.clone(),
144                egui::FontId::default(),
145                egui::Color32::WHITE,
146            ).size().x;
147            Vec2::new(switch_width + 8.0 + text_width, switch_height)
148        } else {
149            Vec2::new(switch_width, switch_height)
150        };
151
152        let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
153
154        if response.clicked() && self.enabled {
155            *self.selected = !*self.selected;
156            response.mark_changed();
157        }
158
159        // Track interaction states
160        let is_pressed = response.is_pointer_button_down_on();
161        let is_hovered = response.hovered();
162        let is_focused = response.has_focus();
163
164        // Material Design 3 colors
165        let primary_color = get_global_color("primary");
166        let on_primary = get_global_color("onPrimary");
167        let primary_container = get_global_color("primaryContainer");
168        let on_primary_container = get_global_color("onPrimaryContainer");
169        let surface_container_highest = get_global_color("surfaceContainerHighest");
170        let on_surface = get_global_color("onSurface");
171        let on_surface_variant = get_global_color("onSurfaceVariant");
172        let outline = get_global_color("outline");
173
174        // Calculate switch area
175        let switch_rect = Rect::from_min_size(
176            Pos2::new(rect.min.x, rect.center().y - switch_height / 2.0),
177            Vec2::new(switch_width, switch_height),
178        );
179
180        let track_rect =
181            Rect::from_center_size(switch_rect.center(), Vec2::new(switch_width, track_height));
182
183        // Material 3: thumb is 16dp when off, 24dp when on, 28dp when pressed
184        let has_icon = if *self.selected {
185            self.selected_icon.is_some()
186        } else {
187            self.unselected_icon.is_some()
188        };
189        
190        let base_thumb_size_on = 24.0;
191        let base_thumb_size_off = if has_icon { 24.0 } else { 16.0 };
192        let pressed_thumb_size = 28.0;
193        
194        let thumb_size = if is_pressed {
195            pressed_thumb_size
196        } else if *self.selected {
197            base_thumb_size_on
198        } else {
199            base_thumb_size_off
200        };
201        
202        let thumb_travel = switch_width - base_thumb_size_on - 4.0;
203        let thumb_x = if *self.selected {
204            switch_rect.min.x + 2.0 + thumb_travel
205        } else {
206            switch_rect.min.x + 2.0
207        };
208
209        let thumb_center = Pos2::new(thumb_x + thumb_size / 2.0, switch_rect.center().y);
210
211        // Material 3 color resolution based on state
212        let (track_color, thumb_color, track_outline_color, icon_color) = if !self.enabled {
213            // Disabled state
214            let disabled_track = if *self.selected {
215                on_surface.linear_multiply(0.12)
216            } else {
217                surface_container_highest.linear_multiply(0.12)
218            };
219            let disabled_thumb = if *self.selected {
220                on_surface.linear_multiply(1.0)
221            } else {
222                on_surface.linear_multiply(0.38)
223            };
224            let disabled_outline = on_surface.linear_multiply(0.12);
225            let disabled_icon = if *self.selected {
226                on_surface.linear_multiply(0.38)
227            } else {
228                surface_container_highest.linear_multiply(0.38)
229            };
230            (disabled_track, disabled_thumb, disabled_outline, disabled_icon)
231        } else if *self.selected {
232            // Selected (on) state
233            let track = primary_color;
234            let thumb = if is_pressed || is_hovered || is_focused {
235                primary_container
236            } else {
237                on_primary
238            };
239            let outline = Color32::TRANSPARENT;
240            let icon = if is_pressed || is_hovered || is_focused {
241                on_primary_container
242            } else {
243                on_primary_container
244            };
245            (track, thumb, outline, icon)
246        } else {
247            // Unselected (off) state
248            let track = if is_pressed || is_hovered || is_focused {
249                surface_container_highest
250            } else {
251                surface_container_highest
252            };
253            let thumb = if is_pressed || is_hovered || is_focused {
254                on_surface_variant
255            } else {
256                outline
257            };
258            let track_outline = outline;
259            let icon = surface_container_highest;
260            (track, thumb, track_outline, icon)
261        };
262
263        // Draw track
264        ui.painter()
265            .rect_filled(track_rect, track_height / 2.0, track_color);
266
267        // Draw track outline (Material 3)
268        if self.show_track_outline && track_outline_color != Color32::TRANSPARENT {
269            ui.painter().rect_stroke(
270                track_rect,
271                track_height / 2.0,
272                Stroke::new(2.0, track_outline_color),
273                StrokeKind::Outside,
274            );
275        }
276
277        // Draw state layer (ripple/overlay effect) - Material 3
278        if self.enabled {
279            let overlay_radius = 20.0; // 40dp diameter / 2
280            let overlay_color = if *self.selected {
281                if is_pressed {
282                    primary_color.linear_multiply(0.1)
283                } else if is_hovered {
284                    primary_color.linear_multiply(0.08)
285                } else if is_focused {
286                    primary_color.linear_multiply(0.1)
287                } else {
288                    Color32::TRANSPARENT
289                }
290            } else {
291                if is_pressed {
292                    on_surface.linear_multiply(0.1)
293                } else if is_hovered {
294                    on_surface.linear_multiply(0.08)
295                } else if is_focused {
296                    on_surface.linear_multiply(0.1)
297                } else {
298                    Color32::TRANSPARENT
299                }
300            };
301
302            if overlay_color != Color32::TRANSPARENT {
303                ui.painter()
304                    .circle_filled(thumb_center, overlay_radius, overlay_color);
305            }
306        }
307
308        // Draw thumb
309        ui.painter()
310            .circle_filled(thumb_center, thumb_size / 2.0, thumb_color);
311
312        // Draw icon on thumb if present
313        let current_icon = if *self.selected {
314            self.selected_icon
315        } else {
316            self.unselected_icon
317        };
318
319        if let Some(icon) = current_icon {
320            let icon_size = 16.0;
321            let icon_font = FontId::proportional(icon_size);
322            
323            ui.painter().text(
324                thumb_center,
325                egui::Align2::CENTER_CENTER,
326                icon.to_string(),
327                icon_font,
328                icon_color,
329            );
330        }
331
332        // Draw label text
333        if let Some(ref text) = self.text {
334            let text_pos = Pos2::new(switch_rect.max.x + 8.0, rect.center().y);
335
336            let text_color = if self.enabled {
337                on_surface
338            } else {
339                on_surface.linear_multiply(0.38)
340            };
341
342            ui.painter().text(
343                text_pos,
344                egui::Align2::LEFT_CENTER,
345                text,
346                egui::FontId::default(),
347                text_color,
348            );
349        }
350
351        response
352    }
353}
354
355pub fn switch(selected: &mut bool) -> MaterialSwitch<'_> {
356    MaterialSwitch::new(selected)
357}