Skip to main content

ui/components/
slider.rs

1use std::{cell::Cell, rc::Rc};
2
3use gpui::{Bounds, Context, MouseButton, MouseDownEvent, MouseMoveEvent, Pixels, Render, canvas};
4
5use crate::prelude::*;
6
7const THUMB_SIZE: Pixels = px(16.);
8const TRACK_HEIGHT: Pixels = px(4.);
9
10/// A single-value or range slider with pointer-drag on the track/thumb.
11///
12/// Pointer geometry mirrors [`crate::scrollbar`]'s thumb-drag pattern: mouse
13/// position within the track bounds maps to a clamped 0.0–1.0 fraction, then to
14/// `[min, max]`.
15///
16/// Stateful view — create with `cx.new(|cx| Slider::new(cx))`.
17pub struct Slider {
18    min: f32,
19    max: f32,
20    step: f32,
21    /// Single-thumb value, or low end when `range` is enabled.
22    value: f32,
23    /// High end when range mode is enabled.
24    range_high: f32,
25    range_mode: bool,
26    disabled: bool,
27    dragging_thumb: Option<usize>,
28    track_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
29}
30
31impl Slider {
32    pub fn new(_cx: &mut Context<Self>) -> Self {
33        Self {
34            min: 0.,
35            max: 100.,
36            step: 1.,
37            value: 0.,
38            range_high: 100.,
39            range_mode: false,
40            disabled: false,
41            dragging_thumb: None,
42            track_bounds: Rc::new(Cell::new(None)),
43        }
44    }
45
46    pub fn min(mut self, min: f32) -> Self {
47        self.min = min;
48        self
49    }
50
51    pub fn max(mut self, max: f32) -> Self {
52        self.max = max;
53        self
54    }
55
56    pub fn step(mut self, step: f32) -> Self {
57        self.step = step.max(f32::MIN_POSITIVE);
58        self
59    }
60
61    pub fn value(mut self, value: f32) -> Self {
62        self.value = value;
63        self
64    }
65
66    /// Enables two-thumb range mode with `(low, high)` initial values.
67    pub fn range(mut self, low: f32, high: f32) -> Self {
68        self.range_mode = true;
69        self.value = low;
70        self.range_high = high;
71        self
72    }
73
74    pub fn disabled(mut self, disabled: bool) -> Self {
75        self.disabled = disabled;
76        self
77    }
78
79    pub fn values(&self) -> (f32, f32) {
80        if self.range_mode {
81            (
82                self.clamp_value(self.value),
83                self.clamp_value(self.range_high),
84            )
85        } else {
86            (self.clamp_value(self.value), self.clamp_value(self.value))
87        }
88    }
89
90    fn clamp_value(&self, raw: f32) -> f32 {
91        // `f32::clamp` panics if `min > max`; guard against a caller passing
92        // an inverted range via `.min()`/`.max()` by normalizing the bounds
93        // here rather than trusting call order.
94        let (lo, hi) = (self.min.min(self.max), self.min.max(self.max));
95        let clamped = raw.clamp(lo, hi);
96        if self.step <= 0. {
97            return clamped;
98        }
99        let steps = ((clamped - lo) / self.step).round();
100        (lo + steps * self.step).clamp(lo, hi)
101    }
102
103    fn fraction_from_x(&self, x: Pixels, bounds: &Bounds<Pixels>) -> f32 {
104        if bounds.size.width <= Pixels::ZERO {
105            return 0.;
106        }
107        let offset = (x - bounds.origin.x).clamp(Pixels::ZERO, bounds.size.width);
108        (offset / bounds.size.width).clamp(0., 1.)
109    }
110
111    fn value_from_x(&self, x: Pixels, bounds: &Bounds<Pixels>) -> f32 {
112        let fraction = self.fraction_from_x(x, bounds);
113        self.clamp_value(self.min + fraction * (self.max - self.min))
114    }
115
116    fn nearest_thumb(&self, value: f32) -> usize {
117        if !self.range_mode {
118            return 0;
119        }
120        let low_dist = (value - self.value).abs();
121        let high_dist = (value - self.range_high).abs();
122        if high_dist < low_dist { 1 } else { 0 }
123    }
124
125    fn set_thumb_value(&mut self, thumb: usize, value: f32, cx: &mut Context<Self>) {
126        let value = self.clamp_value(value);
127        if self.range_mode {
128            if thumb == 0 {
129                self.value = value.min(self.range_high);
130            } else {
131                self.range_high = value.max(self.value);
132            }
133        } else {
134            self.value = value;
135        }
136        cx.notify();
137    }
138
139    fn update_from_position(&mut self, x: Pixels, thumb: Option<usize>, cx: &mut Context<Self>) {
140        let Some(bounds) = self.track_bounds.get() else {
141            return;
142        };
143        let value = self.value_from_x(x, &bounds);
144        let thumb = thumb.unwrap_or_else(|| self.nearest_thumb(value));
145        self.set_thumb_value(thumb, value, cx);
146    }
147}
148
149impl Render for Slider {
150    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
151        let (low, high) = self.values();
152        let span = (self.max - self.min).max(f32::MIN_POSITIVE);
153        let low_pct = (low - self.min) / span;
154        let high_pct = (high - self.min) / span;
155        let range_mode = self.range_mode;
156        let disabled = self.disabled;
157        let track_bounds = self.track_bounds.clone();
158        let dragging = self.dragging_thumb;
159
160        let thumb = |id: ElementId, pct: f32| {
161            div()
162                .id(id)
163                .absolute()
164                .top(px(-6.))
165                .left(relative(pct))
166                .ml(-THUMB_SIZE / 2.)
167                .size(THUMB_SIZE)
168                .rounded_full()
169                .bg(palette::primary(600))
170                .border_2()
171                .border_color(semantic::elevated_surface(cx))
172                .shadow_level(Shadow::Sm)
173                .when(!disabled, |this| this.cursor_grab())
174        };
175
176        let range_left = low_pct.min(high_pct);
177        let range_width = (high_pct - low_pct).abs();
178
179        div()
180            .id("slider")
181            .w_full()
182            .py_3()
183            .when(disabled, |this| this.opacity(0.5))
184            .child(
185                div()
186                    .id("slider-track-container")
187                    .relative()
188                    .w_full()
189                    .h(THUMB_SIZE)
190                    .child(
191                        div()
192                            .absolute()
193                            .top(px(6.))
194                            .left_0()
195                            .right_0()
196                            .h(TRACK_HEIGHT)
197                            .rounded_full()
198                            .bg(semantic::border_muted(cx)),
199                    )
200                    .child(
201                        div()
202                            .absolute()
203                            .top(px(6.))
204                            .left(relative(range_left))
205                            .w(relative(range_width.max(if range_mode {
206                                0.
207                            } else {
208                                high_pct
209                            })))
210                            .h(TRACK_HEIGHT)
211                            .rounded_full()
212                            .bg(palette::primary(500)),
213                    )
214                    .child(thumb("slider-thumb-low".into(), low_pct))
215                    .when(range_mode, |this| {
216                        this.child(thumb("slider-thumb-high".into(), high_pct))
217                    })
218                    .child({
219                        let track_bounds = track_bounds.clone();
220                        canvas(
221                            move |bounds, _window, _cx| track_bounds.set(Some(bounds)),
222                            |_bounds, _state, _window, _cx| {},
223                        )
224                        .absolute()
225                        .top_0()
226                        .left_0()
227                        .size_full()
228                    })
229                    .when(!disabled, |this| {
230                        this.on_mouse_down(
231                            MouseButton::Left,
232                            cx.listener(|this, event: &MouseDownEvent, _window, cx| {
233                                let value = this
234                                    .track_bounds
235                                    .get()
236                                    .map(|b| this.value_from_x(event.position.x, &b));
237                                let thumb = value.map(|v| this.nearest_thumb(v));
238                                this.dragging_thumb = thumb;
239                                this.update_from_position(event.position.x, thumb, cx);
240                            }),
241                        )
242                        .on_mouse_up(
243                            MouseButton::Left,
244                            cx.listener(|this, _, _, cx| {
245                                this.dragging_thumb = None;
246                                cx.notify();
247                            }),
248                        )
249                        .on_mouse_move(cx.listener(
250                            |this, event: &MouseMoveEvent, _, cx| {
251                                if event.dragging() || this.dragging_thumb.is_some() {
252                                    this.update_from_position(
253                                        event.position.x,
254                                        this.dragging_thumb,
255                                        cx,
256                                    );
257                                }
258                            },
259                        ))
260                    }),
261            )
262            .child(
263                h_flex()
264                    .mt_1()
265                    .justify_between()
266                    .child(
267                        Label::new(format!("{low:.0}"))
268                            .size(LabelSize::XSmall)
269                            .color(Color::Muted),
270                    )
271                    .when(range_mode, |this| {
272                        this.child(
273                            Label::new(format!("{high:.0}"))
274                                .size(LabelSize::XSmall)
275                                .color(Color::Muted),
276                        )
277                    }),
278            )
279            .when(dragging.is_some(), |this| this.cursor_grabbing())
280    }
281}
282
283impl Component for Slider {
284    fn scope() -> ComponentScope {
285        ComponentScope::Input
286    }
287
288    fn description() -> Option<&'static str> {
289        Some("A slider for selecting a single value or a range via pointer drag.")
290    }
291
292    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
293        Some(
294            v_flex()
295                .gap_6()
296                .w(px(280.))
297                .child(cx.new(|cx| Slider::new(cx).value(40.)))
298                .child(cx.new(|cx| Slider::new(cx).range(25., 75.)))
299                .into_any_element(),
300        )
301    }
302}