Skip to main content

gpui_kit/controls/
number_input.rs

1//! A numeric field with a step control.
2//!
3//! The number belongs to the caller. The control reports the number that was
4//! asked for and renders whatever it is handed, so a value the host will not
5//! accept is visible as the value not moving. A value outside the range the
6//! control was given is shown as it is and published as invalid: hiding it
7//! behind a silent clamp would report a number nobody entered.
8
9use gpui::{
10    App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable,
11    InteractiveElement, IntoElement, KeyDownEvent, ParentElement, Render, SharedString, Styled,
12    Subscription, Window, div, prelude::FluentBuilder,
13};
14use gpui_kit_assets::Icon;
15use gpui_kit_semantics::{NodeSpec, Role, Semantic};
16use gpui_kit_theme::{ActiveTheme, ControlSize, TypeScale};
17
18use crate::controls::button::IconButton;
19use crate::controls::field::{FieldState, field_shell};
20use crate::controls::input::{TextInput, TextInputEvent};
21use crate::foundation::{Disableable, Ident, Sizable, StyledExt, text as foundation_text};
22use crate::strings::{ActiveStrings, StringKey};
23
24/// How much larger a page step is than a single step.
25const PAGE_FACTOR: f64 = 10.0;
26
27/// What a number field reports. The owner decides what any of it means.
28#[derive(Debug, Clone, PartialEq)]
29pub enum NumberInputEvent {
30    /// The number that was asked for, by typing or by a step. It is reported
31    /// exactly as it was asked for, including outside the range, so the host
32    /// decides what to do about it.
33    Changed(f64),
34    /// What is in the field is not a number at all.
35    Unparsable(SharedString),
36    Submit,
37}
38
39impl EventEmitter<NumberInputEvent> for NumberInput {}
40
41/// A number field with a step grid and, optionally, a range.
42///
43/// It never clamps. A value outside the range stays on screen exactly as it is
44/// and is published `invalid`, because silently correcting a number nobody
45/// chose hides the disagreement instead of reporting it.
46pub struct NumberInput {
47    ident: Ident,
48    focus_handle: FocusHandle,
49    field: Entity<TextInput>,
50    value: Option<f64>,
51    min: Option<f64>,
52    max: Option<f64>,
53    step: f64,
54    page_step: Option<f64>,
55    precision: usize,
56    unit: Option<SharedString>,
57    size: ControlSize,
58    disabled: bool,
59    required: bool,
60    /// What a reader should call this control, when the visible label lives
61    /// outside it.
62    name: Option<SharedString>,
63    /// The number the caller seeded, once it has been put on screen. The text
64    /// belongs to the typist afterwards, so it is written once.
65    ///
66    /// A control nobody gave a number to starts empty rather than at zero: a
67    /// zero would be a number nobody entered, and against a range that
68    /// excludes it the control would open already marked wrong.
69    seeded: bool,
70    /// Held so the field subscription lives as long as the control does.
71    _subscriptions: Vec<Subscription>,
72}
73
74impl std::fmt::Debug for NumberInput {
75    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        formatter
77            .debug_struct("NumberInput")
78            .field("ident", &self.ident)
79            .field("value", &self.value)
80            .field("range", &(self.min, self.max))
81            .field("step", &self.step)
82            .field("disabled", &self.disabled)
83            .finish()
84    }
85}
86
87impl NumberInput {
88    pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
89        let ident = ident.into();
90        let field = cx.new(|cx| TextInput::new(ident.child("field"), window, cx).bare(true));
91        let subscription = cx.subscribe(&field, |number, _field, event, cx| match event {
92            TextInputEvent::Change(text) => {
93                number.report_typed(text.clone(), cx);
94            }
95            TextInputEvent::Submit => cx.emit(NumberInputEvent::Submit),
96            _ => {}
97        });
98
99        Self {
100            ident,
101            focus_handle: cx.focus_handle(),
102            field,
103            value: None,
104            min: None,
105            max: None,
106            step: 1.0,
107            page_step: None,
108            precision: 0,
109            unit: None,
110            size: ControlSize::Md,
111            disabled: false,
112            required: false,
113            name: None,
114            seeded: false,
115            _subscriptions: vec![subscription],
116        }
117    }
118
119    /// Seeds the number the control draws. The caller keeps owning it.
120    pub fn value(mut self, value: f64) -> Self {
121        self.value = Some(value);
122        self
123    }
124
125    pub fn range(mut self, min: f64, max: f64) -> Self {
126        let (min, max) = if min <= max { (min, max) } else { (max, min) };
127        self.min = Some(min);
128        self.max = Some(max);
129        self
130    }
131
132    pub fn min(mut self, min: f64) -> Self {
133        self.min = Some(min);
134        self
135    }
136
137    pub fn max(mut self, max: f64) -> Self {
138        self.max = Some(max);
139        self
140    }
141
142    /// Names the control for a reader. A number field is usually labelled by
143    /// the form around it, and the name is passed to the field that actually
144    /// takes the keystrokes so a reader landing there knows what it is.
145    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
146        let name = name.into();
147        self.name = Some(name);
148        self
149    }
150
151    /// How far one arrow key or one step button moves.
152    pub fn step(mut self, step: f64) -> Self {
153        if step > 0.0 {
154            self.step = step;
155        }
156        self
157    }
158
159    /// How far page-up and page-down move. Ten steps unless told otherwise.
160    pub fn page_step(mut self, page_step: f64) -> Self {
161        if page_step > 0.0 {
162            self.page_step = Some(page_step);
163        }
164        self
165    }
166
167    /// How many decimals the field draws and reports.
168    pub fn precision(mut self, precision: usize) -> Self {
169        self.precision = precision;
170        self
171    }
172
173    /// What the number counts, such as `"ms"`. Shown after the field and
174    /// carried in the published value.
175    pub fn unit(mut self, unit: impl Into<SharedString>) -> Self {
176        self.unit = Some(unit.into());
177        self
178    }
179
180    pub fn required(mut self, required: bool) -> Self {
181        self.required = required;
182        self
183    }
184
185    /// Replaces the number from the host side.
186    ///
187    /// The host already knows the number it just set, so this reports
188    /// nothing; only what a typist asks for is reported.
189    pub fn set_value(&mut self, value: f64, cx: &mut Context<Self>) {
190        self.value = Some(value);
191        self.seeded = true;
192        self.write(value, cx);
193        cx.notify();
194    }
195
196    fn write(&mut self, value: f64, cx: &mut Context<Self>) {
197        let text = self.formatted(value);
198        self.field
199            .update(cx, |field, cx| field.set_text_quietly(text, cx));
200    }
201
202    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
203        self.disabled = disabled;
204        self.field
205            .update(cx, |field, cx| field.set_disabled(disabled, cx));
206        cx.notify();
207    }
208
209    /// The number the control last held, or `None` when nobody has given it
210    /// one and nobody has typed one.
211    pub fn current(&self) -> Option<f64> {
212        self.value
213    }
214
215    pub fn field(&self) -> &Entity<TextInput> {
216        &self.field
217    }
218
219    /// The number in the field, which is the typist's text rather than the
220    /// host's value while an edit is in progress.
221    pub fn shown(&self, cx: &App) -> Option<f64> {
222        let text = self.field.read(cx).value();
223        let trimmed = text.trim();
224        if trimmed.is_empty() {
225            return None;
226        }
227        trimmed.parse::<f64>().ok()
228    }
229
230    fn is_empty(&self, cx: &App) -> bool {
231        self.field.read(cx).value().trim().is_empty()
232    }
233
234    /// Whether what the field holds is something the control was told to
235    /// accept. An empty field holds no number and says nothing about one.
236    pub fn is_invalid(&self, cx: &App) -> bool {
237        if self.is_empty(cx) {
238            return false;
239        }
240        match self.shown(cx) {
241            Some(value) => self.out_of_range(value),
242            None => true,
243        }
244    }
245
246    /// Why the field is invalid, in words, or `None` when it is not. This and
247    /// [`Self::is_invalid`] read the same range, so a control cannot be drawn
248    /// as wrong without being able to say what is wrong with it.
249    pub fn invalid_reason(&self, cx: &App) -> Option<SharedString> {
250        if self.is_empty(cx) {
251            return None;
252        }
253        let strings = cx.strings();
254        let Some(value) = self.shown(cx) else {
255            return Some(strings.text(StringKey::NumberNotANumber));
256        };
257        if let Some(min) = self.min.filter(|min| value < *min) {
258            return Some(strings.format(
259                StringKey::NumberBelowMinimum,
260                &[self.formatted(min).as_ref()],
261            ));
262        }
263        if let Some(max) = self.max.filter(|max| value > *max) {
264            return Some(strings.format(
265                StringKey::NumberAboveMaximum,
266                &[self.formatted(max).as_ref()],
267            ));
268        }
269        None
270    }
271
272    fn out_of_range(&self, value: f64) -> bool {
273        self.min.is_some_and(|min| value < min) || self.max.is_some_and(|max| value > max)
274    }
275
276    fn formatted(&self, value: f64) -> SharedString {
277        SharedString::from(format!("{value:.*}", self.precision))
278    }
279
280    /// What the control publishes as its value: the number and what it counts.
281    fn display(&self, cx: &App) -> SharedString {
282        let number = self.field.read(cx).value().clone();
283        match &self.unit {
284            Some(unit) if !number.is_empty() => SharedString::from(format!("{number} {unit}")),
285            _ => number,
286        }
287    }
288
289    /// Whether a step in this direction has anywhere to go.
290    ///
291    /// At a boundary there is no next number, so nothing is reported and the
292    /// button that would report it is refused rather than inert-looking.
293    pub fn can_step(&self, delta: f64, cx: &App) -> bool {
294        if self.disabled {
295            return false;
296        }
297        let Some(from) = self.current_number(cx) else {
298            return true;
299        };
300        if delta > 0.0 {
301            self.max.is_none_or(|max| from < max)
302        } else {
303            self.min.is_none_or(|min| from > min)
304        }
305    }
306
307    /// The number the control holds: what is on screen, or what it was seeded
308    /// with before anyone typed. `None` when it holds no number at all.
309    fn current_number(&self, cx: &App) -> Option<f64> {
310        if self.is_empty(cx) {
311            return None;
312        }
313        self.shown(cx).or(self.value)
314    }
315
316    fn stepped(&self, amount: f64, cx: &App) -> Option<f64> {
317        if !self.can_step(amount, cx) {
318            return None;
319        }
320        // Stepping an empty field lands on the near bound rather than on a
321        // step away from a zero nobody entered.
322        let Some(from) = self.current_number(cx) else {
323            let first = if amount > 0.0 {
324                self.min.unwrap_or(amount)
325            } else {
326                self.max.unwrap_or(amount)
327            };
328            return Some(round_to(first, self.precision));
329        };
330        let mut next = from + amount;
331        if let Some(max) = self.max {
332            next = next.min(max);
333        }
334        if let Some(min) = self.min {
335            next = next.max(min);
336        }
337        Some(round_to(next, self.precision))
338    }
339
340    /// Reports a step, and writes it into the field so the typist sees the
341    /// number they asked for while the host decides about it.
342    fn take_step(&mut self, amount: f64, cx: &mut Context<Self>) {
343        let Some(next) = self.stepped(amount, cx) else {
344            return;
345        };
346        self.write(next, cx);
347        cx.emit(NumberInputEvent::Changed(next));
348        cx.notify();
349    }
350
351    fn report_typed(&mut self, text: SharedString, cx: &mut Context<Self>) {
352        let trimmed = text.trim();
353        if trimmed.is_empty() {
354            cx.notify();
355            return;
356        }
357        match trimmed.parse::<f64>() {
358            Ok(value) => cx.emit(NumberInputEvent::Changed(value)),
359            Err(_) => cx.emit(NumberInputEvent::Unparsable(text)),
360        }
361        cx.notify();
362    }
363
364    fn page(&self) -> f64 {
365        self.page_step.unwrap_or(self.step * PAGE_FACTOR)
366    }
367
368    fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
369        if self.disabled {
370            return;
371        }
372        let amount = match event.keystroke.key.as_str() {
373            "up" => self.step,
374            "down" => -self.step,
375            "pageup" => self.page(),
376            "pagedown" => -self.page(),
377            _ => return,
378        };
379        self.take_step(amount, cx);
380        cx.stop_propagation();
381    }
382}
383
384impl Disableable for NumberInput {
385    fn disabled(mut self, disabled: bool) -> Self {
386        self.disabled = disabled;
387        self
388    }
389}
390
391impl Sizable for NumberInput {
392    fn control_size(mut self, size: ControlSize) -> Self {
393        self.size = size;
394        self
395    }
396}
397
398impl Focusable for NumberInput {
399    fn focus_handle(&self, _cx: &App) -> FocusHandle {
400        self.focus_handle.clone()
401    }
402}
403
404impl Render for NumberInput {
405    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
406        let theme = cx.theme().clone();
407        if let Some(name) = self.name.take() {
408            self.field.update(cx, |field, cx| field.set_name(name, cx));
409        }
410        if !self.seeded {
411            self.seeded = true;
412            if let Some(value) = self.value {
413                self.write(value, cx);
414            }
415        }
416        let focused = self.field.read(cx).focus_handle(cx).is_focused(window);
417        let invalid = self.is_invalid(cx);
418        let step = self.step;
419        let can_increment = self.can_step(step, cx);
420        let can_decrement = self.can_step(-step, cx);
421
422        if self.disabled != self.field.read(cx).is_disabled() {
423            let disabled = self.disabled;
424            self.field
425                .update(cx, |field, cx| field.set_disabled(disabled, cx));
426        }
427
428        let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Input)
429            .disabled(self.disabled)
430            .invalid(invalid)
431            .required(self.required)
432            .focus(&self.field.read(cx).focus_handle(cx))
433            .value(self.display(cx));
434        if let (Some(min), Some(max), Some(value)) = (self.min, self.max, self.current_number(cx)) {
435            spec = spec.range(min as f32, max as f32, value as f32);
436        }
437
438        let control = cx.entity().downgrade();
439        let decrement = IconButton::new(
440            self.ident.child("decrement"),
441            Icon::ArrowDown,
442            cx.strings().text(StringKey::NumberDecrease),
443        )
444        .control_size(self.size)
445        .semantic_parent(self.ident.semantic_id())
446        .disabled(!can_decrement)
447        .on_click({
448            let control = control.clone();
449            move |_window, cx| {
450                control
451                    .update(cx, |number, cx| number.take_step(-step, cx))
452                    .ok();
453            }
454        });
455
456        let increment = IconButton::new(
457            self.ident.child("increment"),
458            Icon::ArrowUp,
459            cx.strings().text(StringKey::NumberIncrease),
460        )
461        .control_size(self.size)
462        .semantic_parent(self.ident.semantic_id())
463        .disabled(!can_increment)
464        .on_click(move |_window, cx| {
465            control
466                .update(cx, |number, cx| number.take_step(step, cx))
467                .ok();
468        });
469
470        div()
471            .id(self.ident.element_id())
472            .row()
473            .w_full()
474            .track_focus(&self.focus_handle)
475            .on_key_down(cx.listener(Self::on_key_down))
476            .child(
477                field_shell(
478                    &theme,
479                    self.size,
480                    FieldState::default()
481                        .focused(focused)
482                        .invalid(invalid)
483                        .disabled(self.disabled),
484                )
485                .child(div().flex_1().child(self.field.clone()))
486                .when_some(self.unit.clone(), |element, unit| {
487                    element.child(
488                        foundation_text(&theme, TypeScale::Label, unit)
489                            .flex_none()
490                            .text_tone(&theme, gpui_kit_theme::TextTone::Muted),
491                    )
492                })
493                .child(div().flex_none().row().child(decrement).child(increment)),
494            )
495            .semantic_in(cx, spec)
496    }
497}
498
499/// Rounds onto the grid the field draws, so a reported number is one the
500/// field could show without changing it.
501fn round_to(value: f64, precision: usize) -> f64 {
502    let factor = 10f64.powi(precision as i32);
503    (value * factor).round() / factor
504}
505
506#[cfg(test)]
507mod tests {
508    use super::round_to;
509
510    #[test]
511    fn stepping_lands_on_the_grid_the_field_draws() {
512        assert_eq!(round_to(0.30000000000000004, 2), 0.3);
513        assert_eq!(round_to(1.5, 0), 2.0);
514    }
515}