1use crate::input::InputState;
2use std::rc::Rc;
3
4use gpui::Focusable;
5use gpui::{
6 AnyElement, App, Entity, EventEmitter, InteractiveElement as _, IntoElement, KeyBinding,
7 ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
8 StyleRefinement, Styled, Window, actions, div, prelude::FluentBuilder as _,
9};
10
11use crate::{Button, InputBase, StyledExt as _};
12
13actions!(number_input, [Increment, Decrement]);
14
15const CONTEXT: &str = "NumberInput";
16
17pub(crate) fn init(cx: &mut App) {
18 cx.bind_keys([
19 KeyBinding::new("up", Increment, Some(CONTEXT)),
20 KeyBinding::new("down", Decrement, Some(CONTEXT)),
21 ]);
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum StepAction {
26 Decrement,
27 Increment,
28}
29
30#[derive(Clone)]
32pub enum NumberStep {
33 Fixed(f64),
34 ByValue(Rc<dyn Fn(f64, StepAction, &mut gpui::App) -> f64>),
35}
36
37impl NumberStep {
38 pub fn by_value(f: impl Fn(f64, StepAction, &mut gpui::App) -> f64 + 'static) -> Self {
39 Self::ByValue(Rc::new(f))
40 }
41
42 pub(crate) fn value(&self, current: f64, action: StepAction, cx: &mut gpui::App) -> f64 {
43 match self {
44 Self::Fixed(step) => *step,
45 Self::ByValue(f) => f(current, action, cx),
46 }
47 }
48}
49
50impl From<f64> for NumberStep {
51 fn from(step: f64) -> Self {
52 Self::Fixed(step)
53 }
54}
55
56#[derive(Clone)]
57pub enum NumberInputEvent {
58 Step(StepAction),
59}
60impl EventEmitter<NumberInputEvent> for InputState {}
61
62impl InputState {
63 fn apply_number_step(
65 &mut self,
66 action: StepAction,
67 window: &mut Window,
68 cx: &mut gpui::Context<Self>,
69 ) {
70 if self.disabled {
71 return;
72 }
73 if let Some(step) = self.number_step.clone() {
74 let value = self.unmask_value();
75 let current = value.trim().parse::<f64>().unwrap_or(0.);
76 let step = step.value(current, action, cx);
77 if let Some(new_value) =
78 step_value(&value, action, step, self.number_min, self.number_max)
79 {
80 if self.is_valid_input(&new_value, cx) {
81 let range = self.range_to_utf16(&(0..self.value().len()));
82 self.replace_text_in_range_silent(Some(range), &new_value, window, cx);
83 return;
84 }
85 } else {
86 return;
87 }
88 }
89 cx.emit(NumberInputEvent::Step(action));
90 }
91}
92
93type StepHandler = Rc<dyn Fn(StepAction, &mut Window, &mut App)>;
94type ButtonDecorator = Box<dyn FnOnce(Button) -> Button>;
95
96#[derive(IntoElement)]
98pub struct NumberInput {
99 style: StyleRefinement,
100 children: Vec<AnyElement>,
101 disabled: bool,
102 state: Entity<InputState>,
103 on_step: Option<StepHandler>,
104 decrement_button: Option<ButtonDecorator>,
105 increment_button: Option<ButtonDecorator>,
106 input: Option<AnyElement>,
107 controls_right: bool,
108 accessibility_label: Option<SharedString>,
109}
110
111#[derive(IntoElement)]
116pub struct NumberInputText {
117 style: StyleRefinement,
118 children: Vec<AnyElement>,
119}
120
121impl NumberInputText {
122 pub fn new() -> Self {
123 Self {
124 style: StyleRefinement::default(),
125 children: Vec::new(),
126 }
127 }
128}
129
130impl Default for NumberInputText {
131 fn default() -> Self {
132 Self::new()
133 }
134}
135
136impl Styled for NumberInputText {
137 fn style(&mut self) -> &mut StyleRefinement {
138 &mut self.style
139 }
140}
141
142impl ParentElement for NumberInputText {
143 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
144 self.children.extend(elements);
145 }
146}
147
148impl RenderOnce for NumberInputText {
149 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
150 gpui::div()
151 .min_w_0()
152 .flex_1()
153 .children(self.children)
154 .refine_style(&self.style)
155 }
156}
157
158impl NumberInput {
159 pub fn new(state: &Entity<InputState>) -> Self {
160 Self {
161 style: StyleRefinement::default(),
162 children: Vec::new(),
163 disabled: false,
164 state: state.clone(),
165 on_step: None,
166 decrement_button: None,
167 increment_button: None,
168 input: None,
169 controls_right: false,
170 accessibility_label: None,
171 }
172 }
173
174 pub fn disabled(mut self, disabled: bool) -> Self {
175 self.disabled = disabled;
176 self
177 }
178
179 pub fn on_step(
180 mut self,
181 handler: impl Fn(StepAction, &mut Window, &mut App) + 'static,
182 ) -> Self {
183 self.on_step = Some(Rc::new(handler));
184 self
185 }
186
187 pub fn decrement_button(mut self, decorate: impl FnOnce(Button) -> Button + 'static) -> Self {
189 self.decrement_button = Some(Box::new(decorate));
190 self
191 }
192
193 pub fn increment_button(mut self, decorate: impl FnOnce(Button) -> Button + 'static) -> Self {
195 self.increment_button = Some(Box::new(decorate));
196 self
197 }
198
199 pub fn input(mut self, input: impl IntoElement) -> Self {
201 self.input = Some(input.into_any_element());
202 self
203 }
204
205 pub fn controls_right(mut self) -> Self {
207 self.controls_right = true;
208 self
209 }
210
211 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
216 self.accessibility_label = Some(label.into());
217 self
218 }
219}
220
221impl Styled for NumberInput {
222 fn style(&mut self) -> &mut StyleRefinement {
223 &mut self.style
224 }
225}
226
227impl ParentElement for NumberInput {
228 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
229 self.children.extend(elements);
230 }
231}
232
233impl RenderOnce for NumberInput {
234 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
235 let disabled = self.disabled;
236 let controls_right = self.controls_right;
237 let on_step = self.on_step.unwrap_or_else(|| {
238 let state = self.state.clone();
239 Rc::new(move |action, window, cx| {
240 state.update(cx, |state, cx| {
241 state.focus(window, cx);
242 state.apply_number_step(action, window, cx);
243 });
244 })
245 });
246 self.state.update(cx, |state, _| state.ensure_number_mask());
247 let value = self.state.read(cx).value().parse::<f64>().ok();
248 let decrement_button = self.decrement_button.map_or_else(
249 || Button::new("decrement"),
250 |decorate| decorate(Button::new("decrement")),
251 );
252 let increment_button = self.increment_button.map_or_else(
253 || Button::new("increment"),
254 |decorate| decorate(Button::new("increment")),
255 );
256 let text = NumberInputText::new().children(self.input);
257
258 let decrement_button = decrement_button
262 .when(controls_right, |this| this.flex_1().min_h_0())
263 .when(!controls_right, |this| this.flex_none())
264 .focusable(false)
265 .disabled(disabled)
266 .on_click({
267 let on_step = on_step.clone();
268 move |_, window, cx| {
269 on_step(StepAction::Decrement, window, cx);
270 }
271 });
272 let increment_button = increment_button
273 .when(controls_right, |this| this.flex_1().min_h_0())
274 .when(!controls_right, |this| this.flex_none())
275 .focusable(false)
276 .disabled(disabled)
277 .on_click({
278 let on_step = on_step.clone();
279 move |_, window, cx| {
280 on_step(StepAction::Increment, window, cx);
281 }
282 });
283
284 let content = if controls_right {
285 div()
286 .flex()
287 .items_center()
288 .size_full()
289 .child(text.children(self.children))
290 .child(
291 div()
292 .h_full()
293 .flex()
294 .flex_col()
295 .child(increment_button)
296 .child(decrement_button),
297 )
298 .into_any_element()
299 } else {
300 div()
301 .flex()
302 .items_center()
303 .size_full()
304 .child(decrement_button)
305 .child(text.children(self.children))
306 .child(increment_button)
307 .into_any_element()
308 };
309
310 InputBase::new(("number-input", self.state.entity_id()))
311 .track_focus(&self.state.focus_handle(cx))
312 .flex()
313 .items_center()
314 .disabled(disabled)
315 .role(Role::SpinButton)
316 .when_some(self.accessibility_label, |this, label| {
317 this.aria_label(label)
318 })
319 .when_some(value, |this, value| this.aria_numeric_value(value))
320 .key_context(CONTEXT)
321 .on_action({
322 let on_step = on_step.clone();
323 move |_: &Increment, window, cx| {
324 if disabled {
325 cx.propagate();
326 } else {
327 on_step(StepAction::Increment, window, cx);
328 }
329 }
330 })
331 .on_action(move |_: &Decrement, window, cx| {
332 if disabled {
333 cx.propagate();
334 } else {
335 on_step(StepAction::Decrement, window, cx);
336 }
337 })
338 .child(content)
339 .refine_style(&self.style)
340 .render(window, cx)
341 }
342}
343
344pub fn step_value(
346 value: &str,
347 action: StepAction,
348 step: f64,
349 min: Option<f64>,
350 max: Option<f64>,
351) -> Option<String> {
352 fn fraction_digits(value: &str) -> usize {
353 value.split('.').nth(1).map_or(0, |fraction| fraction.len())
354 }
355
356 let current = value.trim().parse::<f64>().ok();
357 let mut new_value = match action {
358 StepAction::Increment => current.unwrap_or(0.) + step,
359 StepAction::Decrement => current.unwrap_or(0.) - step,
360 };
361 let mut digits = fraction_digits(value).max(fraction_digits(&step.to_string()));
362 if let Some(min) = min
363 && new_value < min
364 {
365 new_value = min;
366 digits = digits.max(fraction_digits(&min.to_string()));
367 }
368 if let Some(max) = max
369 && new_value > max
370 {
371 new_value = max;
372 digits = digits.max(fraction_digits(&max.to_string()));
373 }
374
375 if let Some(current) = current {
376 let moved = match action {
377 StepAction::Increment => new_value > current,
378 StepAction::Decrement => new_value < current,
379 };
380 if !moved {
381 return None;
382 }
383 }
384
385 Some(format!("{new_value:.digits$}"))
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 use crate::theme::Theme;
393 use gpui::{
394 AppContext as _, Context, Entity, Modifiers, MouseButton, Render, TestAppContext,
395 VisualTestContext, point, px,
396 };
397
398 struct StepperHarness {
399 state: Entity<InputState>,
400 }
401
402 impl Render for StepperHarness {
403 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
404 NumberInput::new(&self.state)
405 .w(px(120.))
406 .h(px(20.))
407 .decrement_button(|button| button.w(px(20.)).h_full())
408 .input(div().size_full())
409 .increment_button(|button| button.w(px(20.)).h_full())
410 }
411 }
412
413 #[gpui::test]
417 fn pressing_a_step_button_never_takes_focus_off_the_editor(cx: &mut TestAppContext) {
418 cx.update(crate::init);
419
420 let mut created: Option<Entity<InputState>> = None;
421 let window = cx.update(|cx| {
422 cx.open_window(Default::default(), |window, cx| {
423 cx.set_global(Theme::default());
424 let state = cx.new(|cx| InputState::new(window, cx).step(1.));
425 created = Some(state.clone());
426 cx.new(|_| StepperHarness { state })
427 })
428 .unwrap()
429 });
430 let state = created.unwrap();
431 let cx = &mut VisualTestContext::from_window(window.into(), cx);
432
433 cx.update(|window, cx| {
434 state.update(cx, |state, cx| state.focus(window, cx));
435 window.draw(cx).clear(cx);
436 });
437 cx.update(|window, cx| {
438 assert!(
439 state.read(cx).focus_handle(cx).is_focused(window),
440 "the editor should start focused"
441 );
442 });
443
444 cx.simulate_mouse_move(
446 point(px(10.), px(10.)),
447 MouseButton::Left,
448 Modifiers::default(),
449 );
450 cx.simulate_event(gpui::MouseDownEvent {
451 button: MouseButton::Left,
452 position: point(px(10.), px(10.)),
453 modifiers: Modifiers::default(),
454 click_count: 1,
455 first_mouse: false,
456 });
457
458 cx.update(|window, cx| {
459 assert!(
460 state.read(cx).focus_handle(cx).is_focused(window),
461 "pressing the step button pulled focus off the editor"
462 );
463 });
464
465 cx.simulate_event(gpui::MouseUpEvent {
466 button: MouseButton::Left,
467 position: point(px(10.), px(10.)),
468 modifiers: Modifiers::default(),
469 click_count: 1,
470 });
471
472 cx.update(|window, cx| {
473 assert!(
474 state.read(cx).focus_handle(cx).is_focused(window),
475 "releasing the step button left focus off the editor"
476 );
477 });
478 }
479
480 #[test]
481 fn stepping_preserves_precision_and_directional_bounds() {
482 assert_eq!(
483 step_value("0.1", StepAction::Increment, 0.2, None, None).as_deref(),
484 Some("0.3")
485 );
486 assert_eq!(
487 step_value("10", StepAction::Decrement, 1., Some(10.), None),
488 None
489 );
490 assert_eq!(
491 step_value("99.5", StepAction::Increment, 1., None, Some(100.)).as_deref(),
492 Some("100.0")
493 );
494 assert_eq!(
495 step_value("10", StepAction::Increment, 1., None, Some(10.)),
496 None
497 );
498 assert_eq!(
499 step_value("5", StepAction::Decrement, 10., Some(0.), None).as_deref(),
500 Some("0")
501 );
502 }
503}