Skip to main content

elegance/
switch.rs

1//! A sliding on/off switch — the boolean toggle for "turn this feature on."
2//!
3//! Visually distinct from [`Checkbox`](crate::Checkbox): a capsule track with
4//! an animated knob that slides between off and on. Use it for settings and
5//! feature flags; use `Checkbox` for "select this item in a list."
6//!
7//! ```no_run
8//! # use elegance::{Accent, Switch};
9//! # egui::__run_test_ui(|ui| {
10//! let mut notify = true;
11//! ui.add(Switch::new(&mut notify, "Notify on Slack").accent(Accent::Green));
12//! # });
13//! ```
14//!
15//! Clicking anywhere on the switch or its label toggles the bound boolean.
16//! The knob transition is animated via [`egui::Context::animate_bool_responsive`].
17
18use egui::{
19    pos2, vec2, Color32, CornerRadius, FontSelection, Response, Sense, Stroke, Ui, Vec2, Widget,
20    WidgetInfo, WidgetText, WidgetType,
21};
22
23use crate::theme::{mix, with_alpha, Theme};
24use crate::Accent;
25
26/// A sliding on/off switch bound to a `&mut bool`.
27#[must_use = "Add this widget with `ui.add(...)`."]
28pub struct Switch<'a> {
29    state: &'a mut bool,
30    label: WidgetText,
31    accent: Accent,
32    enabled: bool,
33}
34
35impl<'a> std::fmt::Debug for Switch<'a> {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("Switch")
38            .field("state", &*self.state)
39            .field("label", &self.label.text())
40            .field("accent", &self.accent)
41            .field("enabled", &self.enabled)
42            .finish()
43    }
44}
45
46impl<'a> Switch<'a> {
47    /// Create a switch bound to `state` with the given label.
48    ///
49    /// Pass `""` for the label if the switch is rendered alongside a
50    /// separately-laid-out caption (e.g., in a settings row).
51    pub fn new(state: &'a mut bool, label: impl Into<WidgetText>) -> Self {
52        Self {
53            state,
54            label: label.into(),
55            accent: Accent::Sky,
56            enabled: true,
57        }
58    }
59
60    /// Colour the "on" state with the given accent. Default: [`Accent::Sky`].
61    pub fn accent(mut self, accent: Accent) -> Self {
62        self.accent = accent;
63        self
64    }
65
66    /// Disable the switch. Disabled switches do not respond to clicks and
67    /// render with muted colours.
68    pub fn enabled(mut self, enabled: bool) -> Self {
69        self.enabled = enabled;
70        self
71    }
72}
73
74impl<'a> Widget for Switch<'a> {
75    fn ui(self, ui: &mut Ui) -> Response {
76        let theme = Theme::current(ui.ctx());
77        let p = &theme.palette;
78        let t = &theme.typography;
79
80        let track_w: f32 = 32.0;
81        let track_h: f32 = 18.0;
82        let knob_pad: f32 = 2.0;
83        let knob_d: f32 = track_h - knob_pad * 2.0;
84        let gap: f32 = 8.0;
85
86        let label_text = self.label.text();
87        let has_label = !label_text.is_empty();
88
89        let galley = has_label.then(|| {
90            egui::WidgetText::from(egui::RichText::new(label_text).color(p.text).size(t.body))
91                .into_galley(
92                    ui,
93                    Some(egui::TextWrapMode::Extend),
94                    ui.available_width(),
95                    FontSelection::FontId(egui::FontId::proportional(t.body)),
96                )
97        });
98
99        let text_size = galley.as_ref().map_or(Vec2::ZERO, |g| g.size());
100        let desired = vec2(
101            track_w + if has_label { gap + text_size.x } else { 0.0 },
102            track_h.max(text_size.y),
103        );
104
105        let sense = if self.enabled {
106            Sense::click()
107        } else {
108            Sense::hover()
109        };
110        let (rect, mut response) = ui.allocate_exact_size(desired, sense);
111
112        if self.enabled && response.clicked() {
113            *self.state = !*self.state;
114            response.mark_changed();
115        }
116
117        if ui.is_rect_visible(rect) {
118            let on = *self.state;
119            let progress = ui.ctx().animate_bool_responsive(response.id, on);
120
121            let track_rect = egui::Rect::from_min_size(
122                pos2(rect.min.x, rect.center().y - track_h * 0.5),
123                vec2(track_w, track_h),
124            );
125
126            let accent = p.accent_fill(self.accent);
127            let hovered = self.enabled && response.hovered();
128
129            let off_fill = p.input_bg;
130            let track_fill = if !self.enabled {
131                with_alpha(off_fill, 160)
132            } else {
133                mix(off_fill, accent, progress)
134            };
135            let stroke_color = if !self.enabled {
136                with_alpha(p.border, 160)
137            } else if progress > 0.05 {
138                mix(p.border, accent, progress)
139            } else if hovered {
140                p.sky
141            } else {
142                p.border
143            };
144
145            ui.painter().rect(
146                track_rect,
147                CornerRadius::same((track_h * 0.5) as u8),
148                track_fill,
149                Stroke::new(1.0, stroke_color),
150                egui::StrokeKind::Inside,
151            );
152
153            let travel = track_w - knob_d - knob_pad * 2.0;
154            let knob_center = pos2(
155                track_rect.min.x + knob_pad + knob_d * 0.5 + travel * progress,
156                track_rect.center().y,
157            );
158            let knob_color = if self.enabled {
159                if p.is_dark {
160                    Color32::WHITE
161                } else {
162                    // Light themes: off-track is white, so a white knob would
163                    // vanish. Fade a muted-grey knob to white as the switch
164                    // turns on, where the track turns saturated.
165                    mix(p.text_muted, Color32::WHITE, progress)
166                }
167            } else {
168                p.text_muted
169            };
170            ui.painter()
171                .circle_filled(knob_center, knob_d * 0.5, knob_color);
172
173            if let Some(g) = galley {
174                let text_pos = pos2(track_rect.max.x + gap, rect.center().y - text_size.y * 0.5);
175                let color = if self.enabled { p.text } else { p.text_faint };
176                ui.painter().galley(text_pos, g, color);
177            }
178        }
179
180        response.widget_info(|| {
181            WidgetInfo::labeled(WidgetType::Checkbox, self.enabled, self.label.text())
182        });
183        response
184    }
185}