Skip to main content

kitchen_ui/widgets/
slider.rs

1//! The slider.
2
3use std::cell::{Cell, RefCell};
4use std::rc::Rc;
5
6use gpui_kit::component::h_flex;
7use gpui_kit::prelude::FluentBuilder as _;
8use gpui_kit::{
9    App, Bounds, DispatchPhase, InteractiveElement as _, IntoElement, MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels,
10    SharedString, Styled as _, TestSupportExt as _, Window, canvas, div, px, relative,
11};
12
13use crate::theme::{self, color};
14
15/// What a slider calls: the new value and whether the drag ended (to commit one undo step).
16pub type OnSlide = Rc<dyn Fn(f32, bool, &mut Window, &mut App)>;
17
18thread_local! {
19    /// The slider being dragged: its id and its track (window px: left, width).
20    static SLIDING: RefCell<Option<(String, f32, f32)>> = const { RefCell::new(None) };
21}
22
23/// Height of the track's pointer target (px): [`theme::TARGET_MIN`], though the bar drawn is 4.
24const TRACK_H: f32 = theme::TARGET_MIN;
25/// Diameter of the thumb (px).
26const THUMB: f32 = 12.;
27
28/// A horizontal slider over `min..=max` with the value printed beside it (`fmt`). A click sets the
29/// value; a drag follows the pointer (even off the track) and ends on release, when `on_change`
30/// gets `true`. It keeps no state: the caller holds the value, and `id` (owned, unique in the
31/// window) names the drag in progress. The track's element id is `{id}-track`.
32pub fn slider(id: impl Into<String>, value: f32, (min, max): (f32, f32), fmt: impl Fn(f32) -> String, on_change: impl Fn(f32, bool, &mut Window, &mut App) + 'static) -> impl IntoElement {
33    let on_change: OnSlide = Rc::new(on_change);
34    let id: String = id.into();
35    let t = ((value - min) / (max - min).max(1e-12)).clamp(0.0, 1.0);
36    let active = SLIDING.with(|s| s.borrow().as_ref().is_some_and(|(k, _, _)| *k == id));
37    let bounds = Rc::new(Cell::new(Bounds::<Pixels>::default()));
38    let to_value = move |x: f32, left: f32, width: f32| min + ((x - left) / width.max(1.0)).clamp(0.0, 1.0) * (max - min);
39    // While dragging, the window's pointer events drive it
40    let (b1, id1, ch1) = (bounds.clone(), id.clone(), on_change.clone());
41    let listener = canvas(
42        move |b, _, _| b1.set(b),
43        move |_, _, window, _| {
44            if !active {
45                return;
46            }
47            let (id, ch) = (id1.clone(), ch1.clone());
48            window.on_mouse_event(move |e: &MouseMoveEvent, phase, window, cx| {
49                if phase != DispatchPhase::Bubble {
50                    return;
51                }
52                let Some((_, left, width)) = SLIDING.with(|s| s.borrow().clone()).filter(|(k, _, _)| *k == id) else { return };
53                if e.pressed_button != Some(MouseButton::Left) {
54                    SLIDING.with(|s| *s.borrow_mut() = None);
55                    ch(to_value(e.position.x.as_f32(), left, width), true, window, cx);
56                    window.refresh();
57                    return;
58                }
59                ch(to_value(e.position.x.as_f32(), left, width), false, window, cx);
60            });
61            let (id, ch) = (id1.clone(), ch1.clone());
62            window.on_mouse_event(move |e: &MouseUpEvent, phase, window, cx| {
63                if phase != DispatchPhase::Bubble {
64                    return;
65                }
66                if let Some((_, left, width)) = SLIDING.with(|s| s.borrow_mut().take()).filter(|(k, _, _)| *k == id) {
67                    ch(to_value(e.position.x.as_f32(), left, width), true, window, cx);
68                    window.refresh();
69                }
70            });
71        },
72    )
73    .absolute()
74    .size_full();
75    let (b2, id2, ch2) = (bounds, id.clone(), on_change);
76    let bar_top = (TRACK_H - 4.) / 2.;
77    let track = div()
78        .id(SharedString::from(format!("{id}-track")))
79        .relative()
80        .flex_1()
81        .min_w(px(60.))
82        .h(px(TRACK_H))
83        .cursor_pointer()
84        .on_mouse_down(MouseButton::Left, move |e, window, cx| {
85            cx.stop_propagation();
86            let b = b2.get();
87            let (left, width) = (b.origin.x.as_f32(), b.size.width.as_f32());
88            SLIDING.with(|s| *s.borrow_mut() = Some((id2.clone(), left, width)));
89            ch2(to_value(e.position.x.as_f32(), left, width), false, window, cx);
90            window.refresh();
91        })
92        // The track, its filled part (a mark: ≥ 3:1 on the panel), the thumb
93        .child(div().absolute().left_0().right_0().top(px(bar_top)).h(px(4.)).rounded_full().bg(color::control()))
94        .child(div().absolute().left_0().top(px(bar_top)).h(px(4.)).w(relative(t)).rounded_full().bg(color::accent_mark()))
95        .child(
96            div()
97                .absolute()
98                .top(px((TRACK_H - THUMB) / 2.))
99                .left(relative(t))
100                .ml(px(-THUMB / 2.))
101                .size(px(THUMB))
102                .rounded_full()
103                .bg(gpui_kit::white())
104                .border_color(color::accent_mark())
105                .map(|d| if active { d.border_2() } else { d.border_1() }),
106        )
107        .child(listener)
108        .test_support();
109    h_flex()
110        .w_full()
111        .min_w_0()
112        .gap(px(theme::SM))
113        .child(track)
114        .child(div().flex_none().w(px(44.)).text_right().text_xs().font_family("monospace").child(fmt(value)))
115}