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