Skip to main content

guise/input/
slider.rs

1//! `Slider` — a draggable value track (gpui entity).
2//!
3//! Holds a continuous value in `min..=max` snapped to `step`. The track paints a
4//! filled portion and a knob; an overlaid row of invisible segment cells turns
5//! clicks into values (gpui doesn't hand elements their own bounds, so position
6//! is derived from discrete cells rather than the raw pointer x). Arrow keys
7//! nudge by one step. Emits [`SliderEvent`] on change.
8
9use gpui::prelude::*;
10use gpui::{
11    div, px, relative, App, Context, Entity, EventEmitter, FocusHandle, IntoElement, KeyDownEvent,
12    SharedString, Window,
13};
14
15use crate::devtools::Probed;
16use crate::reactive::Signal;
17use crate::theme::{theme, ColorName, Size};
18
19/// Emitted when the slider value changes.
20#[derive(Debug, Clone, Copy)]
21pub struct SliderEvent(pub f64);
22
23/// A horizontal slider. Create with `cx.new(|cx| Slider::new(cx))`.
24pub struct Slider {
25    value: f64,
26    min: f64,
27    max: f64,
28    step: f64,
29    color: ColorName,
30    focus: FocusHandle,
31    disabled: bool,
32}
33
34impl EventEmitter<SliderEvent> for Slider {}
35
36impl Slider {
37    pub fn new(cx: &mut Context<Self>) -> Self {
38        Slider {
39            value: 0.0,
40            min: 0.0,
41            max: 100.0,
42            step: 1.0,
43            color: ColorName::Blue,
44            focus: cx.focus_handle(),
45            disabled: false,
46        }
47    }
48
49    pub fn value(mut self, value: f64) -> Self {
50        self.value = value;
51        self
52    }
53
54    pub fn min(mut self, min: f64) -> Self {
55        self.min = min;
56        self
57    }
58
59    pub fn max(mut self, max: f64) -> Self {
60        self.max = max;
61        self
62    }
63
64    pub fn step(mut self, step: f64) -> Self {
65        self.step = step.max(f64::EPSILON);
66        self
67    }
68
69    pub fn color(mut self, color: ColorName) -> Self {
70        self.color = color;
71        self
72    }
73
74    pub fn disabled(mut self, disabled: bool) -> Self {
75        self.disabled = disabled;
76        self
77    }
78
79    pub fn value_f64(&self) -> f64 {
80        self.value
81    }
82
83    /// Two-way bind this slider's value to a `Signal<f64>`. The signal is the
84    /// source of truth: the slider adopts its value now (snapped to `step`),
85    /// drags write back through [`Signal::set_if_changed`], and signal writes
86    /// move the knob without emitting [`SliderEvent`]. Equality guards on both
87    /// directions prevent update loops.
88    pub fn bind(entity: &Entity<Slider>, signal: &Signal<f64>, cx: &mut App) {
89        let initial = signal.get(cx);
90        entity.update(cx, |this, cx| this.sync_value(initial, cx));
91        let sink = signal.clone();
92        cx.subscribe(entity, move |_slider, event: &SliderEvent, cx| {
93            sink.set_if_changed(cx, event.0);
94        })
95        .detach();
96        let slider = entity.downgrade();
97        cx.observe(signal.entity(), move |observed, cx| {
98            let value = *observed.read(cx);
99            slider
100                .update(cx, |this, cx| this.sync_value(value, cx))
101                .ok();
102        })
103        .detach();
104    }
105
106    /// Programmatic set: snap and repaint without emitting an event.
107    fn sync_value(&mut self, raw: f64, cx: &mut Context<Self>) {
108        let next = self.snap(raw);
109        if next != self.value {
110            self.value = next;
111            cx.notify();
112        }
113    }
114
115    fn fraction(&self) -> f32 {
116        if self.max <= self.min {
117            0.0
118        } else {
119            (((self.value - self.min) / (self.max - self.min)) as f32).clamp(0.0, 1.0)
120        }
121    }
122
123    fn snap(&self, raw: f64) -> f64 {
124        snap(raw, self.min, self.max, self.step)
125    }
126
127    /// Move the handle programmatically. The value is clamped to the track
128    /// and snapped to the step, so a caller can pass a raw number.
129    pub fn set_value(&mut self, raw: f64, cx: &mut Context<Self>) {
130        if self.disabled {
131            return;
132        }
133        let next = self.snap(raw);
134        if next != self.value {
135            self.value = next;
136            cx.emit(SliderEvent(next));
137            cx.notify();
138        }
139    }
140
141    fn segment_count(&self) -> usize {
142        (((self.max - self.min) / self.step).round() as usize).clamp(1, 200)
143    }
144
145    fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
146        match event.keystroke.key.as_str() {
147            "left" | "down" => self.set_value(self.value - self.step, cx),
148            "right" | "up" => self.set_value(self.value + self.step, cx),
149            "home" => self.set_value(self.min, cx),
150            "end" => self.set_value(self.max, cx),
151            _ => return,
152        }
153        cx.stop_propagation();
154    }
155}
156
157fn snap(raw: f64, min: f64, max: f64, step: f64) -> f64 {
158    let stepped = min + ((raw - min) / step).round() * step;
159    stepped.clamp(min, max)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn step_grid_starts_at_minimum() {
168        assert_eq!(snap(6.8, 5.0, 15.0, 2.0), 7.0);
169        assert_eq!(snap(14.8, 5.0, 15.0, 2.0), 15.0);
170    }
171}
172
173impl Render for Slider {
174    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
175        let t = theme(cx);
176        let accent = t.color(self.color, t.primary_shade()).hsla();
177        let track_color = if t.scheme.is_dark() {
178            t.color(ColorName::Dark, 4)
179        } else {
180            t.color(ColorName::Gray, 2)
181        }
182        .hsla();
183        let knob_bg = t.surface().hsla();
184        let frac = self.fraction();
185
186        let knob = div()
187            .w(px(16.0))
188            .h(px(16.0))
189            .rounded(px(8.0))
190            .bg(knob_bg)
191            .border_2()
192            .border_color(accent);
193
194        let fill = div()
195            .h_full()
196            .w(relative(frac))
197            .rounded(px(3.0))
198            .bg(accent)
199            .flex()
200            .items_center()
201            .justify_end()
202            .child(knob);
203
204        let track = div()
205            .relative()
206            .w_full()
207            .h(px(6.0))
208            .rounded(px(3.0))
209            .bg(track_color)
210            .flex()
211            .items_center()
212            .child(fill);
213
214        let count = self.segment_count();
215        let min = self.min;
216        let span = self.max - self.min;
217        let mut overlay = div()
218            .absolute()
219            .top(px(0.0))
220            .left(px(0.0))
221            .right(px(0.0))
222            .bottom(px(0.0))
223            .flex()
224            .flex_row()
225            .items_center();
226        for i in 0..count {
227            let raw = min + (i as f64) / ((count - 1).max(1) as f64) * span;
228            overlay = overlay.child(
229                div()
230                    .id(("guise-slider-seg", i))
231                    .flex_grow()
232                    .flex_basis(relative(0.0))
233                    .h_full()
234                    .on_click(cx.listener(move |this, _ev, _window, cx| this.set_value(raw, cx))),
235            );
236        }
237
238        let slider = div()
239            .id("guise-slider")
240            .track_focus(&self.focus)
241            .on_key_down(cx.listener(Self::on_key))
242            .relative()
243            .w_full()
244            .h(px(20.0))
245            .flex()
246            .items_center()
247            .child(track)
248            .child(overlay);
249
250        // A label keeps the value visible while dragging via clicks.
251        let value_label = div()
252            .text_size(px(t.font_size(Size::Xs)))
253            .text_color(t.dimmed().hsla())
254            .child(SharedString::from(format!("{}", self.value)));
255
256        let column = div()
257            .flex()
258            .flex_col()
259            .gap(px(4.0))
260            .child(slider)
261            .child(value_label);
262
263        let element = if self.disabled {
264            column.opacity(0.5)
265        } else {
266            column
267        };
268
269        element.probe("Slider")
270    }
271}