Skip to main content

egui_macview/controls/
toggle.rs

1//! Authentic macOS Toggle / Switch.
2//!
3//! Grounded in macOS HIG and W3C DTCG tokens via [`MacTheme`].
4
5use crate::theme::MacTheme;
6use egui::{CornerRadius, Pos2, Response, Sense, Stroke, StrokeKind, Ui, Vec2, Widget};
7
8/// Authentic macOS Toggle / Switch.
9///
10/// Features animated sliding knob via `CASpring`, smooth green activation color transition,
11/// ambient drop shadow, and theme-driven metrics.
12///
13/// # Example
14///
15/// ```rust
16/// use egui_macview::{MacTheme, MacToggle};
17///
18/// # let ctx = egui::Context::default();
19/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
20/// let theme = MacTheme::default();
21/// let mut enabled = true;
22///
23/// ui.add(MacToggle::new(&mut enabled).theme(&theme));
24/// # });
25/// # output.textures_delta.clear();
26/// ```
27pub struct MacToggle<'a> {
28    value: &'a mut bool,
29    enabled: bool,
30    theme: Option<&'a MacTheme>,
31}
32
33impl<'a> MacToggle<'a> {
34    /// Creates a new toggle switch bound to a mutable `bool` reference.
35    pub fn new(value: &'a mut bool) -> Self {
36        Self {
37            value,
38            enabled: true,
39            theme: None,
40        }
41    }
42
43    /// Enables or disables the toggle switch.
44    pub fn enabled(mut self, enabled: bool) -> Self {
45        self.enabled = enabled;
46        self
47    }
48
49    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
50    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
51        self.theme = Some(theme);
52        self
53    }
54}
55
56impl<'a> Widget for MacToggle<'a> {
57    fn ui(self, ui: &mut Ui) -> Response {
58        let fallback_theme;
59        let theme = match self.theme {
60            Some(t) => t,
61            None => {
62                fallback_theme = MacTheme::from_ctx(ui.ctx());
63                &fallback_theme
64            }
65        };
66
67        let metrics = theme.toggle();
68        let height = metrics.height.points();
69        let width = metrics.width.points();
70        let radius = metrics.corner_radius.points() as u8;
71
72        let desired_size = Vec2::new(width, height);
73        let sense = if self.enabled {
74            Sense::click()
75        } else {
76            Sense::hover()
77        };
78        let (rect, mut response) = ui.allocate_exact_size(desired_size, sense);
79        if self.enabled && response.clicked() {
80            *self.value = !*self.value;
81            response.mark_changed();
82        }
83
84        // CASpring thumb interpolation stored in egui temp memory
85        let spring = theme.motion().swiftui_bouncy.to_spring();
86        let now = ui.input(|i| i.time);
87        let spring_key = response.id.with("toggle_caspring");
88        let target_val = if *self.value { 1.0f32 } else { 0.0f32 };
89        let (prev_target, start_val, start_time) = ui.data(|d| {
90            d.get_temp::<(f32, f32, f64)>(spring_key)
91                .unwrap_or((target_val, target_val, now))
92        });
93
94        let anim_progress = if (prev_target - target_val).abs() > f32::EPSILON {
95            let elapsed = (now - start_time).max(0.0) as f32;
96            let cur = start_val + (prev_target - start_val) * spring.position_at(elapsed);
97            ui.data_mut(|d| d.insert_temp(spring_key, (target_val, cur, now)));
98            ui.ctx().request_repaint();
99            cur.clamp(0.0, 1.0)
100        } else {
101            let elapsed = (now - start_time).max(0.0) as f32;
102            if elapsed < spring.settling_duration() {
103                ui.ctx().request_repaint();
104                (start_val + (target_val - start_val) * spring.position_at(elapsed)).clamp(0.0, 1.0)
105            } else {
106                target_val
107            }
108        };
109
110        let alpha_factor = if self.enabled {
111            1.0
112        } else {
113            theme.disabled_opacity()
114        };
115        let painter = ui.painter_at(rect);
116        let bg_color = theme
117            .control()
118            .lerp_to_gamma(theme.system_green(), anim_progress)
119            .gamma_multiply(alpha_factor);
120
121        painter.rect(
122            rect,
123            CornerRadius::same(radius),
124            bg_color,
125            Stroke::new(
126                metrics.border_width.points(),
127                theme.separator().gamma_multiply(alpha_factor),
128            ),
129            StrokeKind::Inside,
130        );
131
132        let knob_radius = metrics.knob_size.points() * 0.5;
133        let x_off = rect.min.x + metrics.knob_offset_off.points() + knob_radius;
134        let x_on = rect.min.x + metrics.knob_offset_on.points() + knob_radius;
135        let knob_x = x_off + (x_on - x_off) * anim_progress;
136        let knob_center = Pos2::new(knob_x, rect.center().y);
137
138        painter.circle_filled(
139            knob_center + Vec2::new(0.0, metrics.border_width.points()),
140            knob_radius,
141            theme.drop_shadow().gamma_multiply(alpha_factor),
142        );
143        painter.circle_filled(
144            knob_center,
145            knob_radius,
146            theme.knob_fill().gamma_multiply(alpha_factor),
147        );
148
149        response
150    }
151}