egui_theme_switch/
lib.rs

1#![deny(clippy::dbg_macro, clippy::unwrap_used)]
2
3//! A *very* pretty theme switch widget for your egui app.
4//! It allows you to choose between dark, light and follow system.
5//!
6#![cfg_attr(doc, doc = include_str!("../doc/preview.md"))]
7//!
8//! ## Example
9//! ```
10//! use egui::ThemePreference;
11//! use egui_theme_switch::global_theme_switch;
12//!
13//! # egui::__run_test_ui(|ui| {
14//! global_theme_switch(ui);
15//! # });
16//! ```
17
18use egui::emath::{Pos2, Rect};
19use egui::epaint::Color32;
20use egui::{Painter, Response, ThemePreference, Ui, Widget};
21
22mod arc;
23mod cogwheel;
24mod moon;
25mod rotated_rect;
26mod sun;
27
28/// A switch control to configure the global theme preference.
29pub fn global_theme_switch(ui: &mut Ui) {
30    let mut preference = ui.ctx().options(|opt| opt.theme_preference);
31    if ui.add(ThemeSwitch::new(&mut preference)).changed() {
32        ui.ctx().set_theme(preference);
33    }
34}
35
36/// A switch control that allows choosing the theme
37/// preference (dark, light or follow system).
38///
39/// ```
40/// use egui::ThemePreference;
41/// use egui_theme_switch::ThemeSwitch;
42///
43/// # egui::__run_test_ui(|ui| {
44/// let mut preference = ThemePreference::System;
45/// if ui.add(ThemeSwitch::new(&mut preference)).changed() {
46///     // ...
47/// }
48/// # });
49/// ```
50#[must_use = "You should put this widget in an ui with `ui.add(widget);`"]
51#[derive(Debug)]
52pub struct ThemeSwitch<'a> {
53    value: &'a mut ThemePreference,
54}
55
56impl<'a> ThemeSwitch<'a> {
57    pub fn new(value: &'a mut ThemePreference) -> Self {
58        Self { value }
59    }
60}
61
62impl Widget for ThemeSwitch<'_> {
63    fn ui(self, ui: &mut crate::Ui) -> crate::Response {
64        static OPTIONS: [SwitchOption<ThemePreference>; 3] = [
65            SwitchOption {
66                value: ThemePreference::System,
67                icon: cogwheel::cogwheel,
68                label: "Follow System",
69            },
70            SwitchOption {
71                value: ThemePreference::Dark,
72                icon: moon::moon,
73                label: "Dark",
74            },
75            SwitchOption {
76                value: ThemePreference::Light,
77                icon: sun::sun,
78                label: "Light",
79            },
80        ];
81        let (update, response) = switch(ui, *self.value, "Theme", &OPTIONS);
82
83        if let Some(value) = update {
84            *self.value = value;
85        }
86
87        response
88    }
89}
90
91#[derive(Debug, Clone)]
92struct SwitchOption<T> {
93    value: T,
94    icon: IconPainter,
95    label: &'static str,
96}
97
98type IconPainter = fn(&Painter, Pos2, f32, Color32);
99
100fn switch<T>(
101    ui: &mut Ui,
102    value: T,
103    label: &str,
104    options: &[SwitchOption<T>],
105) -> (Option<T>, Response)
106where
107    T: PartialEq + Clone,
108{
109    let mut space = space_allocation::allocate_space(ui, options);
110
111    let updated_value = interactivity::update_value_on_click(&mut space, &value);
112    let value = updated_value.clone().unwrap_or(value);
113
114    if ui.is_rect_visible(space.rect) {
115        painting::draw_switch_background(ui, &space);
116        painting::draw_active_indicator(ui, &space, &value);
117
118        for button in &space.buttons {
119            painting::draw_button(ui, button, value == button.option.value);
120        }
121    }
122
123    accessibility::attach_widget_info(ui, &space, label, &value);
124
125    (updated_value, unioned_response(space))
126}
127
128fn unioned_response<T>(space: AllocatedSpace<T>) -> Response {
129    space
130        .buttons
131        .into_iter()
132        .fold(space.response, |r, button| r.union(button.response))
133}
134
135struct AllocatedSpace<T> {
136    response: Response,
137    rect: Rect,
138    buttons: Vec<ButtonSpace<T>>,
139    radius: f32,
140}
141
142struct ButtonSpace<T> {
143    center: Pos2,
144    response: Response,
145    radius: f32,
146    option: SwitchOption<T>,
147}
148
149mod space_allocation {
150    use super::*;
151    use egui::emath::vec2;
152    use egui::{Id, Sense};
153
154    pub(super) fn allocate_space<T>(ui: &mut Ui, options: &[SwitchOption<T>]) -> AllocatedSpace<T>
155    where
156        T: Clone,
157    {
158        let (rect, response, measurements) = allocate_switch(ui, options);
159        let id = response.id;
160
161        // Focusable elements always get an accessible node, so let's ensure that
162        // the parent is set correctly when the responses are created the first time.
163        ui.ctx().with_accessibility_parent(id, || {
164            let buttons = options
165                .iter()
166                .enumerate()
167                .scan(rect, |remaining, (n, option)| {
168                    Some(allocate_button(ui, remaining, id, &measurements, n, option))
169                })
170                .collect();
171
172            AllocatedSpace {
173                response,
174                rect,
175                buttons,
176                radius: measurements.radius,
177            }
178        })
179    }
180
181    fn allocate_switch<T>(
182        ui: &mut Ui,
183        options: &[SwitchOption<T>],
184    ) -> (Rect, Response, SwitchMeasurements) {
185        let diameter = ui.spacing().interact_size.y;
186        let radius = diameter / 2.0;
187        let padding = ui.spacing().button_padding.min_elem();
188        let min_gap = 0.5 * ui.spacing().item_spacing.x;
189        let gap_count = options.len().saturating_sub(1) as f32;
190        let button_count = options.len() as f32;
191
192        let min_size = vec2(
193            button_count * diameter + (gap_count * min_gap) + (2.0 * padding),
194            diameter + (2.0 * padding),
195        );
196        let sense = Sense::focusable_noninteractive();
197        let (rect, response) = ui.allocate_at_least(min_size, sense);
198
199        // The space we're given might be larger so we calculate
200        // the margin based on the allocated rect.
201        let total_gap = rect.width() - (button_count * diameter) - (2.0 * padding);
202        let gap = total_gap / gap_count;
203
204        let measurements = SwitchMeasurements {
205            gap,
206            radius,
207            padding,
208            buttons: options.len(),
209        };
210
211        (rect, response, measurements)
212    }
213
214    struct SwitchMeasurements {
215        gap: f32,
216        radius: f32,
217        padding: f32,
218        buttons: usize,
219    }
220
221    fn allocate_button<T>(
222        ui: &Ui,
223        remaining: &mut Rect,
224        switch_id: Id,
225        measurements: &SwitchMeasurements,
226        n: usize,
227        option: &SwitchOption<T>,
228    ) -> ButtonSpace<T>
229    where
230        T: Clone,
231    {
232        let (rect, center) = partition(remaining, measurements, n);
233        let response = ui.interact(rect, switch_id.with(n), Sense::click());
234        ButtonSpace {
235            center,
236            response,
237            radius: measurements.radius,
238            option: option.clone(),
239        }
240    }
241
242    fn partition(
243        remaining: &mut Rect,
244        measurements: &SwitchMeasurements,
245        n: usize,
246    ) -> (Rect, Pos2) {
247        let (leading, trailing) = offset(n, measurements);
248        let center = remaining.left_center() + vec2(leading + measurements.radius, 0.0);
249        let right = remaining.min.x + leading + 2.0 * measurements.radius + trailing;
250        let (rect, new_remaining) = remaining.split_left_right_at_x(right);
251        *remaining = new_remaining;
252        (rect, center)
253    }
254
255    // Calculates the leading and trailing space for a button.
256    // The gap between buttons is divided up evenly so that the entire
257    // switch is clickable.
258    fn offset(n: usize, measurements: &SwitchMeasurements) -> (f32, f32) {
259        let leading = if n == 0 {
260            measurements.padding
261        } else {
262            measurements.gap / 2.0
263        };
264        let trailing = if n == measurements.buttons - 1 {
265            measurements.padding
266        } else {
267            measurements.gap / 2.0
268        };
269        (leading, trailing)
270    }
271}
272
273mod interactivity {
274    use super::*;
275
276    pub(super) fn update_value_on_click<T>(space: &mut AllocatedSpace<T>, value: &T) -> Option<T>
277    where
278        T: PartialEq + Clone,
279    {
280        let clicked = space
281            .buttons
282            .iter_mut()
283            .find(|b| b.response.clicked())
284            .filter(|b| &b.option.value != value)?;
285        clicked.response.mark_changed();
286        Some(clicked.option.value.clone())
287    }
288}
289
290mod painting {
291    use super::*;
292    use egui::emath::pos2;
293    use egui::epaint::Stroke;
294    use egui::style::WidgetVisuals;
295    use egui::Id;
296
297    pub(super) fn draw_switch_background<T>(ui: &Ui, space: &AllocatedSpace<T>) {
298        let rect = space.rect;
299        let rounding = 0.5 * rect.height();
300        let WidgetVisuals {
301            bg_fill, bg_stroke, ..
302        } = switch_visuals(ui, &space.response);
303        ui.painter().rect(rect, rounding, bg_fill, bg_stroke);
304    }
305
306    fn switch_visuals(ui: &Ui, response: &Response) -> WidgetVisuals {
307        if response.has_focus() {
308            ui.style().visuals.widgets.hovered
309        } else {
310            ui.style().visuals.widgets.inactive
311        }
312    }
313
314    pub(super) fn draw_active_indicator<T: PartialEq>(
315        ui: &Ui,
316        space: &AllocatedSpace<T>,
317        value: &T,
318    ) {
319        let fill = ui.visuals().selection.bg_fill;
320        if let Some(pos) = space
321            .buttons
322            .iter()
323            .find(|button| &button.option.value == value)
324            .map(|button| button.center)
325        {
326            let pos = animate_active_indicator_position(ui, space.response.id, space.rect.min, pos);
327            ui.painter().circle(pos, space.radius, fill, Stroke::NONE);
328        }
329    }
330
331    fn animate_active_indicator_position(ui: &Ui, id: Id, anchor: Pos2, pos: Pos2) -> Pos2 {
332        let animation_time = ui.style().animation_time;
333        // Animate the relative position to prevent
334        // animating the active indicator when the switch itself is moved around.
335        let x = pos.x - anchor.x;
336        let x = anchor.x + ui.ctx().animate_value_with_time(id, x, animation_time);
337        pos2(x, pos.y)
338    }
339
340    pub(super) fn draw_button<T>(ui: &Ui, button: &ButtonSpace<T>, selected: bool) {
341        let visuals = ui.style().interact_selectable(&button.response, selected);
342        let animation_factor = animate_click(ui, &button.response);
343        let radius = animation_factor * button.radius;
344        let icon_radius = 0.5 * radius * animation_factor;
345        let bg_fill = button_fill(&button.response, &visuals);
346
347        let painter = ui.painter();
348        painter.circle(button.center, radius, bg_fill, visuals.bg_stroke);
349        (button.option.icon)(painter, button.center, icon_radius, visuals.fg_stroke.color);
350    }
351
352    // We want to avoid drawing a background when the button is either active itself or was previously active.
353    fn button_fill(response: &Response, visuals: &WidgetVisuals) -> Color32 {
354        if interacted(response) {
355            visuals.bg_fill
356        } else {
357            Color32::TRANSPARENT
358        }
359    }
360
361    fn interacted(response: &Response) -> bool {
362        response.clicked() || response.hovered() || response.has_focus()
363    }
364
365    fn animate_click(ui: &Ui, response: &Response) -> f32 {
366        let ctx = ui.ctx();
367        let animation_time = ui.style().animation_time;
368        let value = if response.is_pointer_button_down_on() {
369            0.9
370        } else {
371            1.0
372        };
373        ctx.animate_value_with_time(response.id, value, animation_time)
374    }
375}
376
377mod accessibility {
378    use super::*;
379    use egui::{WidgetInfo, WidgetType};
380
381    pub(super) fn attach_widget_info<T: PartialEq>(
382        ui: &Ui,
383        space: &AllocatedSpace<T>,
384        label: &str,
385        value: &T,
386    ) {
387        space
388            .response
389            .widget_info(|| radio_group_widget_info(ui, label));
390
391        for button in &space.buttons {
392            let selected = value == &button.option.value;
393            attach_widget_info_to_button(ui, button, selected);
394        }
395    }
396
397    fn attach_widget_info_to_button<T>(ui: &Ui, button: &ButtonSpace<T>, selected: bool) {
398        let response = &button.response;
399        let label = button.option.label;
400        response.widget_info(|| button_widget_info(ui, label, selected));
401        response.clone().on_hover_text(label);
402    }
403
404    fn radio_group_widget_info(ui: &Ui, label: &str) -> WidgetInfo {
405        WidgetInfo::labeled(WidgetType::RadioGroup, ui.is_enabled(), label)
406    }
407
408    fn button_widget_info(ui: &Ui, label: &str, selected: bool) -> WidgetInfo {
409        WidgetInfo::selected(WidgetType::RadioButton, ui.is_enabled(), selected, label)
410    }
411}