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