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