gpui_component/
slider.rs

1use std::ops::Range;
2
3use crate::{h_flex, tooltip::Tooltip, ActiveTheme, AxisExt, StyledExt};
4use gpui::{
5    canvas, div, prelude::FluentBuilder as _, px, Along, App, AppContext as _, Axis, Background,
6    Bounds, Context, Corners, DragMoveEvent, Empty, Entity, EntityId, EventEmitter, Hsla,
7    InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels,
8    Point, Render, RenderOnce, StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
9};
10
11#[derive(Clone)]
12pub struct DragThumb((EntityId, bool));
13
14impl Render for DragThumb {
15    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
16        Empty
17    }
18}
19
20pub enum SliderEvent {
21    Change(SliderValue),
22}
23
24/// The value of the slider, can be a single value or a range of values.
25///
26/// - Can from a f32 value, which will be treated as a single value.
27/// - Or from a (f32, f32) tuple, which will be treated as a range of values.
28///
29/// The default value is `SliderValue::Single(0.0)`.
30#[derive(Clone, Copy, Debug, PartialEq)]
31pub enum SliderValue {
32    Single(f32),
33    Range(f32, f32),
34}
35
36impl std::fmt::Display for SliderValue {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            SliderValue::Single(value) => write!(f, "{}", value),
40            SliderValue::Range(start, end) => write!(f, "{}..{}", start, end),
41        }
42    }
43}
44
45impl From<f32> for SliderValue {
46    fn from(value: f32) -> Self {
47        SliderValue::Single(value)
48    }
49}
50
51impl From<(f32, f32)> for SliderValue {
52    fn from(value: (f32, f32)) -> Self {
53        SliderValue::Range(value.0, value.1)
54    }
55}
56
57impl From<Range<f32>> for SliderValue {
58    fn from(value: Range<f32>) -> Self {
59        SliderValue::Range(value.start, value.end)
60    }
61}
62
63impl Default for SliderValue {
64    fn default() -> Self {
65        SliderValue::Single(0.)
66    }
67}
68
69impl SliderValue {
70    /// Clamp the value to the given range.
71    pub fn clamp(self, min: f32, max: f32) -> Self {
72        match self {
73            SliderValue::Single(value) => SliderValue::Single(value.clamp(min, max)),
74            SliderValue::Range(start, end) => {
75                SliderValue::Range(start.clamp(min, max), end.clamp(min, max))
76            }
77        }
78    }
79
80    #[inline]
81    pub fn is_single(&self) -> bool {
82        matches!(self, SliderValue::Single(_))
83    }
84
85    #[inline]
86    pub fn is_range(&self) -> bool {
87        matches!(self, SliderValue::Range(_, _))
88    }
89
90    pub fn start(&self) -> f32 {
91        match self {
92            SliderValue::Single(value) => *value,
93            SliderValue::Range(start, _) => *start,
94        }
95    }
96
97    pub fn end(&self) -> f32 {
98        match self {
99            SliderValue::Single(value) => *value,
100            SliderValue::Range(_, end) => *end,
101        }
102    }
103
104    fn set_start(&mut self, value: f32) {
105        if let SliderValue::Range(_, end) = self {
106            *self = SliderValue::Range(value.min(*end), *end);
107        } else {
108            *self = SliderValue::Single(value);
109        }
110    }
111
112    fn set_end(&mut self, value: f32) {
113        if let SliderValue::Range(start, _) = self {
114            *self = SliderValue::Range(*start, value.max(*start));
115        } else {
116            *self = SliderValue::Single(value);
117        }
118    }
119}
120
121/// State of the [`Slider`].
122pub struct SliderState {
123    min: f32,
124    max: f32,
125    step: f32,
126    value: SliderValue,
127    /// When is single value mode, only `end` is used, the start is always 0.0.
128    percentage: Range<f32>,
129    /// The bounds of the slider after rendered.
130    bounds: Bounds<Pixels>,
131}
132
133impl SliderState {
134    pub fn new() -> Self {
135        Self {
136            min: 0.0,
137            max: 100.0,
138            step: 1.0,
139            value: SliderValue::default(),
140            percentage: (0.0..0.0),
141            bounds: Bounds::default(),
142        }
143    }
144
145    /// Set the minimum value of the slider, default: 0.0
146    pub fn min(mut self, min: f32) -> Self {
147        self.min = min;
148        self.update_thumb_pos();
149        self
150    }
151
152    /// Set the maximum value of the slider, default: 100.0
153    pub fn max(mut self, max: f32) -> Self {
154        self.max = max;
155        self.update_thumb_pos();
156        self
157    }
158
159    /// Set the step value of the slider, default: 1.0
160    pub fn step(mut self, step: f32) -> Self {
161        self.step = step;
162        self
163    }
164
165    /// Set the default value of the slider, default: 0.0
166    pub fn default_value(mut self, value: impl Into<SliderValue>) -> Self {
167        self.value = value.into();
168        self.update_thumb_pos();
169        self
170    }
171
172    /// Set the value of the slider.
173    pub fn set_value(
174        &mut self,
175        value: impl Into<SliderValue>,
176        _: &mut Window,
177        cx: &mut Context<Self>,
178    ) {
179        self.value = value.into();
180        self.update_thumb_pos();
181        cx.notify();
182    }
183
184    /// Get the value of the slider.
185    pub fn value(&self) -> SliderValue {
186        self.value
187    }
188
189    fn update_thumb_pos(&mut self) {
190        match self.value {
191            SliderValue::Single(value) => {
192                let percentage = value.clamp(self.min, self.max) / self.max;
193                self.percentage = 0.0..percentage;
194            }
195            SliderValue::Range(start, end) => {
196                let clamped_start = start.clamp(self.min, self.max);
197                let clamped_end = end.clamp(self.min, self.max);
198                self.percentage = (clamped_start / self.max)..(clamped_end / self.max);
199            }
200        }
201    }
202
203    /// Update value by mouse position
204    fn update_value_by_position(
205        &mut self,
206        axis: Axis,
207        position: Point<Pixels>,
208        is_start: bool,
209        _: &mut Window,
210        cx: &mut Context<Self>,
211    ) {
212        let bounds = self.bounds;
213        let min = self.min;
214        let max = self.max;
215        let step = self.step;
216
217        let inner_pos = if axis.is_horizontal() {
218            position.x - bounds.left()
219        } else {
220            bounds.bottom() - position.y
221        };
222        let total_size = bounds.size.along(axis);
223        let percentage = inner_pos.clamp(px(0.), total_size) / total_size;
224
225        let percentage = if is_start {
226            percentage.clamp(0.0, self.percentage.end)
227        } else {
228            percentage.clamp(self.percentage.start, 1.0)
229        };
230
231        let value = min + (max - min) * percentage;
232        let value = (value / step).round() * step;
233
234        if is_start {
235            self.percentage.start = percentage;
236            self.value.set_start(value);
237        } else {
238            self.percentage.end = percentage;
239            self.value.set_end(value);
240        }
241        cx.emit(SliderEvent::Change(self.value));
242        cx.notify();
243    }
244}
245
246impl EventEmitter<SliderEvent> for SliderState {}
247impl Render for SliderState {
248    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
249        Empty
250    }
251}
252
253/// A Slider element.
254#[derive(IntoElement)]
255pub struct Slider {
256    state: Entity<SliderState>,
257    axis: Axis,
258    style: StyleRefinement,
259    disabled: bool,
260}
261
262impl Slider {
263    /// Create a new [`Slider`] element bind to the [`SliderState`].
264    pub fn new(state: &Entity<SliderState>) -> Self {
265        Self {
266            axis: Axis::Horizontal,
267            state: state.clone(),
268            style: StyleRefinement::default(),
269            disabled: false,
270        }
271    }
272
273    /// As a horizontal slider.
274    pub fn horizontal(mut self) -> Self {
275        self.axis = Axis::Horizontal;
276        self
277    }
278
279    /// As a vertical slider.
280    pub fn vertical(mut self) -> Self {
281        self.axis = Axis::Vertical;
282        self
283    }
284
285    /// Set the disabled state of the slider, default: false
286    pub fn disabled(mut self, disabled: bool) -> Self {
287        self.disabled = disabled;
288        self
289    }
290
291    #[allow(clippy::too_many_arguments)]
292    fn render_thumb(
293        &self,
294        start_pos: Pixels,
295        is_start: bool,
296        bar_color: Background,
297        thumb_color: Hsla,
298        radius: Corners<Pixels>,
299        window: &mut Window,
300        cx: &mut App,
301    ) -> impl gpui::IntoElement {
302        let state = self.state.read(cx);
303        let entity_id = self.state.entity_id();
304        let value = state.value;
305        let axis = self.axis;
306        let id = ("slider-thumb", is_start as u32);
307
308        if self.disabled {
309            return div().id(id);
310        }
311
312        div()
313            .id(id)
314            .absolute()
315            .when(axis.is_horizontal(), |this| {
316                this.top(px(-5.)).left(start_pos).ml(-px(8.))
317            })
318            .when(axis.is_vertical(), |this| {
319                this.bottom(start_pos).left(px(-5.)).mb(-px(8.))
320            })
321            .flex()
322            .items_center()
323            .justify_center()
324            .flex_shrink_0()
325            .corner_radii(radius)
326            .bg(bar_color.opacity(0.5))
327            .when(cx.theme().shadow, |this| this.shadow_md())
328            .size_4()
329            .p(px(1.))
330            .child(
331                div()
332                    .flex_shrink_0()
333                    .size_full()
334                    .corner_radii(radius)
335                    .bg(thumb_color),
336            )
337            .on_mouse_down(MouseButton::Left, |_, _, cx| {
338                cx.stop_propagation();
339            })
340            .on_drag(DragThumb((entity_id, is_start)), |drag, _, _, cx| {
341                cx.stop_propagation();
342                cx.new(|_| drag.clone())
343            })
344            .on_drag_move(window.listener_for(
345                &self.state,
346                move |view, e: &DragMoveEvent<DragThumb>, window, cx| {
347                    match e.drag(cx) {
348                        DragThumb((id, is_start)) => {
349                            if *id != entity_id {
350                                return;
351                            }
352
353                            // set value by mouse position
354                            view.update_value_by_position(
355                                axis,
356                                e.event.position,
357                                *is_start,
358                                window,
359                                cx,
360                            )
361                        }
362                    }
363                },
364            ))
365            .tooltip(move |window, cx| {
366                Tooltip::new(format!(
367                    "{}",
368                    if is_start { value.start() } else { value.end() }
369                ))
370                .build(window, cx)
371            })
372    }
373}
374
375impl Styled for Slider {
376    fn style(&mut self) -> &mut StyleRefinement {
377        &mut self.style
378    }
379}
380
381impl RenderOnce for Slider {
382    fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
383        let axis = self.axis;
384        let state = self.state.read(cx);
385        let is_range = state.value().is_range();
386        let bar_size = state.bounds.size.along(axis);
387        let bar_start = state.percentage.start * bar_size;
388        let bar_end = state.percentage.end * bar_size;
389        let rem_size = window.rem_size();
390
391        let bar_color = self
392            .style
393            .background
394            .clone()
395            .and_then(|bg| bg.color())
396            .unwrap_or(cx.theme().slider_bar.into());
397        let thumb_color = self
398            .style
399            .text
400            .clone()
401            .and_then(|text| text.color)
402            .unwrap_or_else(|| cx.theme().slider_thumb);
403        let corner_radii = self.style.corner_radii.clone();
404        let default_radius = px(999.);
405        let radius = Corners {
406            top_left: corner_radii
407                .top_left
408                .map(|v| v.to_pixels(rem_size))
409                .unwrap_or(default_radius),
410            top_right: corner_radii
411                .top_right
412                .map(|v| v.to_pixels(rem_size))
413                .unwrap_or(default_radius),
414            bottom_left: corner_radii
415                .bottom_left
416                .map(|v| v.to_pixels(rem_size))
417                .unwrap_or(default_radius),
418            bottom_right: corner_radii
419                .bottom_right
420                .map(|v| v.to_pixels(rem_size))
421                .unwrap_or(default_radius),
422        };
423
424        div()
425            .id(("slider", self.state.entity_id()))
426            .flex()
427            .flex_1()
428            .items_center()
429            .justify_center()
430            .when(axis.is_vertical(), |this| this.h(px(120.)))
431            .when(axis.is_horizontal(), |this| this.w_full())
432            .refine_style(&self.style)
433            .bg(cx.theme().transparent)
434            .text_color(cx.theme().foreground)
435            .child(
436                h_flex()
437                    .when(!self.disabled, |this| {
438                        this.on_mouse_down(
439                            MouseButton::Left,
440                            window.listener_for(
441                                &self.state,
442                                move |state, e: &MouseDownEvent, window, cx| {
443                                    let mut is_start = false;
444                                    if is_range {
445                                        let inner_pos = if axis.is_horizontal() {
446                                            e.position.x - state.bounds.left()
447                                        } else {
448                                            state.bounds.bottom() - e.position.y
449                                        };
450                                        let center = (bar_end - bar_start) / 2.0 + bar_start;
451                                        is_start = inner_pos < center;
452                                    }
453
454                                    state.update_value_by_position(
455                                        axis, e.position, is_start, window, cx,
456                                    )
457                                },
458                            ),
459                        )
460                    })
461                    .when(axis.is_horizontal(), |this| {
462                        this.items_center().h_6().w_full()
463                    })
464                    .when(axis.is_vertical(), |this| {
465                        this.justify_center().w_6().h_full()
466                    })
467                    .flex_shrink_0()
468                    .child(
469                        div()
470                            .id("slider-bar")
471                            .relative()
472                            .when(axis.is_horizontal(), |this| this.w_full().h_1p5())
473                            .when(axis.is_vertical(), |this| this.h_full().w_1p5())
474                            .bg(bar_color.opacity(0.2))
475                            .active(|this| this.bg(bar_color.opacity(0.4)))
476                            .corner_radii(radius)
477                            .child(
478                                div()
479                                    .absolute()
480                                    .when(axis.is_horizontal(), |this| {
481                                        this.h_full().left(bar_start).right(bar_size - bar_end)
482                                    })
483                                    .when(axis.is_vertical(), |this| {
484                                        this.w_full().bottom(bar_start).top(bar_size - bar_end)
485                                    })
486                                    .bg(bar_color)
487                                    .rounded_full(),
488                            )
489                            .when(is_range, |this| {
490                                this.child(self.render_thumb(
491                                    bar_start,
492                                    true,
493                                    bar_color,
494                                    thumb_color,
495                                    radius,
496                                    window,
497                                    cx,
498                                ))
499                            })
500                            .child(self.render_thumb(
501                                bar_end,
502                                false,
503                                bar_color,
504                                thumb_color,
505                                radius,
506                                window,
507                                cx,
508                            ))
509                            .child({
510                                let state = self.state.clone();
511                                canvas(
512                                    move |bounds, _, cx| state.update(cx, |r, _| r.bounds = bounds),
513                                    |_, _, _, _| {},
514                                )
515                                .absolute()
516                                .size_full()
517                            }),
518                    ),
519            )
520    }
521}