Skip to main content

gpui_kit/controls/
slider.rs

1//! A control for choosing a number inside a known range.
2
3use std::rc::Rc;
4
5use gpui::{
6    App, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, SharedString,
7    Styled, Window, div, prelude::FluentBuilder, px,
8};
9use gpui_kit_semantics::{NodeSpec, Role, Semantic};
10use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TypeScale};
11
12use crate::foundation::{
13    Disableable, FocusRing, Ident, Sizable, StyledExt, text as foundation_text,
14};
15use crate::layout::measure;
16use crate::motion::{self, keyed};
17
18/// Set by the slider's own pointer handlers, and cleared by the render that
19/// reads it.
20///
21/// A value the pointer is holding must be exactly under the pointer, so the
22/// spring is skipped for a change this slider caused itself and used for one
23/// that arrived from anywhere else.
24#[derive(Default)]
25struct PointerDriven(bool);
26
27type ChangeHandler = Rc<dyn Fn(f32, &mut Window, &mut App)>;
28
29/// A horizontal track with one handle.
30///
31/// The value is caller-owned: the slider reports where the typist pointed and
32/// renders whatever the caller decides, so a rejected or clamped change is
33/// visible as the value not moving.
34#[derive(IntoElement)]
35pub struct Slider {
36    ident: Ident,
37    label: Option<SharedString>,
38    min: f32,
39    max: f32,
40    value: f32,
41    step: Option<f32>,
42    size: ControlSize,
43    disabled: bool,
44    /// Rendered next to the label, for a unit the number alone does not carry.
45    display: Option<SharedString>,
46    on_change: Option<ChangeHandler>,
47}
48
49impl std::fmt::Debug for Slider {
50    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        formatter
52            .debug_struct("Slider")
53            .field("ident", &self.ident)
54            .field("range", &(self.min, self.max))
55            .field("value", &self.value)
56            .field("disabled", &self.disabled)
57            .field("has_handler", &self.on_change.is_some())
58            .finish()
59    }
60}
61
62impl Slider {
63    pub fn new(ident: impl Into<Ident>) -> Self {
64        Self {
65            ident: ident.into(),
66            label: None,
67            min: 0.0,
68            max: 1.0,
69            value: 0.0,
70            step: None,
71            size: ControlSize::Md,
72            disabled: false,
73            display: None,
74            on_change: None,
75        }
76    }
77
78    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
79        self.label = Some(label.into());
80        self
81    }
82
83    /// The bounds the value lives in. A reversed or empty range is corrected
84    /// here rather than producing a handle at an arbitrary position.
85    pub fn range(mut self, min: f32, max: f32) -> Self {
86        let (min, max) = if min <= max { (min, max) } else { (max, min) };
87        self.min = min;
88        self.max = if (max - min).abs() < f32::EPSILON {
89            min + 1.0
90        } else {
91            max
92        };
93        self
94    }
95
96    pub fn value(mut self, value: f32) -> Self {
97        self.value = value;
98        self
99    }
100
101    /// Rounds every reported value to a multiple of `step`.
102    pub fn step(mut self, step: f32) -> Self {
103        self.step = (step > 0.0).then_some(step);
104        self
105    }
106
107    /// What to show for the current value, such as `"70%"`.
108    pub fn display(mut self, display: impl Into<SharedString>) -> Self {
109        self.display = Some(display.into());
110        self
111    }
112
113    pub fn on_change(mut self, handler: impl Fn(f32, &mut Window, &mut App) + 'static) -> Self {
114        self.on_change = Some(Rc::new(handler));
115        self
116    }
117
118    fn clamped(&self) -> f32 {
119        self.value.clamp(self.min, self.max)
120    }
121
122    fn fraction(&self) -> f32 {
123        (self.clamped() - self.min) / (self.max - self.min)
124    }
125}
126
127impl Disableable for Slider {
128    fn disabled(mut self, disabled: bool) -> Self {
129        self.disabled = disabled;
130        self
131    }
132}
133
134impl Sizable for Slider {
135    fn control_size(mut self, size: ControlSize) -> Self {
136        self.size = size;
137        self
138    }
139}
140
141impl RenderOnce for Slider {
142    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
143        let theme = cx.theme().clone();
144        let metrics = theme.control.get(self.size);
145        let actionable = !self.disabled && self.on_change.is_some();
146        let dragging = keyed::slot::<PointerDriven>(&self.ident.semantic_id(), cx);
147        let snap = std::mem::take(&mut dragging.borrow_mut().0);
148        let fraction = motion::tracked_or_snap(
149            &self.ident.semantic_id(),
150            self.fraction(),
151            motion::tracking(&theme),
152            snap,
153            window,
154            cx,
155        );
156        let track_height = px(4.0);
157        // The handle is the control's only tappable part, so it is sized from
158        // the same scale step the other controls take their glyphs from.
159        let knob = px(metrics.icon_size);
160
161        // The track is the assertion target, not the row: an automated click
162        // on the centre of a slider has to land on something draggable.
163        let track_id = self.ident.clone();
164        // The handlers need the track's measured width to turn a pointer
165        // position into a value, and only prepaint knows it.
166        let measured = measure::cell(&track_id.semantic_id(), cx);
167        let mut track = div()
168            .id(track_id.element_id())
169            .relative()
170            .w_full()
171            .h(knob)
172            .flex()
173            .items_center()
174            .when(actionable, |element| element.cursor_pointer())
175            .child(
176                div()
177                    .absolute()
178                    .left_0()
179                    .right_0()
180                    .h(track_height)
181                    .rounded_full()
182                    .bg(theme.colors.hairline_strong),
183            )
184            .child(
185                div()
186                    .absolute()
187                    .left_0()
188                    .w(gpui::relative(fraction))
189                    .h(track_height)
190                    .rounded_full()
191                    .bg(theme.colors.accent),
192            )
193            .child(
194                div()
195                    .absolute()
196                    // The knob is centred on the value, so its own width is
197                    // taken out of the offset rather than pushing the handle
198                    // past the end of the track.
199                    .left(gpui::relative(fraction))
200                    .ml(-(knob / 2.0))
201                    .size(knob)
202                    .rounded_full()
203                    .bg(theme.colors.text)
204                    .border(px(theme.borders.hairline))
205                    .border_color(theme.colors.hairline_strong),
206            );
207
208        if actionable && let Some(handler) = self.on_change.clone() {
209            let (min, max, step) = (self.min, self.max, self.step);
210            let down = Rc::clone(&handler);
211            let down_bounds = Rc::clone(&measured);
212            let down_dragging = Rc::clone(&dragging);
213            track = track.on_mouse_down(MouseButton::Left, move |event, window, cx| {
214                let bounds = down_bounds.get();
215                let width = f32::from(bounds.size.width);
216                if width <= 0.0 {
217                    return;
218                }
219                down_dragging.borrow_mut().0 = true;
220                let fraction =
221                    (f32::from(event.position.x - bounds.left()) / width).clamp(0.0, 1.0);
222                down(
223                    quantize(min + fraction * (max - min), min, max, step),
224                    window,
225                    cx,
226                );
227            });
228
229            let drag = Rc::clone(&handler);
230            // Dragging is tracked as movement with the button held over the
231            // track, which is what a mouse reports without a captured drag
232            // payload. Leaving the track ends the drag.
233            let move_bounds = Rc::clone(&measured);
234            let move_dragging = Rc::clone(&dragging);
235            track = track.on_mouse_move(move |event, window, cx| {
236                if event.pressed_button != Some(MouseButton::Left) {
237                    return;
238                }
239                let bounds = move_bounds.get();
240                let width = f32::from(bounds.size.width);
241                if width <= 0.0 {
242                    return;
243                }
244                move_dragging.borrow_mut().0 = true;
245                let fraction =
246                    (f32::from(event.position.x - bounds.left()) / width).clamp(0.0, 1.0);
247                drag(
248                    quantize(min + fraction * (max - min), min, max, step),
249                    window,
250                    cx,
251                );
252            });
253        }
254
255        let value = self.clamped();
256        let keyboard_step = self.step.unwrap_or((self.max - self.min) / 20.0);
257        let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Slider)
258            .disabled(self.disabled)
259            .range(self.min, self.max, value);
260        if let Some(label) = self.label.clone() {
261            spec = spec.text(label);
262        }
263        if let Some(display) = self.display.clone() {
264            spec = spec.value(display);
265        }
266
267        let mut frame = div()
268            .id(self.ident.child("frame").element_id())
269            .flex()
270            .flex_col()
271            .gap(px(theme.space(Space::Xs)))
272            .w_full()
273            .when(self.disabled, |element| {
274                element.opacity(theme.opacity.disabled)
275            })
276            .when(actionable, |element| {
277                element.tab_index(0).focus_ring(&theme)
278            })
279            .when_some(self.label.clone(), |element, label| {
280                element.child(
281                    div()
282                        .flex()
283                        .flex_row()
284                        .justify_between()
285                        .child(
286                            foundation_text(&theme, TypeScale::Label, label)
287                                .text_size(px(metrics.font_size))
288                                .text_tone(&theme, gpui_kit_theme::TextTone::Muted),
289                        )
290                        .when_some(self.display.clone(), |element, display| {
291                            element.child(
292                                foundation_text(&theme, TypeScale::Label, display)
293                                    .text_size(px(metrics.font_size)),
294                            )
295                        }),
296                )
297            })
298            .child(
299                div()
300                    .w_full()
301                    .on_children_prepainted({
302                        let measured = Rc::clone(&measured);
303                        move |bounds, window, _| {
304                            if let Some(first) = bounds.first() {
305                                measure::record(&measured, *first, window);
306                            }
307                        }
308                    })
309                    .child(track)
310                    .semantic_in(cx, spec),
311            );
312
313        if actionable && let Some(handler) = self.on_change.clone() {
314            let (min, max) = (self.min, self.max);
315            frame.interactivity().on_key_down(move |event, window, cx| {
316                let next = match event.keystroke.key.as_str() {
317                    "left" | "down" => value - keyboard_step,
318                    "right" | "up" => value + keyboard_step,
319                    "home" => min,
320                    "end" => max,
321                    _ => return,
322                };
323                handler(next.clamp(min, max), window, cx);
324                cx.stop_propagation();
325            });
326        }
327
328        frame
329    }
330}
331
332/// Rounds a value onto the step grid, so a reported value is one the caller
333/// could have produced itself.
334fn quantize(value: f32, min: f32, max: f32, step: Option<f32>) -> f32 {
335    let value = value.clamp(min, max);
336    match step {
337        Some(step) => {
338            let steps = ((value - min) / step).round();
339            (min + steps * step).clamp(min, max)
340        }
341        None => value,
342    }
343}