numeric_stepper.rs

  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}