1use gpui::prelude::*;
8use gpui::{
9 div, px, Context, EventEmitter, FocusHandle, IntoElement, KeyDownEvent, MouseButton,
10 SharedString, Window,
11};
12
13use super::{control_metrics, Field, TextEdit};
14use crate::icon::{Icon, IconName};
15use crate::theme::{theme, Size};
16
17#[derive(Debug, Clone, Copy)]
19pub struct NumberInputEvent(pub f64);
20
21pub struct NumberInput {
23 edit: TextEdit,
24 focus: FocusHandle,
25 min: Option<f64>,
26 max: Option<f64>,
27 step: f64,
28 label: Option<SharedString>,
29 description: Option<SharedString>,
30 error: Option<SharedString>,
31 size: Size,
32 disabled: bool,
33}
34
35impl EventEmitter<NumberInputEvent> for NumberInput {}
36
37fn parse_number(s: &str) -> Option<f64> {
39 let t = s.trim();
40 if t.is_empty() || t == "-" {
41 return None;
42 }
43 t.parse::<f64>().ok()
44}
45
46fn clamp(v: f64, min: Option<f64>, max: Option<f64>) -> f64 {
47 let v = min.map_or(v, |m| v.max(m));
48 max.map_or(v, |m| v.min(m))
49}
50
51fn format_number(v: f64) -> String {
53 if v.fract() == 0.0 {
54 format!("{}", v as i64)
55 } else {
56 format!("{v}")
57 }
58}
59
60impl NumberInput {
61 pub fn new(cx: &mut Context<Self>) -> Self {
62 NumberInput {
63 edit: TextEdit::new(""),
64 focus: cx.focus_handle(),
65 min: None,
66 max: None,
67 step: 1.0,
68 label: None,
69 description: None,
70 error: None,
71 size: Size::Sm,
72 disabled: false,
73 }
74 }
75
76 pub fn value(mut self, value: f64) -> Self {
77 let value = clamp(value, self.min, self.max);
78 self.edit = TextEdit::new(&format_number(value));
79 self
80 }
81
82 pub fn min(mut self, min: f64) -> Self {
83 self.min = Some(min);
84 self
85 }
86
87 pub fn max(mut self, max: f64) -> Self {
88 self.max = Some(max);
89 self
90 }
91
92 pub fn step(mut self, step: f64) -> Self {
93 self.step = step;
94 self
95 }
96
97 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
98 self.label = Some(label.into());
99 self
100 }
101
102 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
103 self.description = Some(description.into());
104 self
105 }
106
107 pub fn error(mut self, error: impl Into<SharedString>) -> Self {
108 self.error = Some(error.into());
109 self
110 }
111
112 pub fn size(mut self, size: Size) -> Self {
113 self.size = size;
114 self
115 }
116
117 pub fn disabled(mut self, disabled: bool) -> Self {
118 self.disabled = disabled;
119 self
120 }
121
122 pub fn value_f64(&self) -> Option<f64> {
124 parse_number(&self.edit.text())
125 }
126
127 fn nudge(&mut self, dir: f64, cx: &mut Context<Self>) {
128 if self.disabled {
129 return;
130 }
131 let current = parse_number(&self.edit.text()).unwrap_or(0.0);
132 let next = clamp(current + dir * self.step, self.min, self.max);
133 self.edit = TextEdit::new(&format_number(next));
134 cx.emit(NumberInputEvent(next));
135 cx.notify();
136 }
137
138 fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
139 if self.disabled {
140 return;
141 }
142 let ks = &event.keystroke;
143 if ks.modifiers.platform || ks.modifiers.control {
144 return;
145 }
146 match ks.key.as_str() {
147 "up" => return self.nudge(1.0, cx),
148 "down" => return self.nudge(-1.0, cx),
149 "backspace" => {
150 self.edit.backspace();
151 }
152 "delete" => {
153 self.edit.delete();
154 }
155 "left" => self.edit.left(),
156 "right" => self.edit.right(),
157 "home" => self.edit.home(),
158 "end" => self.edit.end(),
159 _ => {
160 if let Some(text) = ks.key_char.as_deref().filter(|t| {
161 !t.is_empty()
162 && !ks.modifiers.alt
163 && t.chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-')
164 }) {
165 self.edit.insert(text);
166 }
167 }
168 }
169 if let Some(value) = parse_number(&self.edit.text()) {
170 cx.emit(NumberInputEvent(value));
171 }
172 cx.notify();
173 cx.stop_propagation();
174 }
175}
176
177impl Render for NumberInput {
178 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
179 let t = theme(cx);
180 let (height, pad_x, font) = control_metrics(self.size);
181 let radius = t.radius(t.default_radius);
182 let focused = self.focus.is_focused(window) && !self.disabled;
183 let border = if self.error.is_some() {
184 t.color(crate::theme::ColorName::Red, 6)
185 } else if focused {
186 t.primary()
187 } else {
188 t.border()
189 }
190 .hsla();
191 let text_color = t.text().hsla();
192 let dimmed = t.dimmed().hsla();
193 let surface = t.surface().hsla();
194 let caret = t.primary().hsla();
195
196 let interior = if focused {
197 let (before, after) = self.edit.split();
198 div()
199 .flex()
200 .items_center()
201 .text_color(text_color)
202 .child(SharedString::from(before))
203 .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
204 .child(SharedString::from(after))
205 } else if self.edit.is_empty() {
206 div().text_color(dimmed).child(SharedString::new_static("0"))
207 } else {
208 div()
209 .text_color(text_color)
210 .child(SharedString::from(self.edit.text()))
211 };
212
213 let stepper = |id: &'static str, icon: IconName| {
214 div()
215 .id(id)
216 .flex()
217 .items_center()
218 .justify_center()
219 .w(px(20.0))
220 .h(px(height / 2.0 - 1.0))
221 .text_color(dimmed)
222 .hover(move |s| s.text_color(text_color))
223 .child(Icon::new(icon).size(Size::Xs))
224 };
225
226 let steppers = div()
227 .flex()
228 .flex_col()
229 .border_l_1()
230 .border_color(border)
231 .child(stepper("guise-number-inc", IconName::ChevronUp).on_click(
232 cx.listener(|this, _ev, _window, cx| this.nudge(1.0, cx)),
233 ))
234 .child(stepper("guise-number-dec", IconName::ChevronDown).on_click(
235 cx.listener(|this, _ev, _window, cx| this.nudge(-1.0, cx)),
236 ));
237
238 let field = div()
239 .id("guise-numberinput")
240 .track_focus(&self.focus)
241 .on_key_down(cx.listener(Self::on_key))
242 .on_mouse_down(
243 MouseButton::Left,
244 cx.listener(|this, _ev, window, cx| {
245 window.focus(&this.focus);
246 cx.notify();
247 }),
248 )
249 .flex()
250 .items_center()
251 .justify_between()
252 .h(px(height))
253 .pl(px(pad_x))
254 .rounded(px(radius))
255 .border_1()
256 .border_color(border)
257 .bg(surface)
258 .text_size(px(font))
259 .child(interior)
260 .child(steppers);
261
262 let mut chrome = Field::new().child(if self.disabled {
263 field.opacity(0.6)
264 } else {
265 field
266 });
267 if let Some(label) = self.label.clone() {
268 chrome = chrome.label(label);
269 }
270 if let Some(error) = self.error.clone() {
271 chrome = chrome.error(error);
272 } else if let Some(description) = self.description.clone() {
273 chrome = chrome.description(description);
274 }
275 chrome
276 }
277}