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