gpui_component/
slider.rs

1use std::ops::Range;
2
3use crate::{h_flex, 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)]
12struct 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
20#[derive(Clone)]
21struct DragSlider(EntityId);
22
23impl Render for DragSlider {
24    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
25        Empty
26    }
27}
28
29/// Events emitted by the [`SliderState`].
30pub enum SliderEvent {
31    Change(SliderValue),
32}
33
34/// The value of the slider, can be a single value or a range of values.
35///
36/// - Can from a f32 value, which will be treated as a single value.
37/// - Or from a (f32, f32) tuple, which will be treated as a range of values.
38///
39/// The default value is `SliderValue::Single(0.0)`.
40#[derive(Clone, Copy, Debug, PartialEq)]
41pub enum SliderValue {
42    Single(f32),
43    Range(f32, f32),
44}
45
46impl std::fmt::Display for SliderValue {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            SliderValue::Single(value) => write!(f, "{}", value),
50            SliderValue::Range(start, end) => write!(f, "{}..{}", start, end),
51        }
52    }
53}
54
55impl From<f32> for SliderValue {
56    fn from(value: f32) -> Self {
57        SliderValue::Single(value)
58    }
59}
60
61impl From<(f32, f32)> for SliderValue {
62    fn from(value: (f32, f32)) -> Self {
63        SliderValue::Range(value.0, value.1)
64    }
65}
66
67impl From<Range<f32>> for SliderValue {
68    fn from(value: Range<f32>) -> Self {
69        SliderValue::Range(value.start, value.end)
70    }
71}
72
73impl Default for SliderValue {
74    fn default() -> Self {
75        SliderValue::Single(0.)
76    }
77}
78
79impl SliderValue {
80    /// Clamp the value to the given range.
81    pub fn clamp(self, min: f32, max: f32) -> Self {
82        match self {
83            SliderValue::Single(value) => SliderValue::Single(value.clamp(min, max)),
84            SliderValue::Range(start, end) => {
85                SliderValue::Range(start.clamp(min, max), end.clamp(min, max))
86            }
87        }
88    }
89
90    /// Check if the value is a single value.
91    #[inline]
92    pub fn is_single(&self) -> bool {
93        matches!(self, SliderValue::Single(_))
94    }
95
96    /// Check if the value is a range of values.
97    #[inline]
98    pub fn is_range(&self) -> bool {
99        matches!(self, SliderValue::Range(_, _))
100    }
101
102    /// Get the start value.
103    pub fn start(&self) -> f32 {
104        match self {
105            SliderValue::Single(value) => *value,
106            SliderValue::Range(start, _) => *start,
107        }
108    }
109
110    /// Get the end value.
111    pub fn end(&self) -> f32 {
112        match self {
113            SliderValue::Single(value) => *value,
114            SliderValue::Range(_, end) => *end,
115        }
116    }
117
118    fn set_start(&mut self, value: f32) {
119        if let SliderValue::Range(_, end) = self {
120            *self = SliderValue::Range(value.min(*end), *end);
121        } else {
122            *self = SliderValue::Single(value);
123        }
124    }
125
126    fn set_end(&mut self, value: f32) {
127        if let SliderValue::Range(start, _) = self {
128            *self = SliderValue::Range(*start, value.max(*start));
129        } else {
130            *self = SliderValue::Single(value);
131        }
132    }
133}
134
135/// The scale mode of the slider.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
137pub enum SliderScale {
138    /// Linear scale where values change uniformly across the slider range.
139    /// This is the default mode.
140    #[default]
141    Linear,
142    /// Logarithmic scale where the distance between values increases exponentially.
143    ///
144    /// This is useful for parameters that have a large range of values where smaller
145    /// changes are more significant at lower values. Common examples include:
146    ///
147    /// - Volume controls (human hearing perception is logarithmic)
148    /// - Frequency controls (musical notes follow a logarithmic scale)
149    /// - Zoom levels
150    /// - Any parameter where you want finer control at lower values
151    ///
152    /// # For example
153    ///
154    /// ```
155    /// use gpui_component::slider::{SliderState, SliderScale};
156    ///
157    /// let slider = SliderState::new()
158    ///     .min(1.0)    // Must be > 0 for logarithmic scale
159    ///     .max(1000.0)
160    ///     .scale(SliderScale::Logarithmic);
161    /// ```
162    ///
163    /// - Moving the slider 1/3 of the way will yield ~10
164    /// - Moving it 2/3 of the way will yield ~100
165    /// - The full range covers 3 orders of magnitude evenly
166    Logarithmic,
167}
168
169impl SliderScale {
170    #[inline]
171    pub fn is_linear(&self) -> bool {
172        matches!(self, SliderScale::Linear)
173    }
174
175    #[inline]
176    pub fn is_logarithmic(&self) -> bool {
177        matches!(self, SliderScale::Logarithmic)
178    }
179}
180
181/// State of the [`Slider`].
182pub struct SliderState {
183    min: f32,
184    max: f32,
185    step: f32,
186    value: SliderValue,
187    /// When is single value mode, only `end` is used, the start is always 0.0.
188    percentage: Range<f32>,
189    /// The bounds of the slider after rendered.
190    bounds: Bounds<Pixels>,
191    scale: SliderScale,
192}
193
194impl SliderState {
195    /// Create a new [`SliderState`].
196    pub fn new() -> Self {
197        Self {
198            min: 0.0,
199            max: 100.0,
200            step: 1.0,
201            value: SliderValue::default(),
202            percentage: (0.0..0.0),
203            bounds: Bounds::default(),
204            scale: SliderScale::default(),
205        }
206    }
207
208    /// Set the minimum value of the slider, default: 0.0
209    pub fn min(mut self, min: f32) -> Self {
210        if self.scale.is_logarithmic() {
211            assert!(
212                min > 0.0,
213                "`min` must be greater than 0 for SliderScale::Logarithmic"
214            );
215            assert!(
216                min < self.max,
217                "`min` must be less than `max` for Logarithmic scale"
218            );
219        }
220        self.min = min;
221        self.update_thumb_pos();
222        self
223    }
224
225    /// Set the maximum value of the slider, default: 100.0
226    pub fn max(mut self, max: f32) -> Self {
227        if self.scale.is_logarithmic() {
228            assert!(
229                max > self.min,
230                "`max` must be greater than `min` for Logarithmic scale"
231            );
232        }
233        self.max = max;
234        self.update_thumb_pos();
235        self
236    }
237
238    /// Set the step value of the slider, default: 1.0
239    pub fn step(mut self, step: f32) -> Self {
240        self.step = step;
241        self
242    }
243
244    /// Set the scale of the slider, default: [`SliderScale::Linear`].
245    pub fn scale(mut self, scale: SliderScale) -> Self {
246        if scale.is_logarithmic() {
247            assert!(
248                self.min > 0.0,
249                "`min` must be greater than 0 for Logarithmic scale"
250            );
251            assert!(
252                self.max > self.min,
253                "`max` must be greater than `min` for Logarithmic scale"
254            );
255        }
256        self.scale = scale;
257        self.update_thumb_pos();
258        self
259    }
260
261    /// Set the default value of the slider, default: 0.0
262    pub fn default_value(mut self, value: impl Into<SliderValue>) -> Self {
263        self.value = value.into();
264        self.update_thumb_pos();
265        self
266    }
267
268    /// Set the value of the slider.
269    pub fn set_value(
270        &mut self,
271        value: impl Into<SliderValue>,
272        _: &mut Window,
273        cx: &mut Context<Self>,
274    ) {
275        self.value = value.into();
276        self.update_thumb_pos();
277        cx.notify();
278    }
279
280    /// Get the value of the slider.
281    pub fn value(&self) -> SliderValue {
282        self.value
283    }
284
285    /// Converts a value between 0.0 and 1.0 to a value between the minimum and maximum value,
286    /// depending on the chosen scale.
287    fn percentage_to_value(&self, percentage: f32) -> f32 {
288        match self.scale {
289            SliderScale::Linear => self.min + (self.max - self.min) * percentage,
290            SliderScale::Logarithmic => {
291                // when percentage is 0, this simplifies to (max/min)^0 * min = 1 * min = min
292                // when percentage is 1, this simplifies to (max/min)^1 * min = (max*min)/min = max
293                // we clamp just to make sure we don't have issue with floating point precision
294                let base = self.max / self.min;
295                (base.powf(percentage) * self.min).clamp(self.min, self.max)
296            }
297        }
298    }
299
300    /// Converts a value between the minimum and maximum value to a value between 0.0 and 1.0,
301    /// depending on the chosen scale.
302    fn value_to_percentage(&self, value: f32) -> f32 {
303        match self.scale {
304            SliderScale::Linear => {
305                let range = self.max - self.min;
306                if range <= 0.0 {
307                    0.0
308                } else {
309                    (value - self.min) / range
310                }
311            }
312            SliderScale::Logarithmic => {
313                let base = self.max / self.min;
314                (value / self.min).log(base).clamp(0.0, 1.0)
315            }
316        }
317    }
318
319    fn update_thumb_pos(&mut self) {
320        match self.value {
321            SliderValue::Single(value) => {
322                let percentage = self.value_to_percentage(value.clamp(self.min, self.max));
323                self.percentage = 0.0..percentage;
324            }
325            SliderValue::Range(start, end) => {
326                let clamped_start = start.clamp(self.min, self.max);
327                let clamped_end = end.clamp(self.min, self.max);
328                self.percentage =
329                    self.value_to_percentage(clamped_start)..self.value_to_percentage(clamped_end);
330            }
331        }
332    }
333
334    /// Update value by mouse position
335    fn update_value_by_position(
336        &mut self,
337        axis: Axis,
338        position: Point<Pixels>,
339        is_start: bool,
340        _: &mut Window,
341        cx: &mut Context<Self>,
342    ) {
343        let bounds = self.bounds;
344        let step = self.step;
345
346        let inner_pos = if axis.is_horizontal() {
347            position.x - bounds.left()
348        } else {
349            bounds.bottom() - position.y
350        };
351        let total_size = bounds.size.along(axis);
352        let percentage = inner_pos.clamp(px(0.), total_size) / total_size;
353
354        let percentage = if is_start {
355            percentage.clamp(0.0, self.percentage.end)
356        } else {
357            percentage.clamp(self.percentage.start, 1.0)
358        };
359
360        let value = self.percentage_to_value(percentage);
361        let value = (value / step).round() * step;
362
363        if is_start {
364            self.percentage.start = percentage;
365            self.value.set_start(value);
366        } else {
367            self.percentage.end = percentage;
368            self.value.set_end(value);
369        }
370        cx.emit(SliderEvent::Change(self.value));
371        cx.notify();
372    }
373}
374
375impl EventEmitter<SliderEvent> for SliderState {}
376impl Render for SliderState {
377    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
378        Empty
379    }
380}
381
382/// A Slider element.
383#[derive(IntoElement)]
384pub struct Slider {
385    state: Entity<SliderState>,
386    axis: Axis,
387    style: StyleRefinement,
388    disabled: bool,
389}
390
391impl Slider {
392    /// Create a new [`Slider`] element bind to the [`SliderState`].
393    pub fn new(state: &Entity<SliderState>) -> Self {
394        Self {
395            axis: Axis::Horizontal,
396            state: state.clone(),
397            style: StyleRefinement::default(),
398            disabled: false,
399        }
400    }
401
402    /// As a horizontal slider.
403    pub fn horizontal(mut self) -> Self {
404        self.axis = Axis::Horizontal;
405        self
406    }
407
408    /// As a vertical slider.
409    pub fn vertical(mut self) -> Self {
410        self.axis = Axis::Vertical;
411        self
412    }
413
414    /// Set the disabled state of the slider, default: false
415    pub fn disabled(mut self, disabled: bool) -> Self {
416        self.disabled = disabled;
417        self
418    }
419
420    #[allow(clippy::too_many_arguments)]
421    fn render_thumb(
422        &self,
423        start_pos: Pixels,
424        is_start: bool,
425        bar_color: Background,
426        thumb_color: Hsla,
427        radius: Corners<Pixels>,
428        window: &mut Window,
429        cx: &mut App,
430    ) -> impl gpui::IntoElement {
431        let entity_id = self.state.entity_id();
432        let axis = self.axis;
433        let id = ("slider-thumb", is_start as u32);
434
435        if self.disabled {
436            return div().id(id);
437        }
438
439        div()
440            .id(id)
441            .absolute()
442            .when(axis.is_horizontal(), |this| {
443                this.top(px(-5.)).left(start_pos).ml(-px(8.))
444            })
445            .when(axis.is_vertical(), |this| {
446                this.bottom(start_pos).left(px(-5.)).mb(-px(8.))
447            })
448            .flex()
449            .items_center()
450            .justify_center()
451            .flex_shrink_0()
452            .corner_radii(radius)
453            .bg(bar_color.opacity(0.5))
454            .when(cx.theme().shadow, |this| this.shadow_md())
455            .size_4()
456            .p(px(1.))
457            .child(
458                div()
459                    .flex_shrink_0()
460                    .size_full()
461                    .corner_radii(radius)
462                    .bg(thumb_color),
463            )
464            .on_mouse_down(MouseButton::Left, |_, _, cx| {
465                cx.stop_propagation();
466            })
467            .on_drag(DragThumb((entity_id, is_start)), |drag, _, _, cx| {
468                cx.stop_propagation();
469                cx.new(|_| drag.clone())
470            })
471            .on_drag_move(window.listener_for(
472                &self.state,
473                move |view, e: &DragMoveEvent<DragThumb>, window, cx| {
474                    match e.drag(cx) {
475                        DragThumb((id, is_start)) => {
476                            if *id != entity_id {
477                                return;
478                            }
479
480                            // set value by mouse position
481                            view.update_value_by_position(
482                                axis,
483                                e.event.position,
484                                *is_start,
485                                window,
486                                cx,
487                            )
488                        }
489                    }
490                },
491            ))
492    }
493}
494
495impl Styled for Slider {
496    fn style(&mut self) -> &mut StyleRefinement {
497        &mut self.style
498    }
499}
500
501impl RenderOnce for Slider {
502    fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
503        let axis = self.axis;
504        let entity_id = self.state.entity_id();
505        let state = self.state.read(cx);
506        let is_range = state.value().is_range();
507        let bar_size = state.bounds.size.along(axis);
508        let bar_start = state.percentage.start * bar_size;
509        let bar_end = state.percentage.end * bar_size;
510        let rem_size = window.rem_size();
511
512        let bar_color = self
513            .style
514            .background
515            .clone()
516            .and_then(|bg| bg.color())
517            .unwrap_or(cx.theme().slider_bar.into());
518        let thumb_color = self
519            .style
520            .text
521            .clone()
522            .and_then(|text| text.color)
523            .unwrap_or_else(|| cx.theme().slider_thumb);
524        let corner_radii = self.style.corner_radii.clone();
525        let default_radius = px(999.);
526        let radius = Corners {
527            top_left: corner_radii
528                .top_left
529                .map(|v| v.to_pixels(rem_size))
530                .unwrap_or(default_radius),
531            top_right: corner_radii
532                .top_right
533                .map(|v| v.to_pixels(rem_size))
534                .unwrap_or(default_radius),
535            bottom_left: corner_radii
536                .bottom_left
537                .map(|v| v.to_pixels(rem_size))
538                .unwrap_or(default_radius),
539            bottom_right: corner_radii
540                .bottom_right
541                .map(|v| v.to_pixels(rem_size))
542                .unwrap_or(default_radius),
543        };
544
545        div()
546            .id(("slider", self.state.entity_id()))
547            .flex()
548            .flex_1()
549            .items_center()
550            .justify_center()
551            .when(axis.is_vertical(), |this| this.h(px(120.)))
552            .when(axis.is_horizontal(), |this| this.w_full())
553            .refine_style(&self.style)
554            .bg(cx.theme().transparent)
555            .text_color(cx.theme().foreground)
556            .child(
557                h_flex()
558                    .id("slider-bar-container")
559                    .when(!self.disabled, |this| {
560                        this.on_mouse_down(
561                            MouseButton::Left,
562                            window.listener_for(
563                                &self.state,
564                                move |state, e: &MouseDownEvent, window, cx| {
565                                    let mut is_start = false;
566                                    if is_range {
567                                        let inner_pos = if axis.is_horizontal() {
568                                            e.position.x - state.bounds.left()
569                                        } else {
570                                            state.bounds.bottom() - e.position.y
571                                        };
572                                        let center = (bar_end - bar_start) / 2.0 + bar_start;
573                                        is_start = inner_pos < center;
574                                    }
575
576                                    state.update_value_by_position(
577                                        axis, e.position, is_start, window, cx,
578                                    )
579                                },
580                            ),
581                        )
582                    })
583                    .when(!self.disabled && !is_range, |this| {
584                        this.on_drag(DragSlider(entity_id), |drag, _, _, cx| {
585                            cx.stop_propagation();
586                            cx.new(|_| drag.clone())
587                        })
588                        .on_drag_move(window.listener_for(
589                            &self.state,
590                            move |view, e: &DragMoveEvent<DragSlider>, window, cx| match e.drag(cx)
591                            {
592                                DragSlider(id) => {
593                                    if *id != entity_id {
594                                        return;
595                                    }
596
597                                    view.update_value_by_position(
598                                        axis,
599                                        e.event.position,
600                                        false,
601                                        window,
602                                        cx,
603                                    )
604                                }
605                            },
606                        ))
607                    })
608                    .when(axis.is_horizontal(), |this| {
609                        this.items_center().h_6().w_full()
610                    })
611                    .when(axis.is_vertical(), |this| {
612                        this.justify_center().w_6().h_full()
613                    })
614                    .flex_shrink_0()
615                    .child(
616                        div()
617                            .id("slider-bar")
618                            .relative()
619                            .when(axis.is_horizontal(), |this| this.w_full().h_1p5())
620                            .when(axis.is_vertical(), |this| this.h_full().w_1p5())
621                            .bg(bar_color.opacity(0.2))
622                            .active(|this| this.bg(bar_color.opacity(0.4)))
623                            .corner_radii(radius)
624                            .child(
625                                div()
626                                    .absolute()
627                                    .when(axis.is_horizontal(), |this| {
628                                        this.h_full().left(bar_start).right(bar_size - bar_end)
629                                    })
630                                    .when(axis.is_vertical(), |this| {
631                                        this.w_full().bottom(bar_start).top(bar_size - bar_end)
632                                    })
633                                    .bg(bar_color)
634                                    .rounded_full(),
635                            )
636                            .when(is_range, |this| {
637                                this.child(self.render_thumb(
638                                    bar_start,
639                                    true,
640                                    bar_color,
641                                    thumb_color,
642                                    radius,
643                                    window,
644                                    cx,
645                                ))
646                            })
647                            .child(self.render_thumb(
648                                bar_end,
649                                false,
650                                bar_color,
651                                thumb_color,
652                                radius,
653                                window,
654                                cx,
655                            ))
656                            .child({
657                                let state = self.state.clone();
658                                canvas(
659                                    move |bounds, _, cx| state.update(cx, |r, _| r.bounds = bounds),
660                                    |_, _, _, _| {},
661                                )
662                                .absolute()
663                                .size_full()
664                            }),
665                    ),
666            )
667    }
668}