numeric_stepper.rs

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