Skip to main content

gpui_component/
slider.rs

1use std::sync::Arc;
2
3use crate::{ActiveTheme, AxisExt, StyledExt, ThemeStyled as _};
4pub use gpui_base::slider::{SliderEvent, SliderScale, SliderState, SliderValue};
5use gpui_base::{Slider as BaseSlider, SliderIndicator, SliderThumb, SliderTrack, spring};
6
7use gpui::{
8    App, Axis, Background, Corners, DefiniteLength, ElementId, Entity, EntityId, Hsla,
9    InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels, RenderOnce,
10    SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
11    prelude::FluentBuilder as _, px, relative,
12};
13
14/// Width of the translucent ring that grows outside a hovered thumb.
15const THUMB_RING_WIDTH: Pixels = px(3.);
16/// Opacity of the fully grown thumb ring.
17const THUMB_RING_OPACITY: f32 = 0.5;
18/// The animated hover ring of one thumb.
19struct ThumbRing {
20    interaction: Entity<ThumbInteraction>,
21    width: Pixels,
22    color: Hsla,
23}
24
25/// Pointer state of one thumb, written by its own listeners and read on the
26/// next frame to size the ring.
27#[derive(Default)]
28struct ThumbInteraction {
29    hovered: bool,
30    pressed: bool,
31}
32
33impl ThumbInteraction {
34    /// The ring shows while the pointer is over the thumb, and stays while the
35    /// thumb is dragged: dragging moves the thumb under the pointer, so hover
36    /// alone drops out for a frame on every move and the ring would flicker.
37    fn is_active(&self) -> bool {
38        self.hovered || self.pressed
39    }
40}
41
42impl ThumbRing {
43    /// Samples the ring for the `start` or end thumb of the slider `id`.
44    fn new(id: EntityId, start: bool, color: Hsla, window: &mut Window, cx: &mut App) -> Self {
45        let channel = if start { "start" } else { "end" };
46        let interaction = window.use_keyed_state(
47            ElementId::NamedChild(Arc::new(("slider-thumb-ring", id).into()), channel.into()),
48            cx,
49            |_, _| ThumbInteraction::default(),
50        );
51        let progress = spring(
52            (("slider-thumb-ring", id), channel),
53            if interaction.read(cx).is_active() {
54                1.
55            } else {
56                0.
57            },
58            cx.theme().motion_tokens().spring_control,
59            window,
60            cx,
61        );
62
63        Self {
64            interaction,
65            width: THUMB_RING_WIDTH * progress,
66            color: color.alpha(THUMB_RING_OPACITY * progress),
67        }
68    }
69
70    /// Returns a listener that records whether the thumb is being pressed.
71    fn press_listener<E>(&self, pressed: bool) -> impl Fn(&E, &mut Window, &mut App) + use<E> {
72        let interaction = self.interaction.clone();
73        move |_, _, cx| {
74            interaction.update(cx, |interaction, cx| {
75                if interaction.pressed != pressed {
76                    interaction.pressed = pressed;
77                    cx.notify();
78                }
79            });
80        }
81    }
82}
83
84/// A Slider element.
85#[derive(IntoElement)]
86pub struct Slider {
87    state: Entity<SliderState>,
88    axis: Axis,
89    style: StyleRefinement,
90    disabled: bool,
91    accessibility_label: Option<SharedString>,
92    reverse: bool,
93}
94
95impl Slider {
96    /// Create a new [`Slider`] element bind to the [`SliderState`].
97    pub fn new(state: &Entity<SliderState>) -> Self {
98        Self {
99            axis: Axis::Horizontal,
100            state: state.clone(),
101            style: StyleRefinement::default(),
102            disabled: false,
103            accessibility_label: None,
104            reverse: false,
105        }
106    }
107
108    /// As a horizontal slider.
109    pub fn horizontal(mut self) -> Self {
110        self.axis = Axis::Horizontal;
111        self
112    }
113
114    /// As a vertical slider.
115    pub fn vertical(mut self) -> Self {
116        self.axis = Axis::Vertical;
117        self
118    }
119
120    /// Set the disabled state of the slider, default: false
121    pub fn disabled(mut self, disabled: bool) -> Self {
122        self.disabled = disabled;
123        self
124    }
125
126    /// Sets the label exposed to accessibility clients.
127    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
128        self.accessibility_label = Some(label.into());
129        self
130    }
131
132    /// Reverse the filled (highlighted) side of the track, default: false.
133    ///
134    /// By default the track is filled from the min end to the thumb. With
135    /// `reverse`, the fill goes from the thumb to the max end instead — useful
136    /// when the slider represents a remaining amount (e.g. time left).
137    ///
138    /// This only changes the visual fill; values, events and interactions are
139    /// unaffected. It applies to single-value sliders and is ignored for
140    /// range sliders.
141    pub fn reverse(mut self) -> Self {
142        self.reverse = true;
143        self
144    }
145}
146
147impl Styled for Slider {
148    fn style(&mut self) -> &mut StyleRefinement {
149        &mut self.style
150    }
151}
152
153impl RenderOnce for Slider {
154    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
155        let axis = self.axis;
156        let state = self.state.read(cx);
157        let is_range = state.value().is_range();
158        let percentage = state.percentage();
159        let (bar_start, bar_end) = if self.reverse && !is_range {
160            // Fill from the thumb to the max end (remaining side).
161            (relative(percentage.end), relative(0.))
162        } else {
163            (relative(percentage.start), relative(1. - percentage.end))
164        };
165        let rem_size = window.rem_size();
166
167        let bar_color = self
168            .style
169            .background
170            .clone()
171            .and_then(|bg| bg.color())
172            .unwrap_or(cx.theme().tokens.slider_bar.into());
173        let thumb_bg: Background = self
174            .style
175            .text
176            .color
177            .map(Into::into)
178            .unwrap_or_else(|| cx.theme().tokens.slider_thumb.into());
179        let corner_radii = self.style.corner_radii.clone();
180        // The track is a pill by default, and square when the theme squares its
181        // corners. A caller's own corner radii still win.
182        let default_radius = cx.theme().radius_full();
183        let radius = Corners {
184            top_left: corner_radii
185                .top_left
186                .map(|v| v.to_pixels(rem_size))
187                .unwrap_or(default_radius),
188            top_right: corner_radii
189                .top_right
190                .map(|v| v.to_pixels(rem_size))
191                .unwrap_or(default_radius),
192            bottom_left: corner_radii
193                .bottom_left
194                .map(|v| v.to_pixels(rem_size))
195                .unwrap_or(default_radius),
196            bottom_right: corner_radii
197                .bottom_right
198                .map(|v| v.to_pixels(rem_size))
199                .unwrap_or(default_radius),
200        };
201
202        let ring_color = cx.theme().ring;
203        let entity_id = self.state.entity_id();
204        let start_ring = is_range.then(|| ThumbRing::new(entity_id, true, ring_color, window, cx));
205        let end_ring = ThumbRing::new(entity_id, false, ring_color, window, cx);
206
207        let thumb = |position: DefiniteLength, start: bool, ring: ThumbRing| {
208            SliderThumb::new(&self.state)
209                .axis(axis)
210                .start(start)
211                .disabled(self.disabled)
212                .when(!self.disabled, |this| {
213                    this.absolute()
214                        .when(axis.is_horizontal(), |this| {
215                            this.top(px(-5.)).left(position).ml(-px(8.))
216                        })
217                        .when(axis.is_vertical(), |this| {
218                            this.bottom(position).left(px(-5.)).mb(-px(8.))
219                        })
220                        .flex()
221                        .items_center()
222                        .justify_center()
223                        .flex_shrink_0()
224                        .rounded_full_style(cx)
225                        .bg(bar_color.opacity(0.5))
226                        .size_4()
227                        .p(px(1.))
228                        .on_hover({
229                            let interaction = ring.interaction.clone();
230                            move |entered, _, cx| {
231                                let entered = *entered;
232                                interaction.update(cx, |interaction, cx| {
233                                    if interaction.hovered != entered {
234                                        interaction.hovered = entered;
235                                        cx.notify();
236                                    }
237                                });
238                            }
239                        })
240                        // The base thumb stops propagation on mouse down to own
241                        // the drag, so the press is read in the capture phase.
242                        .capture_any_mouse_down(ring.press_listener(true))
243                        .capture_any_mouse_up(ring.press_listener(false))
244                        .on_mouse_up_out(MouseButton::Left, ring.press_listener(false))
245                        // The ring grows outward from the thumb's edge: inset by
246                        // its own width so its inner edge sits flush against it.
247                        .child(
248                            div()
249                                .flex_none()
250                                .absolute()
251                                .top(-ring.width)
252                                .left(-ring.width)
253                                .right(-ring.width)
254                                .bottom(-ring.width)
255                                .rounded_full_style(cx)
256                                .border(ring.width)
257                                .border_color(ring.color),
258                        )
259                        .child(
260                            div()
261                                .flex_shrink_0()
262                                .size_full()
263                                .rounded_full_style(cx)
264                                .bg(thumb_bg),
265                        )
266                })
267        };
268
269        BaseSlider::new(&self.state)
270            .axis(axis)
271            .disabled(self.disabled)
272            .when_some(self.accessibility_label, |this, label| {
273                this.accessibility_label(label)
274            })
275            .flex()
276            .flex_1()
277            .items_center()
278            .justify_center()
279            .when(axis.is_vertical(), |this| this.h(px(120.)))
280            .when(axis.is_horizontal(), |this| this.w_full())
281            .refine_style(&self.style)
282            .bg(cx.theme().transparent)
283            .text_color(cx.theme().foreground)
284            .child(
285                SliderTrack::new(&self.state)
286                    .axis(axis)
287                    .disabled(self.disabled)
288                    .flex()
289                    .when(axis.is_horizontal(), |this| {
290                        this.items_center().h_6().w_full()
291                    })
292                    .when(axis.is_vertical(), |this| {
293                        this.justify_center().w_6().h_full()
294                    })
295                    .flex_shrink_0()
296                    .child(
297                        SliderIndicator::new(&self.state)
298                            .relative()
299                            .when(axis.is_horizontal(), |this| this.w_full().h_1p5())
300                            .when(axis.is_vertical(), |this| this.h_full().w_1p5())
301                            .bg(bar_color.opacity(0.2))
302                            .active(|this| this.bg(bar_color.opacity(0.4)))
303                            .corner_radii(radius)
304                            .child(
305                                div()
306                                    .absolute()
307                                    .when(axis.is_horizontal(), |this| {
308                                        this.h_full().left(bar_start).right(bar_end)
309                                    })
310                                    .when(axis.is_vertical(), |this| {
311                                        this.w_full().bottom(bar_start).top(bar_end)
312                                    })
313                                    .bg(bar_color)
314                                    .rounded_full_style(cx),
315                            )
316                            .when_some(start_ring, |this, ring| {
317                                this.child(thumb(relative(percentage.start), true, ring))
318                            })
319                            .child(thumb(relative(percentage.end), false, end_ring)),
320                    ),
321            )
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use gpui::{AppContext as _, Context, Modifiers, Render, TestAppContext, point};
328
329    use super::*;
330
331    struct Harness {
332        state: Entity<SliderState>,
333        disabled: bool,
334    }
335
336    impl Render for Harness {
337        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
338            div()
339                .w(px(100.))
340                .h(px(24.))
341                .child(Slider::new(&self.state).disabled(self.disabled))
342        }
343    }
344
345    fn harness(
346        cx: &mut TestAppContext,
347        disabled: bool,
348    ) -> (&mut gpui::VisualTestContext, Entity<SliderState>) {
349        cx.update(crate::theme::init);
350        let state = cx.new(|_| SliderState::new());
351        let result = state.clone();
352        let (_, cx) = cx.add_window_view(move |_, _| Harness { state, disabled });
353        cx.update(|window, cx| window.draw(cx).clear(cx));
354        (cx, result)
355    }
356
357    #[gpui::test]
358    fn pointer_updates_the_migrated_state(cx: &mut TestAppContext) {
359        let (cx, state) = harness(cx, false);
360        cx.simulate_click(point(px(50.), px(12.)), Modifiers::default());
361        cx.update(|_, cx| assert!((state.read(cx).value().end() - 50.).abs() < 1.));
362    }
363
364    #[gpui::test]
365    fn disabled_slider_is_inert(cx: &mut TestAppContext) {
366        let (cx, state) = harness(cx, true);
367        cx.simulate_click(point(px(50.), px(12.)), Modifiers::default());
368        cx.update(|_, cx| assert_eq!(state.read(cx).value(), SliderValue::Single(0.)));
369    }
370}