Skip to main content

egui_material3/
slider.rs

1use crate::get_global_color;
2use eframe::egui::{self, Color32, FontId, Pos2, Rect, Response, Sense, Ui, Vec2, Widget};
3use std::ops::RangeInclusive;
4
5/// Interaction modes for sliders
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum SliderInteraction {
8    /// Allow both tapping and sliding (default)
9    TapAndSlide,
10    /// Only allow tapping to set value
11    TapOnly,
12    /// Only allow sliding from current position
13    SlideOnly,
14    /// Only allow sliding the thumb itself
15    SlideThumb,
16}
17
18impl Default for SliderInteraction {
19    fn default() -> Self {
20        Self::TapAndSlide
21    }
22}
23
24/// Thumb shape variants
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub enum ThumbShape {
27    /// Round thumb (classic Material Design)
28    Round,
29    /// Handle thumb (Material Design 3 2024)
30    Handle,
31}
32
33impl Default for ThumbShape {
34    fn default() -> Self {
35        Self::Round
36    }
37}
38
39/// Range values for RangeSlider
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct RangeValues {
42    pub start: f32,
43    pub end: f32,
44}
45
46impl RangeValues {
47    pub fn new(start: f32, end: f32) -> Self {
48        Self { start, end }
49    }
50}
51
52/// Material Design slider component following Material Design 3 specifications
53///
54/// Sliders allow users to make selections from a range of values. They're ideal for
55/// adjusting settings such as volume, brightness, or applying image filters.
56pub struct MaterialSlider<'a> {
57    /// Mutable reference to the slider value
58    value: &'a mut f32,
59    /// Valid range of values for the slider
60    range: RangeInclusive<f32>,
61    /// Optional text label displayed above the slider
62    text: Option<String>,
63    /// Whether the slider is interactive (enabled/disabled)
64    enabled: bool,
65    /// Custom width for the slider (None uses available width)
66    width: Option<f32>,
67    /// Optional step increment for discrete values
68    step: Option<f32>,
69    /// Whether to show the current value next to the slider
70    show_value: bool,
71    /// Secondary track value (e.g., for buffering indicators)
72    secondary_track_value: Option<f32>,
73    /// Whether to show value indicator while dragging
74    show_value_indicator: bool,
75    /// Interaction mode
76    interaction_mode: SliderInteraction,
77    /// Thumb shape
78    thumb_shape: ThumbShape,
79    /// Custom overlay/ripple color
80    overlay_color: Option<Color32>,
81    /// Custom thumb color
82    thumb_color: Option<Color32>,
83    /// Secondary active track color
84    secondary_active_color: Option<Color32>,
85}
86
87impl<'a> MaterialSlider<'a> {
88    pub fn new(value: &'a mut f32, range: RangeInclusive<f32>) -> Self {
89        Self {
90            value,
91            range,
92            text: None,
93            enabled: true,
94            width: None,
95            step: None,
96            show_value: true,
97            secondary_track_value: None,
98            show_value_indicator: false,
99            interaction_mode: SliderInteraction::default(),
100            thumb_shape: ThumbShape::default(),
101            overlay_color: None,
102            thumb_color: None,
103            secondary_active_color: None,
104        }
105    }
106
107    pub fn text(mut self, text: impl Into<String>) -> Self {
108        self.text = Some(text.into());
109        self
110    }
111
112    pub fn enabled(mut self, enabled: bool) -> Self {
113        self.enabled = enabled;
114        self
115    }
116
117    pub fn width(mut self, width: f32) -> Self {
118        self.width = Some(width);
119        self
120    }
121
122    pub fn step(mut self, step: f32) -> Self {
123        self.step = Some(step);
124        self
125    }
126
127    pub fn show_value(mut self, show_value: bool) -> Self {
128        self.show_value = show_value;
129        self
130    }
131
132    pub fn secondary_track_value(mut self, value: f32) -> Self {
133        self.secondary_track_value = Some(value);
134        self
135    }
136
137    pub fn show_value_indicator(mut self, show: bool) -> Self {
138        self.show_value_indicator = show;
139        self
140    }
141
142    pub fn interaction_mode(mut self, mode: SliderInteraction) -> Self {
143        self.interaction_mode = mode;
144        self
145    }
146
147    pub fn thumb_shape(mut self, shape: ThumbShape) -> Self {
148        self.thumb_shape = shape;
149        self
150    }
151
152    pub fn overlay_color(mut self, color: Color32) -> Self {
153        self.overlay_color = Some(color);
154        self
155    }
156
157    pub fn thumb_color(mut self, color: Color32) -> Self {
158        self.thumb_color = Some(color);
159        self
160    }
161
162    pub fn secondary_active_color(mut self, color: Color32) -> Self {
163        self.secondary_active_color = Some(color);
164        self
165    }
166}
167
168impl<'a> Widget for MaterialSlider<'a> {
169    fn ui(self, ui: &mut Ui) -> Response {
170        let slider_width = self.width.unwrap_or(200.0);
171        let height = 48.0;
172
173        let desired_size = if self.text.is_some() || self.show_value {
174            Vec2::new(slider_width + 100.0, height)
175        } else {
176            Vec2::new(slider_width, height)
177        };
178
179        let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click_and_drag());
180
181        // Material Design colors
182        let primary_color = get_global_color("primary");
183        let surface_variant = get_global_color("surfaceVariant");
184        let on_surface = get_global_color("onSurface");
185        let on_surface_variant = get_global_color("onSurfaceVariant");
186
187        // Calculate slider track area
188        let track_rect = Rect::from_min_size(
189            Pos2::new(rect.min.x, rect.center().y - 2.0),
190            Vec2::new(slider_width, 4.0),
191        );
192
193        let old_value = *self.value;
194
195        // Handle interaction based on mode
196        let can_interact = match self.interaction_mode {
197            SliderInteraction::TapAndSlide => response.clicked() || response.dragged(),
198            SliderInteraction::TapOnly => response.clicked(),
199            SliderInteraction::SlideOnly => response.dragged(),
200            SliderInteraction::SlideThumb => {
201                // Check if mouse is over thumb
202                let normalized_value =
203                    (*self.value - self.range.start()) / (self.range.end() - self.range.start());
204                let normalized_value = normalized_value.clamp(0.0, 1.0);
205                let thumb_x = track_rect.min.x + normalized_value * track_rect.width();
206                let thumb_center = Pos2::new(thumb_x, track_rect.center().y);
207                
208                if let Some(mouse_pos) = response.interact_pointer_pos() {
209                    let dist = (mouse_pos - thumb_center).length();
210                    response.dragged() && dist < 20.0
211                } else {
212                    false
213                }
214            }
215        };
216
217        if can_interact && self.enabled {
218            if let Some(mouse_pos) = response.interact_pointer_pos() {
219                let normalized =
220                    ((mouse_pos.x - track_rect.min.x) / track_rect.width()).clamp(0.0, 1.0);
221                let mut new_value =
222                    *self.range.start() + normalized * (self.range.end() - self.range.start());
223
224                // Apply step if specified
225                if let Some(step) = self.step {
226                    new_value = (new_value / step).round() * step;
227                }
228
229                *self.value = new_value.clamp(*self.range.start(), *self.range.end());
230                if (*self.value - old_value).abs() > f32::EPSILON {
231                    response.mark_changed();
232                }
233            }
234        }
235
236        if !self.enabled {
237            response = response.on_disabled_hover_text("Slider is disabled");
238        }
239
240        // Calculate positions
241        let normalized_value =
242            (*self.value - self.range.start()) / (self.range.end() - self.range.start());
243        let normalized_value = normalized_value.clamp(0.0, 1.0);
244        let thumb_x = track_rect.min.x + normalized_value * track_rect.width();
245        let thumb_center = Pos2::new(thumb_x, track_rect.center().y);
246
247        // Determine colors based on state
248        let effective_thumb_color = self.thumb_color.unwrap_or(primary_color);
249        let (track_active_color, track_inactive_color, thumb_color) = if !self.enabled {
250            let disabled_color = get_global_color("onSurface").linear_multiply(0.38);
251            (disabled_color, disabled_color, disabled_color)
252        } else if response.hovered() || response.dragged() {
253            (
254                Color32::from_rgba_premultiplied(
255                    primary_color.r(),
256                    primary_color.g(),
257                    primary_color.b(),
258                    200,
259                ),
260                surface_variant,
261                Color32::from_rgba_premultiplied(
262                    effective_thumb_color.r().saturating_add(20),
263                    effective_thumb_color.g().saturating_add(20),
264                    effective_thumb_color.b().saturating_add(20),
265                    255,
266                ),
267            )
268        } else {
269            (primary_color, surface_variant, effective_thumb_color)
270        };
271
272        // Draw inactive track
273        ui.painter()
274            .rect_filled(track_rect, 2.0, track_inactive_color);
275
276        // Draw secondary track if specified
277        if let Some(secondary_value) = self.secondary_track_value {
278            let secondary_normalized =
279                (secondary_value - self.range.start()) / (self.range.end() - self.range.start());
280            let secondary_normalized = secondary_normalized.clamp(0.0, 1.0);
281            let secondary_x = track_rect.min.x + secondary_normalized * track_rect.width();
282            
283            if secondary_x > thumb_x {
284                let secondary_rect = Rect::from_min_size(
285                    Pos2::new(thumb_x, track_rect.min.y),
286                    Vec2::new(secondary_x - thumb_x, track_rect.height()),
287                );
288                let secondary_color = self.secondary_active_color.unwrap_or_else(|| {
289                    Color32::from_rgba_premultiplied(
290                        primary_color.r(),
291                        primary_color.g(),
292                        primary_color.b(),
293                        128,
294                    )
295                });
296                ui.painter().rect_filled(secondary_rect, 2.0, secondary_color);
297            }
298        }
299
300        // Draw active track (from start to thumb)
301        let active_track_rect = Rect::from_min_size(
302            track_rect.min,
303            Vec2::new(thumb_x - track_rect.min.x, track_rect.height()),
304        );
305
306        if active_track_rect.width() > 0.0 {
307            ui.painter()
308                .rect_filled(active_track_rect, 2.0, track_active_color);
309        }
310
311        // Draw thumb based on shape
312        match self.thumb_shape {
313            ThumbShape::Round => {
314                let thumb_radius = if response.hovered() || response.dragged() {
315                    12.0
316                } else {
317                    10.0
318                };
319                ui.painter()
320                    .circle_filled(thumb_center, thumb_radius, thumb_color);
321            }
322            ThumbShape::Handle => {
323                // Handle shape: rounded rectangle
324                let handle_width = if response.hovered() || response.dragged() {
325                    8.0
326                } else {
327                    4.0
328                };
329                let handle_height = 20.0;
330                let handle_rect = Rect::from_center_size(
331                    thumb_center,
332                    Vec2::new(handle_width, handle_height),
333                );
334                ui.painter().rect_filled(handle_rect, 2.0, thumb_color);
335            }
336        }
337
338        // Add ripple effect when interacting
339        if response.hovered() && self.enabled {
340            let ripple_color = self.overlay_color.unwrap_or_else(|| {
341                Color32::from_rgba_premultiplied(
342                    primary_color.r(),
343                    primary_color.g(),
344                    primary_color.b(),
345                    30,
346                )
347            });
348            let ripple_radius = match self.thumb_shape {
349                ThumbShape::Round => 28.0,
350                ThumbShape::Handle => 24.0,
351            };
352            ui.painter()
353                .circle_filled(thumb_center, ripple_radius, ripple_color);
354        }
355
356        // Draw value indicator if enabled and dragging
357        if self.show_value_indicator && response.dragged() && self.enabled {
358            let value_text = if let Some(step) = self.step {
359                if step >= 1.0 {
360                    format!("{:.0}", *self.value)
361                } else {
362                    format!("{:.2}", *self.value)
363                }
364            } else {
365                format!("{:.2}", *self.value)
366            };
367
368            // Simple rectangle indicator
369            let indicator_font = FontId::proportional(12.0);
370            let galley = ui.fonts(|f| f.layout_no_wrap(value_text, indicator_font, on_surface));
371            let indicator_size = Vec2::new(galley.size().x + 16.0, galley.size().y + 8.0);
372            let indicator_pos = Pos2::new(
373                thumb_center.x - indicator_size.x / 2.0,
374                thumb_center.y - indicator_size.y - 16.0,
375            );
376            let indicator_rect = Rect::from_min_size(indicator_pos, indicator_size);
377
378            // Draw indicator background
379            ui.painter().rect_filled(
380                indicator_rect,
381                4.0,
382                primary_color,
383            );
384
385            // Draw indicator text
386            ui.painter().galley(
387                Pos2::new(
388                    indicator_rect.center().x - galley.size().x / 2.0,
389                    indicator_rect.center().y - galley.size().y / 2.0,
390                ),
391                galley,
392                Color32::WHITE,
393            );
394        }
395
396        // Draw label text
397        if let Some(ref text) = self.text {
398            let text_pos = Pos2::new(track_rect.max.x + 16.0, rect.center().y - 16.0);
399            let text_color = if self.enabled {
400                on_surface
401            } else {
402                get_global_color("onSurface").linear_multiply(0.38)
403            };
404
405            ui.painter().text(
406                text_pos,
407                egui::Align2::LEFT_CENTER,
408                text,
409                egui::FontId::default(),
410                text_color,
411            );
412        }
413
414        // Draw value
415        if self.show_value {
416            let value_text = if let Some(step) = self.step {
417                if step >= 1.0 {
418                    format!("{:.0}", *self.value)
419                } else {
420                    format!("{:.2}", *self.value)
421                }
422            } else {
423                format!("{:.2}", *self.value)
424            };
425
426            let value_pos = Pos2::new(
427                track_rect.max.x + 16.0,
428                rect.center().y + if self.text.is_some() { 8.0 } else { 0.0 },
429            );
430
431            let value_color = if self.enabled {
432                on_surface_variant
433            } else {
434                get_global_color("onSurface").linear_multiply(0.38)
435            };
436
437            ui.painter().text(
438                value_pos,
439                egui::Align2::LEFT_CENTER,
440                &value_text,
441                egui::FontId::proportional(12.0),
442                value_color,
443            );
444        }
445
446        response
447    }
448}
449
450/// Range Slider component for selecting a range of values
451pub struct MaterialRangeSlider<'a> {
452    values: &'a mut RangeValues,
453    range: RangeInclusive<f32>,
454    text: Option<String>,
455    enabled: bool,
456    width: Option<f32>,
457    step: Option<f32>,
458    show_values: bool,
459    show_value_indicator: bool,
460    thumb_shape: ThumbShape,
461    min_separation: f32,
462}
463
464impl<'a> MaterialRangeSlider<'a> {
465    pub fn new(values: &'a mut RangeValues, range: RangeInclusive<f32>) -> Self {
466        Self {
467            values,
468            range,
469            text: None,
470            enabled: true,
471            width: None,
472            step: None,
473            show_values: true,
474            show_value_indicator: false,
475            thumb_shape: ThumbShape::default(),
476            min_separation: 0.0,
477        }
478    }
479
480    pub fn text(mut self, text: impl Into<String>) -> Self {
481        self.text = Some(text.into());
482        self
483    }
484
485    pub fn enabled(mut self, enabled: bool) -> Self {
486        self.enabled = enabled;
487        self
488    }
489
490    pub fn width(mut self, width: f32) -> Self {
491        self.width = Some(width);
492        self
493    }
494
495    pub fn step(mut self, step: f32) -> Self {
496        self.step = Some(step);
497        self
498    }
499
500    pub fn show_values(mut self, show: bool) -> Self {
501        self.show_values = show;
502        self
503    }
504
505    pub fn show_value_indicator(mut self, show: bool) -> Self {
506        self.show_value_indicator = show;
507        self
508    }
509
510    pub fn thumb_shape(mut self, shape: ThumbShape) -> Self {
511        self.thumb_shape = shape;
512        self
513    }
514
515    pub fn min_separation(mut self, separation: f32) -> Self {
516        self.min_separation = separation;
517        self
518    }
519}
520
521impl<'a> Widget for MaterialRangeSlider<'a> {
522    fn ui(self, ui: &mut Ui) -> Response {
523        let slider_width = self.width.unwrap_or(200.0);
524        let height = 48.0;
525
526        let desired_size = if self.text.is_some() || self.show_values {
527            Vec2::new(slider_width + 120.0, height)
528        } else {
529            Vec2::new(slider_width, height)
530        };
531
532        let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click_and_drag());
533
534        // Material Design colors
535        let primary_color = get_global_color("primary");
536        let surface_variant = get_global_color("surfaceVariant");
537        let on_surface = get_global_color("onSurface");
538        let on_surface_variant = get_global_color("onSurfaceVariant");
539
540        // Calculate slider track area
541        let track_rect = Rect::from_min_size(
542            Pos2::new(rect.min.x, rect.center().y - 2.0),
543            Vec2::new(slider_width, 4.0),
544        );
545
546        // Handle interaction
547        if response.dragged() && self.enabled {
548            if let Some(mouse_pos) = response.interact_pointer_pos() {
549                let normalized =
550                    ((mouse_pos.x - track_rect.min.x) / track_rect.width()).clamp(0.0, 1.0);
551                let mut new_value =
552                    *self.range.start() + normalized * (self.range.end() - self.range.start());
553
554                // Apply step if specified
555                if let Some(step) = self.step {
556                    new_value = (new_value / step).round() * step;
557                }
558
559                // Determine which thumb is closer
560                let dist_to_start = (new_value - self.values.start).abs();
561                let dist_to_end = (new_value - self.values.end).abs();
562
563                if dist_to_start < dist_to_end {
564                    // Move start thumb
565                    self.values.start = new_value.clamp(
566                        *self.range.start(),
567                        (self.values.end - self.min_separation).min(*self.range.end()),
568                    );
569                } else {
570                    // Move end thumb
571                    self.values.end = new_value.clamp(
572                        (self.values.start + self.min_separation).max(*self.range.start()),
573                        *self.range.end(),
574                    );
575                }
576            }
577        }
578
579        // Calculate thumb positions
580        let start_normalized =
581            (self.values.start - self.range.start()) / (self.range.end() - self.range.start());
582        let start_normalized = start_normalized.clamp(0.0, 1.0);
583        let start_x = track_rect.min.x + start_normalized * track_rect.width();
584        let start_center = Pos2::new(start_x, track_rect.center().y);
585
586        let end_normalized =
587            (self.values.end - self.range.start()) / (self.range.end() - self.range.start());
588        let end_normalized = end_normalized.clamp(0.0, 1.0);
589        let end_x = track_rect.min.x + end_normalized * track_rect.width();
590        let end_center = Pos2::new(end_x, track_rect.center().y);
591
592        // Determine colors
593        let (track_active_color, track_inactive_color, thumb_color) = if !self.enabled {
594            let disabled_color = get_global_color("onSurface").linear_multiply(0.38);
595            (disabled_color, disabled_color, disabled_color)
596        } else if response.hovered() || response.dragged() {
597            (
598                Color32::from_rgba_premultiplied(
599                    primary_color.r(),
600                    primary_color.g(),
601                    primary_color.b(),
602                    200,
603                ),
604                surface_variant,
605                primary_color,
606            )
607        } else {
608            (primary_color, surface_variant, primary_color)
609        };
610
611        // Draw inactive track (full width)
612        ui.painter()
613            .rect_filled(track_rect, 2.0, track_inactive_color);
614
615        // Draw active track (between thumbs)
616        let active_track_rect = Rect::from_min_size(
617            Pos2::new(start_x, track_rect.min.y),
618            Vec2::new(end_x - start_x, track_rect.height()),
619        );
620
621        if active_track_rect.width() > 0.0 {
622            ui.painter()
623                .rect_filled(active_track_rect, 2.0, track_active_color);
624        }
625
626        // Draw thumbs
627        let thumb_radius = if response.hovered() || response.dragged() {
628            12.0
629        } else {
630            10.0
631        };
632
633        match self.thumb_shape {
634            ThumbShape::Round => {
635                ui.painter()
636                    .circle_filled(start_center, thumb_radius, thumb_color);
637                ui.painter()
638                    .circle_filled(end_center, thumb_radius, thumb_color);
639            }
640            ThumbShape::Handle => {
641                let handle_width = if response.hovered() || response.dragged() {
642                    8.0
643                } else {
644                    4.0
645                };
646                let handle_height = 20.0;
647
648                let start_handle_rect = Rect::from_center_size(
649                    start_center,
650                    Vec2::new(handle_width, handle_height),
651                );
652                ui.painter()
653                    .rect_filled(start_handle_rect, 2.0, thumb_color);
654
655                let end_handle_rect = Rect::from_center_size(
656                    end_center,
657                    Vec2::new(handle_width, handle_height),
658                );
659                ui.painter()
660                    .rect_filled(end_handle_rect, 2.0, thumb_color);
661            }
662        }
663
664        // Add ripple effects
665        if response.hovered() && self.enabled {
666            let ripple_color = Color32::from_rgba_premultiplied(
667                primary_color.r(),
668                primary_color.g(),
669                primary_color.b(),
670                30,
671            );
672            ui.painter()
673                .circle_filled(start_center, 28.0, ripple_color);
674            ui.painter()
675                .circle_filled(end_center, 28.0, ripple_color);
676        }
677
678        // Draw label
679        if let Some(ref text) = self.text {
680            let text_pos = Pos2::new(track_rect.max.x + 16.0, rect.center().y - 16.0);
681            let text_color = if self.enabled {
682                on_surface
683            } else {
684                get_global_color("onSurface").linear_multiply(0.38)
685            };
686
687            ui.painter().text(
688                text_pos,
689                egui::Align2::LEFT_CENTER,
690                text,
691                egui::FontId::default(),
692                text_color,
693            );
694        }
695
696        // Draw values
697        if self.show_values {
698            let format_value = |value: f32| {
699                if let Some(step) = self.step {
700                    if step >= 1.0 {
701                        format!("{:.0}", value)
702                    } else {
703                        format!("{:.2}", value)
704                    }
705                } else {
706                    format!("{:.2}", value)
707                }
708            };
709
710            let value_text = format!(
711                "{} - {}",
712                format_value(self.values.start),
713                format_value(self.values.end)
714            );
715
716            let value_pos = Pos2::new(
717                track_rect.max.x + 16.0,
718                rect.center().y + if self.text.is_some() { 8.0 } else { 0.0 },
719            );
720
721            let value_color = if self.enabled {
722                on_surface_variant
723            } else {
724                get_global_color("onSurface").linear_multiply(0.38)
725            };
726
727            ui.painter().text(
728                value_pos,
729                egui::Align2::LEFT_CENTER,
730                &value_text,
731                egui::FontId::proportional(12.0),
732                value_color,
733            );
734        }
735
736        response
737    }
738}
739
740pub fn slider<'a>(value: &'a mut f32, range: RangeInclusive<f32>) -> MaterialSlider<'a> {
741    MaterialSlider::new(value, range)
742}
743
744pub fn range_slider<'a>(
745    values: &'a mut RangeValues,
746    range: RangeInclusive<f32>,
747) -> MaterialRangeSlider<'a> {
748    MaterialRangeSlider::new(values, range)
749}