Skip to main content

gpui_base/
number_input.rs

1use crate::input::InputState;
2use std::rc::Rc;
3
4use gpui::Focusable;
5use gpui::{
6    AnyElement, App, Entity, EventEmitter, InteractiveElement as _, IntoElement, KeyBinding,
7    ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
8    StyleRefinement, Styled, Window, actions, div, prelude::FluentBuilder as _,
9};
10
11use crate::{Button, InputBase, StyledExt as _};
12
13actions!(number_input, [Increment, Decrement]);
14
15const CONTEXT: &str = "NumberInput";
16
17pub(crate) fn init(cx: &mut App) {
18    cx.bind_keys([
19        KeyBinding::new("up", Increment, Some(CONTEXT)),
20        KeyBinding::new("down", Decrement, Some(CONTEXT)),
21    ]);
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum StepAction {
26    Decrement,
27    Increment,
28}
29
30/// Strategy used by numeric editors when stepping their value.
31#[derive(Clone)]
32pub enum NumberStep {
33    Fixed(f64),
34    ByValue(Rc<dyn Fn(f64, StepAction, &mut gpui::App) -> f64>),
35}
36
37impl NumberStep {
38    pub fn by_value(f: impl Fn(f64, StepAction, &mut gpui::App) -> f64 + 'static) -> Self {
39        Self::ByValue(Rc::new(f))
40    }
41
42    pub(crate) fn value(&self, current: f64, action: StepAction, cx: &mut gpui::App) -> f64 {
43        match self {
44            Self::Fixed(step) => *step,
45            Self::ByValue(f) => f(current, action, cx),
46        }
47    }
48}
49
50impl From<f64> for NumberStep {
51    fn from(step: f64) -> Self {
52        Self::Fixed(step)
53    }
54}
55
56#[derive(Clone)]
57pub enum NumberInputEvent {
58    Step(StepAction),
59}
60impl EventEmitter<NumberInputEvent> for InputState {}
61
62impl InputState {
63    /// Apply a number-input step or emit a step event when stepping is caller-controlled.
64    fn apply_number_step(
65        &mut self,
66        action: StepAction,
67        window: &mut Window,
68        cx: &mut gpui::Context<Self>,
69    ) {
70        if self.disabled {
71            return;
72        }
73        if let Some(step) = self.number_step.clone() {
74            let value = self.unmask_value();
75            let current = value.trim().parse::<f64>().unwrap_or(0.);
76            let step = step.value(current, action, cx);
77            if let Some(new_value) =
78                step_value(&value, action, step, self.number_min, self.number_max)
79            {
80                if self.is_valid_input(&new_value, cx) {
81                    let range = self.range_to_utf16(&(0..self.value().len()));
82                    self.replace_text_in_range_silent(Some(range), &new_value, window, cx);
83                    return;
84                }
85            } else {
86                return;
87            }
88        }
89        cx.emit(NumberInputEvent::Step(action));
90    }
91}
92
93type StepHandler = Rc<dyn Fn(StepAction, &mut Window, &mut App)>;
94type ButtonDecorator = Box<dyn FnOnce(Button) -> Button>;
95
96/// An unstyled spinbutton root composed from the foundational [`InputBase`] frame.
97#[derive(IntoElement)]
98pub struct NumberInput {
99    style: StyleRefinement,
100    children: Vec<AnyElement>,
101    disabled: bool,
102    state: Entity<InputState>,
103    on_step: Option<StepHandler>,
104    decrement_button: Option<ButtonDecorator>,
105    increment_button: Option<ButtonDecorator>,
106    input: Option<AnyElement>,
107    controls_right: bool,
108    accessibility_label: Option<SharedString>,
109}
110
111/// The built-in text region of a [`NumberInput`].
112///
113/// Applications provide the editor itself as a child and can style this region
114/// without having to recreate the number input's fixed three-part structure.
115#[derive(IntoElement)]
116pub struct NumberInputText {
117    style: StyleRefinement,
118    children: Vec<AnyElement>,
119}
120
121impl NumberInputText {
122    pub fn new() -> Self {
123        Self {
124            style: StyleRefinement::default(),
125            children: Vec::new(),
126        }
127    }
128}
129
130impl Default for NumberInputText {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl Styled for NumberInputText {
137    fn style(&mut self) -> &mut StyleRefinement {
138        &mut self.style
139    }
140}
141
142impl ParentElement for NumberInputText {
143    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
144        self.children.extend(elements);
145    }
146}
147
148impl RenderOnce for NumberInputText {
149    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
150        gpui::div()
151            .min_w_0()
152            .flex_1()
153            .children(self.children)
154            .refine_style(&self.style)
155    }
156}
157
158impl NumberInput {
159    pub fn new(state: &Entity<InputState>) -> Self {
160        Self {
161            style: StyleRefinement::default(),
162            children: Vec::new(),
163            disabled: false,
164            state: state.clone(),
165            on_step: None,
166            decrement_button: None,
167            increment_button: None,
168            input: None,
169            controls_right: false,
170            accessibility_label: None,
171        }
172    }
173
174    pub fn disabled(mut self, disabled: bool) -> Self {
175        self.disabled = disabled;
176        self
177    }
178
179    pub fn on_step(
180        mut self,
181        handler: impl Fn(StepAction, &mut Window, &mut App) + 'static,
182    ) -> Self {
183        self.on_step = Some(Rc::new(handler));
184        self
185    }
186
187    /// Decorate the built-in decrement button with application-owned content and styles.
188    pub fn decrement_button(mut self, decorate: impl FnOnce(Button) -> Button + 'static) -> Self {
189        self.decrement_button = Some(Box::new(decorate));
190        self
191    }
192
193    /// Decorate the built-in increment button with application-owned content and styles.
194    pub fn increment_button(mut self, decorate: impl FnOnce(Button) -> Button + 'static) -> Self {
195        self.increment_button = Some(Box::new(decorate));
196        self
197    }
198
199    /// Decorate the built-in text region with an editor, adornments, and styles.
200    pub fn input(mut self, input: impl IntoElement) -> Self {
201        self.input = Some(input.into_any_element());
202        self
203    }
204
205    /// Stack both step buttons on the right side of the text region.
206    pub fn controls_right(mut self) -> Self {
207        self.controls_right = true;
208        self
209    }
210
211    /// Sets the name exposed to accessibility clients.
212    ///
213    /// A number input has no visible text of its own to be named by, so set
214    /// this unless the input is named some other way.
215    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
216        self.accessibility_label = Some(label.into());
217        self
218    }
219}
220
221impl Styled for NumberInput {
222    fn style(&mut self) -> &mut StyleRefinement {
223        &mut self.style
224    }
225}
226
227impl ParentElement for NumberInput {
228    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
229        self.children.extend(elements);
230    }
231}
232
233impl RenderOnce for NumberInput {
234    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
235        let disabled = self.disabled;
236        let controls_right = self.controls_right;
237        let on_step = self.on_step.unwrap_or_else(|| {
238            let state = self.state.clone();
239            Rc::new(move |action, window, cx| {
240                state.update(cx, |state, cx| {
241                    state.focus(window, cx);
242                    state.apply_number_step(action, window, cx);
243                });
244            })
245        });
246        self.state.update(cx, |state, _| state.ensure_number_mask());
247        let value = self.state.read(cx).value().parse::<f64>().ok();
248        let decrement_button = self.decrement_button.map_or_else(
249            || Button::new("decrement"),
250            |decorate| decorate(Button::new("decrement")),
251        );
252        let increment_button = self.increment_button.map_or_else(
253            || Button::new("increment"),
254            |decorate| decorate(Button::new("increment")),
255        );
256        let text = NumberInputText::new().children(self.input);
257
258        // Stepping is driven from the focused editor, so the buttons never take
259        // focus themselves; otherwise every press would pull the focus ring off
260        // the frame and hand it straight back.
261        let decrement_button = decrement_button
262            .when(controls_right, |this| this.flex_1().min_h_0())
263            .when(!controls_right, |this| this.flex_none())
264            .focusable(false)
265            .disabled(disabled)
266            .on_click({
267                let on_step = on_step.clone();
268                move |_, window, cx| {
269                    on_step(StepAction::Decrement, window, cx);
270                }
271            });
272        let increment_button = increment_button
273            .when(controls_right, |this| this.flex_1().min_h_0())
274            .when(!controls_right, |this| this.flex_none())
275            .focusable(false)
276            .disabled(disabled)
277            .on_click({
278                let on_step = on_step.clone();
279                move |_, window, cx| {
280                    on_step(StepAction::Increment, window, cx);
281                }
282            });
283
284        let content = if controls_right {
285            div()
286                .flex()
287                .items_center()
288                .size_full()
289                .child(text.children(self.children))
290                .child(
291                    div()
292                        .h_full()
293                        .flex()
294                        .flex_col()
295                        .child(increment_button)
296                        .child(decrement_button),
297                )
298                .into_any_element()
299        } else {
300            div()
301                .flex()
302                .items_center()
303                .size_full()
304                .child(decrement_button)
305                .child(text.children(self.children))
306                .child(increment_button)
307                .into_any_element()
308        };
309
310        InputBase::new(("number-input", self.state.entity_id()))
311            .track_focus(&self.state.focus_handle(cx))
312            .flex()
313            .items_center()
314            .disabled(disabled)
315            .role(Role::SpinButton)
316            .when_some(self.accessibility_label, |this, label| {
317                this.aria_label(label)
318            })
319            .when_some(value, |this, value| this.aria_numeric_value(value))
320            .key_context(CONTEXT)
321            .on_action({
322                let on_step = on_step.clone();
323                move |_: &Increment, window, cx| {
324                    if disabled {
325                        cx.propagate();
326                    } else {
327                        on_step(StepAction::Increment, window, cx);
328                    }
329                }
330            })
331            .on_action(move |_: &Decrement, window, cx| {
332                if disabled {
333                    cx.propagate();
334                } else {
335                    on_step(StepAction::Decrement, window, cx);
336                }
337            })
338            .child(content)
339            .refine_style(&self.style)
340            .render(window, cx)
341    }
342}
343
344/// Step a numeric string while preserving decimal precision and range direction.
345pub fn step_value(
346    value: &str,
347    action: StepAction,
348    step: f64,
349    min: Option<f64>,
350    max: Option<f64>,
351) -> Option<String> {
352    fn fraction_digits(value: &str) -> usize {
353        value.split('.').nth(1).map_or(0, |fraction| fraction.len())
354    }
355
356    let current = value.trim().parse::<f64>().ok();
357    let mut new_value = match action {
358        StepAction::Increment => current.unwrap_or(0.) + step,
359        StepAction::Decrement => current.unwrap_or(0.) - step,
360    };
361    let mut digits = fraction_digits(value).max(fraction_digits(&step.to_string()));
362    if let Some(min) = min
363        && new_value < min
364    {
365        new_value = min;
366        digits = digits.max(fraction_digits(&min.to_string()));
367    }
368    if let Some(max) = max
369        && new_value > max
370    {
371        new_value = max;
372        digits = digits.max(fraction_digits(&max.to_string()));
373    }
374
375    if let Some(current) = current {
376        let moved = match action {
377            StepAction::Increment => new_value > current,
378            StepAction::Decrement => new_value < current,
379        };
380        if !moved {
381            return None;
382        }
383    }
384
385    Some(format!("{new_value:.digits$}"))
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    use crate::theme::Theme;
393    use gpui::{
394        AppContext as _, Context, Entity, Modifiers, MouseButton, Render, TestAppContext,
395        VisualTestContext, point, px,
396    };
397
398    struct StepperHarness {
399        state: Entity<InputState>,
400    }
401
402    impl Render for StepperHarness {
403        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
404            NumberInput::new(&self.state)
405                .w(px(120.))
406                .h(px(20.))
407                .decrement_button(|button| button.w(px(20.)).h_full())
408                .input(div().size_full())
409                .increment_button(|button| button.w(px(20.)).h_full())
410        }
411    }
412
413    /// Pressing a step button must not move focus off the editor, not even for
414    /// the press itself: the frame draws a focus ring, so a focus round-trip
415    /// makes it flicker on every click.
416    #[gpui::test]
417    fn pressing_a_step_button_never_takes_focus_off_the_editor(cx: &mut TestAppContext) {
418        cx.update(crate::init);
419
420        let mut created: Option<Entity<InputState>> = None;
421        let window = cx.update(|cx| {
422            cx.open_window(Default::default(), |window, cx| {
423                cx.set_global(Theme::default());
424                let state = cx.new(|cx| InputState::new(window, cx).step(1.));
425                created = Some(state.clone());
426                cx.new(|_| StepperHarness { state })
427            })
428            .unwrap()
429        });
430        let state = created.unwrap();
431        let cx = &mut VisualTestContext::from_window(window.into(), cx);
432
433        cx.update(|window, cx| {
434            state.update(cx, |state, cx| state.focus(window, cx));
435            window.draw(cx).clear(cx);
436        });
437        cx.update(|window, cx| {
438            assert!(
439                state.read(cx).focus_handle(cx).is_focused(window),
440                "the editor should start focused"
441            );
442        });
443
444        // The decrement button sits at the left edge of the control.
445        cx.simulate_mouse_move(
446            point(px(10.), px(10.)),
447            MouseButton::Left,
448            Modifiers::default(),
449        );
450        cx.simulate_event(gpui::MouseDownEvent {
451            button: MouseButton::Left,
452            position: point(px(10.), px(10.)),
453            modifiers: Modifiers::default(),
454            click_count: 1,
455            first_mouse: false,
456        });
457
458        cx.update(|window, cx| {
459            assert!(
460                state.read(cx).focus_handle(cx).is_focused(window),
461                "pressing the step button pulled focus off the editor"
462            );
463        });
464
465        cx.simulate_event(gpui::MouseUpEvent {
466            button: MouseButton::Left,
467            position: point(px(10.), px(10.)),
468            modifiers: Modifiers::default(),
469            click_count: 1,
470        });
471
472        cx.update(|window, cx| {
473            assert!(
474                state.read(cx).focus_handle(cx).is_focused(window),
475                "releasing the step button left focus off the editor"
476            );
477        });
478    }
479
480    #[test]
481    fn stepping_preserves_precision_and_directional_bounds() {
482        assert_eq!(
483            step_value("0.1", StepAction::Increment, 0.2, None, None).as_deref(),
484            Some("0.3")
485        );
486        assert_eq!(
487            step_value("10", StepAction::Decrement, 1., Some(10.), None),
488            None
489        );
490        assert_eq!(
491            step_value("99.5", StepAction::Increment, 1., None, Some(100.)).as_deref(),
492            Some("100.0")
493        );
494        assert_eq!(
495            step_value("10", StepAction::Increment, 1., None, Some(10.)),
496            None
497        );
498        assert_eq!(
499            step_value("5", StepAction::Decrement, 10., Some(0.), None).as_deref(),
500            Some("0")
501        );
502    }
503}