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, StatefulInteractiveElement as _, StyleRefinement, Styled,
8    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}
109
110/// The built-in text region of a [`NumberInput`].
111///
112/// Applications provide the editor itself as a child and can style this region
113/// without having to recreate the number input's fixed three-part structure.
114#[derive(IntoElement)]
115pub struct NumberInputText {
116    style: StyleRefinement,
117    children: Vec<AnyElement>,
118}
119
120impl NumberInputText {
121    pub fn new() -> Self {
122        Self {
123            style: StyleRefinement::default(),
124            children: Vec::new(),
125        }
126    }
127}
128
129impl Default for NumberInputText {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135impl Styled for NumberInputText {
136    fn style(&mut self) -> &mut StyleRefinement {
137        &mut self.style
138    }
139}
140
141impl ParentElement for NumberInputText {
142    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
143        self.children.extend(elements);
144    }
145}
146
147impl RenderOnce for NumberInputText {
148    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
149        gpui::div()
150            .min_w_0()
151            .flex_1()
152            .children(self.children)
153            .refine_style(&self.style)
154    }
155}
156
157impl NumberInput {
158    pub fn new(state: &Entity<InputState>) -> Self {
159        Self {
160            style: StyleRefinement::default(),
161            children: Vec::new(),
162            disabled: false,
163            state: state.clone(),
164            on_step: None,
165            decrement_button: None,
166            increment_button: None,
167            input: None,
168            controls_right: false,
169        }
170    }
171
172    pub fn disabled(mut self, disabled: bool) -> Self {
173        self.disabled = disabled;
174        self
175    }
176
177    pub fn on_step(
178        mut self,
179        handler: impl Fn(StepAction, &mut Window, &mut App) + 'static,
180    ) -> Self {
181        self.on_step = Some(Rc::new(handler));
182        self
183    }
184
185    /// Decorate the built-in decrement button with application-owned content and styles.
186    pub fn decrement_button(mut self, decorate: impl FnOnce(Button) -> Button + 'static) -> Self {
187        self.decrement_button = Some(Box::new(decorate));
188        self
189    }
190
191    /// Decorate the built-in increment button with application-owned content and styles.
192    pub fn increment_button(mut self, decorate: impl FnOnce(Button) -> Button + 'static) -> Self {
193        self.increment_button = Some(Box::new(decorate));
194        self
195    }
196
197    /// Decorate the built-in text region with an editor, adornments, and styles.
198    pub fn input(mut self, input: impl IntoElement) -> Self {
199        self.input = Some(input.into_any_element());
200        self
201    }
202
203    /// Stack both step buttons on the right side of the text region.
204    pub fn controls_right(mut self) -> Self {
205        self.controls_right = true;
206        self
207    }
208}
209
210impl Styled for NumberInput {
211    fn style(&mut self) -> &mut StyleRefinement {
212        &mut self.style
213    }
214}
215
216impl ParentElement for NumberInput {
217    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
218        self.children.extend(elements);
219    }
220}
221
222impl RenderOnce for NumberInput {
223    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
224        let disabled = self.disabled;
225        let controls_right = self.controls_right;
226        let on_step = self.on_step.unwrap_or_else(|| {
227            let state = self.state.clone();
228            Rc::new(move |action, window, cx| {
229                state.update(cx, |state, cx| {
230                    state.focus(window, cx);
231                    state.apply_number_step(action, window, cx);
232                });
233            })
234        });
235        self.state.update(cx, |state, _| state.ensure_number_mask());
236        let value = self.state.read(cx).value().parse::<f64>().ok();
237        let decrement_button = self.decrement_button.map_or_else(
238            || Button::new("decrement"),
239            |decorate| decorate(Button::new("decrement")),
240        );
241        let increment_button = self.increment_button.map_or_else(
242            || Button::new("increment"),
243            |decorate| decorate(Button::new("increment")),
244        );
245        let text = NumberInputText::new().children(self.input);
246
247        // Stepping is driven from the focused editor, so the buttons never take
248        // focus themselves; otherwise every press would pull the focus ring off
249        // the frame and hand it straight back.
250        let decrement_button = decrement_button
251            .when(controls_right, |this| this.flex_1().min_h_0())
252            .when(!controls_right, |this| this.flex_none())
253            .focusable(false)
254            .disabled(disabled)
255            .on_click({
256                let on_step = on_step.clone();
257                move |_, window, cx| {
258                    on_step(StepAction::Decrement, window, cx);
259                }
260            });
261        let increment_button = increment_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::Increment, window, cx);
270                }
271            });
272
273        let content = if controls_right {
274            div()
275                .flex()
276                .items_center()
277                .size_full()
278                .child(text.children(self.children))
279                .child(
280                    div()
281                        .h_full()
282                        .flex()
283                        .flex_col()
284                        .child(increment_button)
285                        .child(decrement_button),
286                )
287                .into_any_element()
288        } else {
289            div()
290                .flex()
291                .items_center()
292                .size_full()
293                .child(decrement_button)
294                .child(text.children(self.children))
295                .child(increment_button)
296                .into_any_element()
297        };
298
299        InputBase::new(("number-input", self.state.entity_id()))
300            .track_focus(&self.state.focus_handle(cx))
301            .flex()
302            .items_center()
303            .disabled(disabled)
304            .role(Role::SpinButton)
305            .when_some(value, |this, value| this.aria_numeric_value(value))
306            .key_context(CONTEXT)
307            .on_action({
308                let on_step = on_step.clone();
309                move |_: &Increment, window, cx| {
310                    if disabled {
311                        cx.propagate();
312                    } else {
313                        on_step(StepAction::Increment, window, cx);
314                    }
315                }
316            })
317            .on_action(move |_: &Decrement, window, cx| {
318                if disabled {
319                    cx.propagate();
320                } else {
321                    on_step(StepAction::Decrement, window, cx);
322                }
323            })
324            .child(content)
325            .refine_style(&self.style)
326            .render(window, cx)
327    }
328}
329
330/// Step a numeric string while preserving decimal precision and range direction.
331pub fn step_value(
332    value: &str,
333    action: StepAction,
334    step: f64,
335    min: Option<f64>,
336    max: Option<f64>,
337) -> Option<String> {
338    fn fraction_digits(value: &str) -> usize {
339        value.split('.').nth(1).map_or(0, |fraction| fraction.len())
340    }
341
342    let current = value.trim().parse::<f64>().ok();
343    let mut new_value = match action {
344        StepAction::Increment => current.unwrap_or(0.) + step,
345        StepAction::Decrement => current.unwrap_or(0.) - step,
346    };
347    let mut digits = fraction_digits(value).max(fraction_digits(&step.to_string()));
348    if let Some(min) = min
349        && new_value < min
350    {
351        new_value = min;
352        digits = digits.max(fraction_digits(&min.to_string()));
353    }
354    if let Some(max) = max
355        && new_value > max
356    {
357        new_value = max;
358        digits = digits.max(fraction_digits(&max.to_string()));
359    }
360
361    if let Some(current) = current {
362        let moved = match action {
363            StepAction::Increment => new_value > current,
364            StepAction::Decrement => new_value < current,
365        };
366        if !moved {
367            return None;
368        }
369    }
370
371    Some(format!("{new_value:.digits$}"))
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    use crate::theme::Theme;
379    use gpui::{
380        AppContext as _, Context, Entity, Modifiers, MouseButton, Render, TestAppContext,
381        VisualTestContext, point, px,
382    };
383
384    struct StepperHarness {
385        state: Entity<InputState>,
386    }
387
388    impl Render for StepperHarness {
389        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
390            NumberInput::new(&self.state)
391                .w(px(120.))
392                .h(px(20.))
393                .decrement_button(|button| button.w(px(20.)).h_full())
394                .input(div().size_full())
395                .increment_button(|button| button.w(px(20.)).h_full())
396        }
397    }
398
399    /// Pressing a step button must not move focus off the editor, not even for
400    /// the press itself: the frame draws a focus ring, so a focus round-trip
401    /// makes it flicker on every click.
402    #[gpui::test]
403    fn pressing_a_step_button_never_takes_focus_off_the_editor(cx: &mut TestAppContext) {
404        cx.update(crate::init);
405
406        let mut created: Option<Entity<InputState>> = None;
407        let window = cx.update(|cx| {
408            cx.open_window(Default::default(), |window, cx| {
409                cx.set_global(Theme::default());
410                let state = cx.new(|cx| InputState::new(window, cx).step(1.));
411                created = Some(state.clone());
412                cx.new(|_| StepperHarness { state })
413            })
414            .unwrap()
415        });
416        let state = created.unwrap();
417        let cx = &mut VisualTestContext::from_window(window.into(), cx);
418
419        cx.update(|window, cx| {
420            state.update(cx, |state, cx| state.focus(window, cx));
421            window.draw(cx).clear(cx);
422        });
423        cx.update(|window, cx| {
424            assert!(
425                state.read(cx).focus_handle(cx).is_focused(window),
426                "the editor should start focused"
427            );
428        });
429
430        // The decrement button sits at the left edge of the control.
431        cx.simulate_mouse_move(
432            point(px(10.), px(10.)),
433            MouseButton::Left,
434            Modifiers::default(),
435        );
436        cx.simulate_event(gpui::MouseDownEvent {
437            button: MouseButton::Left,
438            position: point(px(10.), px(10.)),
439            modifiers: Modifiers::default(),
440            click_count: 1,
441            first_mouse: false,
442        });
443
444        cx.update(|window, cx| {
445            assert!(
446                state.read(cx).focus_handle(cx).is_focused(window),
447                "pressing the step button pulled focus off the editor"
448            );
449        });
450
451        cx.simulate_event(gpui::MouseUpEvent {
452            button: MouseButton::Left,
453            position: point(px(10.), px(10.)),
454            modifiers: Modifiers::default(),
455            click_count: 1,
456        });
457
458        cx.update(|window, cx| {
459            assert!(
460                state.read(cx).focus_handle(cx).is_focused(window),
461                "releasing the step button left focus off the editor"
462            );
463        });
464    }
465
466    #[test]
467    fn stepping_preserves_precision_and_directional_bounds() {
468        assert_eq!(
469            step_value("0.1", StepAction::Increment, 0.2, None, None).as_deref(),
470            Some("0.3")
471        );
472        assert_eq!(
473            step_value("10", StepAction::Decrement, 1., Some(10.), None),
474            None
475        );
476        assert_eq!(
477            step_value("99.5", StepAction::Increment, 1., None, Some(100.)).as_deref(),
478            Some("100.0")
479        );
480        assert_eq!(
481            step_value("10", StepAction::Increment, 1., None, Some(10.)),
482            None
483        );
484        assert_eq!(
485            step_value("5", StepAction::Decrement, 10., Some(0.), None).as_deref(),
486            Some("0")
487        );
488    }
489}