Skip to main content

kitchen_ui/widgets/
stepper.rs

1//! The stepper and the dragged number (scrub label).
2
3use std::cell::RefCell;
4use std::rc::Rc;
5
6use gpui_kit::component::button::{Button, ButtonVariants as _};
7use gpui_kit::component::{IconName, Sizable as _, h_flex};
8use gpui_kit::prelude::FluentBuilder as _;
9use gpui_kit::{
10    App, CursorStyle, DispatchPhase, InteractiveElement as _, IntoElement, MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _,
11    SharedString, Styled as _, Window, canvas, div, px,
12};
13
14use crate::theme::{self, color};
15
16/// What a stepped number calls with its step (−1 or +1; a fast drag calls it once per step).
17pub type OnStep = Rc<dyn Fn(f32, &mut Window, &mut App)>;
18
19/// Pointer px per step of a dragged number.
20pub const SCRUB_PX: f32 = 6.0;
21
22thread_local! {
23    /// The number being dragged: its id and the pointer x of the last step.
24    static SCRUBBING: RefCell<Option<(String, f32)>> = const { RefCell::new(None) };
25}
26
27/// `− value +`: a number changed in steps, by its buttons or by dragging the number (see
28/// [`scrub_label`]). The caller holds the value and formats it. Element ids: `{id}-minus`,
29/// `{id}-value`, `{id}-plus`. The buttons are gpui-kit's small size, [`theme::XS`] apart
30/// (designparser `touch-target`: about 24 px each).
31pub fn stepper(id: impl Into<String>, value: impl Into<SharedString>, on_step: impl Fn(f32, &mut Window, &mut App) + 'static) -> impl IntoElement {
32    let id: String = id.into();
33    let on_step: OnStep = Rc::new(on_step);
34    let (a, b, c) = (on_step.clone(), on_step.clone(), on_step);
35    h_flex()
36        .gap(px(theme::XS))
37        .child(Button::new(SharedString::from(format!("{id}-minus"))).ghost().small().icon(IconName::Minus).tooltip("Decrease").on_click(move |_, w, cx| a(-1.0, w, cx)))
38        .child(scrub(format!("{id}-value"), value.into(), 52.0, c))
39        .child(Button::new(SharedString::from(format!("{id}-plus"))).ghost().small().icon(IconName::Plus).tooltip("Increase").on_click(move |_, w, cx| b(1.0, w, cx)))
40}
41
42/// A number that changes as it is dragged (Affinity, Blender): press it and move left or right;
43/// every [`SCRUB_PX`] px is one step down or up. The pointer may leave it while dragging. The
44/// caller holds the value and formats it; `id` (owned, unique in the window) names the drag.
45pub fn scrub_label(id: impl Into<String>, value: impl Into<SharedString>, on_step: impl Fn(f32, &mut Window, &mut App) + 'static) -> impl IntoElement {
46    scrub(id.into(), value.into(), 0.0, Rc::new(on_step))
47}
48
49fn scrub(id: String, value: SharedString, min_w: f32, on_step: OnStep) -> impl IntoElement {
50    let active = SCRUBBING.with(|s| s.borrow().as_ref().is_some_and(|(k, _)| *k == id));
51    let down_id = id.clone();
52    // While dragging, the window's pointer events drive it (the pointer leaves the number)
53    let listener = canvas(
54        |_, _, _| {},
55        move |_, _, window, _| {
56            if !active {
57                return;
58            }
59            let (id, step) = (id.clone(), on_step.clone());
60            window.on_mouse_event(move |e: &MouseMoveEvent, phase, window, cx| {
61                if phase != DispatchPhase::Bubble {
62                    return;
63                }
64                if e.pressed_button != Some(MouseButton::Left) {
65                    SCRUBBING.with(|s| *s.borrow_mut() = None);
66                    window.refresh();
67                    return;
68                }
69                let x = e.position.x.as_f32();
70                let steps = SCRUBBING.with(|s| match &mut *s.borrow_mut() {
71                    Some((k, last)) if *k == id => {
72                        let n = ((x - *last) / SCRUB_PX).trunc();
73                        *last += n * SCRUB_PX;
74                        n as i32
75                    }
76                    _ => 0,
77                });
78                for _ in 0..steps.unsigned_abs() {
79                    step(steps.signum() as f32, window, cx);
80                }
81            });
82            window.on_mouse_event(|_: &MouseUpEvent, phase, window, _| {
83                if phase == DispatchPhase::Bubble {
84                    SCRUBBING.with(|s| *s.borrow_mut() = None);
85                    window.refresh();
86                }
87            });
88        },
89    )
90    .absolute()
91    .size_full();
92    div()
93        .id(SharedString::from(down_id.clone()))
94        .relative()
95        .min_w(px(min_w))
96        .min_h(px(theme::TARGET_MIN))
97        .flex()
98        .items_center()
99        .justify_center()
100        .px(px(theme::XS))
101        .rounded(px(theme::RADIUS_SM))
102        .text_sm()
103        .font_family("monospace")
104        .cursor(CursorStyle::ResizeLeftRight)
105        .when(active, |d| d.bg(color::accent().opacity(0.35)))
106        .when(!active, |d| d.hover(|d| d.bg(color::hover())))
107        .on_mouse_down(MouseButton::Left, move |e, window, cx| {
108            cx.stop_propagation();
109            SCRUBBING.with(|s| *s.borrow_mut() = Some((down_id.clone(), e.position.x.as_f32())));
110            window.refresh();
111        })
112        .child(value)
113        .child(listener)
114}