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 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 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 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 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 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}