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
266      .chars()
267      .filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == 'e' || *c == 'E')
268      .collect()
269  }
270
271  fn line_changed(&mut self, cx: &mut Context<Self>) {
272    if let Some(value) = parse_number(&self.edit.text()) {
273      cx.emit(NumberInputEvent(value));
274    }
275    cx.notify();
276  }
277}
278
279line::line_input_handler!(NumberInput);
280line::line_focus_builders!(NumberInput);
281
282impl Render for NumberInput {
283  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
284    let t = theme(cx);
285    let (height, pad_x, font) = control_metrics(self.size);
286    let radius = t.radius(t.default_radius);
287    let focused = self.focus.is_focused(window) && !self.disabled;
288    let border = if self.error.is_some() {
289      t.color(crate::theme::ColorName::Red, 6)
290    } else if focused {
291      t.primary()
292    } else {
293      t.border()
294    }
295    .hsla();
296    let text_color = t.text().hsla();
297    let dimmed = t.dimmed().hsla();
298    let surface = t.surface().hsla();
299
300    let interior = Line::new(cx.entity()).placeholder(SharedString::new_static("0"), dimmed);
301
302    let stepper = |id: &'static str, icon: IconName| {
303      div()
304        .id(id)
305        .flex()
306        .items_center()
307        .justify_center()
308        .w(px(20.0))
309        .h(px(height / 2.0 - 1.0))
310        .text_color(dimmed)
311        .hover(move |s| s.text_color(text_color))
312        .child(Icon::new(icon).size(Size::Xs))
313    };
314
315    let steppers = div()
316      .flex()
317      .flex_col()
318      .border_l_1()
319      .border_color(border)
320      .child(
321        stepper("guise-number-inc", IconName::ChevronUp)
322          .on_click(cx.listener(|this, _ev, _window, cx| this.nudge(1.0, cx))),
323      )
324      .child(
325        stepper("guise-number-dec", IconName::ChevronDown)
326          .on_click(cx.listener(|this, _ev, _window, cx| this.nudge(-1.0, cx))),
327      );
328
329    let field = line::wire(div().id("guise-numberinput"), &self.focus, cx)
330      .on_key_down(cx.listener(Self::on_key))
331      .flex()
332      .items_center()
333      .justify_between()
334      .h(px(height))
335      .pl(px(pad_x))
336      .rounded(px(radius))
337      .border_1()
338      .border_color(border)
339      .bg(surface)
340      .text_size(px(font))
341      .line_height(px(font * 1.3))
342      .child(div().flex_1().min_w(px(0.0)).child(interior))
343      .child(steppers);
344
345    let mut chrome = Field::new().child(if self.disabled {
346      field.opacity(0.6)
347    } else {
348      field
349    });
350    if let Some(label) = self.label.clone() {
351      chrome = chrome.label(label);
352    }
353    if let Some(error) = self.error.clone() {
354      chrome = chrome.error(error);
355    } else if let Some(description) = self.description.clone() {
356      chrome = chrome.description(description);
357    }
358    chrome.probe_any("NumberInput")
359  }
360}