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(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
197 if self.disabled {
198 return;
199 }
200 window.focus(&self.focus);
201 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
272fn snap(raw: f64, min: f64, step: f64) -> f64 {
274 min + ((raw - min) / step).round() * step
275}
276
277fn 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
298fn 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
306fn 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
320fn 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 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 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 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 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 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 let (f0, f1) = (0.5, 0.52);
520 assert_eq!(thumb_under(202.4, 400.0, f0, f1, 16.0), Some(1));
522 assert_eq!(thumb_under(196.0, 400.0, f0, f1, 16.0), Some(0));
524 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 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 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}