1use 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#[derive(Debug, Clone, Copy)]
22pub struct NumberInputEvent(pub f64);
23
24pub 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
41fn 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
55fn 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 pub fn value_f64(&self) -> Option<f64> {
129 parse_number(&self.edit.text())
130 }
131
132 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 pub fn set_value(&mut self, value: f64, cx: &mut Context<Self>) {
156 self.sync_value(value, cx);
157 }
158
159 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 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 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 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 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}