Skip to main content

guise/input/
number.rs

1//! `NumberInput` — a numeric text field with stepper buttons (gpui entity).
2//!
3//! Owns an editable buffer (reusing [`TextEdit`]) constrained to numeric input,
4//! plus optional min/max/step. Emits [`NumberInputEvent`] with the parsed value
5//! whenever it changes.
6
7use gpui::prelude::*;
8use gpui::{
9    div, px, Context, EventEmitter, FocusHandle, IntoElement, KeyDownEvent, MouseButton,
10    SharedString, Window,
11};
12
13use super::{control_metrics, Field, TextEdit};
14use crate::icon::{Icon, IconName};
15use crate::theme::{theme, Size};
16
17/// Emitted when the numeric value changes. Carries the parsed value.
18#[derive(Debug, Clone, Copy)]
19pub struct NumberInputEvent(pub f64);
20
21/// A numeric input. Create with `cx.new(|cx| NumberInput::new(cx))`.
22pub struct NumberInput {
23    edit: TextEdit,
24    focus: FocusHandle,
25    min: Option<f64>,
26    max: Option<f64>,
27    step: f64,
28    label: Option<SharedString>,
29    description: Option<SharedString>,
30    error: Option<SharedString>,
31    size: Size,
32    disabled: bool,
33}
34
35impl EventEmitter<NumberInputEvent> for NumberInput {}
36
37/// Parse a numeric buffer, tolerating surrounding whitespace and a lone `-`.
38fn parse_number(s: &str) -> Option<f64> {
39    let t = s.trim();
40    if t.is_empty() || t == "-" {
41        return None;
42    }
43    t.parse::<f64>().ok()
44}
45
46fn clamp(v: f64, min: Option<f64>, max: Option<f64>) -> f64 {
47    let v = min.map_or(v, |m| v.max(m));
48    max.map_or(v, |m| v.min(m))
49}
50
51/// Format without a trailing `.0` for whole numbers.
52fn format_number(v: f64) -> String {
53    if v.fract() == 0.0 {
54        format!("{}", v as i64)
55    } else {
56        format!("{v}")
57    }
58}
59
60impl NumberInput {
61    pub fn new(cx: &mut Context<Self>) -> Self {
62        NumberInput {
63            edit: TextEdit::new(""),
64            focus: cx.focus_handle(),
65            min: None,
66            max: None,
67            step: 1.0,
68            label: None,
69            description: None,
70            error: None,
71            size: Size::Sm,
72            disabled: false,
73        }
74    }
75
76    pub fn value(mut self, value: f64) -> Self {
77        let value = clamp(value, self.min, self.max);
78        self.edit = TextEdit::new(&format_number(value));
79        self
80    }
81
82    pub fn min(mut self, min: f64) -> Self {
83        self.min = Some(min);
84        self
85    }
86
87    pub fn max(mut self, max: f64) -> Self {
88        self.max = Some(max);
89        self
90    }
91
92    pub fn step(mut self, step: f64) -> Self {
93        self.step = step;
94        self
95    }
96
97    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
98        self.label = Some(label.into());
99        self
100    }
101
102    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
103        self.description = Some(description.into());
104        self
105    }
106
107    pub fn error(mut self, error: impl Into<SharedString>) -> Self {
108        self.error = Some(error.into());
109        self
110    }
111
112    pub fn size(mut self, size: Size) -> Self {
113        self.size = size;
114        self
115    }
116
117    pub fn disabled(mut self, disabled: bool) -> Self {
118        self.disabled = disabled;
119        self
120    }
121
122    /// The current parsed value, or `None` if the buffer isn't a number.
123    pub fn value_f64(&self) -> Option<f64> {
124        parse_number(&self.edit.text())
125    }
126
127    fn nudge(&mut self, dir: f64, cx: &mut Context<Self>) {
128        if self.disabled {
129            return;
130        }
131        let current = parse_number(&self.edit.text()).unwrap_or(0.0);
132        let next = clamp(current + dir * self.step, self.min, self.max);
133        self.edit = TextEdit::new(&format_number(next));
134        cx.emit(NumberInputEvent(next));
135        cx.notify();
136    }
137
138    fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
139        if self.disabled {
140            return;
141        }
142        let ks = &event.keystroke;
143        if ks.modifiers.platform || ks.modifiers.control {
144            return;
145        }
146        match ks.key.as_str() {
147            "up" => return self.nudge(1.0, cx),
148            "down" => return self.nudge(-1.0, cx),
149            "backspace" => {
150                self.edit.backspace();
151            }
152            "delete" => {
153                self.edit.delete();
154            }
155            "left" => self.edit.left(),
156            "right" => self.edit.right(),
157            "home" => self.edit.home(),
158            "end" => self.edit.end(),
159            _ => {
160                if let Some(text) = ks.key_char.as_deref().filter(|t| {
161                    !t.is_empty()
162                        && !ks.modifiers.alt
163                        && t.chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-')
164                }) {
165                    self.edit.insert(text);
166                }
167            }
168        }
169        if let Some(value) = parse_number(&self.edit.text()) {
170            cx.emit(NumberInputEvent(value));
171        }
172        cx.notify();
173        cx.stop_propagation();
174    }
175}
176
177impl Render for NumberInput {
178    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
179        let t = theme(cx);
180        let (height, pad_x, font) = control_metrics(self.size);
181        let radius = t.radius(t.default_radius);
182        let focused = self.focus.is_focused(window) && !self.disabled;
183        let border = if self.error.is_some() {
184            t.color(crate::theme::ColorName::Red, 6)
185        } else if focused {
186            t.primary()
187        } else {
188            t.border()
189        }
190        .hsla();
191        let text_color = t.text().hsla();
192        let dimmed = t.dimmed().hsla();
193        let surface = t.surface().hsla();
194        let caret = t.primary().hsla();
195
196        let interior = if focused {
197            let (before, after) = self.edit.split();
198            div()
199                .flex()
200                .items_center()
201                .text_color(text_color)
202                .child(SharedString::from(before))
203                .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
204                .child(SharedString::from(after))
205        } else if self.edit.is_empty() {
206            div().text_color(dimmed).child(SharedString::new_static("0"))
207        } else {
208            div()
209                .text_color(text_color)
210                .child(SharedString::from(self.edit.text()))
211        };
212
213        let stepper = |id: &'static str, icon: IconName| {
214            div()
215                .id(id)
216                .flex()
217                .items_center()
218                .justify_center()
219                .w(px(20.0))
220                .h(px(height / 2.0 - 1.0))
221                .text_color(dimmed)
222                .hover(move |s| s.text_color(text_color))
223                .child(Icon::new(icon).size(Size::Xs))
224        };
225
226        let steppers = div()
227            .flex()
228            .flex_col()
229            .border_l_1()
230            .border_color(border)
231            .child(stepper("guise-number-inc", IconName::ChevronUp).on_click(
232                cx.listener(|this, _ev, _window, cx| this.nudge(1.0, cx)),
233            ))
234            .child(stepper("guise-number-dec", IconName::ChevronDown).on_click(
235                cx.listener(|this, _ev, _window, cx| this.nudge(-1.0, cx)),
236            ));
237
238        let field = div()
239            .id("guise-numberinput")
240            .track_focus(&self.focus)
241            .on_key_down(cx.listener(Self::on_key))
242            .on_mouse_down(
243                MouseButton::Left,
244                cx.listener(|this, _ev, window, cx| {
245                    window.focus(&this.focus);
246                    cx.notify();
247                }),
248            )
249            .flex()
250            .items_center()
251            .justify_between()
252            .h(px(height))
253            .pl(px(pad_x))
254            .rounded(px(radius))
255            .border_1()
256            .border_color(border)
257            .bg(surface)
258            .text_size(px(font))
259            .child(interior)
260            .child(steppers);
261
262        let mut chrome = Field::new().child(if self.disabled {
263            field.opacity(0.6)
264        } else {
265            field
266        });
267        if let Some(label) = self.label.clone() {
268            chrome = chrome.label(label);
269        }
270        if let Some(error) = self.error.clone() {
271            chrome = chrome.error(error);
272        } else if let Some(description) = self.description.clone() {
273            chrome = chrome.description(description);
274        }
275        chrome
276    }
277}