1use 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#[derive(Debug, Clone, Copy)]
30pub struct RangeSliderEvent(pub (f64, f64));
31
32struct ThumbDrag {
36 owner: EntityId,
37 thumb: usize,
38}
39
40pub 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 active: usize,
53 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 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 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 pub fn value_pair(&self) -> (f64, f64) {
121 self.value
122 }
123
124 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 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 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 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 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
277fn snap(raw: f64, min: f64, step: f64) -> f64 {
279 min + ((raw - min) / step).round() * step
280}
281
282fn 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
303fn 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
311fn 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
325fn 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 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 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 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 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 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 let (f0, f1) = (0.5, 0.52);
525 assert_eq!(thumb_under(202.4, 400.0, f0, f1, 16.0), Some(1));
527 assert_eq!(thumb_under(196.0, 400.0, f0, f1, 16.0), Some(0));
529 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 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 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}