1use std::{
2 fmt::Display,
3 num::{NonZero, NonZeroU32, NonZeroU64},
4 rc::Rc,
5 str::FromStr,
6};
7
8use editor::{Editor, EditorStyle};
9use gpui::{ClickEvent, Entity, FocusHandle, Focusable, FontWeight, Modifiers};
10
11use settings::{CodeFade, MinimumContrast};
12use ui::{IconButtonShape, prelude::*};
13
14#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum NumericStepperStyle {
16 Outlined,
17 #[default]
18 Ghost,
19}
20
21#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum NumericStepperMode {
23 #[default]
24 Read,
25 Edit,
26}
27
28pub trait NumericStepperType:
29 Display + Copy + Clone + Sized + PartialOrd + FromStr + 'static
30{
31 fn default_format(value: &Self) -> String {
32 format!("{}", value)
33 }
34 fn default_step() -> Self;
35 fn large_step() -> Self;
36 fn small_step() -> Self;
37 fn min_value() -> Self;
38 fn max_value() -> Self;
39 fn saturating_add(self, rhs: Self) -> Self;
40 fn saturating_sub(self, rhs: Self) -> Self;
41}
42
43impl NumericStepperType for gpui::FontWeight {
44 fn default_step() -> Self {
45 FontWeight(10.0)
46 }
47 fn large_step() -> Self {
48 FontWeight(50.0)
49 }
50 fn small_step() -> Self {
51 FontWeight(5.0)
52 }
53 fn min_value() -> Self {
54 gpui::FontWeight::THIN
55 }
56 fn max_value() -> Self {
57 gpui::FontWeight::BLACK
58 }
59 fn saturating_add(self, rhs: Self) -> Self {
60 FontWeight((self.0 + rhs.0).min(Self::max_value().0))
61 }
62 fn saturating_sub(self, rhs: Self) -> Self {
63 FontWeight((self.0 - rhs.0).max(Self::min_value().0))
64 }
65}
66
67impl NumericStepperType for settings::CodeFade {
68 fn default_step() -> Self {
69 CodeFade(0.10)
70 }
71 fn large_step() -> Self {
72 CodeFade(0.20)
73 }
74 fn small_step() -> Self {
75 CodeFade(0.05)
76 }
77 fn min_value() -> Self {
78 CodeFade(0.0)
79 }
80 fn max_value() -> Self {
81 CodeFade(0.9)
82 }
83 fn saturating_add(self, rhs: Self) -> Self {
84 CodeFade((self.0 + rhs.0).min(Self::max_value().0))
85 }
86 fn saturating_sub(self, rhs: Self) -> Self {
87 CodeFade((self.0 - rhs.0).max(Self::min_value().0))
88 }
89}
90
91impl NumericStepperType for settings::MinimumContrast {
92 fn default_step() -> Self {
93 MinimumContrast(1.0)
94 }
95 fn large_step() -> Self {
96 MinimumContrast(10.0)
97 }
98 fn small_step() -> Self {
99 MinimumContrast(0.5)
100 }
101 fn min_value() -> Self {
102 MinimumContrast(0.0)
103 }
104 fn max_value() -> Self {
105 MinimumContrast(106.0)
106 }
107 fn saturating_add(self, rhs: Self) -> Self {
108 MinimumContrast((self.0 + rhs.0).min(Self::max_value().0))
109 }
110 fn saturating_sub(self, rhs: Self) -> Self {
111 MinimumContrast((self.0 - rhs.0).max(Self::min_value().0))
112 }
113}
114
115macro_rules! impl_numeric_stepper_int {
116 ($type:ident) => {
117 impl NumericStepperType for $type {
118 fn default_step() -> Self {
119 1
120 }
121
122 fn large_step() -> Self {
123 10
124 }
125
126 fn small_step() -> Self {
127 1
128 }
129
130 fn min_value() -> Self {
131 <$type>::MIN
132 }
133
134 fn max_value() -> Self {
135 <$type>::MAX
136 }
137
138 fn saturating_add(self, rhs: Self) -> Self {
139 self.saturating_add(rhs)
140 }
141
142 fn saturating_sub(self, rhs: Self) -> Self {
143 self.saturating_sub(rhs)
144 }
145 }
146 };
147}
148
149macro_rules! impl_numeric_stepper_nonzero_int {
150 ($nonzero:ty, $inner:ty) => {
151 impl NumericStepperType for $nonzero {
152 fn default_step() -> Self {
153 <$nonzero>::new(1).unwrap()
154 }
155
156 fn large_step() -> Self {
157 <$nonzero>::new(10).unwrap()
158 }
159
160 fn small_step() -> Self {
161 <$nonzero>::new(1).unwrap()
162 }
163
164 fn min_value() -> Self {
165 <$nonzero>::MIN
166 }
167
168 fn max_value() -> Self {
169 <$nonzero>::MAX
170 }
171
172 fn saturating_add(self, rhs: Self) -> Self {
173 let result = self.get().saturating_add(rhs.get());
174 <$nonzero>::new(result.max(1)).unwrap()
175 }
176
177 fn saturating_sub(self, rhs: Self) -> Self {
178 let result = self.get().saturating_sub(rhs.get()).max(1);
179 <$nonzero>::new(result).unwrap()
180 }
181 }
182 };
183}
184
185macro_rules! impl_numeric_stepper_float {
186 ($type:ident) => {
187 impl NumericStepperType for $type {
188 fn default_format(value: &Self) -> String {
189 format!("{:.2}", value)
190 }
191
192 fn default_step() -> Self {
193 1.0
194 }
195
196 fn large_step() -> Self {
197 10.0
198 }
199
200 fn small_step() -> Self {
201 0.1
202 }
203
204 fn min_value() -> Self {
205 <$type>::MIN
206 }
207
208 fn max_value() -> Self {
209 <$type>::MAX
210 }
211
212 fn saturating_add(self, rhs: Self) -> Self {
213 (self + rhs).clamp(Self::min_value(), Self::max_value())
214 }
215
216 fn saturating_sub(self, rhs: Self) -> Self {
217 (self - rhs).clamp(Self::min_value(), Self::max_value())
218 }
219 }
220 };
221}
222
223impl_numeric_stepper_float!(f32);
224impl_numeric_stepper_float!(f64);
225impl_numeric_stepper_int!(isize);
226impl_numeric_stepper_int!(usize);
227impl_numeric_stepper_int!(i32);
228impl_numeric_stepper_int!(u32);
229impl_numeric_stepper_int!(i64);
230impl_numeric_stepper_int!(u64);
231
232impl_numeric_stepper_nonzero_int!(NonZeroU32, u32);
233impl_numeric_stepper_nonzero_int!(NonZeroU64, u64);
234impl_numeric_stepper_nonzero_int!(NonZero<usize>, usize);
235
236#[derive(RegisterComponent)]
237pub struct NumericStepper<T = usize> {
238 id: ElementId,
239 value: T,
240 style: NumericStepperStyle,
241 focus_handle: FocusHandle,
242 mode: Entity<NumericStepperMode>,
243 format: Box<dyn FnOnce(&T) -> String>,
244 large_step: T,
245 small_step: T,
246 step: T,
247 min_value: T,
248 max_value: T,
249 on_reset: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
250 on_change: Rc<dyn Fn(&T, &mut Window, &mut App) + 'static>,
251 tab_index: Option<isize>,
252}
253
254impl<T: NumericStepperType> NumericStepper<T> {
255 pub fn new(id: impl Into<ElementId>, value: T, window: &mut Window, cx: &mut App) -> Self {
256 let id = id.into();
257
258 let (mode, focus_handle) = window.with_id(id.clone(), |window| {
259 let mode = window.use_state(cx, |_, _| NumericStepperMode::default());
260 let focus_handle = window.use_state(cx, |_, cx| cx.focus_handle());
261 (mode, focus_handle)
262 });
263
264 Self {
265 id,
266 mode,
267 value,
268 focus_handle: focus_handle.read(cx).clone(),
269 style: NumericStepperStyle::default(),
270 format: Box::new(T::default_format),
271 large_step: T::large_step(),
272 step: T::default_step(),
273 small_step: T::small_step(),
274 min_value: T::min_value(),
275 max_value: T::max_value(),
276 on_reset: None,
277 on_change: Rc::new(|_, _, _| {}),
278 tab_index: None,
279 }
280 }
281
282 pub fn format(mut self, format: impl FnOnce(&T) -> String + 'static) -> Self {
283 self.format = Box::new(format);
284 self
285 }
286
287 pub fn small_step(mut self, step: T) -> Self {
288 self.small_step = step;
289 self
290 }
291
292 pub fn normal_step(mut self, step: T) -> Self {
293 self.step = step;
294 self
295 }
296
297 pub fn large_step(mut self, step: T) -> Self {
298 self.large_step = step;
299 self
300 }
301
302 pub fn min(mut self, min: T) -> Self {
303 self.min_value = min;
304 self
305 }
306
307 pub fn max(mut self, max: T) -> Self {
308 self.max_value = max;
309 self
310 }
311
312 pub fn style(mut self, style: NumericStepperStyle) -> Self {
313 self.style = style;
314 self
315 }
316
317 pub fn on_reset(
318 mut self,
319 on_reset: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
320 ) -> Self {
321 self.on_reset = Some(Box::new(on_reset));
322 self
323 }
324
325 pub fn tab_index(mut self, tab_index: isize) -> Self {
326 self.tab_index = Some(tab_index);
327 self
328 }
329
330 pub fn on_change(mut self, on_change: impl Fn(&T, &mut Window, &mut App) + 'static) -> Self {
331 self.on_change = Rc::new(on_change);
332 self
333 }
334}
335
336impl<T: NumericStepperType> IntoElement for NumericStepper<T> {
337 type Element = gpui::Component<Self>;
338
339 fn into_element(self) -> Self::Element {
340 gpui::Component::new(self)
341 }
342}
343
344impl<T: NumericStepperType> RenderOnce for NumericStepper<T> {
345 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
346 let shape = IconButtonShape::Square;
347 let icon_size = IconSize::Small;
348
349 let is_outlined = matches!(self.style, NumericStepperStyle::Outlined);
350 let mut tab_index = self.tab_index;
351
352 let get_step = {
353 let large_step = self.large_step;
354 let step = self.step;
355 let small_step = self.small_step;
356 move |modifiers: Modifiers| -> T {
357 if modifiers.shift {
358 large_step
359 } else if modifiers.alt {
360 small_step
361 } else {
362 step
363 }
364 }
365 };
366
367 h_flex()
368 .id(self.id.clone())
369 .track_focus(&self.focus_handle)
370 .gap_1()
371 .when_some(self.on_reset, |this, on_reset| {
372 this.child(
373 IconButton::new("reset", IconName::RotateCcw)
374 .shape(shape)
375 .icon_size(icon_size)
376 .when_some(tab_index.as_mut(), |this, tab_index| {
377 *tab_index += 1;
378 this.tab_index(*tab_index - 1)
379 })
380 .on_click(on_reset),
381 )
382 })
383 .child(
384 h_flex()
385 .when(!is_outlined, |this| this.gap_1())
386 .map(|decrement| {
387 let decrement_handler = {
388 let value = self.value;
389 let on_change = self.on_change.clone();
390 let min = self.min_value;
391 move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
392 let step = get_step(click.modifiers());
393 let new_value = value.saturating_sub(step);
394 let new_value = if new_value < min { min } else { new_value };
395 on_change(&new_value, window, cx);
396 window.focus_prev();
397 }
398 };
399
400 if is_outlined {
401 decrement.child(
402 h_flex()
403 .id("decrement_button")
404 .cursor(gpui::CursorStyle::PointingHand)
405 .p_1p5()
406 .size_full()
407 .justify_center()
408 .overflow_hidden()
409 .rounded_tl_sm()
410 .rounded_bl_sm()
411 .border_1()
412 .border_color(cx.theme().colors().border_variant)
413 .bg(cx.theme().colors().surface_background)
414 .hover(|s| s.bg(cx.theme().colors().element_hover))
415 .child(Icon::new(IconName::Dash).size(IconSize::Small))
416 .when_some(tab_index.as_mut(), |this, tab_index| {
417 *tab_index += 1;
418 this.tab_index(*tab_index - 1).focus(|style| {
419 style
420 .border_color(cx.theme().colors().border_focused)
421 .bg(cx.theme().colors().element_hover)
422 })
423 })
424 .on_click(decrement_handler),
425 )
426 } else {
427 decrement.child(
428 IconButton::new("decrement", IconName::Dash)
429 .shape(shape)
430 .icon_size(icon_size)
431 .when_some(tab_index.as_mut(), |this, tab_index| {
432 *tab_index += 1;
433 this.tab_index(*tab_index - 1)
434 })
435 .on_click(decrement_handler),
436 )
437 }
438 })
439 .child(
440 h_flex()
441 .min_w_16()
442 .size_full()
443 .when(is_outlined, |this| {
444 this.border_y_1()
445 .border_color(cx.theme().colors().border_variant)
446 .bg(cx.theme().colors().surface_background)
447 })
448 .in_focus(|this| this.border_color(cx.theme().colors().border_focused))
449 .child(match *self.mode.read(cx) {
450 NumericStepperMode::Read => h_flex()
451 .id("numeric_stepper_label")
452 .px_1()
453 .flex_1()
454 .justify_center()
455 .child(Label::new((self.format)(&self.value)))
456 .when_some(tab_index.as_mut(), |this, tab_index| {
457 *tab_index += 1;
458 this.tab_index(*tab_index - 1).focus(|style| {
459 style
460 .border_color(cx.theme().colors().border_focused)
461 .bg(cx.theme().colors().element_hover)
462 })
463 })
464 .on_click({
465 let _mode = self.mode.clone();
466 move |click, _, _cx| {
467 if click.click_count() == 2 || click.is_keyboard() {
468 // Edit mode is disabled until we implement center text alignment for editor
469 // mode.write(cx, NumericStepperMode::Edit);
470 }
471 }
472 })
473 .into_any_element(),
474 NumericStepperMode::Edit => h_flex()
475 .flex_1()
476 .child(window.use_state(cx, {
477 |window, cx| {
478 let previous_focus_handle = window.focused(cx);
479 let mut editor = Editor::single_line(window, cx);
480 let mut style = EditorStyle::default();
481 style.text.text_align = gpui::TextAlign::Right;
482 editor.set_style(style, window, cx);
483
484 editor.set_text(format!("{}", self.value), window, cx);
485 cx.on_focus_out(&editor.focus_handle(cx), window, {
486 let mode = self.mode.clone();
487 let min = self.min_value;
488 let max = self.max_value;
489 let on_change = self.on_change.clone();
490 move |this, _, window, cx| {
491 if let Ok(new_value) =
492 this.text(cx).parse::<T>()
493 {
494 let new_value = if new_value < min {
495 min
496 } else if new_value > max {
497 max
498 } else {
499 new_value
500 };
501
502 if let Some(previous) =
503 previous_focus_handle.as_ref()
504 {
505 window.focus(previous);
506 }
507 on_change(&new_value, window, cx);
508 };
509 mode.write(cx, NumericStepperMode::Read);
510 }
511 })
512 .detach();
513
514 window.focus(&editor.focus_handle(cx));
515
516 editor
517 }
518 }))
519 .on_action::<menu::Confirm>({
520 move |_, window, _| {
521 window.blur();
522 }
523 })
524 .into_any_element(),
525 }),
526 )
527 .map(|increment| {
528 let increment_handler = {
529 let value = self.value;
530 let on_change = self.on_change.clone();
531 let max = self.max_value;
532 move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
533 let step = get_step(click.modifiers());
534 let new_value = value.saturating_add(step);
535 let new_value = if new_value > max { max } else { new_value };
536 on_change(&new_value, window, cx);
537 }
538 };
539
540 if is_outlined {
541 increment.child(
542 h_flex()
543 .id("increment_button")
544 .cursor(gpui::CursorStyle::PointingHand)
545 .p_1p5()
546 .size_full()
547 .justify_center()
548 .overflow_hidden()
549 .rounded_tr_sm()
550 .rounded_br_sm()
551 .border_1()
552 .border_color(cx.theme().colors().border_variant)
553 .bg(cx.theme().colors().surface_background)
554 .hover(|s| s.bg(cx.theme().colors().element_hover))
555 .child(Icon::new(IconName::Plus).size(IconSize::Small))
556 .when_some(tab_index.as_mut(), |this, tab_index| {
557 *tab_index += 1;
558 this.tab_index(*tab_index - 1).focus(|style| {
559 style
560 .border_color(cx.theme().colors().border_focused)
561 .bg(cx.theme().colors().element_hover)
562 })
563 })
564 .on_click(increment_handler),
565 )
566 } else {
567 increment.child(
568 IconButton::new("increment", IconName::Plus)
569 .shape(shape)
570 .icon_size(icon_size)
571 .when_some(tab_index.as_mut(), |this, tab_index| {
572 *tab_index += 1;
573 this.tab_index(*tab_index - 1)
574 })
575 .on_click(increment_handler),
576 )
577 }
578 }),
579 )
580 }
581}
582
583impl Component for NumericStepper<usize> {
584 fn scope() -> ComponentScope {
585 ComponentScope::Input
586 }
587
588 fn name() -> &'static str {
589 "Numeric Stepper"
590 }
591
592 fn sort_name() -> &'static str {
593 Self::name()
594 }
595
596 fn description() -> Option<&'static str> {
597 Some("A button used to increment or decrement a numeric value.")
598 }
599
600 fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
601 let first_stepper = window.use_state(cx, |_, _| 100usize);
602 let second_stepper = window.use_state(cx, |_, _| 100.0);
603 Some(
604 v_flex()
605 .gap_6()
606 .children(vec![example_group_with_title(
607 "Styles",
608 vec![
609 single_example(
610 "Default",
611 NumericStepper::new(
612 "numeric-stepper-component-preview",
613 *first_stepper.read(cx),
614 window,
615 cx,
616 )
617 .on_change({
618 let first_stepper = first_stepper.clone();
619 move |value, _, cx| first_stepper.write(cx, *value)
620 })
621 .into_any_element(),
622 ),
623 single_example(
624 "Outlined",
625 NumericStepper::new(
626 "numeric-stepper-with-border-component-preview",
627 *second_stepper.read(cx),
628 window,
629 cx,
630 )
631 .on_change({
632 let second_stepper = second_stepper.clone();
633 move |value, _, cx| second_stepper.write(cx, *value)
634 })
635 .min(1.0)
636 .max(100.0)
637 .style(NumericStepperStyle::Outlined)
638 .into_any_element(),
639 ),
640 ],
641 )])
642 .into_any_element(),
643 )
644 }
645}