Skip to main content

gpui_component/input/
number_input.rs

1use crate::theme::ActiveTheme;
2use gpui::{
3    AnyElement, App, Entity, FocusHandle, Focusable, InteractiveElement as _,
4    StatefulInteractiveElement as _, Window, div, px,
5};
6use gpui::{
7    IntoElement, ParentElement, RenderOnce, SharedString, StyleRefinement, Styled, TextAlign,
8    prelude::FluentBuilder as _,
9};
10
11use crate::{Disableable, Icon, IconName, Sizable, Size, StyleSized as _, StyledExt as _};
12
13use super::{Input, InputState, input::input_style};
14use crate::ThemeStyled as _;
15use gpui_base::NumberInput as BaseNumberInput;
16pub use gpui_base::{NumberInputEvent, NumberStep, StepAction};
17use rust_i18n::t;
18
19/// A number input element with increment and decrement buttons.
20#[derive(IntoElement)]
21pub struct NumberInput {
22    state: Entity<InputState>,
23    placeholder: SharedString,
24    size: Size,
25    prefix: Option<AnyElement>,
26    suffix: Option<AnyElement>,
27    appearance: bool,
28    focus_ring_enabled: bool,
29    disabled: bool,
30    accessibility_label: Option<SharedString>,
31    style: StyleRefinement,
32}
33
34impl NumberInput {
35    /// Create a new [`NumberInput`] element bind to the [`InputState`].
36    pub fn new(state: &Entity<InputState>) -> Self {
37        Self {
38            state: state.clone(),
39            size: Size::default(),
40            placeholder: SharedString::default(),
41            prefix: None,
42            suffix: None,
43            appearance: true,
44            focus_ring_enabled: true,
45            disabled: false,
46            accessibility_label: None,
47            style: StyleRefinement::default(),
48        }
49    }
50
51    /// Set the placeholder text of the number input.
52    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
53        self.placeholder = placeholder.into();
54        self
55    }
56
57    /// Set the prefix element of the number input.
58    pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
59        self.prefix = Some(prefix.into_any_element());
60        self
61    }
62
63    /// Set the suffix element of the number input.
64    pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
65        self.suffix = Some(suffix.into_any_element());
66        self
67    }
68
69    /// Set the appearance of the number input, if false will no border and background.
70    pub fn appearance(mut self, appearance: bool) -> Self {
71        self.appearance = appearance;
72        self
73    }
74
75    /// Set the name a screen reader announces.
76    ///
77    /// A number input has no visible text of its own to be named by, so set
78    /// this whenever it is shown without one, for example next to a separate
79    /// label. This does not change what is displayed.
80    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
81        self.accessibility_label = Some(label.into());
82        self
83    }
84}
85
86impl Disableable for NumberInput {
87    fn disabled(mut self, disabled: bool) -> Self {
88        self.disabled = disabled;
89        self
90    }
91}
92
93impl crate::FocusableExt for NumberInput {
94    fn focus_ring(mut self, enabled: bool) -> Self {
95        self.focus_ring_enabled = enabled;
96        self
97    }
98
99    fn is_focus_ring_enabled(&self) -> bool {
100        self.focus_ring_enabled
101    }
102}
103
104impl Focusable for NumberInput {
105    fn focus_handle(&self, cx: &App) -> FocusHandle {
106        self.state.focus_handle(cx)
107    }
108}
109
110impl Sizable for NumberInput {
111    fn with_size(mut self, size: impl Into<Size>) -> Self {
112        self.size = size.into();
113        self
114    }
115}
116
117impl Styled for NumberInput {
118    fn style(&mut self) -> &mut StyleRefinement {
119        &mut self.style
120    }
121}
122
123impl RenderOnce for NumberInput {
124    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
125        let focused = self.state.read(cx).focus_handle(cx).is_focused(window) && !self.disabled;
126        let (bg, _) = input_style(self.disabled, cx);
127        let border_color = if self.disabled {
128            cx.theme().input.opacity(0.5)
129        } else {
130            cx.theme().input
131        };
132        // Transparent like a ghost button, but tinted to the frame on hover.
133        let button_foreground = cx.theme().secondary_foreground;
134        let button_hover = cx.theme().input.opacity(0.4);
135        let button_active = cx.theme().input.opacity(0.6);
136        let button_size = self.size;
137        // The buttons sit inside the 1px frame, so their corners are a pixel
138        // tighter than the frame's, or they paint over its inner curve.
139        let button_radius = if self.appearance {
140            (cx.theme().radius - px(1.)).max(px(0.))
141        } else {
142            cx.theme().radius
143        };
144        let base_state = self.state.clone();
145        let content = BaseNumberInput::new(&base_state)
146            .disabled(self.disabled)
147            .when_some(self.accessibility_label, |this, label| {
148                this.accessibility_label(label)
149            })
150            .size_full()
151            .decrement_button(move |this| {
152                this.accessibility_label(t!("Input.Decrement"))
153                    .flex()
154                    .items_center()
155                    .justify_center()
156                    .text_color(button_foreground)
157                    .hover(move |this| this.bg(button_hover))
158                    .active(move |this| this.bg(button_active))
159                    // The frame owns the control height, so the buttons fill it
160                    // rather than setting their own and outgrowing the border.
161                    .h_full()
162                    .map(|this| match button_size {
163                        Size::XSmall | Size::Small => this.min_w_6(),
164                        Size::Medium | Size::Large => this.min_w_8(),
165                        Size::Size(size) => this.min_w(size),
166                    })
167                    // Only the outer corners are rounded, to follow the frame.
168                    .rounded_tl(button_radius)
169                    .rounded_bl(button_radius)
170                    .child(Icon::new(IconName::Minus).with_size(button_size))
171            })
172            .input(
173                Input::new(&self.state)
174                    .appearance(false)
175                    .with_size(button_size)
176                    .h_full()
177                    .disabled(self.disabled)
178                    .gap_0()
179                    .rounded_none()
180                    .text_align(TextAlign::Center)
181                    .when_some(self.prefix, |this, prefix| this.prefix(prefix))
182                    .when_some(self.suffix, |this, suffix| this.suffix(suffix)),
183            )
184            .increment_button(move |this| {
185                this.accessibility_label(t!("Input.Increment"))
186                    .flex()
187                    .items_center()
188                    .justify_center()
189                    .text_color(button_foreground)
190                    .hover(move |this| this.bg(button_hover))
191                    .active(move |this| this.bg(button_active))
192                    .h_full()
193                    .map(|this| match button_size {
194                        Size::XSmall | Size::Small => this.min_w_6(),
195                        Size::Medium | Size::Large => this.min_w_8(),
196                        Size::Size(size) => this.min_w(size),
197                    })
198                    .rounded_tr(button_radius)
199                    .rounded_br(button_radius)
200                    .child(Icon::new(IconName::Plus).with_size(button_size))
201            });
202
203        // The visual frame wraps the complete spinbutton. BaseNumberInput routes
204        // application children into its text slot, so putting the ring on that
205        // element would incorrectly surround only the editable middle region.
206        div()
207            .flex_1()
208            .input_h(self.size)
209            .rounded(cx.theme().radius)
210            .when(self.appearance, |this| {
211                this.bg(bg)
212                    .border_1()
213                    .border_color(border_color)
214                    .when(focused, |this| {
215                        this.border_1().border_color(cx.theme().ring)
216                    })
217            })
218            .refine_style(&self.style)
219            .when(self.disabled, |this| this.opacity(0.5))
220            .child(content)
221            .when(
222                focused && self.appearance && self.focus_ring_enabled,
223                |this| this.focus_ring_style(window, cx),
224            )
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::StepAction;
231    use gpui_base::step_value;
232
233    // `test_number_step` lives in `state::tests` because `NumberStep::value`
234    // now needs a `Context<InputState>` to invoke the `by_value` closure.
235
236    #[test]
237    fn test_step_value() {
238        fn some(value: &str) -> Option<String> {
239            Some(value.to_string())
240        }
241
242        // Step from empty value
243        assert_eq!(
244            step_value("", StepAction::Increment, 1., None, None),
245            some("1")
246        );
247        assert_eq!(
248            step_value("", StepAction::Decrement, 1., None, None),
249            some("-1")
250        );
251        // Invalid intermediate values are treated as 0
252        assert_eq!(
253            step_value("-", StepAction::Increment, 1., None, None),
254            some("1")
255        );
256        assert_eq!(
257            step_value("1", StepAction::Increment, 1., None, None),
258            some("2")
259        );
260        assert_eq!(
261            step_value("-2", StepAction::Increment, 1., None, None),
262            some("-1")
263        );
264
265        // Avoid float precision issue, e.g. 0.1 + 0.2 != 0.30000000000000004
266        assert_eq!(
267            step_value("0.1", StepAction::Increment, 0.2, None, None),
268            some("0.3")
269        );
270        assert_eq!(
271            step_value("0.3", StepAction::Decrement, 0.1, None, None),
272            some("0.2")
273        );
274        // Keep the fraction digits of the current value
275        assert_eq!(
276            step_value("1.25", StepAction::Increment, 1., None, None),
277            some("2.25")
278        );
279
280        // Step from empty value always steps into the range
281        assert_eq!(
282            step_value("", StepAction::Increment, 1., Some(10.), None),
283            some("10")
284        );
285        assert_eq!(
286            step_value("", StepAction::Decrement, 1., Some(10.), None),
287            some("10")
288        );
289        // Clamp to min/max
290        assert_eq!(
291            step_value("99.5", StepAction::Increment, 1., None, Some(100.)),
292            some("100.0")
293        );
294        assert_eq!(
295            step_value("1000", StepAction::Decrement, 1., None, Some(100.)),
296            some("100")
297        );
298        // Keep the fraction digits of the clamped bound
299        assert_eq!(
300            step_value("1", StepAction::Decrement, 1., Some(0.25), None),
301            some("0.25")
302        );
303
304        // Stepping must move the value in the pressed direction:
305        // no-op at the boundary
306        assert_eq!(
307            step_value("10", StepAction::Decrement, 1., Some(10.), None),
308            None
309        );
310        assert_eq!(
311            step_value("100", StepAction::Increment, 1., None, Some(100.)),
312            None
313        );
314        // Decrement on a below-min value (or Increment on an above-max value)
315        // does nothing, instead of moving the value in the opposite direction
316        assert_eq!(
317            step_value("5", StepAction::Decrement, 1., Some(10.), None),
318            None
319        );
320        assert_eq!(
321            step_value("1000", StepAction::Increment, 1., None, Some(100.)),
322            None
323        );
324    }
325}