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