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    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    reverse: bool,
92}
93
94impl Slider {
95    /// Create a new [`Slider`] element bind to the [`SliderState`].
96    pub fn new(state: &Entity<SliderState>) -> Self {
97        Self {
98            axis: Axis::Horizontal,
99            state: state.clone(),
100            style: StyleRefinement::default(),
101            disabled: false,
102            reverse: false,
103        }
104    }
105
106    /// As a horizontal slider.
107    pub fn horizontal(mut self) -> Self {
108        self.axis = Axis::Horizontal;
109        self
110    }
111
112    /// As a vertical slider.
113    pub fn vertical(mut self) -> Self {
114        self.axis = Axis::Vertical;
115        self
116    }
117
118    /// Set the disabled state of the slider, default: false
119    pub fn disabled(mut self, disabled: bool) -> Self {
120        self.disabled = disabled;
121        self
122    }
123
124    /// Reverse the filled (highlighted) side of the track, default: false.
125    ///
126    /// By default the track is filled from the min end to the thumb. With
127    /// `reverse`, the fill goes from the thumb to the max end instead — useful
128    /// when the slider represents a remaining amount (e.g. time left).
129    ///
130    /// This only changes the visual fill; values, events and interactions are
131    /// unaffected. It applies to single-value sliders and is ignored for
132    /// range sliders.
133    pub fn reverse(mut self) -> Self {
134        self.reverse = true;
135        self
136    }
137}
138
139impl Styled for Slider {
140    fn style(&mut self) -> &mut StyleRefinement {
141        &mut self.style
142    }
143}
144
145impl RenderOnce for Slider {
146    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
147        let axis = self.axis;
148        let state = self.state.read(cx);
149        let is_range = state.value().is_range();
150        let percentage = state.percentage();
151        let (bar_start, bar_end) = if self.reverse && !is_range {
152            // Fill from the thumb to the max end (remaining side).
153            (relative(percentage.end), relative(0.))
154        } else {
155            (relative(percentage.start), relative(1. - percentage.end))
156        };
157        let rem_size = window.rem_size();
158
159        let bar_color = self
160            .style
161            .background
162            .clone()
163            .and_then(|bg| bg.color())
164            .unwrap_or(cx.theme().tokens.slider_bar.into());
165        let thumb_bg: Background = self
166            .style
167            .text
168            .color
169            .map(Into::into)
170            .unwrap_or_else(|| cx.theme().tokens.slider_thumb.into());
171        let corner_radii = self.style.corner_radii.clone();
172        // The track is a pill by default, and square when the theme squares its
173        // corners. A caller's own corner radii still win.
174        let default_radius = cx.theme().radius_full();
175        let radius = Corners {
176            top_left: corner_radii
177                .top_left
178                .map(|v| v.to_pixels(rem_size))
179                .unwrap_or(default_radius),
180            top_right: corner_radii
181                .top_right
182                .map(|v| v.to_pixels(rem_size))
183                .unwrap_or(default_radius),
184            bottom_left: corner_radii
185                .bottom_left
186                .map(|v| v.to_pixels(rem_size))
187                .unwrap_or(default_radius),
188            bottom_right: corner_radii
189                .bottom_right
190                .map(|v| v.to_pixels(rem_size))
191                .unwrap_or(default_radius),
192        };
193
194        let ring_color = cx.theme().ring;
195        let entity_id = self.state.entity_id();
196        let start_ring = is_range.then(|| ThumbRing::new(entity_id, true, ring_color, window, cx));
197        let end_ring = ThumbRing::new(entity_id, false, ring_color, window, cx);
198
199        let thumb = |position: DefiniteLength, start: bool, ring: ThumbRing| {
200            SliderThumb::new(&self.state)
201                .axis(axis)
202                .start(start)
203                .disabled(self.disabled)
204                .when(!self.disabled, |this| {
205                    this.absolute()
206                        .when(axis.is_horizontal(), |this| {
207                            this.top(px(-5.)).left(position).ml(-px(8.))
208                        })
209                        .when(axis.is_vertical(), |this| {
210                            this.bottom(position).left(px(-5.)).mb(-px(8.))
211                        })
212                        .flex()
213                        .items_center()
214                        .justify_center()
215                        .flex_shrink_0()
216                        .rounded_full_style(cx)
217                        .bg(bar_color.opacity(0.5))
218                        .size_4()
219                        .p(px(1.))
220                        .on_hover({
221                            let interaction = ring.interaction.clone();
222                            move |entered, _, cx| {
223                                let entered = *entered;
224                                interaction.update(cx, |interaction, cx| {
225                                    if interaction.hovered != entered {
226                                        interaction.hovered = entered;
227                                        cx.notify();
228                                    }
229                                });
230                            }
231                        })
232                        // The base thumb stops propagation on mouse down to own
233                        // the drag, so the press is read in the capture phase.
234                        .capture_any_mouse_down(ring.press_listener(true))
235                        .capture_any_mouse_up(ring.press_listener(false))
236                        .on_mouse_up_out(MouseButton::Left, ring.press_listener(false))
237                        // The ring grows outward from the thumb's edge: inset by
238                        // its own width so its inner edge sits flush against it.
239                        .child(
240                            div()
241                                .flex_none()
242                                .absolute()
243                                .top(-ring.width)
244                                .left(-ring.width)
245                                .right(-ring.width)
246                                .bottom(-ring.width)
247                                .rounded_full_style(cx)
248                                .border(ring.width)
249                                .border_color(ring.color),
250                        )
251                        .child(
252                            div()
253                                .flex_shrink_0()
254                                .size_full()
255                                .rounded_full_style(cx)
256                                .bg(thumb_bg),
257                        )
258                })
259        };
260
261        BaseSlider::new(&self.state)
262            .axis(axis)
263            .disabled(self.disabled)
264            .flex()
265            .flex_1()
266            .items_center()
267            .justify_center()
268            .when(axis.is_vertical(), |this| this.h(px(120.)))
269            .when(axis.is_horizontal(), |this| this.w_full())
270            .refine_style(&self.style)
271            .bg(cx.theme().transparent)
272            .text_color(cx.theme().foreground)
273            .child(
274                SliderTrack::new(&self.state)
275                    .axis(axis)
276                    .disabled(self.disabled)
277                    .flex()
278                    .when(axis.is_horizontal(), |this| {
279                        this.items_center().h_6().w_full()
280                    })
281                    .when(axis.is_vertical(), |this| {
282                        this.justify_center().w_6().h_full()
283                    })
284                    .flex_shrink_0()
285                    .child(
286                        SliderIndicator::new(&self.state)
287                            .relative()
288                            .when(axis.is_horizontal(), |this| this.w_full().h_1p5())
289                            .when(axis.is_vertical(), |this| this.h_full().w_1p5())
290                            .bg(bar_color.opacity(0.2))
291                            .active(|this| this.bg(bar_color.opacity(0.4)))
292                            .corner_radii(radius)
293                            .child(
294                                div()
295                                    .absolute()
296                                    .when(axis.is_horizontal(), |this| {
297                                        this.h_full().left(bar_start).right(bar_end)
298                                    })
299                                    .when(axis.is_vertical(), |this| {
300                                        this.w_full().bottom(bar_start).top(bar_end)
301                                    })
302                                    .bg(bar_color)
303                                    .rounded_full_style(cx),
304                            )
305                            .when_some(start_ring, |this, ring| {
306                                this.child(thumb(relative(percentage.start), true, ring))
307                            })
308                            .child(thumb(relative(percentage.end), false, end_ring)),
309                    ),
310            )
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use gpui::{AppContext as _, Context, Modifiers, Render, TestAppContext, point};
317
318    use super::*;
319
320    struct Harness {
321        state: Entity<SliderState>,
322        disabled: bool,
323    }
324
325    impl Render for Harness {
326        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
327            div()
328                .w(px(100.))
329                .h(px(24.))
330                .child(Slider::new(&self.state).disabled(self.disabled))
331        }
332    }
333
334    fn harness(
335        cx: &mut TestAppContext,
336        disabled: bool,
337    ) -> (&mut gpui::VisualTestContext, Entity<SliderState>) {
338        cx.update(crate::theme::init);
339        let state = cx.new(|_| SliderState::new());
340        let result = state.clone();
341        let (_, cx) = cx.add_window_view(move |_, _| Harness { state, disabled });
342        cx.update(|window, cx| window.draw(cx).clear(cx));
343        (cx, result)
344    }
345
346    #[gpui::test]
347    fn pointer_updates_the_migrated_state(cx: &mut TestAppContext) {
348        let (cx, state) = harness(cx, false);
349        cx.simulate_click(point(px(50.), px(12.)), Modifiers::default());
350        cx.update(|_, cx| assert!((state.read(cx).value().end() - 50.).abs() < 1.));
351    }
352
353    #[gpui::test]
354    fn disabled_slider_is_inert(cx: &mut TestAppContext) {
355        let (cx, state) = harness(cx, true);
356        cx.simulate_click(point(px(50.), px(12.)), Modifiers::default());
357        cx.update(|_, cx| assert_eq!(state.read(cx).value(), SliderValue::Single(0.)));
358    }
359}