Skip to main content

elegance/
slider.rs

1//! Horizontal numeric slider with a pill track, accent-coloured fill, and
2//! an optional right-aligned value display.
3//!
4//! The widget is generic over [`egui::emath::Numeric`], so any built-in
5//! numeric type (`f32`, `f64`, integer types) can be used directly.
6
7use std::ops::RangeInclusive;
8
9use egui::{
10    emath::Numeric, CornerRadius, CursorIcon, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui,
11    Vec2, Widget, WidgetInfo, WidgetText, WidgetType,
12};
13
14use crate::theme::{with_alpha, Accent, Theme};
15
16/// A horizontal numeric slider.
17///
18/// ```no_run
19/// # use elegance::Slider;
20/// # egui::__run_test_ui(|ui| {
21/// let mut cpu = 42.0_f32;
22/// ui.add(Slider::new(&mut cpu, 0.0..=100.0).label("CPU limit").suffix("%"));
23/// # });
24/// ```
25#[must_use = "Add with `ui.add(...)`."]
26pub struct Slider<'a, T: Numeric> {
27    value: &'a mut T,
28    range: RangeInclusive<T>,
29    label: Option<WidgetText>,
30    suffix: String,
31    decimals: Option<usize>,
32    value_fmt: Option<Box<dyn Fn(f64) -> String + 'a>>,
33    show_value: bool,
34    step: Option<f64>,
35    accent: Accent,
36    desired_width: Option<f32>,
37}
38
39impl<'a, T: Numeric> std::fmt::Debug for Slider<'a, T> {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("Slider")
42            .field("range_lo", &self.range.start().to_f64())
43            .field("range_hi", &self.range.end().to_f64())
44            .field("suffix", &self.suffix)
45            .field("decimals", &self.decimals)
46            .field("show_value", &self.show_value)
47            .field("step", &self.step)
48            .field("accent", &self.accent)
49            .field("desired_width", &self.desired_width)
50            .finish()
51    }
52}
53
54impl<'a, T: Numeric> Slider<'a, T> {
55    /// Create a slider bound to `value`, constrained to `range`.
56    pub fn new(value: &'a mut T, range: RangeInclusive<T>) -> Self {
57        Self {
58            value,
59            range,
60            label: None,
61            suffix: String::new(),
62            decimals: None,
63            value_fmt: None,
64            show_value: true,
65            step: None,
66            accent: Accent::Sky,
67            desired_width: None,
68        }
69    }
70
71    /// Show a label above the slider.
72    pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
73        self.label = Some(label.into());
74        self
75    }
76
77    /// Suffix appended to the formatted value (e.g. `"%"`, `" dB"`).
78    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
79        self.suffix = suffix.into();
80        self
81    }
82
83    /// Number of decimal places in the value display. Defaults to `0` for
84    /// integer-typed sliders and `2` for float-typed.
85    pub fn decimals(mut self, n: usize) -> Self {
86        self.decimals = Some(n);
87        self
88    }
89
90    /// Supply a custom formatter for the value display. Overrides
91    /// [`suffix`](Self::suffix) and [`decimals`](Self::decimals).
92    pub fn value_fmt(mut self, fmt: impl Fn(f64) -> String + 'a) -> Self {
93        self.value_fmt = Some(Box::new(fmt));
94        self
95    }
96
97    /// Hide the right-aligned value display.
98    pub fn show_value(mut self, show: bool) -> Self {
99        self.show_value = show;
100        self
101    }
102
103    /// Snap the value to multiples of `step` (in the slider's value units).
104    /// Integer-typed sliders snap to `1.0` automatically unless overridden.
105    pub fn step(mut self, step: f64) -> Self {
106        self.step = Some(step);
107        self
108    }
109
110    /// Pick the fill colour from one of the theme accents. Default: [`Accent::Sky`].
111    pub fn accent(mut self, accent: Accent) -> Self {
112        self.accent = accent;
113        self
114    }
115
116    /// Override the slider width. Defaults to `ui.available_width()`.
117    pub fn desired_width(mut self, width: f32) -> Self {
118        self.desired_width = Some(width);
119        self
120    }
121
122    fn format_value(&self, v: f64) -> String {
123        if let Some(fmt) = &self.value_fmt {
124            return fmt(v);
125        }
126        let n = self.decimals.unwrap_or(if T::INTEGRAL { 0 } else { 2 });
127        if self.suffix.is_empty() {
128            format!("{v:.n$}")
129        } else {
130            format!("{v:.n$}{}", self.suffix)
131        }
132    }
133}
134
135impl<'a, T: Numeric> Widget for Slider<'a, T> {
136    fn ui(self, ui: &mut Ui) -> Response {
137        let theme = Theme::current(ui.ctx());
138        let p = &theme.palette;
139        let t = &theme.typography;
140        let accent_fill = p.accent_fill(self.accent);
141
142        let lo_raw = self.range.start().to_f64();
143        let hi_raw = self.range.end().to_f64();
144        let (lo, hi) = if lo_raw <= hi_raw {
145            (lo_raw, hi_raw)
146        } else {
147            (hi_raw, lo_raw)
148        };
149
150        let mut current = self.value.to_f64();
151        if current.is_nan() {
152            current = lo;
153        }
154        current = current.clamp(lo, hi);
155
156        let step = self.step.or(if T::INTEGRAL { Some(1.0) } else { None });
157
158        let track_h: f32 = 6.0;
159        let thumb_d: f32 = 14.0;
160        let row_h = thumb_d.max(t.label + 2.0);
161        let value_gap: f32 = 10.0;
162
163        let value_reserve = if self.show_value {
164            let lo_text = self.format_value(lo);
165            let hi_text = self.format_value(hi);
166            let w_lo =
167                crate::theme::placeholder_galley(ui, &lo_text, t.label, false, f32::INFINITY)
168                    .size()
169                    .x;
170            let w_hi =
171                crate::theme::placeholder_galley(ui, &hi_text, t.label, false, f32::INFINITY)
172                    .size()
173                    .x;
174            w_lo.max(w_hi).ceil() + value_gap
175        } else {
176            0.0
177        };
178
179        let label_text = self
180            .label
181            .as_ref()
182            .map(|l| l.text().to_string())
183            .unwrap_or_default();
184
185        ui.vertical(|ui| {
186            if !label_text.is_empty() {
187                ui.add_space(2.0);
188                let rich = egui::RichText::new(&label_text)
189                    .color(p.text_muted)
190                    .size(t.label);
191                ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
192                ui.add_space(2.0);
193            }
194
195            let total_w = self
196                .desired_width
197                .unwrap_or_else(|| ui.available_width())
198                .max(value_reserve + thumb_d * 2.0);
199            let (rect, mut response) =
200                ui.allocate_exact_size(Vec2::new(total_w, row_h), Sense::click_and_drag());
201
202            let track_w = (total_w - value_reserve).max(thumb_d);
203            let thumb_pad = thumb_d * 0.5;
204            let track_left = rect.min.x + thumb_pad;
205            let track_right = rect.min.x + track_w - thumb_pad;
206            let track_span = (track_right - track_left).max(1.0);
207            let track_y = rect.center().y;
208            let track_rect = Rect::from_min_max(
209                Pos2::new(rect.min.x, track_y - track_h * 0.5),
210                Pos2::new(rect.min.x + track_w, track_y + track_h * 0.5),
211            );
212
213            // Update value from pointer while the button is held on the widget.
214            if response.is_pointer_button_down_on() {
215                if let Some(pos) = response.interact_pointer_pos() {
216                    let clamped_x = pos.x.clamp(track_left, track_right);
217                    let frac = ((clamped_x - track_left) / track_span).clamp(0.0, 1.0) as f64;
218                    let mut new_value = lo + frac * (hi - lo);
219                    if let Some(step) = step {
220                        if step > 0.0 {
221                            new_value = lo + ((new_value - lo) / step).round() * step;
222                        }
223                    }
224                    new_value = new_value.clamp(lo, hi);
225                    if (new_value - current).abs() > f64::EPSILON {
226                        current = new_value;
227                        *self.value = T::from_f64(current);
228                        response.mark_changed();
229                    }
230                }
231            }
232
233            if response.hovered() {
234                ui.ctx().set_cursor_icon(CursorIcon::Grab);
235            }
236            if response.is_pointer_button_down_on() {
237                ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
238            }
239
240            if ui.is_rect_visible(rect) {
241                let frac = if hi > lo {
242                    ((current - lo) / (hi - lo)).clamp(0.0, 1.0) as f32
243                } else {
244                    0.0
245                };
246                let thumb_x = track_left + track_span * frac;
247                let thumb_center = Pos2::new(thumb_x, track_y);
248
249                let painter = ui.painter();
250                let track_radius = CornerRadius::same((track_h * 0.5).round() as u8);
251
252                // Unfilled track.
253                painter.rect(
254                    track_rect,
255                    track_radius,
256                    p.input_bg,
257                    Stroke::new(1.0, p.border),
258                    StrokeKind::Inside,
259                );
260
261                // Filled portion up to the thumb.
262                if thumb_x > track_rect.min.x + 0.5 {
263                    let fill_rect = Rect::from_min_max(
264                        Pos2::new(track_rect.min.x, track_rect.min.y),
265                        Pos2::new(thumb_x, track_rect.max.y),
266                    );
267                    painter.rect_filled(fill_rect, track_radius, accent_fill);
268                }
269
270                // Focus / drag halo.
271                if response.has_focus() || response.is_pointer_button_down_on() {
272                    painter.circle_filled(
273                        thumb_center,
274                        thumb_d * 0.5 + 4.0,
275                        with_alpha(accent_fill, 55),
276                    );
277                }
278
279                // Thumb: pale fill, accent-coloured ring.
280                painter.circle(
281                    thumb_center,
282                    thumb_d * 0.5,
283                    p.text,
284                    Stroke::new(2.0, accent_fill),
285                );
286
287                if self.show_value {
288                    let text = self.format_value(current);
289                    let galley =
290                        crate::theme::placeholder_galley(ui, &text, t.label, false, f32::INFINITY);
291                    let text_pos = Pos2::new(
292                        rect.max.x - galley.size().x,
293                        rect.center().y - galley.size().y * 0.5,
294                    );
295                    painter.galley(text_pos, galley, p.text);
296                }
297            }
298
299            response.widget_info(|| WidgetInfo::labeled(WidgetType::Slider, true, &label_text));
300            response
301        })
302        .inner
303    }
304}