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