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(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
197    if self.disabled {
198      return;
199    }
200    window.focus(&self.focus);
201    // A press on a knob starts a drag (the knobs' `on_drag` doesn't stop
202    // this event from bubbling here) — jumping a thumb toward the press
203    // would move it by up to half a knob, or move the *other* thumb when
204    // they sit close. Only track-presses jump.
205    let width = f32::from(self.bounds.size.width);
206    if width > 0.0 {
207      let x = f32::from(event.position.x - self.bounds.left());
208      let (thumb_w, _) = self.metrics();
209      let (f0, f1) = (self.fraction(self.value.0), self.fraction(self.value.1));
210      if let Some(thumb) = thumb_under(x, width, f0, f1, thumb_w) {
211        self.active = thumb;
212        cx.notify();
213        return;
214      }
215    }
216    if let Some(raw) = self.value_at(event.position.x) {
217      let thumb = nearest_thumb(self.value.0, self.value.1, raw);
218      self.set_thumb(thumb, raw, cx);
219    }
220    cx.notify();
221  }
222
223  fn on_drag_move(
224    &mut self,
225    event: &DragMoveEvent<ThumbDrag>,
226    _window: &mut Window,
227    cx: &mut Context<Self>,
228  ) {
229    let (owner, thumb) = {
230      let drag = event.drag(cx);
231      (drag.owner, drag.thumb)
232    };
233    if owner != cx.entity_id() {
234      return;
235    }
236    let width = event.bounds.size.width;
237    if width <= px(0.0) {
238      return;
239    }
240    let frac = ((event.event.position.x - event.bounds.left()) / width).clamp(0.0, 1.0);
241    let raw = self.min + frac as f64 * (self.max - self.min);
242    self.set_thumb(thumb, raw, cx);
243  }
244
245  fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
246    let current = if self.active == 0 {
247      self.value.0
248    } else {
249      self.value.1
250    };
251    match event.keystroke.key.as_str() {
252      "left" | "down" => self.set_thumb(self.active, current - self.step, cx),
253      "right" | "up" => self.set_thumb(self.active, current + self.step, cx),
254      "home" => self.set_thumb(self.active, self.min, cx),
255      "end" => self.set_thumb(self.active, self.max, cx),
256      _ => return,
257    }
258    cx.stop_propagation();
259  }
260
261  fn metrics(&self) -> (f32, f32) {
262    match self.size {
263      Size::Xs => (12.0, 4.0),
264      Size::Sm => (14.0, 5.0),
265      Size::Md => (16.0, 6.0),
266      Size::Lg => (20.0, 8.0),
267      Size::Xl => (24.0, 10.0),
268    }
269  }
270}
271
272/// Snap `raw` to the step grid.
273fn snap(raw: f64, min: f64, step: f64) -> f64 {
274  min + ((raw - min) / step).round() * step
275}
276
277/// Move one end of `current` toward `raw`, snapped and kept `min_gap` away
278/// from the other end, inside `min..=max`.
279fn clamp_thumb(
280  current: (f64, f64),
281  thumb: usize,
282  raw: f64,
283  min: f64,
284  max: f64,
285  step: f64,
286  min_gap: f64,
287) -> (f64, f64) {
288  let snapped = snap(raw, min, step);
289  if thumb == 0 {
290    let upper = (current.1 - min_gap).max(min);
291    (snapped.max(min).min(upper), current.1)
292  } else {
293    let lower = (current.0 + min_gap).min(max);
294    (current.0, snapped.min(max).max(lower))
295  }
296}
297
298/// Order, snap and clamp a raw pair, enforcing the gap where the range allows.
299fn normalize_pair(raw: (f64, f64), min: f64, max: f64, step: f64, min_gap: f64) -> (f64, f64) {
300  let (a, b) = if raw.0 <= raw.1 { raw } else { (raw.1, raw.0) };
301  let lo = snap(a, min, step).max(min).min((max - min_gap).max(min));
302  let hi = snap(b, min, step).min(max).max((lo + min_gap).min(max));
303  (lo, hi)
304}
305
306/// The knob whose painted extent contains local `x`, if any. Knob 1 paints
307/// last (topmost) and wins the subsequent drag when the knobs overlap, so it
308/// is checked first to stay consistent.
309fn thumb_under(x: f32, width: f32, f0: f32, f1: f32, thumb_w: f32) -> Option<usize> {
310  let hit = |frac: f32| (x - frac * width).abs() <= thumb_w / 2.0;
311  if hit(f1) {
312    Some(1)
313  } else if hit(f0) {
314    Some(0)
315  } else {
316    None
317  }
318}
319
320/// Which thumb a click at `raw` should move.
321fn nearest_thumb(lo: f64, hi: f64, raw: f64) -> usize {
322  if raw <= lo {
323    0
324  } else if raw >= hi {
325    1
326  } else if raw - lo < hi - raw {
327    0
328  } else {
329    1
330  }
331}
332
333impl Render for RangeSlider {
334  fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
335    let t = theme(cx);
336    let accent = t.color(self.color, t.primary_shade()).hsla();
337    let track_color = if t.scheme.is_dark() {
338      t.color(ColorName::Dark, 4)
339    } else {
340      t.color(ColorName::Gray, 2)
341    }
342    .hsla();
343    let knob_bg = t.surface().hsla();
344    let label_color = t.dimmed().hsla();
345    let font_xs = t.font_size(Size::Xs);
346
347    let (thumb, track_h) = self.metrics();
348    let container_h = thumb + 4.0;
349    let track_top = (container_h - track_h) / 2.0;
350    let (f0, f1) = (self.fraction(self.value.0), self.fraction(self.value.1));
351    let owner = cx.entity_id();
352
353    let track = div()
354      .absolute()
355      .left(px(0.0))
356      .right(px(0.0))
357      .top(px(track_top))
358      .h(px(track_h))
359      .rounded(px(track_h / 2.0))
360      .bg(track_color);
361
362    let fill = div()
363      .absolute()
364      .left(relative(f0))
365      .w(relative((f1 - f0).max(0.0)))
366      .top(px(track_top))
367      .h(px(track_h))
368      .rounded(px(track_h / 2.0))
369      .bg(accent);
370
371    let knob = |i: usize, frac: f32| {
372      div()
373        .id(("guise-rangeslider-thumb", i))
374        .absolute()
375        .left(relative(frac))
376        .ml(px(-thumb / 2.0))
377        .top(px(2.0))
378        .w(px(thumb))
379        .h(px(thumb))
380        .rounded(px(thumb / 2.0))
381        .bg(knob_bg)
382        .border_2()
383        .border_color(accent)
384        .cursor_grab()
385        .on_drag(
386          ThumbDrag { owner, thumb: i },
387          |_drag, _offset, _window, cx| cx.new(|_| Empty),
388        )
389    };
390
391    // Invisible canvas capturing the container's bounds for click math.
392    let this = cx.entity();
393    let bounds_probe = canvas(
394      move |bounds, _window, cx| {
395        this.update(cx, |this, _| this.bounds = bounds);
396      },
397      |_, _, _, _| {},
398    )
399    .absolute()
400    .size_full();
401
402    let slider = div()
403      .id("guise-rangeslider")
404      .track_focus(&self.focus)
405      .on_key_down(cx.listener(Self::on_key))
406      .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
407      .on_drag_move::<ThumbDrag>(cx.listener(Self::on_drag_move))
408      .relative()
409      .w_full()
410      .h(px(container_h))
411      .child(bounds_probe)
412      .child(track)
413      .child(fill)
414      .child(knob(0, f0))
415      .child(knob(1, f1));
416
417    let value_label =
418      div()
419        .text_size(px(font_xs))
420        .text_color(label_color)
421        .child(SharedString::from(format!(
422          "{} \u{2013} {}",
423          self.value.0, self.value.1
424        )));
425
426    let column = div()
427      .flex()
428      .flex_col()
429      .gap(px(4.0))
430      .child(slider)
431      .child(value_label);
432
433    let element = if self.disabled {
434      column.opacity(0.5)
435    } else {
436      column
437    };
438
439    element.probe("RangeSlider")
440  }
441}
442
443#[cfg(test)]
444mod tests {
445  use super::*;
446
447  #[test]
448  fn clamp_thumb_snaps_and_respects_bounds() {
449    assert_eq!(
450      clamp_thumb((20.0, 80.0), 0, 33.4, 0.0, 100.0, 1.0, 0.0),
451      (33.0, 80.0)
452    );
453    assert_eq!(
454      clamp_thumb((20.0, 80.0), 0, -10.0, 0.0, 100.0, 1.0, 0.0),
455      (0.0, 80.0)
456    );
457    assert_eq!(
458      clamp_thumb((20.0, 80.0), 1, 250.0, 0.0, 100.0, 1.0, 0.0),
459      (20.0, 100.0)
460    );
461  }
462
463  #[test]
464  fn clamp_thumb_enforces_the_gap() {
465    // Low thumb pushed past high stops `min_gap` short of it.
466    assert_eq!(
467      clamp_thumb((20.0, 50.0), 0, 60.0, 0.0, 100.0, 1.0, 10.0),
468      (40.0, 50.0)
469    );
470    // High thumb pushed past low stops `min_gap` above it.
471    assert_eq!(
472      clamp_thumb((20.0, 50.0), 1, 5.0, 0.0, 100.0, 1.0, 10.0),
473      (20.0, 30.0)
474    );
475    // The gap clamp never escapes min/max even when the gap can't fit.
476    assert_eq!(
477      clamp_thumb((0.0, 5.0), 0, -20.0, 0.0, 100.0, 1.0, 10.0),
478      (0.0, 5.0)
479    );
480  }
481
482  #[test]
483  fn clamp_thumb_snaps_to_coarse_steps() {
484    assert_eq!(
485      clamp_thumb((0.0, 100.0), 0, 37.0, 0.0, 100.0, 25.0, 0.0),
486      (25.0, 100.0)
487    );
488    assert_eq!(
489      clamp_thumb((0.0, 100.0), 0, 38.0, 0.0, 100.0, 25.0, 0.0),
490      (50.0, 100.0)
491    );
492  }
493
494  #[test]
495  fn normalize_orders_and_clamps_the_pair() {
496    assert_eq!(
497      normalize_pair((80.0, 20.0), 0.0, 100.0, 1.0, 0.0),
498      (20.0, 80.0)
499    );
500    assert_eq!(
501      normalize_pair((-5.0, 120.0), 0.0, 100.0, 1.0, 0.0),
502      (0.0, 100.0)
503    );
504    assert_eq!(
505      normalize_pair((40.0, 45.0), 0.0, 100.0, 1.0, 10.0),
506      (40.0, 50.0)
507    );
508    // A gap wider than the range collapses to the range itself.
509    assert_eq!(
510      normalize_pair((0.0, 100.0), 0.0, 100.0, 1.0, 500.0),
511      (0.0, 100.0)
512    );
513  }
514
515  #[test]
516  fn thumb_under_hits_knob_extents_only() {
517    // 400px track, values 50/52 of 0..100 → knob centers at 200 and 208px,
518    // a 16px knob spans ±8.
519    let (f0, f1) = (0.5, 0.52);
520    // Inside the high knob (and the low one) → the topmost wins.
521    assert_eq!(thumb_under(202.4, 400.0, f0, f1, 16.0), Some(1));
522    // Only inside the low knob.
523    assert_eq!(thumb_under(196.0, 400.0, f0, f1, 16.0), Some(0));
524    // On the bare track.
525    assert_eq!(thumb_under(100.0, 400.0, f0, f1, 16.0), None);
526    assert_eq!(thumb_under(300.0, 400.0, f0, f1, 16.0), None);
527    // Coincident knobs: the topmost (high) one wins.
528    assert_eq!(thumb_under(200.0, 400.0, 0.5, 0.5, 16.0), Some(1));
529  }
530
531  #[test]
532  fn nearest_thumb_splits_the_track() {
533    assert_eq!(nearest_thumb(20.0, 80.0, 5.0), 0);
534    assert_eq!(nearest_thumb(20.0, 80.0, 30.0), 0);
535    assert_eq!(nearest_thumb(20.0, 80.0, 70.0), 1);
536    assert_eq!(nearest_thumb(20.0, 80.0, 95.0), 1);
537    // Coincident thumbs: clicks left move the low, right the high.
538    assert_eq!(nearest_thumb(50.0, 50.0, 40.0), 0);
539    assert_eq!(nearest_thumb(50.0, 50.0, 60.0), 1);
540  }
541}