1#![deny(clippy::dbg_macro, clippy::unwrap_used)]
2
3#![cfg_attr(doc, doc = include_str!("../doc/preview.md"))]
7use 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
28pub 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#[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 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 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 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 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 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}