numeric_stepper.rs

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