Skip to main content

elegance/
range_slider.rs

1//! Dual-handle range slider for selecting a `[low, high]` interval.
2//!
3//! Shares the pill-track styling with [`Slider`](crate::Slider) but renders
4//! two thumbs and an accent fill that spans only the selected portion of
5//! the track. The header row above the track shows the label on the left
6//! and the current low / high values on the right.
7
8use std::ops::RangeInclusive;
9
10use egui::{
11    emath::Numeric, CornerRadius, CursorIcon, Event, Id, Key, Pos2, Rect, Response, Sense, Stroke,
12    StrokeKind, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType,
13};
14
15use crate::theme::{mix, with_alpha, Accent, Theme};
16
17/// A horizontal numeric range slider with two thumbs.
18///
19/// Both endpoints are bound to caller-owned values; the widget keeps
20/// `low <= high` automatically (a thumb dragged past its sibling clamps
21/// to the sibling's value rather than swapping).
22///
23/// ```no_run
24/// # use elegance::RangeSlider;
25/// # egui::__run_test_ui(|ui| {
26/// let (mut lo, mut hi): (u32, u32) = (24, 118);
27/// ui.add(
28///     RangeSlider::new(&mut lo, &mut hi, 0u32..=200u32)
29///         .label("Price")
30///         .suffix("$"),
31/// );
32/// # });
33/// ```
34#[must_use = "Add with `ui.add(...)`."]
35pub struct RangeSlider<'a, T: Numeric> {
36    low: &'a mut T,
37    high: &'a mut T,
38    range: RangeInclusive<T>,
39    label: Option<WidgetText>,
40    suffix: String,
41    decimals: Option<usize>,
42    value_fmt: Option<Box<dyn Fn(f64) -> String + 'a>>,
43    show_value: bool,
44    step: Option<f64>,
45    ticks: Option<usize>,
46    show_tick_labels: bool,
47    accent: Accent,
48    desired_width: Option<f32>,
49    enabled: bool,
50    id_salt: Option<Id>,
51}
52
53impl<'a, T: Numeric> std::fmt::Debug for RangeSlider<'a, T> {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("RangeSlider")
56            .field("range_lo", &self.range.start().to_f64())
57            .field("range_hi", &self.range.end().to_f64())
58            .field("suffix", &self.suffix)
59            .field("decimals", &self.decimals)
60            .field("show_value", &self.show_value)
61            .field("step", &self.step)
62            .field("ticks", &self.ticks)
63            .field("show_tick_labels", &self.show_tick_labels)
64            .field("accent", &self.accent)
65            .field("desired_width", &self.desired_width)
66            .field("enabled", &self.enabled)
67            .finish()
68    }
69}
70
71impl<'a, T: Numeric> RangeSlider<'a, T> {
72    /// Create a range slider bound to `low` and `high`, constrained to `range`.
73    pub fn new(low: &'a mut T, high: &'a mut T, range: RangeInclusive<T>) -> Self {
74        Self {
75            low,
76            high,
77            range,
78            label: None,
79            suffix: String::new(),
80            decimals: None,
81            value_fmt: None,
82            show_value: true,
83            step: None,
84            ticks: None,
85            show_tick_labels: false,
86            accent: Accent::Sky,
87            desired_width: None,
88            enabled: true,
89            id_salt: None,
90        }
91    }
92
93    /// Show a label on the left of the header row above the track.
94    #[inline]
95    pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
96        self.label = Some(label.into());
97        self
98    }
99
100    /// Suffix appended to each formatted endpoint value (e.g. `"%"`, `" dB"`).
101    #[inline]
102    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
103        self.suffix = suffix.into();
104        self
105    }
106
107    /// Number of decimal places in the value display. Defaults to `0` for
108    /// integer-typed sliders and `2` for float-typed.
109    #[inline]
110    pub fn decimals(mut self, n: usize) -> Self {
111        self.decimals = Some(n);
112        self
113    }
114
115    /// Custom formatter applied to both endpoint values. Overrides
116    /// [`suffix`](Self::suffix) and [`decimals`](Self::decimals).
117    pub fn value_fmt(mut self, fmt: impl Fn(f64) -> String + 'a) -> Self {
118        self.value_fmt = Some(Box::new(fmt));
119        self
120    }
121
122    /// Hide the right-aligned `low – high` display in the header row. The
123    /// label, if any, still renders. Default: shown.
124    #[inline]
125    pub fn show_value(mut self, show: bool) -> Self {
126        self.show_value = show;
127        self
128    }
129
130    /// Snap values to multiples of `step` (in the slider's value units).
131    /// Integer-typed sliders snap to `1.0` automatically unless overridden.
132    #[inline]
133    pub fn step(mut self, step: f64) -> Self {
134        self.step = Some(step);
135        self
136    }
137
138    /// Draw `n` evenly-spaced tick marks across the track (including both
139    /// endpoints, so `.ticks(5)` draws 5 ticks at 0%, 25%, 50%, 75%, 100%).
140    /// Pass `0` or `1` to suppress ticks.
141    #[inline]
142    pub fn ticks(mut self, n: usize) -> Self {
143        self.ticks = Some(n);
144        self
145    }
146
147    /// When tick marks are drawn, also render the value at each tick beneath
148    /// the track. No effect without [`ticks`](Self::ticks).
149    #[inline]
150    pub fn show_tick_labels(mut self, show: bool) -> Self {
151        self.show_tick_labels = show;
152        self
153    }
154
155    /// Pick the fill colour. Default: [`Accent::Sky`].
156    #[inline]
157    pub fn accent(mut self, accent: Accent) -> Self {
158        self.accent = accent;
159        self
160    }
161
162    /// Override the slider width. Defaults to `ui.available_width()`.
163    #[inline]
164    pub fn desired_width(mut self, width: f32) -> Self {
165        self.desired_width = Some(width);
166        self
167    }
168
169    /// Disable the widget. A disabled slider still paints its current values
170    /// but ignores pointer and keyboard input. Default: enabled.
171    #[inline]
172    pub fn enabled(mut self, enabled: bool) -> Self {
173        self.enabled = enabled;
174        self
175    }
176
177    /// Salt used to derive the per-thumb interaction ids. Set this when
178    /// multiple range sliders share a parent ui so their thumbs don't collide
179    /// on focus or drag state.
180    #[inline]
181    pub fn id_salt(mut self, id_salt: impl std::hash::Hash) -> Self {
182        self.id_salt = Some(Id::new(id_salt));
183        self
184    }
185
186    fn format_value(&self, v: f64) -> String {
187        if let Some(fmt) = &self.value_fmt {
188            return fmt(v);
189        }
190        let n = self.decimals.unwrap_or(if T::INTEGRAL { 0 } else { 2 });
191        if self.suffix.is_empty() {
192            format!("{v:.n$}")
193        } else {
194            format!("{v:.n$}{}", self.suffix)
195        }
196    }
197}
198
199impl<'a, T: Numeric> Widget for RangeSlider<'a, T> {
200    fn ui(self, ui: &mut Ui) -> Response {
201        let theme = Theme::current(ui.ctx());
202        let p = &theme.palette;
203        let t = &theme.typography;
204        let accent_fill = p.accent_fill(self.accent);
205
206        let lo_raw = self.range.start().to_f64();
207        let hi_raw = self.range.end().to_f64();
208        let (range_lo, range_hi) = if lo_raw <= hi_raw {
209            (lo_raw, hi_raw)
210        } else {
211            (hi_raw, lo_raw)
212        };
213        let span = (range_hi - range_lo).max(f64::EPSILON);
214
215        let mut low_v = self.low.to_f64();
216        let mut high_v = self.high.to_f64();
217        if low_v.is_nan() {
218            low_v = range_lo;
219        }
220        if high_v.is_nan() {
221            high_v = range_hi;
222        }
223        low_v = low_v.clamp(range_lo, range_hi);
224        high_v = high_v.clamp(range_lo, range_hi);
225        if low_v > high_v {
226            std::mem::swap(&mut low_v, &mut high_v);
227        }
228
229        let step = self.step.or(if T::INTEGRAL { Some(1.0) } else { None });
230
231        let track_h: f32 = 6.0;
232        let thumb_d: f32 = 14.0;
233        let halo_pad: f32 = 4.0; // accounts for the focus halo painted beyond the thumb radius
234        let strip_h = thumb_d + 2.0 * halo_pad;
235        let mut row_h = strip_h;
236        // Reserve space below the track for tick marks (and optional labels)
237        // so the next widget in the parent layout doesn't overlap them.
238        if let Some(n) = self.ticks {
239            if n >= 2 {
240                row_h += 4.0;
241                if self.show_tick_labels {
242                    row_h += t.small + 4.0;
243                }
244            }
245        }
246
247        let id_salt = self.id_salt.unwrap_or_else(|| Id::new(ui.next_auto_id()));
248        let drag_state_id = id_salt.with("range_slider_drag_idx");
249        let label_text = self
250            .label
251            .as_ref()
252            .map(|l| l.text().to_string())
253            .unwrap_or_default();
254
255        ui.vertical(|ui| {
256            // Header row: optional label on the left, optional value display on the right.
257            if self.label.is_some() || self.show_value {
258                ui.horizontal(|ui| {
259                    if let Some(label) = &self.label {
260                        let color = if self.enabled { p.text } else { p.text_faint };
261                        let rich = egui::RichText::new(label.text()).color(color).size(t.label);
262                        ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
263                    }
264                    if self.show_value {
265                        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
266                            let lo_text = self.format_value(low_v);
267                            let hi_text = self.format_value(high_v);
268                            let value_text = format!("{lo_text} \u{2013} {hi_text}");
269                            let color = if self.enabled {
270                                p.text_muted
271                            } else {
272                                p.text_faint
273                            };
274                            let rich = egui::RichText::new(value_text)
275                                .color(color)
276                                .size(t.label)
277                                .family(egui::FontFamily::Monospace);
278                            ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
279                        });
280                    }
281                });
282                ui.add_space(4.0);
283            }
284
285            // Allocate the slider strip.
286            let total_w = self
287                .desired_width
288                .unwrap_or_else(|| ui.available_width())
289                .max(thumb_d * 4.0);
290            let bg_sense = if self.enabled {
291                Sense::click_and_drag()
292            } else {
293                Sense::hover()
294            };
295            let (rect, bg_resp) = ui.allocate_exact_size(Vec2::new(total_w, row_h), bg_sense);
296
297            let thumb_pad = thumb_d * 0.5;
298            let track_left = rect.min.x + thumb_pad;
299            let track_right = rect.max.x - thumb_pad;
300            let track_span = (track_right - track_left).max(1.0);
301            // Centre the track within the strip so the focus halo fits above
302            // and below; tick labels (if any) sit in the extra space below.
303            let track_y = rect.min.y + strip_h * 0.5;
304
305            let to_x = |v: f64| -> f32 {
306                let frac = ((v - range_lo) / span).clamp(0.0, 1.0) as f32;
307                track_left + track_span * frac
308            };
309            let to_value = |x: f32| -> f64 {
310                let frac = ((x - track_left) / track_span).clamp(0.0, 1.0) as f64;
311                range_lo + frac * span
312            };
313            let snap = |mut v: f64| -> f64 {
314                if let Some(s) = step {
315                    if s > 0.0 {
316                        v = range_lo + ((v - range_lo) / s).round() * s;
317                    }
318                }
319                v.clamp(range_lo, range_hi)
320            };
321
322            let thumb_x = [to_x(low_v), to_x(high_v)];
323            let thumb_centers = [
324                Pos2::new(thumb_x[0], track_y),
325                Pos2::new(thumb_x[1], track_y),
326            ];
327
328            // Per-thumb interaction rects with comfortable click targets.
329            let thumb_hit = thumb_d.max(20.0);
330            let thumb_rects = [
331                Rect::from_center_size(thumb_centers[0], Vec2::splat(thumb_hit)),
332                Rect::from_center_size(thumb_centers[1], Vec2::splat(thumb_hit)),
333            ];
334            let thumb_sense = if self.enabled {
335                Sense::click_and_drag()
336            } else {
337                Sense::hover()
338            };
339            let thumb_resp = [
340                ui.interact(thumb_rects[0], id_salt.with("low"), thumb_sense),
341                ui.interact(thumb_rects[1], id_salt.with("high"), thumb_sense),
342            ];
343
344            let mut new_low = low_v;
345            let mut new_high = high_v;
346            let mut changed = false;
347
348            // 1. Per-thumb pointer drag takes priority.
349            let mut handled_thumb_drag = false;
350            for (i, resp) in thumb_resp.iter().enumerate() {
351                if self.enabled && resp.is_pointer_button_down_on() {
352                    if let Some(pos) = resp.interact_pointer_pos() {
353                        let v = snap(to_value(pos.x));
354                        apply_to_endpoint(&mut new_low, &mut new_high, i, v);
355                        changed = true;
356                        handled_thumb_drag = true;
357                    }
358                }
359            }
360
361            // 2. Background click/drag: pick the nearer thumb and update it.
362            // Sticky during a drag — once we pick a thumb, keep using it until
363            // the pointer is released.
364            if self.enabled && bg_resp.is_pointer_button_down_on() && !handled_thumb_drag {
365                if let Some(pos) = bg_resp.interact_pointer_pos() {
366                    let v = to_value(pos.x);
367                    let stored: Option<usize> = ui.ctx().data(|d| d.get_temp(drag_state_id));
368                    let idx = match stored {
369                        Some(i) => i,
370                        None => {
371                            let i = if (v - low_v).abs() <= (v - high_v).abs() {
372                                0
373                            } else {
374                                1
375                            };
376                            ui.ctx().data_mut(|d| d.insert_temp(drag_state_id, i));
377                            thumb_resp[i].request_focus();
378                            i
379                        }
380                    };
381                    let snapped = snap(v);
382                    apply_to_endpoint(&mut new_low, &mut new_high, idx, snapped);
383                    changed = true;
384                }
385            } else {
386                ui.ctx().data_mut(|d| d.remove::<usize>(drag_state_id));
387            }
388
389            // 3. Keyboard nudges per focused thumb.
390            if self.enabled {
391                for (i, resp) in thumb_resp.iter().enumerate() {
392                    if !resp.has_focus() {
393                        continue;
394                    }
395                    let small_step = step.unwrap_or(span * 0.01);
396                    let big_step = step.map(|s| s * 10.0).unwrap_or(span * 0.1);
397                    let events = ui.input(|input| input.events.clone());
398                    for ev in &events {
399                        if let Event::Key {
400                            key,
401                            pressed: true,
402                            modifiers,
403                            ..
404                        } = ev
405                        {
406                            let cur = if i == 0 { new_low } else { new_high };
407                            let next = match key {
408                                Key::ArrowLeft | Key::ArrowDown => Some(
409                                    cur - if modifiers.shift {
410                                        big_step
411                                    } else {
412                                        small_step
413                                    },
414                                ),
415                                Key::ArrowRight | Key::ArrowUp => Some(
416                                    cur + if modifiers.shift {
417                                        big_step
418                                    } else {
419                                        small_step
420                                    },
421                                ),
422                                Key::Home => Some(range_lo),
423                                Key::End => Some(range_hi),
424                                _ => None,
425                            };
426                            if let Some(v) = next {
427                                let v = snap(v);
428                                apply_to_endpoint(&mut new_low, &mut new_high, i, v);
429                                changed = true;
430                            }
431                        }
432                    }
433                }
434            }
435
436            // Cursor.
437            if self.enabled {
438                let any_hovered =
439                    bg_resp.hovered() || thumb_resp[0].hovered() || thumb_resp[1].hovered();
440                let any_pressed = bg_resp.is_pointer_button_down_on()
441                    || thumb_resp[0].is_pointer_button_down_on()
442                    || thumb_resp[1].is_pointer_button_down_on();
443                if any_pressed {
444                    ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
445                } else if any_hovered {
446                    ui.ctx().set_cursor_icon(CursorIcon::Grab);
447                }
448            }
449
450            // Commit changes back to the bound state.
451            if changed {
452                if (new_low - low_v).abs() > f64::EPSILON {
453                    *self.low = T::from_f64(new_low);
454                    low_v = new_low;
455                }
456                if (new_high - high_v).abs() > f64::EPSILON {
457                    *self.high = T::from_f64(new_high);
458                    high_v = new_high;
459                }
460            }
461
462            // Render.
463            if ui.is_rect_visible(rect) {
464                let painter = ui.painter();
465                let track_radius = CornerRadius::same((track_h * 0.5).round() as u8);
466                let track_rect = Rect::from_min_max(
467                    Pos2::new(rect.min.x, track_y - track_h * 0.5),
468                    Pos2::new(rect.max.x, track_y + track_h * 0.5),
469                );
470
471                // Unfilled track.
472                painter.rect(
473                    track_rect,
474                    track_radius,
475                    p.input_bg,
476                    Stroke::new(1.0, p.border),
477                    StrokeKind::Inside,
478                );
479
480                // Filled portion between low and high.
481                let lo_x = to_x(low_v);
482                let hi_x = to_x(high_v);
483                if hi_x > lo_x + 0.5 {
484                    let fill_rect = Rect::from_min_max(
485                        Pos2::new(lo_x, track_rect.min.y),
486                        Pos2::new(hi_x, track_rect.max.y),
487                    );
488                    let fill = if self.enabled {
489                        accent_fill
490                    } else {
491                        mix(accent_fill, p.card, 0.55)
492                    };
493                    painter.rect_filled(fill_rect, track_radius, fill);
494                }
495
496                // Tick marks.
497                if let Some(n) = self.ticks {
498                    if n >= 2 {
499                        for i in 0..n {
500                            let frac = i as f32 / (n - 1) as f32;
501                            let x = track_left + track_span * frac;
502                            let v = range_lo + (frac as f64) * span;
503                            let inside_fill = v >= low_v && v <= high_v;
504                            let color = if inside_fill {
505                                p.text_muted
506                            } else {
507                                p.text_faint
508                            };
509                            painter.line_segment(
510                                [
511                                    Pos2::new(x, track_y - track_h * 0.5 - 3.0),
512                                    Pos2::new(x, track_y - track_h * 0.5 - 7.0),
513                                ],
514                                Stroke::new(1.0, color),
515                            );
516
517                            if self.show_tick_labels {
518                                let label = self.format_value(v);
519                                let galley = crate::theme::placeholder_galley(
520                                    ui,
521                                    &label,
522                                    t.small,
523                                    false,
524                                    f32::INFINITY,
525                                );
526                                let pos = Pos2::new(
527                                    x - galley.size().x * 0.5,
528                                    track_y + track_h * 0.5 + 4.0,
529                                );
530                                painter.galley(pos, galley, p.text_faint);
531                            }
532                        }
533                    }
534                }
535
536                // Thumbs.
537                for i in 0..2 {
538                    let center = thumb_centers[i];
539                    let active = self.enabled
540                        && (thumb_resp[i].has_focus() || thumb_resp[i].is_pointer_button_down_on());
541                    if active {
542                        painter.circle_filled(
543                            center,
544                            thumb_d * 0.5 + 4.0,
545                            with_alpha(accent_fill, 55),
546                        );
547                    }
548                    let (fill, ring) = if !self.enabled {
549                        (mix(p.text, p.card, 0.5), Stroke::new(1.0, p.border))
550                    } else {
551                        (p.text, Stroke::new(2.0, accent_fill))
552                    };
553                    painter.circle(center, thumb_d * 0.5, fill, ring);
554                }
555            }
556
557            // a11y info on each thumb response.
558            for (i, side) in ["low", "high"].iter().enumerate() {
559                let v = if i == 0 { low_v } else { high_v };
560                let label = if label_text.is_empty() {
561                    (*side).to_string()
562                } else {
563                    format!("{label_text} ({side})")
564                };
565                let resp = thumb_resp[i].clone();
566                resp.widget_info(|| {
567                    let mut info = WidgetInfo::labeled(WidgetType::Slider, self.enabled, &label);
568                    info.value = Some(v);
569                    info
570                });
571            }
572
573            let mut combined = bg_resp;
574            combined |= thumb_resp[0].clone();
575            combined |= thumb_resp[1].clone();
576            if changed {
577                combined.mark_changed();
578            }
579            combined
580        })
581        .inner
582    }
583}
584
585fn apply_to_endpoint(low: &mut f64, high: &mut f64, idx: usize, v: f64) {
586    if idx == 0 {
587        *low = v.min(*high);
588    } else {
589        *high = v.max(*low);
590    }
591}