1use 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
24const PAGE_FACTOR: f64 = 10.0;
26
27#[derive(Debug, Clone, PartialEq)]
29pub enum NumberInputEvent {
30 Changed(f64),
34 Unparsable(SharedString),
36 Submit,
37}
38
39impl EventEmitter<NumberInputEvent> for NumberInput {}
40
41pub 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 name: Option<SharedString>,
63 seeded: bool,
70 _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 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 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 pub fn step(mut self, step: f64) -> Self {
153 if step > 0.0 {
154 self.step = step;
155 }
156 self
157 }
158
159 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 pub fn precision(mut self, precision: usize) -> Self {
169 self.precision = precision;
170 self
171 }
172
173 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 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 pub fn current(&self) -> Option<f64> {
212 self.value
213 }
214
215 pub fn field(&self) -> &Entity<TextInput> {
216 &self.field
217 }
218
219 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 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 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 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 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 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 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 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
499fn 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}