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::reactive::Signal;
25use crate::theme::{theme, ColorName, Size};
26
27#[derive(Debug, Clone, Copy)]
29pub struct RangeSliderEvent(pub (f64, f64));
30
31struct ThumbDrag {
35 owner: EntityId,
36 thumb: usize,
37}
38
39pub 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 active: usize,
52 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 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 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 pub fn value_pair(&self) -> (f64, f64) {
120 self.value
121 }
122
123 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 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 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 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 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
276fn snap(raw: f64, step: f64) -> f64 {
278 (raw / step).round() * step
279}
280
281fn 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
302fn 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
310fn 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
324fn 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 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 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 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 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 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 let (f0, f1) = (0.5, 0.52);
522 assert_eq!(thumb_under(202.4, 400.0, f0, f1, 16.0), Some(1));
524 assert_eq!(thumb_under(196.0, 400.0, f0, f1, 16.0), Some(0));
526 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 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 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}