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, StatefulInteractiveElement as _, StyleRefinement, Styled,
8 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}
109
110#[derive(IntoElement)]
115pub struct NumberInputText {
116 style: StyleRefinement,
117 children: Vec<AnyElement>,
118}
119
120impl NumberInputText {
121 pub fn new() -> Self {
122 Self {
123 style: StyleRefinement::default(),
124 children: Vec::new(),
125 }
126 }
127}
128
129impl Default for NumberInputText {
130 fn default() -> Self {
131 Self::new()
132 }
133}
134
135impl Styled for NumberInputText {
136 fn style(&mut self) -> &mut StyleRefinement {
137 &mut self.style
138 }
139}
140
141impl ParentElement for NumberInputText {
142 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
143 self.children.extend(elements);
144 }
145}
146
147impl RenderOnce for NumberInputText {
148 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
149 gpui::div()
150 .min_w_0()
151 .flex_1()
152 .children(self.children)
153 .refine_style(&self.style)
154 }
155}
156
157impl NumberInput {
158 pub fn new(state: &Entity<InputState>) -> Self {
159 Self {
160 style: StyleRefinement::default(),
161 children: Vec::new(),
162 disabled: false,
163 state: state.clone(),
164 on_step: None,
165 decrement_button: None,
166 increment_button: None,
167 input: None,
168 controls_right: false,
169 }
170 }
171
172 pub fn disabled(mut self, disabled: bool) -> Self {
173 self.disabled = disabled;
174 self
175 }
176
177 pub fn on_step(
178 mut self,
179 handler: impl Fn(StepAction, &mut Window, &mut App) + 'static,
180 ) -> Self {
181 self.on_step = Some(Rc::new(handler));
182 self
183 }
184
185 pub fn decrement_button(mut self, decorate: impl FnOnce(Button) -> Button + 'static) -> Self {
187 self.decrement_button = Some(Box::new(decorate));
188 self
189 }
190
191 pub fn increment_button(mut self, decorate: impl FnOnce(Button) -> Button + 'static) -> Self {
193 self.increment_button = Some(Box::new(decorate));
194 self
195 }
196
197 pub fn input(mut self, input: impl IntoElement) -> Self {
199 self.input = Some(input.into_any_element());
200 self
201 }
202
203 pub fn controls_right(mut self) -> Self {
205 self.controls_right = true;
206 self
207 }
208}
209
210impl Styled for NumberInput {
211 fn style(&mut self) -> &mut StyleRefinement {
212 &mut self.style
213 }
214}
215
216impl ParentElement for NumberInput {
217 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
218 self.children.extend(elements);
219 }
220}
221
222impl RenderOnce for NumberInput {
223 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
224 let disabled = self.disabled;
225 let controls_right = self.controls_right;
226 let on_step = self.on_step.unwrap_or_else(|| {
227 let state = self.state.clone();
228 Rc::new(move |action, window, cx| {
229 state.update(cx, |state, cx| {
230 state.focus(window, cx);
231 state.apply_number_step(action, window, cx);
232 });
233 })
234 });
235 self.state.update(cx, |state, _| state.ensure_number_mask());
236 let value = self.state.read(cx).value().parse::<f64>().ok();
237 let decrement_button = self.decrement_button.map_or_else(
238 || Button::new("decrement"),
239 |decorate| decorate(Button::new("decrement")),
240 );
241 let increment_button = self.increment_button.map_or_else(
242 || Button::new("increment"),
243 |decorate| decorate(Button::new("increment")),
244 );
245 let text = NumberInputText::new().children(self.input);
246
247 let decrement_button = decrement_button
251 .when(controls_right, |this| this.flex_1().min_h_0())
252 .when(!controls_right, |this| this.flex_none())
253 .focusable(false)
254 .disabled(disabled)
255 .on_click({
256 let on_step = on_step.clone();
257 move |_, window, cx| {
258 on_step(StepAction::Decrement, window, cx);
259 }
260 });
261 let increment_button = increment_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::Increment, window, cx);
270 }
271 });
272
273 let content = if controls_right {
274 div()
275 .flex()
276 .items_center()
277 .size_full()
278 .child(text.children(self.children))
279 .child(
280 div()
281 .h_full()
282 .flex()
283 .flex_col()
284 .child(increment_button)
285 .child(decrement_button),
286 )
287 .into_any_element()
288 } else {
289 div()
290 .flex()
291 .items_center()
292 .size_full()
293 .child(decrement_button)
294 .child(text.children(self.children))
295 .child(increment_button)
296 .into_any_element()
297 };
298
299 InputBase::new(("number-input", self.state.entity_id()))
300 .track_focus(&self.state.focus_handle(cx))
301 .flex()
302 .items_center()
303 .disabled(disabled)
304 .role(Role::SpinButton)
305 .when_some(value, |this, value| this.aria_numeric_value(value))
306 .key_context(CONTEXT)
307 .on_action({
308 let on_step = on_step.clone();
309 move |_: &Increment, window, cx| {
310 if disabled {
311 cx.propagate();
312 } else {
313 on_step(StepAction::Increment, window, cx);
314 }
315 }
316 })
317 .on_action(move |_: &Decrement, window, cx| {
318 if disabled {
319 cx.propagate();
320 } else {
321 on_step(StepAction::Decrement, window, cx);
322 }
323 })
324 .child(content)
325 .refine_style(&self.style)
326 .render(window, cx)
327 }
328}
329
330pub fn step_value(
332 value: &str,
333 action: StepAction,
334 step: f64,
335 min: Option<f64>,
336 max: Option<f64>,
337) -> Option<String> {
338 fn fraction_digits(value: &str) -> usize {
339 value.split('.').nth(1).map_or(0, |fraction| fraction.len())
340 }
341
342 let current = value.trim().parse::<f64>().ok();
343 let mut new_value = match action {
344 StepAction::Increment => current.unwrap_or(0.) + step,
345 StepAction::Decrement => current.unwrap_or(0.) - step,
346 };
347 let mut digits = fraction_digits(value).max(fraction_digits(&step.to_string()));
348 if let Some(min) = min
349 && new_value < min
350 {
351 new_value = min;
352 digits = digits.max(fraction_digits(&min.to_string()));
353 }
354 if let Some(max) = max
355 && new_value > max
356 {
357 new_value = max;
358 digits = digits.max(fraction_digits(&max.to_string()));
359 }
360
361 if let Some(current) = current {
362 let moved = match action {
363 StepAction::Increment => new_value > current,
364 StepAction::Decrement => new_value < current,
365 };
366 if !moved {
367 return None;
368 }
369 }
370
371 Some(format!("{new_value:.digits$}"))
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 use crate::theme::Theme;
379 use gpui::{
380 AppContext as _, Context, Entity, Modifiers, MouseButton, Render, TestAppContext,
381 VisualTestContext, point, px,
382 };
383
384 struct StepperHarness {
385 state: Entity<InputState>,
386 }
387
388 impl Render for StepperHarness {
389 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
390 NumberInput::new(&self.state)
391 .w(px(120.))
392 .h(px(20.))
393 .decrement_button(|button| button.w(px(20.)).h_full())
394 .input(div().size_full())
395 .increment_button(|button| button.w(px(20.)).h_full())
396 }
397 }
398
399 #[gpui::test]
403 fn pressing_a_step_button_never_takes_focus_off_the_editor(cx: &mut TestAppContext) {
404 cx.update(crate::init);
405
406 let mut created: Option<Entity<InputState>> = None;
407 let window = cx.update(|cx| {
408 cx.open_window(Default::default(), |window, cx| {
409 cx.set_global(Theme::default());
410 let state = cx.new(|cx| InputState::new(window, cx).step(1.));
411 created = Some(state.clone());
412 cx.new(|_| StepperHarness { state })
413 })
414 .unwrap()
415 });
416 let state = created.unwrap();
417 let cx = &mut VisualTestContext::from_window(window.into(), cx);
418
419 cx.update(|window, cx| {
420 state.update(cx, |state, cx| state.focus(window, cx));
421 window.draw(cx).clear(cx);
422 });
423 cx.update(|window, cx| {
424 assert!(
425 state.read(cx).focus_handle(cx).is_focused(window),
426 "the editor should start focused"
427 );
428 });
429
430 cx.simulate_mouse_move(
432 point(px(10.), px(10.)),
433 MouseButton::Left,
434 Modifiers::default(),
435 );
436 cx.simulate_event(gpui::MouseDownEvent {
437 button: MouseButton::Left,
438 position: point(px(10.), px(10.)),
439 modifiers: Modifiers::default(),
440 click_count: 1,
441 first_mouse: false,
442 });
443
444 cx.update(|window, cx| {
445 assert!(
446 state.read(cx).focus_handle(cx).is_focused(window),
447 "pressing the step button pulled focus off the editor"
448 );
449 });
450
451 cx.simulate_event(gpui::MouseUpEvent {
452 button: MouseButton::Left,
453 position: point(px(10.), px(10.)),
454 modifiers: Modifiers::default(),
455 click_count: 1,
456 });
457
458 cx.update(|window, cx| {
459 assert!(
460 state.read(cx).focus_handle(cx).is_focused(window),
461 "releasing the step button left focus off the editor"
462 );
463 });
464 }
465
466 #[test]
467 fn stepping_preserves_precision_and_directional_bounds() {
468 assert_eq!(
469 step_value("0.1", StepAction::Increment, 0.2, None, None).as_deref(),
470 Some("0.3")
471 );
472 assert_eq!(
473 step_value("10", StepAction::Decrement, 1., Some(10.), None),
474 None
475 );
476 assert_eq!(
477 step_value("99.5", StepAction::Increment, 1., None, Some(100.)).as_deref(),
478 Some("100.0")
479 );
480 assert_eq!(
481 step_value("10", StepAction::Increment, 1., None, Some(10.)),
482 None
483 );
484 assert_eq!(
485 step_value("5", StepAction::Decrement, 10., Some(0.), None).as_deref(),
486 Some("0")
487 );
488 }
489}