Skip to main content

guise/input/
rangeslider.rs

1//! `RangeSlider` — a two-thumb value track (gpui entity).
2//!
3//! Holds a `(low, high)` pair in `min..=max`, snapped to `step` and kept at
4//! least `min_gap` apart. Each thumb is a real gpui drag source (`on_drag` +
5//! `on_drag_move`), so dragging tracks the pointer even outside the element;
6//! clicking the track jumps the nearest thumb; arrow keys nudge the last
7//! active thumb. Emits [`RangeSliderEvent`] on change.
8//!
9//! ```ignore
10//! let range = cx.new(|cx| RangeSlider::new(cx).min(0.0).max(100.0).value((20.0, 80.0)));
11//! cx.subscribe(&range, |_this, _slider, event: &RangeSliderEvent, _cx| {
12//!     let (low, high) = event.0;
13//! })
14//! .detach();
15//! ```
16
17use gpui::prelude::*;
18use gpui::{
19    canvas, div, px, relative, App, Bounds, Context, DragMoveEvent, Empty, Entity, EntityId,
20    EventEmitter, FocusHandle, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, Pixels,
21    SharedString, Window,
22};
23
24use crate::devtools::Probed;
25use crate::reactive::Signal;
26use crate::theme::{theme, ColorName, Size};
27
28/// Emitted when either end of the range changes. Carries `(low, high)`.
29#[derive(Debug, Clone, Copy)]
30pub struct RangeSliderEvent(pub (f64, f64));
31
32/// The drag payload for a thumb. `owner` scopes `on_drag_move` to the
33/// instance that started the drag (the listener fires for every active drag
34/// of this type in the window).
35struct ThumbDrag {
36    owner: EntityId,
37    thumb: usize,
38}
39
40/// A two-thumb range slider. Create with `cx.new(|cx| RangeSlider::new(cx))`.
41pub struct RangeSlider {
42    value: (f64, f64),
43    min: f64,
44    max: f64,
45    step: f64,
46    min_gap: f64,
47    color: ColorName,
48    size: Size,
49    focus: FocusHandle,
50    disabled: bool,
51    /// The thumb arrow keys move: the one last dragged or clicked toward.
52    active: usize,
53    /// Track bounds captured each frame (canvas trick) for click hit-testing.
54    bounds: Bounds<Pixels>,
55}
56
57impl EventEmitter<RangeSliderEvent> for RangeSlider {}
58
59impl RangeSlider {
60    pub fn new(cx: &mut Context<Self>) -> Self {
61        RangeSlider {
62            value: (25.0, 75.0),
63            min: 0.0,
64            max: 100.0,
65            step: 1.0,
66            min_gap: 0.0,
67            color: ColorName::Blue,
68            size: Size::Md,
69            focus: cx.focus_handle(),
70            disabled: false,
71            active: 0,
72            bounds: Bounds::default(),
73        }
74    }
75
76    /// The `(low, high)` pair. Set `min`/`max`/`step`/`min_gap` first — the
77    /// value is normalized against them.
78    pub fn value(mut self, value: (f64, f64)) -> Self {
79        self.value = normalize_pair(value, self.min, self.max, self.step, self.min_gap);
80        self
81    }
82
83    pub fn min(mut self, min: f64) -> Self {
84        self.min = min;
85        self
86    }
87
88    pub fn max(mut self, max: f64) -> Self {
89        self.max = max;
90        self
91    }
92
93    pub fn step(mut self, step: f64) -> Self {
94        self.step = step.max(f64::EPSILON);
95        self
96    }
97
98    /// Minimum distance the thumbs keep between each other (default 0).
99    pub fn min_gap(mut self, min_gap: f64) -> Self {
100        self.min_gap = min_gap.max(0.0);
101        self
102    }
103
104    pub fn color(mut self, color: ColorName) -> Self {
105        self.color = color;
106        self
107    }
108
109    pub fn size(mut self, size: Size) -> Self {
110        self.size = size;
111        self
112    }
113
114    pub fn disabled(mut self, disabled: bool) -> Self {
115        self.disabled = disabled;
116        self
117    }
118
119    /// The current `(low, high)` pair.
120    pub fn value_pair(&self) -> (f64, f64) {
121        self.value
122    }
123
124    /// Two-way bind this slider's range to a `Signal<(f64, f64)>`. The signal
125    /// is the source of truth: the slider adopts its value now (normalized),
126    /// drags write back through [`Signal::set_if_changed`], and signal writes
127    /// move the thumbs without emitting [`RangeSliderEvent`]. Equality guards
128    /// on both directions prevent update loops.
129    pub fn bind(entity: &Entity<RangeSlider>, signal: &Signal<(f64, f64)>, cx: &mut App) {
130        let initial = signal.get(cx);
131        entity.update(cx, |this, cx| this.sync_value(initial, cx));
132        let sink = signal.clone();
133        cx.subscribe(entity, move |_slider, event: &RangeSliderEvent, cx| {
134            sink.set_if_changed(cx, event.0);
135        })
136        .detach();
137        let slider = entity.downgrade();
138        cx.observe(signal.entity(), move |observed, cx| {
139            let value = *observed.read(cx);
140            slider
141                .update(cx, |this, cx| this.sync_value(value, cx))
142                .ok();
143        })
144        .detach();
145    }
146
147    /// Programmatic set: normalize and repaint without emitting an event.
148    fn sync_value(&mut self, raw: (f64, f64), cx: &mut Context<Self>) {
149        let next = normalize_pair(raw, self.min, self.max, self.step, self.min_gap);
150        if next != self.value {
151            self.value = next;
152            cx.notify();
153        }
154    }
155
156    fn fraction(&self, v: f64) -> f32 {
157        if self.max <= self.min {
158            0.0
159        } else {
160            (((v - self.min) / (self.max - self.min)) as f32).clamp(0.0, 1.0)
161        }
162    }
163
164    /// Move one thumb toward `raw`, respecting step, bounds and the gap.
165    fn set_thumb(&mut self, thumb: usize, raw: f64, cx: &mut Context<Self>) {
166        if self.disabled {
167            return;
168        }
169        self.active = thumb;
170        let next = clamp_thumb(
171            self.value,
172            thumb,
173            raw,
174            self.min,
175            self.max,
176            self.step,
177            self.min_gap,
178        );
179        if next != self.value {
180            self.value = next;
181            cx.emit(RangeSliderEvent(next));
182        }
183        cx.notify();
184    }
185
186    /// The raw value under a window-space x, from the captured track bounds.
187    fn value_at(&self, x: Pixels) -> Option<f64> {
188        let width = self.bounds.size.width;
189        if width <= px(0.0) {
190            return None;
191        }
192        let frac = ((x - self.bounds.left()) / width).clamp(0.0, 1.0);
193        Some(self.min + frac as f64 * (self.max - self.min))
194    }
195
196    fn on_mouse_down(
197        &mut self,
198        event: &MouseDownEvent,
199        window: &mut Window,
200        cx: &mut Context<Self>,
201    ) {
202        if self.disabled {
203            return;
204        }
205        window.focus(&self.focus);
206        // A press on a knob starts a drag (the knobs' `on_drag` doesn't stop
207        // this event from bubbling here) — jumping a thumb toward the press
208        // would move it by up to half a knob, or move the *other* thumb when
209        // they sit close. Only track-presses jump.
210        let width = f32::from(self.bounds.size.width);
211        if width > 0.0 {
212            let x = f32::from(event.position.x - self.bounds.left());
213            let (thumb_w, _) = self.metrics();
214            let (f0, f1) = (self.fraction(self.value.0), self.fraction(self.value.1));
215            if let Some(thumb) = thumb_under(x, width, f0, f1, thumb_w) {
216                self.active = thumb;
217                cx.notify();
218                return;
219            }
220        }
221        if let Some(raw) = self.value_at(event.position.x) {
222            let thumb = nearest_thumb(self.value.0, self.value.1, raw);
223            self.set_thumb(thumb, raw, cx);
224        }
225        cx.notify();
226    }
227
228    fn on_drag_move(
229        &mut self,
230        event: &DragMoveEvent<ThumbDrag>,
231        _window: &mut Window,
232        cx: &mut Context<Self>,
233    ) {
234        let (owner, thumb) = {
235            let drag = event.drag(cx);
236            (drag.owner, drag.thumb)
237        };
238        if owner != cx.entity_id() {
239            return;
240        }
241        let width = event.bounds.size.width;
242        if width <= px(0.0) {
243            return;
244        }
245        let frac = ((event.event.position.x - event.bounds.left()) / width).clamp(0.0, 1.0);
246        let raw = self.min + frac as f64 * (self.max - self.min);
247        self.set_thumb(thumb, raw, cx);
248    }
249
250    fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
251        let current = if self.active == 0 {
252            self.value.0
253        } else {
254            self.value.1
255        };
256        match event.keystroke.key.as_str() {
257            "left" | "down" => self.set_thumb(self.active, current - self.step, cx),
258            "right" | "up" => self.set_thumb(self.active, current + self.step, cx),
259            "home" => self.set_thumb(self.active, self.min, cx),
260            "end" => self.set_thumb(self.active, self.max, cx),
261            _ => return,
262        }
263        cx.stop_propagation();
264    }
265
266    fn metrics(&self) -> (f32, f32) {
267        match self.size {
268            Size::Xs => (12.0, 4.0),
269            Size::Sm => (14.0, 5.0),
270            Size::Md => (16.0, 6.0),
271            Size::Lg => (20.0, 8.0),
272            Size::Xl => (24.0, 10.0),
273        }
274    }
275}
276
277/// Snap `raw` to the step grid.
278fn snap(raw: f64, min: f64, step: f64) -> f64 {
279    min + ((raw - min) / step).round() * step
280}
281
282/// Move one end of `current` toward `raw`, snapped and kept `min_gap` away
283/// from the other end, inside `min..=max`.
284fn clamp_thumb(
285    current: (f64, f64),
286    thumb: usize,
287    raw: f64,
288    min: f64,
289    max: f64,
290    step: f64,
291    min_gap: f64,
292) -> (f64, f64) {
293    let snapped = snap(raw, min, step);
294    if thumb == 0 {
295        let upper = (current.1 - min_gap).max(min);
296        (snapped.max(min).min(upper), current.1)
297    } else {
298        let lower = (current.0 + min_gap).min(max);
299        (current.0, snapped.min(max).max(lower))
300    }
301}
302
303/// Order, snap and clamp a raw pair, enforcing the gap where the range allows.
304fn normalize_pair(raw: (f64, f64), min: f64, max: f64, step: f64, min_gap: f64) -> (f64, f64) {
305    let (a, b) = if raw.0 <= raw.1 { raw } else { (raw.1, raw.0) };
306    let lo = snap(a, min, step).max(min).min((max - min_gap).max(min));
307    let hi = snap(b, min, step).min(max).max((lo + min_gap).min(max));
308    (lo, hi)
309}
310
311/// The knob whose painted extent contains local `x`, if any. Knob 1 paints
312/// last (topmost) and wins the subsequent drag when the knobs overlap, so it
313/// is checked first to stay consistent.
314fn thumb_under(x: f32, width: f32, f0: f32, f1: f32, thumb_w: f32) -> Option<usize> {
315    let hit = |frac: f32| (x - frac * width).abs() <= thumb_w / 2.0;
316    if hit(f1) {
317        Some(1)
318    } else if hit(f0) {
319        Some(0)
320    } else {
321        None
322    }
323}
324
325/// Which thumb a click at `raw` should move.
326fn nearest_thumb(lo: f64, hi: f64, raw: f64) -> usize {
327    if raw <= lo {
328        0
329    } else if raw >= hi {
330        1
331    } else if raw - lo < hi - raw {
332        0
333    } else {
334        1
335    }
336}
337
338impl Render for RangeSlider {
339    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
340        let t = theme(cx);
341        let accent = t.color(self.color, t.primary_shade()).hsla();
342        let track_color = if t.scheme.is_dark() {
343            t.color(ColorName::Dark, 4)
344        } else {
345            t.color(ColorName::Gray, 2)
346        }
347        .hsla();
348        let knob_bg = t.surface().hsla();
349        let label_color = t.dimmed().hsla();
350        let font_xs = t.font_size(Size::Xs);
351
352        let (thumb, track_h) = self.metrics();
353        let container_h = thumb + 4.0;
354        let track_top = (container_h - track_h) / 2.0;
355        let (f0, f1) = (self.fraction(self.value.0), self.fraction(self.value.1));
356        let owner = cx.entity_id();
357
358        let track = div()
359            .absolute()
360            .left(px(0.0))
361            .right(px(0.0))
362            .top(px(track_top))
363            .h(px(track_h))
364            .rounded(px(track_h / 2.0))
365            .bg(track_color);
366
367        let fill = div()
368            .absolute()
369            .left(relative(f0))
370            .w(relative((f1 - f0).max(0.0)))
371            .top(px(track_top))
372            .h(px(track_h))
373            .rounded(px(track_h / 2.0))
374            .bg(accent);
375
376        let knob = |i: usize, frac: f32| {
377            div()
378                .id(("guise-rangeslider-thumb", i))
379                .absolute()
380                .left(relative(frac))
381                .ml(px(-thumb / 2.0))
382                .top(px(2.0))
383                .w(px(thumb))
384                .h(px(thumb))
385                .rounded(px(thumb / 2.0))
386                .bg(knob_bg)
387                .border_2()
388                .border_color(accent)
389                .cursor_grab()
390                .on_drag(
391                    ThumbDrag { owner, thumb: i },
392                    |_drag, _offset, _window, cx| cx.new(|_| Empty),
393                )
394        };
395
396        // Invisible canvas capturing the container's bounds for click math.
397        let this = cx.entity();
398        let bounds_probe = canvas(
399            move |bounds, _window, cx| {
400                this.update(cx, |this, _| this.bounds = bounds);
401            },
402            |_, _, _, _| {},
403        )
404        .absolute()
405        .size_full();
406
407        let slider = div()
408            .id("guise-rangeslider")
409            .track_focus(&self.focus)
410            .on_key_down(cx.listener(Self::on_key))
411            .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
412            .on_drag_move::<ThumbDrag>(cx.listener(Self::on_drag_move))
413            .relative()
414            .w_full()
415            .h(px(container_h))
416            .child(bounds_probe)
417            .child(track)
418            .child(fill)
419            .child(knob(0, f0))
420            .child(knob(1, f1));
421
422        let value_label =
423            div()
424                .text_size(px(font_xs))
425                .text_color(label_color)
426                .child(SharedString::from(format!(
427                    "{} \u{2013} {}",
428                    self.value.0, self.value.1
429                )));
430
431        let column = div()
432            .flex()
433            .flex_col()
434            .gap(px(4.0))
435            .child(slider)
436            .child(value_label);
437
438        let element = if self.disabled {
439            column.opacity(0.5)
440        } else {
441            column
442        };
443
444        element.probe("RangeSlider")
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn clamp_thumb_snaps_and_respects_bounds() {
454        assert_eq!(
455            clamp_thumb((20.0, 80.0), 0, 33.4, 0.0, 100.0, 1.0, 0.0),
456            (33.0, 80.0)
457        );
458        assert_eq!(
459            clamp_thumb((20.0, 80.0), 0, -10.0, 0.0, 100.0, 1.0, 0.0),
460            (0.0, 80.0)
461        );
462        assert_eq!(
463            clamp_thumb((20.0, 80.0), 1, 250.0, 0.0, 100.0, 1.0, 0.0),
464            (20.0, 100.0)
465        );
466    }
467
468    #[test]
469    fn clamp_thumb_enforces_the_gap() {
470        // Low thumb pushed past high stops `min_gap` short of it.
471        assert_eq!(
472            clamp_thumb((20.0, 50.0), 0, 60.0, 0.0, 100.0, 1.0, 10.0),
473            (40.0, 50.0)
474        );
475        // High thumb pushed past low stops `min_gap` above it.
476        assert_eq!(
477            clamp_thumb((20.0, 50.0), 1, 5.0, 0.0, 100.0, 1.0, 10.0),
478            (20.0, 30.0)
479        );
480        // The gap clamp never escapes min/max even when the gap can't fit.
481        assert_eq!(
482            clamp_thumb((0.0, 5.0), 0, -20.0, 0.0, 100.0, 1.0, 10.0),
483            (0.0, 5.0)
484        );
485    }
486
487    #[test]
488    fn clamp_thumb_snaps_to_coarse_steps() {
489        assert_eq!(
490            clamp_thumb((0.0, 100.0), 0, 37.0, 0.0, 100.0, 25.0, 0.0),
491            (25.0, 100.0)
492        );
493        assert_eq!(
494            clamp_thumb((0.0, 100.0), 0, 38.0, 0.0, 100.0, 25.0, 0.0),
495            (50.0, 100.0)
496        );
497    }
498
499    #[test]
500    fn normalize_orders_and_clamps_the_pair() {
501        assert_eq!(
502            normalize_pair((80.0, 20.0), 0.0, 100.0, 1.0, 0.0),
503            (20.0, 80.0)
504        );
505        assert_eq!(
506            normalize_pair((-5.0, 120.0), 0.0, 100.0, 1.0, 0.0),
507            (0.0, 100.0)
508        );
509        assert_eq!(
510            normalize_pair((40.0, 45.0), 0.0, 100.0, 1.0, 10.0),
511            (40.0, 50.0)
512        );
513        // A gap wider than the range collapses to the range itself.
514        assert_eq!(
515            normalize_pair((0.0, 100.0), 0.0, 100.0, 1.0, 500.0),
516            (0.0, 100.0)
517        );
518    }
519
520    #[test]
521    fn thumb_under_hits_knob_extents_only() {
522        // 400px track, values 50/52 of 0..100 → knob centers at 200 and 208px,
523        // a 16px knob spans ±8.
524        let (f0, f1) = (0.5, 0.52);
525        // Inside the high knob (and the low one) → the topmost wins.
526        assert_eq!(thumb_under(202.4, 400.0, f0, f1, 16.0), Some(1));
527        // Only inside the low knob.
528        assert_eq!(thumb_under(196.0, 400.0, f0, f1, 16.0), Some(0));
529        // On the bare track.
530        assert_eq!(thumb_under(100.0, 400.0, f0, f1, 16.0), None);
531        assert_eq!(thumb_under(300.0, 400.0, f0, f1, 16.0), None);
532        // Coincident knobs: the topmost (high) one wins.
533        assert_eq!(thumb_under(200.0, 400.0, 0.5, 0.5, 16.0), Some(1));
534    }
535
536    #[test]
537    fn nearest_thumb_splits_the_track() {
538        assert_eq!(nearest_thumb(20.0, 80.0, 5.0), 0);
539        assert_eq!(nearest_thumb(20.0, 80.0, 30.0), 0);
540        assert_eq!(nearest_thumb(20.0, 80.0, 70.0), 1);
541        assert_eq!(nearest_thumb(20.0, 80.0, 95.0), 1);
542        // Coincident thumbs: clicks left move the low, right the high.
543        assert_eq!(nearest_thumb(50.0, 50.0, 40.0), 0);
544        assert_eq!(nearest_thumb(50.0, 50.0, 60.0), 1);
545    }
546}