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