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 .gap_1()
386 .rounded_sm()
387 .map(|this| {
388 if is_outlined {
389 this.overflow_hidden()
390 .bg(cx.theme().colors().surface_background)
391 .border_1()
392 .border_color(cx.theme().colors().border_variant)
393 } else {
394 this.px_1().bg(cx.theme().colors().editor_background)
395 }
396 })
397 .map(|decrement| {
398 let decrement_handler = {
399 let value = self.value;
400 let on_change = self.on_change.clone();
401 let min = self.min_value;
402 move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
403 let step = get_step(click.modifiers());
404 let new_value = value.saturating_sub(step);
405 let new_value = if new_value < min { min } else { new_value };
406 on_change(&new_value, window, cx);
407 window.focus_prev();
408 }
409 };
410
411 if is_outlined {
412 decrement.child(
413 h_flex()
414 .id("decrement_button")
415 .p_1p5()
416 .size_full()
417 .justify_center()
418 .hover(|s| {
419 s.bg(cx.theme().colors().element_hover)
420 .cursor(gpui::CursorStyle::PointingHand)
421 })
422 .border_r_1()
423 .border_color(cx.theme().colors().border_variant)
424 .child(Icon::new(IconName::Dash).size(IconSize::Small))
425 .when_some(tab_index.as_mut(), |this, tab_index| {
426 *tab_index += 1;
427 this.tab_index(*tab_index - 1).focus(|style| {
428 style.bg(cx.theme().colors().element_hover)
429 })
430 })
431 .on_click(decrement_handler),
432 )
433 } else {
434 decrement.child(
435 IconButton::new("decrement", IconName::Dash)
436 .shape(shape)
437 .icon_size(icon_size)
438 .when_some(tab_index.as_mut(), |this, tab_index| {
439 *tab_index += 1;
440 this.tab_index(*tab_index - 1)
441 })
442 .on_click(decrement_handler),
443 )
444 }
445 })
446 .child(
447 h_flex()
448 .min_w_16()
449 .w_full()
450 .border_1()
451 .border_color(cx.theme().colors().border_transparent)
452 .in_focus(|this| this.border_color(cx.theme().colors().border_focused))
453 .child(match *self.mode.read(cx) {
454 NumericStepperMode::Read => h_flex()
455 .id("numeric_stepper_label")
456 .px_1()
457 .flex_1()
458 .justify_center()
459 .child(Label::new((self.format)(&self.value)))
460 .when_some(tab_index.as_mut(), |this, tab_index| {
461 *tab_index += 1;
462 this.tab_index(*tab_index - 1).focus(|style| {
463 style.bg(cx.theme().colors().element_hover)
464 })
465 })
466 .on_click({
467 let _mode = self.mode.clone();
468 move |click, _, _cx| {
469 if click.click_count() == 2 || click.is_keyboard() {
470 // Edit mode is disabled until we implement center text alignment for editor
471 // mode.write(cx, NumericStepperMode::Edit);
472 }
473 }
474 })
475 .into_any_element(),
476 NumericStepperMode::Edit => h_flex()
477 .flex_1()
478 .child(window.use_state(cx, {
479 |window, cx| {
480 let previous_focus_handle = window.focused(cx);
481 let mut editor = Editor::single_line(window, cx);
482 let mut style = EditorStyle::default();
483 style.text.text_align = gpui::TextAlign::Right;
484 editor.set_style(style, window, cx);
485
486 editor.set_text(format!("{}", self.value), window, cx);
487 cx.on_focus_out(&editor.focus_handle(cx), window, {
488 let mode = self.mode.clone();
489 let min = self.min_value;
490 let max = self.max_value;
491 let on_change = self.on_change.clone();
492 move |this, _, window, cx| {
493 if let Ok(new_value) =
494 this.text(cx).parse::<T>()
495 {
496 let new_value = if new_value < min {
497 min
498 } else if new_value > max {
499 max
500 } else {
501 new_value
502 };
503
504 if let Some(previous) =
505 previous_focus_handle.as_ref()
506 {
507 window.focus(previous);
508 }
509 on_change(&new_value, window, cx);
510 };
511 mode.write(cx, NumericStepperMode::Read);
512 }
513 })
514 .detach();
515
516 window.focus(&editor.focus_handle(cx));
517
518 editor
519 }
520 }))
521 .on_action::<menu::Confirm>({
522 move |_, window, _| {
523 window.blur();
524 }
525 })
526 .into_any_element(),
527 }),
528 )
529 .map(|increment| {
530 let increment_handler = {
531 let value = self.value;
532 let on_change = self.on_change.clone();
533 let max = self.max_value;
534 move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
535 let step = get_step(click.modifiers());
536 let new_value = value.saturating_add(step);
537 let new_value = if new_value > max { max } else { new_value };
538 on_change(&new_value, window, cx);
539 }
540 };
541
542 if is_outlined {
543 increment.child(
544 h_flex()
545 .id("increment_button")
546 .p_1p5()
547 .size_full()
548 .justify_center()
549 .hover(|s| {
550 s.bg(cx.theme().colors().element_hover)
551 .cursor(gpui::CursorStyle::PointingHand)
552 })
553 .border_l_1()
554 .border_color(cx.theme().colors().border_variant)
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.bg(cx.theme().colors().element_hover)
560 })
561 })
562 .on_click(increment_handler),
563 )
564 } else {
565 increment.child(
566 IconButton::new("increment", IconName::Plus)
567 .shape(shape)
568 .icon_size(icon_size)
569 .when_some(tab_index.as_mut(), |this, tab_index| {
570 *tab_index += 1;
571 this.tab_index(*tab_index - 1)
572 })
573 .on_click(increment_handler),
574 )
575 }
576 }),
577 )
578 }
579}
580
581impl Component for NumericStepper<usize> {
582 fn scope() -> ComponentScope {
583 ComponentScope::Input
584 }
585
586 fn name() -> &'static str {
587 "Numeric Stepper"
588 }
589
590 fn sort_name() -> &'static str {
591 Self::name()
592 }
593
594 fn description() -> Option<&'static str> {
595 Some("A button used to increment or decrement a numeric value.")
596 }
597
598 fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
599 let first_stepper = window.use_state(cx, |_, _| 100usize);
600 let second_stepper = window.use_state(cx, |_, _| 100.0);
601 Some(
602 v_flex()
603 .gap_6()
604 .children(vec![example_group_with_title(
605 "Styles",
606 vec![
607 single_example(
608 "Default",
609 NumericStepper::new(
610 "numeric-stepper-component-preview",
611 *first_stepper.read(cx),
612 window,
613 cx,
614 )
615 .on_change({
616 let first_stepper = first_stepper.clone();
617 move |value, _, cx| first_stepper.write(cx, *value)
618 })
619 .into_any_element(),
620 ),
621 single_example(
622 "Outlined",
623 NumericStepper::new(
624 "numeric-stepper-with-border-component-preview",
625 *second_stepper.read(cx),
626 window,
627 cx,
628 )
629 .on_change({
630 let second_stepper = second_stepper.clone();
631 move |value, _, cx| second_stepper.write(cx, *value)
632 })
633 .min(1.0)
634 .max(100.0)
635 .style(NumericStepperStyle::Outlined)
636 .into_any_element(),
637 ),
638 ],
639 )])
640 .into_any_element(),
641 )
642 }
643}