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    SharedString, Window,
11};
12
13use super::line::{self, Line, LineEditor, LineState};
14use super::{control_metrics, Field, KeyOutcome, TextEdit};
15use crate::devtools::ProbedAny;
16use crate::icon::{Icon, IconName};
17use crate::reactive::Signal;
18use crate::theme::{theme, Size};
19
20/// Emitted when the numeric value changes. Carries the parsed value.
21#[derive(Debug, Clone, Copy)]
22pub struct NumberInputEvent(pub f64);
23
24/// A numeric input. Create with `cx.new(|cx| NumberInput::new(cx))`.
25pub struct NumberInput {
26    edit: TextEdit,
27    state: LineState,
28    focus: FocusHandle,
29    min: Option<f64>,
30    max: Option<f64>,
31    step: f64,
32    label: Option<SharedString>,
33    description: Option<SharedString>,
34    error: Option<SharedString>,
35    size: Size,
36    disabled: bool,
37}
38
39impl EventEmitter<NumberInputEvent> for NumberInput {}
40
41/// Parse a numeric buffer, tolerating surrounding whitespace and a lone `-`.
42fn parse_number(s: &str) -> Option<f64> {
43    let t = s.trim();
44    if t.is_empty() || t == "-" {
45        return None;
46    }
47    t.parse::<f64>().ok()
48}
49
50fn clamp(v: f64, min: Option<f64>, max: Option<f64>) -> f64 {
51    let v = min.map_or(v, |m| v.max(m));
52    max.map_or(v, |m| v.min(m))
53}
54
55/// Format without a trailing `.0` for whole numbers.
56fn format_number(v: f64) -> String {
57    if v.fract() == 0.0 {
58        format!("{}", v as i64)
59    } else {
60        format!("{v}")
61    }
62}
63
64impl NumberInput {
65    pub fn new(cx: &mut Context<Self>) -> Self {
66        NumberInput {
67            edit: TextEdit::new(""),
68            state: LineState::new(),
69            focus: cx.focus_handle().tab_stop(true),
70            min: None,
71            max: None,
72            step: 1.0,
73            label: None,
74            description: None,
75            error: None,
76            size: Size::Sm,
77            disabled: false,
78        }
79    }
80
81    pub fn value(mut self, value: f64) -> Self {
82        let value = clamp(value, self.min, self.max);
83        self.edit = TextEdit::new(&format_number(value));
84        self
85    }
86
87    pub fn min(mut self, min: f64) -> Self {
88        self.min = Some(min);
89        self
90    }
91
92    pub fn max(mut self, max: f64) -> Self {
93        self.max = Some(max);
94        self
95    }
96
97    pub fn step(mut self, step: f64) -> Self {
98        self.step = step;
99        self
100    }
101
102    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
103        self.label = Some(label.into());
104        self
105    }
106
107    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
108        self.description = Some(description.into());
109        self
110    }
111
112    pub fn error(mut self, error: impl Into<SharedString>) -> Self {
113        self.error = Some(error.into());
114        self
115    }
116
117    pub fn size(mut self, size: Size) -> Self {
118        self.size = size;
119        self
120    }
121
122    pub fn disabled(mut self, disabled: bool) -> Self {
123        self.disabled = disabled;
124        self
125    }
126
127    /// The current parsed value, or `None` if the buffer isn't a number.
128    pub fn value_f64(&self) -> Option<f64> {
129        parse_number(&self.edit.text())
130    }
131
132    /// Two-way bind this input's value to a `Signal<f64>`. The signal is the
133    /// source of truth: the input adopts its value now (clamped to min/max),
134    /// edits write back through [`Signal::set_if_changed`], and signal writes
135    /// replace the buffer without emitting [`NumberInputEvent`]. Equality
136    /// guards on both directions prevent update loops.
137    pub fn bind(entity: &Entity<NumberInput>, signal: &Signal<f64>, cx: &mut App) {
138        let initial = signal.get(cx);
139        entity.update(cx, |this, cx| this.sync_value(initial, cx));
140        let sink = signal.clone();
141        cx.subscribe(entity, move |_input, event: &NumberInputEvent, cx| {
142            sink.set_if_changed(cx, event.0);
143        })
144        .detach();
145        let input = entity.downgrade();
146        cx.observe(signal.entity(), move |observed, cx| {
147            let value = *observed.read(cx);
148            input.update(cx, |this, cx| this.sync_value(value, cx)).ok();
149        })
150        .detach();
151    }
152
153    /// Set the value programmatically, clamped to min/max. Does not emit —
154    /// a host that changed the value already knows.
155    pub fn set_value(&mut self, value: f64, cx: &mut Context<Self>) {
156        self.sync_value(value, cx);
157    }
158
159    /// Raise or lower the ceiling after construction. A value above the new
160    /// maximum is pulled down to it, so the field can never show one the
161    /// bounds forbid.
162    pub fn set_max(&mut self, max: f64, cx: &mut Context<Self>) {
163        self.max = Some(max);
164        if let Some(current) = self.value_f64() {
165            if current > max {
166                self.sync_value(max, cx);
167                return;
168            }
169        }
170        cx.notify();
171    }
172
173    /// Raise or lower the floor after construction, pulling a value below it
174    /// up to match.
175    pub fn set_min(&mut self, min: f64, cx: &mut Context<Self>) {
176        self.min = Some(min);
177        if let Some(current) = self.value_f64() {
178            if current < min {
179                self.sync_value(min, cx);
180                return;
181            }
182        }
183        cx.notify();
184    }
185
186    /// Programmatic set: clamp and repaint without emitting an event.
187    fn sync_value(&mut self, raw: f64, cx: &mut Context<Self>) {
188        let next = clamp(raw, self.min, self.max);
189        if self.value_f64() != Some(next) {
190            self.edit.set_text(&format_number(next));
191            cx.notify();
192        }
193    }
194
195    fn nudge(&mut self, dir: f64, cx: &mut Context<Self>) {
196        if self.disabled {
197            return;
198        }
199        let current = parse_number(&self.edit.text()).unwrap_or(0.0);
200        let next = clamp(current + dir * self.step, self.min, self.max);
201        self.edit.set_text(&format_number(next));
202        cx.emit(NumberInputEvent(next));
203        cx.notify();
204    }
205
206    fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
207        if self.disabled {
208            return;
209        }
210        // The arrows step the value rather than moving a caret up and down a
211        // line that doesn't exist, the way a spinner does.
212        let ks = &event.keystroke;
213        if !ks.modifiers.platform && !ks.modifiers.control && !ks.modifiers.shift {
214            match ks.key.as_str() {
215                "up" => {
216                    self.nudge(1.0, cx);
217                    cx.stop_propagation();
218                    return;
219                }
220                "down" => {
221                    self.nudge(-1.0, cx);
222                    cx.stop_propagation();
223                    return;
224                }
225                _ => {}
226            }
227        }
228        match line::keys(self, event, window, cx) {
229            KeyOutcome::Edited | KeyOutcome::Submit => {
230                self.line_changed(cx);
231                cx.stop_propagation();
232            }
233            KeyOutcome::Cancel | KeyOutcome::Pass => {}
234        }
235    }
236}
237
238impl LineEditor for NumberInput {
239    fn edit(&self) -> &TextEdit {
240        &self.edit
241    }
242
243    fn edit_mut(&mut self) -> &mut TextEdit {
244        &mut self.edit
245    }
246
247    fn line(&self) -> &LineState {
248        &self.state
249    }
250
251    fn line_mut(&mut self) -> &mut LineState {
252        &mut self.state
253    }
254
255    fn line_focus(&self) -> &FocusHandle {
256        &self.focus
257    }
258
259    fn line_read_only(&self) -> bool {
260        self.disabled
261    }
262
263    /// Only what can spell a number gets in — by typing, by IME, or by paste.
264    fn line_filter(&self, text: String) -> String {
265        text.chars()
266            .filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == 'e' || *c == 'E')
267            .collect()
268    }
269
270    fn line_changed(&mut self, cx: &mut Context<Self>) {
271        if let Some(value) = parse_number(&self.edit.text()) {
272            cx.emit(NumberInputEvent(value));
273        }
274        cx.notify();
275    }
276}
277
278line::line_input_handler!(NumberInput);
279line::line_focus_builders!(NumberInput);
280
281impl Render for NumberInput {
282    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
283        let t = theme(cx);
284        let (height, pad_x, font) = control_metrics(self.size);
285        let radius = t.radius(t.default_radius);
286        let focused = self.focus.is_focused(window) && !self.disabled;
287        let border = if self.error.is_some() {
288            t.color(crate::theme::ColorName::Red, 6)
289        } else if focused {
290            t.primary()
291        } else {
292            t.border()
293        }
294        .hsla();
295        let text_color = t.text().hsla();
296        let dimmed = t.dimmed().hsla();
297        let surface = t.surface().hsla();
298
299        let interior = Line::new(cx.entity()).placeholder(SharedString::new_static("0"), dimmed);
300
301        let stepper = |id: &'static str, icon: IconName| {
302            div()
303                .id(id)
304                .flex()
305                .items_center()
306                .justify_center()
307                .w(px(20.0))
308                .h(px(height / 2.0 - 1.0))
309                .text_color(dimmed)
310                .hover(move |s| s.text_color(text_color))
311                .child(Icon::new(icon).size(Size::Xs))
312        };
313
314        let steppers = div()
315            .flex()
316            .flex_col()
317            .border_l_1()
318            .border_color(border)
319            .child(
320                stepper("guise-number-inc", IconName::ChevronUp)
321                    .on_click(cx.listener(|this, _ev, _window, cx| this.nudge(1.0, cx))),
322            )
323            .child(
324                stepper("guise-number-dec", IconName::ChevronDown)
325                    .on_click(cx.listener(|this, _ev, _window, cx| this.nudge(-1.0, cx))),
326            );
327
328        let field = line::wire(div().id("guise-numberinput"), &self.focus, cx)
329            .on_key_down(cx.listener(Self::on_key))
330            .flex()
331            .items_center()
332            .justify_between()
333            .h(px(height))
334            .pl(px(pad_x))
335            .rounded(px(radius))
336            .border_1()
337            .border_color(border)
338            .bg(surface)
339            .text_size(px(font))
340            .line_height(px(font * 1.3))
341            .child(div().flex_1().min_w(px(0.0)).child(interior))
342            .child(steppers);
343
344        let mut chrome = Field::new().child(if self.disabled {
345            field.opacity(0.6)
346        } else {
347            field
348        });
349        if let Some(label) = self.label.clone() {
350            chrome = chrome.label(label);
351        }
352        if let Some(error) = self.error.clone() {
353            chrome = chrome.error(error);
354        } else if let Some(description) = self.description.clone() {
355            chrome = chrome.description(description);
356        }
357        chrome.probe_any("NumberInput")
358    }
359}