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        let ui_builder = egui::UiBuilder::new().accessibility_parent(id);
162        let ui = ui.new_child(ui_builder);
163
164        let buttons = options
165            .iter()
166            .enumerate()
167            .scan(rect, |remaining, (n, option)| {
168                Some(allocate_button(
169                    &ui,
170                    remaining,
171                    id,
172                    &measurements,
173                    n,
174                    option,
175                ))
176            })
177            .collect();
178
179        AllocatedSpace {
180            response,
181            rect,
182            buttons,
183            radius: measurements.radius,
184        }
185    }
186
187    fn allocate_switch<T>(
188        ui: &mut Ui,
189        options: &[SwitchOption<T>],
190    ) -> (Rect, Response, SwitchMeasurements) {
191        let diameter = ui.spacing().interact_size.y;
192        let radius = diameter / 2.0;
193        let padding = ui.spacing().button_padding.min_elem();
194        let min_gap = 0.5 * ui.spacing().item_spacing.x;
195        let gap_count = options.len().saturating_sub(1) as f32;
196        let button_count = options.len() as f32;
197
198        let min_size = vec2(
199            button_count * diameter + (gap_count * min_gap) + (2.0 * padding),
200            diameter + (2.0 * padding),
201        );
202        let sense = Sense::focusable_noninteractive();
203        let (rect, response) = ui.allocate_at_least(min_size, sense);
204
205        // The space we're given might be larger so we calculate
206        // the margin based on the allocated rect.
207        let total_gap = rect.width() - (button_count * diameter) - (2.0 * padding);
208        let gap = total_gap / gap_count;
209
210        let measurements = SwitchMeasurements {
211            gap,
212            radius,
213            padding,
214            buttons: options.len(),
215        };
216
217        (rect, response, measurements)
218    }
219
220    struct SwitchMeasurements {
221        gap: f32,
222        radius: f32,
223        padding: f32,
224        buttons: usize,
225    }
226
227    fn allocate_button<T>(
228        ui: &Ui,
229        remaining: &mut Rect,
230        switch_id: Id,
231        measurements: &SwitchMeasurements,
232        n: usize,
233        option: &SwitchOption<T>,
234    ) -> ButtonSpace<T>
235    where
236        T: Clone,
237    {
238        let (rect, center) = partition(remaining, measurements, n);
239        let response = ui.interact(rect, switch_id.with(n), Sense::click());
240        ButtonSpace {
241            center,
242            response,
243            radius: measurements.radius,
244            option: option.clone(),
245        }
246    }
247
248    fn partition(
249        remaining: &mut Rect,
250        measurements: &SwitchMeasurements,
251        n: usize,
252    ) -> (Rect, Pos2) {
253        let (leading, trailing) = offset(n, measurements);
254        let center = remaining.left_center() + vec2(leading + measurements.radius, 0.0);
255        let right = remaining.min.x + leading + 2.0 * measurements.radius + trailing;
256        let (rect, new_remaining) = remaining.split_left_right_at_x(right);
257        *remaining = new_remaining;
258        (rect, center)
259    }
260
261    // Calculates the leading and trailing space for a button.
262    // The gap between buttons is divided up evenly so that the entire
263    // switch is clickable.
264    fn offset(n: usize, measurements: &SwitchMeasurements) -> (f32, f32) {
265        let leading = if n == 0 {
266            measurements.padding
267        } else {
268            measurements.gap / 2.0
269        };
270        let trailing = if n == measurements.buttons - 1 {
271            measurements.padding
272        } else {
273            measurements.gap / 2.0
274        };
275        (leading, trailing)
276    }
277}
278
279mod interactivity {
280    use super::*;
281
282    pub(super) fn update_value_on_click<T>(space: &mut AllocatedSpace<T>, value: &T) -> Option<T>
283    where
284        T: PartialEq + Clone,
285    {
286        let clicked = space
287            .buttons
288            .iter_mut()
289            .find(|b| b.response.clicked())
290            .filter(|b| &b.option.value != value)?;
291        clicked.response.mark_changed();
292        Some(clicked.option.value.clone())
293    }
294}
295
296mod painting {
297    use super::*;
298    use egui::emath::pos2;
299    use egui::epaint::Stroke;
300    use egui::style::WidgetVisuals;
301    use egui::{Id, StrokeKind};
302
303    pub(super) fn draw_switch_background<T>(ui: &Ui, space: &AllocatedSpace<T>) {
304        let rect = space.rect;
305        let rounding = 0.5 * rect.height();
306        let WidgetVisuals {
307            bg_fill, bg_stroke, ..
308        } = switch_visuals(ui, &space.response);
309        ui.painter()
310            .rect(rect, rounding, bg_fill, bg_stroke, StrokeKind::Middle);
311    }
312
313    fn switch_visuals(ui: &Ui, response: &Response) -> WidgetVisuals {
314        if response.has_focus() {
315            ui.style().visuals.widgets.hovered
316        } else {
317            ui.style().visuals.widgets.inactive
318        }
319    }
320
321    pub(super) fn draw_active_indicator<T: PartialEq>(
322        ui: &Ui,
323        space: &AllocatedSpace<T>,
324        value: &T,
325    ) {
326        let fill = ui.visuals().selection.bg_fill;
327        if let Some(pos) = space
328            .buttons
329            .iter()
330            .find(|button| &button.option.value == value)
331            .map(|button| button.center)
332        {
333            let pos = animate_active_indicator_position(ui, space.response.id, space.rect.min, pos);
334            ui.painter().circle(pos, space.radius, fill, Stroke::NONE);
335        }
336    }
337
338    fn animate_active_indicator_position(ui: &Ui, id: Id, anchor: Pos2, pos: Pos2) -> Pos2 {
339        let animation_time = ui.style().animation_time;
340        // Animate the relative position to prevent
341        // animating the active indicator when the switch itself is moved around.
342        let x = pos.x - anchor.x;
343        let x = anchor.x + ui.ctx().animate_value_with_time(id, x, animation_time);
344        pos2(x, pos.y)
345    }
346
347    pub(super) fn draw_button<T>(ui: &Ui, button: &ButtonSpace<T>, selected: bool) {
348        let visuals = ui.style().interact_selectable(&button.response, selected);
349        let animation_factor = animate_click(ui, &button.response);
350        let radius = animation_factor * button.radius;
351        let icon_radius = 0.5 * radius * animation_factor;
352        let bg_fill = button_fill(&button.response, &visuals);
353
354        let painter = ui.painter();
355        painter.circle(button.center, radius, bg_fill, visuals.bg_stroke);
356        (button.option.icon)(painter, button.center, icon_radius, visuals.fg_stroke.color);
357    }
358
359    // We want to avoid drawing a background when the button is either active itself or was previously active.
360    fn button_fill(response: &Response, visuals: &WidgetVisuals) -> Color32 {
361        if interacted(response) {
362            visuals.bg_fill
363        } else {
364            Color32::TRANSPARENT
365        }
366    }
367
368    fn interacted(response: &Response) -> bool {
369        response.clicked() || response.hovered() || response.has_focus()
370    }
371
372    fn animate_click(ui: &Ui, response: &Response) -> f32 {
373        let ctx = ui.ctx();
374        let animation_time = ui.style().animation_time;
375        let value = if response.is_pointer_button_down_on() {
376            0.9
377        } else {
378            1.0
379        };
380        ctx.animate_value_with_time(response.id, value, animation_time)
381    }
382}
383
384mod accessibility {
385    use super::*;
386    use egui::{WidgetInfo, WidgetType};
387
388    pub(super) fn attach_widget_info<T: PartialEq>(
389        ui: &Ui,
390        space: &AllocatedSpace<T>,
391        label: &str,
392        value: &T,
393    ) {
394        space
395            .response
396            .widget_info(|| radio_group_widget_info(ui, label));
397
398        for button in &space.buttons {
399            let selected = value == &button.option.value;
400            attach_widget_info_to_button(ui, button, selected);
401        }
402    }
403
404    fn attach_widget_info_to_button<T>(ui: &Ui, button: &ButtonSpace<T>, selected: bool) {
405        let response = &button.response;
406        let label = button.option.label;
407        response.widget_info(|| button_widget_info(ui, label, selected));
408        response.clone().on_hover_text(label);
409    }
410
411    fn radio_group_widget_info(ui: &Ui, label: &str) -> WidgetInfo {
412        WidgetInfo::labeled(WidgetType::RadioGroup, ui.is_enabled(), label)
413    }
414
415    fn button_widget_info(ui: &Ui, label: &str, selected: bool) -> WidgetInfo {
416        WidgetInfo::selected(WidgetType::RadioButton, ui.is_enabled(), selected, label)
417    }
418}