Skip to main content

gpui_cn/
slider.rs

1use gpui_kit::{
2    App, Axis, ElementId, Entity, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton,
3    ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
4    Styled, Window,
5    base::{
6        self, Disableable, StyledExt as _, TestSupportExt as _,
7        slider::{SliderEvent, SliderState, SliderValue},
8    },
9    div, point,
10    prelude::FluentBuilder as _,
11    px, relative,
12};
13
14use crate::{ActiveTheme as _, Theme};
15
16/// A shadcn-style slider on `gpui_base::Slider`: a rounded track, a filled
17/// range from the start to the thumb, and a round thumb.
18///
19/// Base owns the pointer behavior (a press on the track moves the thumb,
20/// a drag follows the pointer) and the slider role with its value. The
21/// range, the step, and the value live in a
22/// [`SliderState`](gpui_kit::base::slider::SliderState), which emits a
23/// `Change` event as the value moves and a `Release` event when the
24/// pointer lets go. This type owns the look and the keyboard: with focus,
25/// the arrow keys move the thumb one step, Page Up and Page Down move it a
26/// tenth of the range, and Home and End go to the ends.
27///
28/// With [`stops`](Self::stops) the track shows a tick at each of `n` evenly
29/// spaced values. The state's step should be the range divided by `n - 1`,
30/// so the thumb lands on the ticks.
31///
32/// ```no_run
33/// use gpui_cn::{Slider, SliderState};
34/// use gpui_kit::{AppContext as _, Context};
35///
36/// fn effort(cx: &mut Context<()>) -> Slider {
37///     let state = cx.new(|_| SliderState::new().min(0.).max(5.).step(1.).default_value(2.));
38///     Slider::new(&state).stops(6)
39/// }
40/// ```
41///
42/// The look follows the switch and the radio: a thin fully rounded rail in
43/// a translucent ink, the filled range in the primary color, and a
44/// round white thumb with a hairline and a soft shadow, centered on the rail.
45/// The rail is inset by half the thumb at each end, so the thumb at either
46/// end stays inside the control's box, and the ticks of `stops` are
47/// centered on the stop positions by the same math.
48///
49/// `Styled` refinements apply to the control's box, which is as wide as its
50/// parent by default.
51#[derive(IntoElement)]
52#[non_exhaustive]
53pub struct Slider {
54    state: Entity<SliderState>,
55    id: ElementId,
56    style: StyleRefinement,
57    disabled: bool,
58    stops: Option<usize>,
59    accessibility_label: Option<SharedString>,
60    tab_index: isize,
61    tab_stop: bool,
62}
63
64impl Slider {
65    /// A slider on `state`. The id is derived from the state.
66    pub fn new(state: &Entity<SliderState>) -> Self {
67        Self {
68            state: state.clone(),
69            id: ("cn-slider", state.entity_id()).into(),
70            style: StyleRefinement::default(),
71            disabled: false,
72            stops: None,
73            accessibility_label: None,
74            tab_index: 0,
75            tab_stop: true,
76        }
77    }
78
79    /// The control's id, for tests and focus.
80    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
81        self.id = id.into();
82        self
83    }
84
85    /// Shows a tick at each of `count` evenly spaced values, the ends
86    /// included. Fewer than two ticks show nothing.
87    pub fn stops(mut self, count: usize) -> Self {
88        self.stops = Some(count);
89        self
90    }
91
92    /// The number of ticks the track shows, if it shows any.
93    pub fn stop_count(&self) -> Option<usize> {
94        self.stops
95    }
96
97    /// The name a screen reader announces.
98    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
99        self.accessibility_label = Some(label.into());
100        self
101    }
102
103    /// The focus traversal index. The default is `0`.
104    pub fn tab_index(mut self, tab_index: isize) -> Self {
105        self.tab_index = tab_index;
106        self
107    }
108
109    /// Whether Tab reaches the slider. The default is `true`.
110    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
111        self.tab_stop = tab_stop;
112        self
113    }
114}
115
116impl Disableable for Slider {
117    fn disabled(mut self, disabled: bool) -> Self {
118        self.disabled = disabled;
119        self
120    }
121}
122
123impl Styled for Slider {
124    fn style(&mut self) -> &mut StyleRefinement {
125        &mut self.style
126    }
127}
128
129/// The value a key moves a slider to, or `None` when the key does nothing
130/// or the value does not change.
131fn nudged(key: &str, value: f32, min: f32, max: f32, step: f32) -> Option<f32> {
132    if ![value, min, max, step].iter().all(|n| n.is_finite()) || min > max {
133        return None;
134    }
135    let page = ((max - min) / 10.).max(step);
136    let next = match key {
137        "right" | "up" => value + step,
138        "left" | "down" => value - step,
139        "pageup" => value + page,
140        "pagedown" => value - page,
141        "home" => min,
142        "end" => max,
143        _ => return None,
144    }
145    .clamp(min, max);
146    ((next - value).abs() > f32::EPSILON).then_some(next)
147}
148
149impl RenderOnce for Slider {
150    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
151        let pointer_cursors = Theme::global(cx).pointer_cursors;
152        let disabled = self.disabled;
153        let percentage = self.state.read(cx).percentage().end.clamp(0., 1.);
154        let (rail, range, thumb, border, tick, tick_on, ring, lift) = {
155            let theme = cx.theme();
156            (
157                theme.progress_track,
158                theme.primary(),
159                theme.switch_thumb,
160                theme.field_border,
161                theme.muted_foreground(),
162                theme.solid_foreground,
163                theme.focus_ring(),
164                theme.base.shadow.sm.clone(),
165            )
166        };
167        let (track_height, thumb_size, tick_size, ring_spread, hit_height, radius, strength) = {
168            let theme = cx.theme();
169            let metrics = &theme.metrics;
170            (
171                metrics.slider_track,
172                metrics.slider_thumb,
173                metrics.slider_tick,
174                metrics.focus_ring,
175                if theme.touch {
176                    metrics.control_md
177                } else {
178                    metrics.slider_thumb
179                },
180                theme.radius_full(),
181                theme.disabled_opacity,
182            )
183        };
184        let focus_handle = window
185            .use_keyed_state(
186                ElementId::NamedChild(self.id.clone().into(), "focus".into()),
187                cx,
188                |_, cx| cx.focus_handle(),
189            )
190            .read(cx)
191            .clone();
192        let focus_visible = focus_handle.is_focused(window) && window.last_input_was_keyboard();
193        let state = self.state.clone();
194        let track_id = ElementId::NamedChild(self.id.clone().into(), "track".into());
195        let thumb_id = ElementId::NamedChild(self.id.clone().into(), "thumb".into());
196        let stops = self.stops.filter(|count| *count >= 2);
197        let tick_id = self.id.clone();
198        let ticks = stops.into_iter().flat_map(move |count| {
199            let tick_id = tick_id.clone();
200            (0..count).map(move |index| {
201                let at = index as f32 / (count - 1) as f32;
202                div()
203                    .id(ElementId::NamedChild(
204                        tick_id.clone().into(),
205                        format!("tick-{index}").into(),
206                    ))
207                    .test_support()
208                    .absolute()
209                    .top(relative(0.5))
210                    .left(relative(at))
211                    .mt(-tick_size / 2.)
212                    .ml(-tick_size / 2.)
213                    .size(tick_size)
214                    .rounded(radius)
215                    .bg(if at <= percentage + f32::EPSILON {
216                        tick_on
217                    } else {
218                        tick
219                    })
220            })
221        });
222
223        div()
224            .id(self.id)
225            .test_support()
226            .flex()
227            .items_center()
228            .w_full()
229            .h(hit_height)
230            .when(disabled, |this| this.opacity(strength))
231            .map(|this| {
232                if !disabled && pointer_cursors {
233                    this.cursor_pointer()
234                } else {
235                    this.cursor_default()
236                }
237            })
238            .when_some(self.accessibility_label, |this, label| {
239                this.aria_label(label)
240            })
241            .refine_style(&self.style)
242            .when(!disabled, |this| {
243                let focus = focus_handle.clone();
244                let handle = focus_handle
245                    .tab_index(self.tab_index)
246                    .tab_stop(self.tab_stop);
247                this.track_focus(&handle)
248                    .on_mouse_down(MouseButton::Left, move |_, window, cx| {
249                        window.focus(&focus, cx);
250                    })
251                    .on_key_down(move |event: &KeyDownEvent, window, cx| {
252                        let (value, min, max, step) = {
253                            let state = state.read(cx);
254                            (
255                                state.value().end(),
256                                state.min_value(),
257                                state.max_value(),
258                                state.step_value(),
259                            )
260                        };
261                        if let Some(next) =
262                            nudged(event.keystroke.key.as_str(), value, min, max, step)
263                        {
264                            cx.stop_propagation();
265                            state.update(cx, |state, cx| {
266                                state.set_value(next, window, cx);
267                                let value = SliderValue::Single(next);
268                                cx.emit(SliderEvent::Change(value));
269                                cx.emit(SliderEvent::Release(value));
270                            });
271                        }
272                    })
273            })
274            .child(
275                base::Slider::new(&self.state)
276                    .axis(Axis::Horizontal)
277                    .disabled(disabled)
278                    .flex()
279                    .items_center()
280                    .w_full()
281                    .h(hit_height)
282                    .child(
283                        base::SliderTrack::new(&self.state)
284                            .axis(Axis::Horizontal)
285                            .disabled(disabled)
286                            .flex()
287                            .items_center()
288                            .w_full()
289                            .h(hit_height)
290                            .child(
291                                div().w_full().mx(thumb_size / 2.).child(
292                                    base::SliderIndicator::new(&self.state)
293                                        .relative()
294                                        .w_full()
295                                        .h(track_height)
296                                        .child(
297                                            div()
298                                                .id(track_id)
299                                                .test_support()
300                                                .absolute()
301                                                .size_full()
302                                                .rounded(radius)
303                                                .bg(rail)
304                                                .child(
305                                                    div()
306                                                        .absolute()
307                                                        .top_0()
308                                                        .bottom_0()
309                                                        .left_0()
310                                                        .w(relative(percentage))
311                                                        .rounded(radius)
312                                                        .bg(range),
313                                                ),
314                                        )
315                                        .children(ticks)
316                                        .child(
317                                            base::SliderThumb::new(&self.state)
318                                                .axis(Axis::Horizontal)
319                                                .disabled(disabled)
320                                                .absolute()
321                                                .top(relative(0.5))
322                                                .left(relative(percentage))
323                                                .mt(-thumb_size / 2.)
324                                                .ml(-thumb_size / 2.)
325                                                .size(thumb_size)
326                                                .child(
327                                                    div()
328                                                        .id(thumb_id)
329                                                        .test_support()
330                                                        .size_full()
331                                                        .rounded(radius)
332                                                        .bg(thumb)
333                                                        .border_1()
334                                                        .border_color(border)
335                                                        .shadow(if focus_visible {
336                                                            let mut shadows = lift.clone();
337                                                            shadows.push(gpui_kit::BoxShadow {
338                                                                color: ring,
339                                                                offset: point(px(0.), px(0.)),
340                                                                blur_radius: px(0.),
341                                                                spread_radius: ring_spread,
342                                                                inset: false,
343                                                            });
344                                                            shadows
345                                                        } else {
346                                                            lift
347                                                        }),
348                                                ),
349                                        ),
350                                ),
351                            ),
352                    ),
353            )
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn a_key_never_panics_on_a_reversed_or_broken_range() {
363        assert_eq!(nudged("right", 1., 5., 0., 1.), None, "min above max");
364        assert_eq!(nudged("right", 1., 0., f32::NAN, 1.), None);
365        assert_eq!(nudged("end", 1., 0., 5., f32::INFINITY), None);
366        assert_eq!(nudged("left", f32::NAN, 0., 5., 1.), None);
367        assert_eq!(nudged("right", 2., 0., 5., 1.), Some(3.));
368        assert_eq!(nudged("end", 2., 0., 5., 1.), Some(5.));
369        assert_eq!(nudged("right", 5., 0., 5., 1.), None);
370    }
371
372    #[test]
373    fn keys_move_the_value_one_step_and_clamp_at_the_ends() {
374        assert_eq!(nudged("right", 2., 0., 5., 1.), Some(3.));
375        assert_eq!(nudged("up", 2., 0., 5., 1.), Some(3.));
376        assert_eq!(nudged("left", 2., 0., 5., 1.), Some(1.));
377        assert_eq!(nudged("down", 2., 0., 5., 1.), Some(1.));
378        assert_eq!(nudged("home", 2., 0., 5., 1.), Some(0.));
379        assert_eq!(nudged("end", 2., 0., 5., 1.), Some(5.));
380        assert_eq!(nudged("right", 5., 0., 5., 1.), None, "already at the end");
381        assert_eq!(nudged("left", 0., 0., 5., 1.), None);
382        assert_eq!(nudged("pageup", 10., 0., 100., 1.), Some(20.));
383        assert_eq!(nudged("pagedown", 3., 0., 100., 1.), Some(0.));
384        assert_eq!(nudged("a", 2., 0., 5., 1.), None);
385    }
386
387    #[test]
388    fn a_page_is_never_smaller_than_a_step() {
389        assert_eq!(nudged("pageup", 0., 0., 5., 1.), Some(1.));
390    }
391}