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    Color32, CornerRadius, CursorIcon, Event, EventFilter, Key, Painter, Pos2, Rect, Response,
11    Role, Sense, Stroke, StrokeKind, Ui, Vec2, Widget, WidgetInfo, WidgetText, emath::Numeric,
12};
13
14use crate::theme::{Accent, Theme, with_alpha};
15
16/// Visual style of the slider thumb.
17///
18/// Affects [`Slider`], [`RangeSlider`](crate::RangeSlider),
19/// [`MetricSlider`](crate::MetricSlider), and
20/// [`PercentSlider`](crate::PercentSlider). All four default to
21/// [`SliderHandle::Circle`].
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum SliderHandle {
24    /// A circular thumb with a pale interior and an accent-coloured ring.
25    /// This is the default and matches the look of egui's built-in slider.
26    #[default]
27    Circle,
28    /// A thin, vertical accent-coloured bar. Useful for scrubber-style
29    /// sliders where the pointer position should sit on top of the rail
30    /// without a wide thumb obscuring nearby ticks or fill.
31    Line,
32}
33
34/// Width of the line-handle bar, in points.
35pub(crate) const LINE_HANDLE_W: f32 = 3.0;
36/// Total height of the line-handle bar, in points. Sized so the bar pokes a
37/// few points above and below the 6 pt track to read as a positive marker
38/// rather than a hairline.
39pub(crate) const LINE_HANDLE_H: f32 = 18.0;
40
41/// Paint a slider thumb at `center`.
42///
43/// `body` is the primary fill: for [`SliderHandle::Circle`] this is the pale
44/// interior; for [`SliderHandle::Line`] this is the bar fill itself
45/// (typically the accent colour or a desaturated variant when disabled).
46/// `accent_stroke` is the circle's outer ring and is ignored for `Line`.
47/// `halo` paints a soft, larger backdrop in the same shape when `Some`.
48pub(crate) fn paint_handle(
49    painter: &Painter,
50    handle: SliderHandle,
51    center: Pos2,
52    thumb_d: f32,
53    body: Color32,
54    accent_stroke: Stroke,
55    halo: Option<Color32>,
56) {
57    match handle {
58        SliderHandle::Circle => {
59            if let Some(c) = halo {
60                painter.circle_filled(center, thumb_d * 0.5 + 4.0, c);
61            }
62            painter.circle(center, thumb_d * 0.5, body, accent_stroke);
63        }
64        SliderHandle::Line => {
65            if let Some(c) = halo {
66                let halo_rect = Rect::from_center_size(
67                    center,
68                    Vec2::new(LINE_HANDLE_W + 10.0, LINE_HANDLE_H + 8.0),
69                );
70                painter.rect_filled(halo_rect, CornerRadius::same(6), c);
71            }
72            let bar = Rect::from_center_size(center, Vec2::new(LINE_HANDLE_W, LINE_HANDLE_H));
73            painter.rect_filled(bar, CornerRadius::same(1), body);
74        }
75    }
76}
77
78/// A horizontal numeric slider.
79///
80/// ```no_run
81/// # use elegance::Slider;
82/// # egui::__run_test_ui(|ui| {
83/// let mut cpu = 42.0_f32;
84/// ui.add(Slider::new(&mut cpu, 0.0..=100.0).label("CPU limit").suffix("%"));
85/// # });
86/// ```
87#[must_use = "Add with `ui.add(...)`."]
88pub struct Slider<'a, T: Numeric> {
89    value: &'a mut T,
90    range: RangeInclusive<T>,
91    label: Option<WidgetText>,
92    suffix: String,
93    decimals: Option<usize>,
94    value_fmt: Option<Box<dyn Fn(f64) -> String + 'a>>,
95    show_value: bool,
96    step: Option<f64>,
97    accent: Accent,
98    desired_width: Option<f32>,
99    handle: SliderHandle,
100}
101
102impl<'a, T: Numeric> std::fmt::Debug for Slider<'a, T> {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("Slider")
105            .field("range_lo", &self.range.start().to_f64())
106            .field("range_hi", &self.range.end().to_f64())
107            .field("suffix", &self.suffix)
108            .field("decimals", &self.decimals)
109            .field("show_value", &self.show_value)
110            .field("step", &self.step)
111            .field("accent", &self.accent)
112            .field("desired_width", &self.desired_width)
113            .field("handle", &self.handle)
114            .finish()
115    }
116}
117
118impl<'a, T: Numeric> Slider<'a, T> {
119    /// Create a slider bound to `value`, constrained to `range`.
120    pub fn new(value: &'a mut T, range: RangeInclusive<T>) -> Self {
121        Self {
122            value,
123            range,
124            label: None,
125            suffix: String::new(),
126            decimals: None,
127            value_fmt: None,
128            show_value: true,
129            step: None,
130            accent: Accent::Sky,
131            desired_width: None,
132            handle: SliderHandle::Circle,
133        }
134    }
135
136    /// Show a label above the slider.
137    ///
138    /// The label also names the slider for screen readers. Without one, name
139    /// the returned response with [`egui::Response::accessible_name`], or with
140    /// [`egui::Response::labelled_by`] when a caption is drawn beside it.
141    pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
142        self.label = Some(label.into());
143        self
144    }
145
146    /// Suffix appended to the formatted value (e.g. `"%"`, `" dB"`).
147    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
148        self.suffix = suffix.into();
149        self
150    }
151
152    /// Number of decimal places in the value display. Defaults to `0` for
153    /// integer-typed sliders and `2` for float-typed.
154    pub fn decimals(mut self, n: usize) -> Self {
155        self.decimals = Some(n);
156        self
157    }
158
159    /// Supply a custom formatter for the value display. Overrides
160    /// [`suffix`](Self::suffix) and [`decimals`](Self::decimals).
161    pub fn value_fmt(mut self, fmt: impl Fn(f64) -> String + 'a) -> Self {
162        self.value_fmt = Some(Box::new(fmt));
163        self
164    }
165
166    /// Hide the right-aligned value display.
167    pub fn show_value(mut self, show: bool) -> Self {
168        self.show_value = show;
169        self
170    }
171
172    /// Snap the value to multiples of `step` (in the slider's value units).
173    /// Integer-typed sliders snap to `1.0` automatically unless overridden.
174    pub fn step(mut self, step: f64) -> Self {
175        self.step = Some(step);
176        self
177    }
178
179    /// Pick the fill colour from one of the theme accents. Default: [`Accent::Sky`].
180    pub fn accent(mut self, accent: Accent) -> Self {
181        self.accent = accent;
182        self
183    }
184
185    /// Override the slider width. Defaults to `ui.available_width()`.
186    pub fn desired_width(mut self, width: f32) -> Self {
187        self.desired_width = Some(width);
188        self
189    }
190
191    /// Pick the thumb shape. Default: [`SliderHandle::Circle`]. Switch to
192    /// [`SliderHandle::Line`] for a thin vertical bar instead of the standard
193    /// circular knob.
194    pub fn handle(mut self, handle: SliderHandle) -> Self {
195        self.handle = handle;
196        self
197    }
198
199    fn format_value(&self, v: f64) -> String {
200        if let Some(fmt) = &self.value_fmt {
201            return fmt(v);
202        }
203        let n = self.decimals.unwrap_or(if T::INTEGRAL { 0 } else { 2 });
204        if self.suffix.is_empty() {
205            format!("{v:.n$}")
206        } else {
207            format!("{v:.n$}{}", self.suffix)
208        }
209    }
210}
211
212impl<'a, T: Numeric> Widget for Slider<'a, T> {
213    fn ui(self, ui: &mut Ui) -> Response {
214        let theme = Theme::current(ui.ctx());
215        let p = &theme.palette;
216        let t = &theme.typography;
217        let accent_fill = p.accent_fill(self.accent);
218
219        let lo_raw = self.range.start().to_f64();
220        let hi_raw = self.range.end().to_f64();
221        let (lo, hi) = if lo_raw <= hi_raw {
222            (lo_raw, hi_raw)
223        } else {
224            (hi_raw, lo_raw)
225        };
226
227        let mut current = self.value.to_f64();
228        if current.is_nan() {
229            current = lo;
230        }
231        current = current.clamp(lo, hi);
232
233        let step = self.step.or(if T::INTEGRAL { Some(1.0) } else { None });
234
235        let track_h: f32 = 6.0;
236        let thumb_d: f32 = 14.0;
237        let row_h = thumb_d.max(t.label + 2.0);
238        let value_gap: f32 = 10.0;
239
240        let value_reserve = if self.show_value {
241            let lo_text = self.format_value(lo);
242            let hi_text = self.format_value(hi);
243            let w_lo =
244                crate::theme::placeholder_galley(ui, &lo_text, t.label, false, f32::INFINITY)
245                    .size()
246                    .x;
247            let w_hi =
248                crate::theme::placeholder_galley(ui, &hi_text, t.label, false, f32::INFINITY)
249                    .size()
250                    .x;
251            w_lo.max(w_hi).ceil() + value_gap
252        } else {
253            0.0
254        };
255
256        let label_text = self
257            .label
258            .as_ref()
259            .map(|l| l.text().to_string())
260            .unwrap_or_default();
261
262        ui.vertical(|ui| {
263            if !label_text.is_empty() {
264                ui.add_space(2.0);
265                let rich = egui::RichText::new(&label_text)
266                    .color(p.text_muted)
267                    .size(t.label);
268                ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
269                ui.add_space(2.0);
270            }
271
272            let total_w = self
273                .desired_width
274                .unwrap_or_else(|| ui.available_width())
275                .max(value_reserve + thumb_d * 2.0);
276            let (rect, mut response) =
277                ui.allocate_exact_size(Vec2::new(total_w, row_h), Sense::click_and_drag());
278
279            let track_w = (total_w - value_reserve).max(thumb_d);
280            let thumb_pad = thumb_d * 0.5;
281            let track_left = rect.min.x + thumb_pad;
282            let track_right = rect.min.x + track_w - thumb_pad;
283            let track_span = (track_right - track_left).max(1.0);
284            let track_y = rect.center().y;
285            let track_rect = Rect::from_min_max(
286                Pos2::new(rect.min.x, track_y - track_h * 0.5),
287                Pos2::new(rect.min.x + track_w, track_y + track_h * 0.5),
288            );
289
290            crate::focus::focus_on_press(&response);
291
292            // Update value from pointer while the button is held on the widget.
293            if response.is_pointer_button_down_on()
294                && let Some(pos) = response.interact_pointer_pos()
295            {
296                let clamped_x = pos.x.clamp(track_left, track_right);
297                let frac = ((clamped_x - track_left) / track_span).clamp(0.0, 1.0) as f64;
298                let mut new_value = lo + frac * (hi - lo);
299                if let Some(step) = step
300                    && step > 0.0
301                {
302                    new_value = lo + ((new_value - lo) / step).round() * step;
303                }
304                new_value = new_value.clamp(lo, hi);
305                if (new_value - current).abs() > f64::EPSILON {
306                    current = new_value;
307                    *self.value = T::from_f64(current);
308                    response.mark_changed();
309                }
310            }
311
312            // Keyboard nudges when the slider has focus. Left / Right step by
313            // `step` (or 1% of the range if continuous); Shift bumps to 10x;
314            // Home / End jump to the endpoints. Up / Down are intentionally
315            // left to egui's focus navigation so the user can move between
316            // stacked controls vertically.
317            //
318            // `set_focus_lock_filter` claims horizontal arrows for this
319            // widget while it's focused; without it, egui's focus system
320            // consumes Left / Right for spatial navigation before the value
321            // handler below ever sees them.
322            if response.has_focus() {
323                ui.memory_mut(|m| {
324                    m.set_focus_lock_filter(
325                        response.id,
326                        EventFilter {
327                            horizontal_arrows: true,
328                            ..Default::default()
329                        },
330                    );
331                });
332
333                let span = hi - lo;
334                let small_step = step.unwrap_or(span * 0.01);
335                let big_step = step.map(|s| s * 10.0).unwrap_or(span * 0.1);
336                let events = ui.input(|input| input.events.clone());
337                for ev in &events {
338                    if let Event::Key {
339                        key,
340                        pressed: true,
341                        modifiers,
342                        ..
343                    } = ev
344                    {
345                        let nudge = if modifiers.shift {
346                            big_step
347                        } else {
348                            small_step
349                        };
350                        let next = match key {
351                            Key::ArrowLeft => Some(current - nudge),
352                            Key::ArrowRight => Some(current + nudge),
353                            Key::Home => Some(lo),
354                            Key::End => Some(hi),
355                            _ => None,
356                        };
357                        if let Some(mut new_value) = next {
358                            if let Some(s) = step
359                                && s > 0.0
360                            {
361                                new_value = lo + ((new_value - lo) / s).round() * s;
362                            }
363                            new_value = new_value.clamp(lo, hi);
364                            if (new_value - current).abs() > f64::EPSILON {
365                                current = new_value;
366                                *self.value = T::from_f64(current);
367                                response.mark_changed();
368                                // A key press is a discrete adjustment: already
369                                // settled the moment it lands.
370                                crate::commit::mark_commit(ui.ctx(), response.id);
371                            }
372                        }
373                    }
374                }
375            }
376
377            if response.hovered() {
378                ui.ctx().set_cursor_icon(CursorIcon::Grab);
379            }
380            if response.is_pointer_button_down_on() {
381                ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
382            }
383
384            if ui.is_rect_visible(rect) {
385                let frac = if hi > lo {
386                    ((current - lo) / (hi - lo)).clamp(0.0, 1.0) as f32
387                } else {
388                    0.0
389                };
390                let thumb_x = track_left + track_span * frac;
391                let thumb_center = Pos2::new(thumb_x, track_y);
392
393                let painter = ui.painter();
394                let track_radius = CornerRadius::same((track_h * 0.5).round() as u8);
395
396                // Unfilled track.
397                painter.rect(
398                    track_rect,
399                    track_radius,
400                    p.input_bg,
401                    Stroke::new(1.0, p.border),
402                    StrokeKind::Inside,
403                );
404
405                // Filled portion up to the thumb.
406                if thumb_x > track_rect.min.x + 0.5 {
407                    let fill_rect = Rect::from_min_max(
408                        Pos2::new(track_rect.min.x, track_rect.min.y),
409                        Pos2::new(thumb_x, track_rect.max.y),
410                    );
411                    painter.rect_filled(fill_rect, track_radius, accent_fill);
412                }
413
414                let active = response.has_focus() || response.is_pointer_button_down_on();
415                let halo = active.then(|| with_alpha(accent_fill, 55));
416                let line_body = if p.is_dark {
417                    p.text
418                } else {
419                    p.accent_hover(self.accent)
420                };
421                let (body, ring) = match self.handle {
422                    SliderHandle::Circle => (p.text, Stroke::new(2.0, accent_fill)),
423                    SliderHandle::Line => (line_body, Stroke::NONE),
424                };
425                paint_handle(
426                    painter,
427                    self.handle,
428                    thumb_center,
429                    thumb_d,
430                    body,
431                    ring,
432                    halo,
433                );
434
435                if self.show_value {
436                    let text = self.format_value(current);
437                    let galley =
438                        crate::theme::placeholder_galley(ui, &text, t.label, false, f32::INFINITY);
439                    let text_pos = Pos2::new(
440                        rect.max.x - galley.size().x,
441                        rect.center().y - galley.size().y * 0.5,
442                    );
443                    painter.galley(text_pos, galley, p.text);
444                }
445            }
446
447            response.widget_info(|| WidgetInfo::labeled(Role::Slider, true, &label_text));
448            response
449        })
450        .inner
451    }
452}