settings ui: Improve numeric stepper component interface (#36513)

Anthony Eid , Mikayla Maki , and Gaauwe Rombouts created

This is the first step to allowing users to type into a numeric stepper
to set its value. This PR makes the numeric stepper take in a generic
type `T` where T: `NumericStepperType`

```rust
pub trait NumericStepperType:
    Display
    + Add<Output = Self>
    + Sub<Output = Self>
    + Copy
    + Clone
    + Sized
    + PartialOrd
    + FromStr
    + 'static
{
    fn default_format(value: &Self) -> String {
        format!("{}", value)
    }
    fn default_step() -> Self;
    fn large_step() -> Self;
    fn small_step() -> Self;
    fn min_value() -> Self;
    fn max_value() -> Self;
}
```

This allows setting of step sizes and min/max values as well as making
the component easier to use.

cc @danilo-leal 

Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
Co-authored-by: Gaauwe Rombouts <mail@grombouts.nl>

Change summary

Cargo.lock                                        |   2 
crates/editor/src/editor_settings_controls.rs     |  16 
crates/gpui/src/app/context.rs                    |  14 
crates/gpui/src/geometry.rs                       |   6 
crates/gpui/src/interactive.rs                    |   8 
crates/onboarding/Cargo.toml                      |   1 
crates/onboarding/src/editing_page.rs             |  99 ++-
crates/ui/src/components.rs                       |   2 
crates/ui/src/components/numeric_stepper.rs       | 237 -------
crates/ui_input/Cargo.toml                        |   1 
crates/ui_input/src/numeric_stepper.rs            | 517 +++++++++++++++++
crates/ui_input/src/ui_input.rs                   |   2 
crates/ui_macros/src/derive_register_component.rs |   1 
13 files changed, 617 insertions(+), 289 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -10640,6 +10640,7 @@ dependencies = [
  "telemetry",
  "theme",
  "ui",
+ "ui_input",
  "util",
  "vim_mode_setting",
  "workspace",
@@ -17324,6 +17325,7 @@ dependencies = [
  "component",
  "editor",
  "gpui",
+ "menu",
  "settings",
  "theme",
  "ui",

crates/editor/src/editor_settings_controls.rs 🔗

@@ -5,8 +5,7 @@ use project::project_settings::ProjectSettings;
 use settings::{EditableSettingControl, Settings, SettingsContent};
 use theme::{FontFamilyCache, FontFamilyName, ThemeSettings};
 use ui::{
-    CheckboxWithLabel, ContextMenu, DropdownMenu, NumericStepper, SettingsContainer, SettingsGroup,
-    prelude::*,
+    CheckboxWithLabel, ContextMenu, DropdownMenu, SettingsContainer, SettingsGroup, prelude::*,
 };
 
 use crate::EditorSettings;
@@ -129,21 +128,12 @@ impl EditableSettingControl for BufferFontSizeControl {
 
 impl RenderOnce for BufferFontSizeControl {
     fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
-        let value = Self::read(cx);
+        let _value = Self::read(cx);
 
         h_flex()
             .gap_2()
             .child(Icon::new(IconName::FontSize))
-            .child(NumericStepper::new(
-                "buffer-font-size",
-                value.to_string(),
-                move |_, _, cx| {
-                    Self::write(value - px(1.), cx);
-                },
-                move |_, _, cx| {
-                    Self::write(value + px(1.), cx);
-                },
-            ))
+            .child(div()) // TODO: Re-evaluate this whole crate once settings UI is complete
     }
 }
 

crates/gpui/src/app/context.rs 🔗

@@ -80,6 +80,20 @@ impl<'a, T: 'static> Context<'a, T> {
         })
     }
 
+    /// Observe changes to ourselves
+    pub fn observe_self(
+        &mut self,
+        mut on_event: impl FnMut(&mut T, &mut Context<T>) + 'static,
+    ) -> Subscription
+    where
+        T: 'static,
+    {
+        let this = self.entity();
+        self.app.observe(&this, move |this, cx| {
+            this.update(cx, |this, cx| on_event(this, cx))
+        })
+    }
+
     /// Subscribe to an event type from another entity
     pub fn subscribe<T2, Evt>(
         &mut self,

crates/gpui/src/geometry.rs 🔗

@@ -2809,6 +2809,12 @@ impl From<Pixels> for u32 {
     }
 }
 
+impl From<&Pixels> for u32 {
+    fn from(pixels: &Pixels) -> Self {
+        pixels.0 as u32
+    }
+}
+
 impl From<u32> for Pixels {
     fn from(pixels: u32) -> Self {
         Pixels(pixels as f32)

crates/gpui/src/interactive.rs 🔗

@@ -259,6 +259,14 @@ impl ClickEvent {
             ClickEvent::Mouse(event) => event.up.click_count,
         }
     }
+
+    /// Returns whether the click event is generated by a keyboard event
+    pub fn is_keyboard(&self) -> bool {
+        match self {
+            ClickEvent::Mouse(_) => false,
+            ClickEvent::Keyboard(_) => true,
+        }
+    }
 }
 
 /// An enum representing the keyboard button that was pressed for a click event.

crates/onboarding/Cargo.toml 🔗

@@ -39,6 +39,7 @@ settings.workspace = true
 telemetry.workspace = true
 theme.workspace = true
 ui.workspace = true
+ui_input.workspace = true
 util.workspace = true
 vim_mode_setting.workspace = true
 workspace-hack.workspace = true

crates/onboarding/src/editing_page.rs 🔗

@@ -12,10 +12,10 @@ use project::project_settings::ProjectSettings;
 use settings::{Settings as _, update_settings_file};
 use theme::{FontFamilyCache, FontFamilyName, ThemeSettings};
 use ui::{
-    ButtonLike, ListItem, ListItemSpacing, NumericStepper, PopoverMenu, SwitchField,
-    ToggleButtonGroup, ToggleButtonGroupStyle, ToggleButtonSimple, ToggleState, Tooltip,
-    prelude::*,
+    ButtonLike, ListItem, ListItemSpacing, PopoverMenu, SwitchField, ToggleButtonGroup,
+    ToggleButtonGroupStyle, ToggleButtonSimple, ToggleState, Tooltip, prelude::*,
 };
+use ui_input::NumericStepper;
 
 use crate::{ImportCursorSettings, ImportVsCodeSettings, SettingsImportState};
 
@@ -341,23 +341,14 @@ fn render_font_customization_section(
                                 })
                                 .with_handle(ui_font_handle),
                         )
-                        .child(
-                            NumericStepper::new(
-                                "ui-font-size",
-                                ui_font_size.to_string(),
-                                move |_, _, cx| {
-                                    write_ui_font_size(ui_font_size - px(1.), cx);
-                                },
-                                move |_, _, cx| {
-                                    write_ui_font_size(ui_font_size + px(1.), cx);
-                                },
-                            )
-                            .style(ui::NumericStepperStyle::Outlined)
-                            .tab_index({
-                                *tab_index += 2;
-                                *tab_index - 2
-                            }),
-                        ),
+                        .child(font_picker_stepper(
+                            "ui-font-size",
+                            &ui_font_size,
+                            tab_index,
+                            write_ui_font_size,
+                            window,
+                            cx,
+                        )),
                 ),
         )
         .child(
@@ -405,27 +396,61 @@ fn render_font_customization_section(
                                 })
                                 .with_handle(buffer_font_handle),
                         )
-                        .child(
-                            NumericStepper::new(
-                                "buffer-font-size",
-                                buffer_font_size.to_string(),
-                                move |_, _, cx| {
-                                    write_buffer_font_size(buffer_font_size - px(1.), cx);
-                                },
-                                move |_, _, cx| {
-                                    write_buffer_font_size(buffer_font_size + px(1.), cx);
-                                },
-                            )
-                            .style(ui::NumericStepperStyle::Outlined)
-                            .tab_index({
-                                *tab_index += 2;
-                                *tab_index - 2
-                            }),
-                        ),
+                        .child(font_picker_stepper(
+                            "buffer-font-size",
+                            &buffer_font_size,
+                            tab_index,
+                            write_buffer_font_size,
+                            window,
+                            cx,
+                        )),
                 ),
         )
 }
 
+fn font_picker_stepper(
+    id: &'static str,
+    font_size: &Pixels,
+    tab_index: &mut isize,
+    write_font_size: fn(Pixels, &mut App),
+    window: &mut Window,
+    cx: &mut App,
+) -> NumericStepper<u32> {
+    window.with_id(id, |window| {
+        let optimistic_font_size: gpui::Entity<Option<u32>> = window.use_state(cx, |_, _| None);
+        optimistic_font_size.update(cx, |optimistic_font_size, _| {
+            if let Some(optimistic_font_size_val) = optimistic_font_size {
+                if *optimistic_font_size_val == u32::from(font_size) {
+                    *optimistic_font_size = None;
+                }
+            }
+        });
+
+        let stepper_font_size = optimistic_font_size
+            .read(cx)
+            .unwrap_or_else(|| font_size.into());
+
+        NumericStepper::new(
+            SharedString::new(format!("{}-stepper", id)),
+            stepper_font_size,
+            window,
+            cx,
+        )
+        .on_change(move |new_value, _, cx| {
+            optimistic_font_size.write(cx, Some(*new_value));
+            write_font_size(Pixels::from(*new_value), cx);
+        })
+        .format(|value| format!("{value}px"))
+        .style(ui_input::NumericStepperStyle::Outlined)
+        .tab_index({
+            *tab_index += 2;
+            *tab_index - 2
+        })
+        .min(6)
+        .max(32)
+    })
+}
+
 type FontPicker = Picker<FontPickerDelegate>;
 
 pub struct FontPickerDelegate {

crates/ui/src/components.rs 🔗

@@ -23,7 +23,6 @@ mod list;
 mod modal;
 mod navigable;
 mod notification;
-mod numeric_stepper;
 mod popover;
 mod popover_menu;
 mod progress;
@@ -68,7 +67,6 @@ pub use list::*;
 pub use modal::*;
 pub use navigable::*;
 pub use notification::*;
-pub use numeric_stepper::*;
 pub use popover::*;
 pub use popover_menu::*;
 pub use progress::*;

crates/ui/src/components/numeric_stepper.rs 🔗

@@ -1,237 +0,0 @@
-use gpui::ClickEvent;
-
-use crate::{IconButtonShape, prelude::*};
-
-#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
-pub enum NumericStepperStyle {
-    Outlined,
-    #[default]
-    Ghost,
-}
-
-#[derive(IntoElement, RegisterComponent)]
-pub struct NumericStepper {
-    id: ElementId,
-    value: SharedString,
-    style: NumericStepperStyle,
-    on_decrement: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
-    on_increment: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
-    /// Whether to reserve space for the reset button.
-    reserve_space_for_reset: bool,
-    on_reset: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
-    tab_index: Option<isize>,
-}
-
-impl NumericStepper {
-    pub fn new(
-        id: impl Into<ElementId>,
-        value: impl Into<SharedString>,
-        on_decrement: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
-        on_increment: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
-    ) -> Self {
-        Self {
-            id: id.into(),
-            value: value.into(),
-            style: NumericStepperStyle::default(),
-            on_decrement: Box::new(on_decrement),
-            on_increment: Box::new(on_increment),
-            reserve_space_for_reset: false,
-            on_reset: None,
-            tab_index: None,
-        }
-    }
-
-    pub fn style(mut self, style: NumericStepperStyle) -> Self {
-        self.style = style;
-        self
-    }
-
-    pub fn reserve_space_for_reset(mut self, reserve_space_for_reset: bool) -> Self {
-        self.reserve_space_for_reset = reserve_space_for_reset;
-        self
-    }
-
-    pub fn on_reset(
-        mut self,
-        on_reset: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
-    ) -> Self {
-        self.on_reset = Some(Box::new(on_reset));
-        self
-    }
-
-    pub fn tab_index(mut self, tab_index: isize) -> Self {
-        self.tab_index = Some(tab_index);
-        self
-    }
-}
-
-impl RenderOnce for NumericStepper {
-    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
-        let shape = IconButtonShape::Square;
-        let icon_size = IconSize::Small;
-
-        let is_outlined = matches!(self.style, NumericStepperStyle::Outlined);
-        let mut tab_index = self.tab_index;
-
-        h_flex()
-            .id(self.id)
-            .gap_1()
-            .map(|element| {
-                if let Some(on_reset) = self.on_reset {
-                    element.child(
-                        IconButton::new("reset", IconName::RotateCcw)
-                            .shape(shape)
-                            .icon_size(icon_size)
-                            .when_some(tab_index.as_mut(), |this, tab_index| {
-                                *tab_index += 1;
-                                this.tab_index(*tab_index - 1)
-                            })
-                            .on_click(on_reset),
-                    )
-                } else if self.reserve_space_for_reset {
-                    element.child(
-                        h_flex()
-                            .size(icon_size.square(window, cx))
-                            .flex_none()
-                            .into_any_element(),
-                    )
-                } else {
-                    element
-                }
-            })
-            .child(
-                h_flex()
-                    .gap_1()
-                    .rounded_sm()
-                    .map(|this| {
-                        if is_outlined {
-                            this.overflow_hidden()
-                                .bg(cx.theme().colors().surface_background)
-                                .border_1()
-                                .border_color(cx.theme().colors().border_variant)
-                        } else {
-                            this.px_1().bg(cx.theme().colors().editor_background)
-                        }
-                    })
-                    .map(|decrement| {
-                        if is_outlined {
-                            decrement.child(
-                                h_flex()
-                                    .id("decrement_button")
-                                    .p_1p5()
-                                    .size_full()
-                                    .justify_center()
-                                    .hover(|s| s.bg(cx.theme().colors().element_hover))
-                                    .border_r_1()
-                                    .border_color(cx.theme().colors().border_variant)
-                                    .child(Icon::new(IconName::Dash).size(IconSize::Small))
-                                    .when_some(tab_index.as_mut(), |this, tab_index| {
-                                        *tab_index += 1;
-                                        this.tab_index(*tab_index - 1).focus(|style| {
-                                            style.bg(cx.theme().colors().element_hover)
-                                        })
-                                    })
-                                    .on_click(self.on_decrement),
-                            )
-                        } else {
-                            decrement.child(
-                                IconButton::new("decrement", IconName::Dash)
-                                    .shape(shape)
-                                    .icon_size(icon_size)
-                                    .when_some(tab_index.as_mut(), |this, tab_index| {
-                                        *tab_index += 1;
-                                        this.tab_index(*tab_index - 1)
-                                    })
-                                    .on_click(self.on_decrement),
-                            )
-                        }
-                    })
-                    .child(Label::new(self.value).mx_3())
-                    .map(|increment| {
-                        if is_outlined {
-                            increment.child(
-                                h_flex()
-                                    .id("increment_button")
-                                    .p_1p5()
-                                    .size_full()
-                                    .justify_center()
-                                    .hover(|s| s.bg(cx.theme().colors().element_hover))
-                                    .border_l_1()
-                                    .border_color(cx.theme().colors().border_variant)
-                                    .child(Icon::new(IconName::Plus).size(IconSize::Small))
-                                    .when_some(tab_index.as_mut(), |this, tab_index| {
-                                        *tab_index += 1;
-                                        this.tab_index(*tab_index - 1).focus(|style| {
-                                            style.bg(cx.theme().colors().element_hover)
-                                        })
-                                    })
-                                    .on_click(self.on_increment),
-                            )
-                        } else {
-                            increment.child(
-                                IconButton::new("increment", IconName::Dash)
-                                    .shape(shape)
-                                    .icon_size(icon_size)
-                                    .when_some(tab_index.as_mut(), |this, tab_index| {
-                                        *tab_index += 1;
-                                        this.tab_index(*tab_index - 1)
-                                    })
-                                    .on_click(self.on_increment),
-                            )
-                        }
-                    }),
-            )
-    }
-}
-
-impl Component for NumericStepper {
-    fn scope() -> ComponentScope {
-        ComponentScope::Input
-    }
-
-    fn name() -> &'static str {
-        "Numeric Stepper"
-    }
-
-    fn sort_name() -> &'static str {
-        Self::name()
-    }
-
-    fn description() -> Option<&'static str> {
-        Some("A button used to increment or decrement a numeric value.")
-    }
-
-    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
-        Some(
-            v_flex()
-                .gap_6()
-                .children(vec![example_group_with_title(
-                    "Styles",
-                    vec![
-                        single_example(
-                            "Default",
-                            NumericStepper::new(
-                                "numeric-stepper-component-preview",
-                                "10",
-                                move |_, _, _| {},
-                                move |_, _, _| {},
-                            )
-                            .into_any_element(),
-                        ),
-                        single_example(
-                            "Outlined",
-                            NumericStepper::new(
-                                "numeric-stepper-with-border-component-preview",
-                                "10",
-                                move |_, _, _| {},
-                                move |_, _, _| {},
-                            )
-                            .style(NumericStepperStyle::Outlined)
-                            .into_any_element(),
-                        ),
-                    ],
-                )])
-                .into_any_element(),
-        )
-    }
-}

crates/ui_input/Cargo.toml 🔗

@@ -15,6 +15,7 @@ path = "src/ui_input.rs"
 component.workspace = true
 editor.workspace = true
 gpui.workspace = true
+menu.workspace = true
 settings.workspace = true
 theme.workspace = true
 ui.workspace = true

crates/ui_input/src/numeric_stepper.rs 🔗

@@ -0,0 +1,517 @@
+use std::{
+    fmt::Display,
+    ops::{Add, Sub},
+    rc::Rc,
+    str::FromStr,
+};
+
+use editor::{Editor, EditorStyle};
+use gpui::{ClickEvent, Entity, FocusHandle, Focusable, Modifiers};
+
+use ui::{IconButtonShape, prelude::*};
+
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum NumericStepperStyle {
+    Outlined,
+    #[default]
+    Ghost,
+}
+
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum NumericStepperMode {
+    #[default]
+    Read,
+    Edit,
+}
+
+pub trait NumericStepperType:
+    Display
+    + Add<Output = Self>
+    + Sub<Output = Self>
+    + Copy
+    + Clone
+    + Sized
+    + PartialOrd
+    + FromStr
+    + 'static
+{
+    fn default_format(value: &Self) -> String {
+        format!("{}", value)
+    }
+    fn default_step() -> Self;
+    fn large_step() -> Self;
+    fn small_step() -> Self;
+    fn min_value() -> Self;
+    fn max_value() -> Self;
+}
+
+macro_rules! impl_numeric_stepper_int {
+    ($type:ident) => {
+        impl NumericStepperType for $type {
+            fn default_step() -> Self {
+                1
+            }
+
+            fn large_step() -> Self {
+                10
+            }
+
+            fn small_step() -> Self {
+                1
+            }
+
+            fn min_value() -> Self {
+                <$type>::MIN
+            }
+
+            fn max_value() -> Self {
+                <$type>::MAX
+            }
+        }
+    };
+}
+
+macro_rules! impl_numeric_stepper_float {
+    ($type:ident) => {
+        impl NumericStepperType for $type {
+            fn default_format(value: &Self) -> String {
+                format!("{:^4}", value)
+                    .trim_end_matches('0')
+                    .trim_end_matches('.')
+                    .to_string()
+            }
+
+            fn default_step() -> Self {
+                1.0
+            }
+
+            fn large_step() -> Self {
+                10.0
+            }
+
+            fn small_step() -> Self {
+                0.1
+            }
+
+            fn min_value() -> Self {
+                <$type>::MIN
+            }
+
+            fn max_value() -> Self {
+                <$type>::MAX
+            }
+        }
+    };
+}
+
+impl_numeric_stepper_float!(f32);
+impl_numeric_stepper_float!(f64);
+impl_numeric_stepper_int!(isize);
+impl_numeric_stepper_int!(usize);
+impl_numeric_stepper_int!(i32);
+impl_numeric_stepper_int!(u32);
+impl_numeric_stepper_int!(i64);
+impl_numeric_stepper_int!(u64);
+
+#[derive(RegisterComponent)]
+pub struct NumericStepper<T = usize> {
+    id: ElementId,
+    value: T,
+    style: NumericStepperStyle,
+    focus_handle: FocusHandle,
+    mode: Entity<NumericStepperMode>,
+    format: Box<dyn FnOnce(&T) -> String>,
+    large_step: T,
+    small_step: T,
+    step: T,
+    min_value: T,
+    max_value: T,
+    on_reset: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
+    on_change: Rc<dyn Fn(&T, &mut Window, &mut App) + 'static>,
+    tab_index: Option<isize>,
+}
+
+impl<T: NumericStepperType> NumericStepper<T> {
+    pub fn new(id: impl Into<ElementId>, value: T, window: &mut Window, cx: &mut App) -> Self {
+        let id = id.into();
+
+        let (mode, focus_handle) = window.with_id(id.clone(), |window| {
+            let mode = window.use_state(cx, |_, _| NumericStepperMode::default());
+            let focus_handle = window.use_state(cx, |_, cx| cx.focus_handle());
+            (mode, focus_handle)
+        });
+
+        Self {
+            id,
+            mode,
+            value,
+            focus_handle: focus_handle.read(cx).clone(),
+            style: NumericStepperStyle::default(),
+            format: Box::new(T::default_format),
+            large_step: T::large_step(),
+            step: T::default_step(),
+            small_step: T::small_step(),
+            min_value: T::min_value(),
+            max_value: T::max_value(),
+            on_reset: None,
+            on_change: Rc::new(|_, _, _| {}),
+            tab_index: None,
+        }
+    }
+
+    pub fn format(mut self, format: impl FnOnce(&T) -> String + 'static) -> Self {
+        self.format = Box::new(format);
+        self
+    }
+
+    pub fn small_step(mut self, step: T) -> Self {
+        self.small_step = step;
+        self
+    }
+
+    pub fn normal_step(mut self, step: T) -> Self {
+        self.step = step;
+        self
+    }
+
+    pub fn large_step(mut self, step: T) -> Self {
+        self.large_step = step;
+        self
+    }
+
+    pub fn min(mut self, min: T) -> Self {
+        self.min_value = min;
+        self
+    }
+
+    pub fn max(mut self, max: T) -> Self {
+        self.max_value = max;
+        self
+    }
+
+    pub fn style(mut self, style: NumericStepperStyle) -> Self {
+        self.style = style;
+        self
+    }
+
+    pub fn on_reset(
+        mut self,
+        on_reset: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_reset = Some(Box::new(on_reset));
+        self
+    }
+
+    pub fn tab_index(mut self, tab_index: isize) -> Self {
+        self.tab_index = Some(tab_index);
+        self
+    }
+
+    pub fn on_change(mut self, on_change: impl Fn(&T, &mut Window, &mut App) + 'static) -> Self {
+        self.on_change = Rc::new(on_change);
+        self
+    }
+}
+
+impl<T: NumericStepperType> IntoElement for NumericStepper<T> {
+    type Element = gpui::Component<Self>;
+
+    fn into_element(self) -> Self::Element {
+        gpui::Component::new(self)
+    }
+}
+
+impl<T: NumericStepperType> RenderOnce for NumericStepper<T> {
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let shape = IconButtonShape::Square;
+        let icon_size = IconSize::Small;
+
+        let is_outlined = matches!(self.style, NumericStepperStyle::Outlined);
+        let mut tab_index = self.tab_index;
+
+        let get_step = {
+            let large_step = self.large_step;
+            let step = self.step;
+            let small_step = self.small_step;
+            move |modifiers: Modifiers| -> T {
+                if modifiers.shift {
+                    large_step
+                } else if modifiers.alt {
+                    small_step
+                } else {
+                    step
+                }
+            }
+        };
+
+        h_flex()
+            .id(self.id.clone())
+            .track_focus(&self.focus_handle)
+            .gap_1()
+            .when_some(self.on_reset, |this, on_reset| {
+                this.child(
+                    IconButton::new("reset", IconName::RotateCcw)
+                        .shape(shape)
+                        .icon_size(icon_size)
+                        .when_some(tab_index.as_mut(), |this, tab_index| {
+                            *tab_index += 1;
+                            this.tab_index(*tab_index - 1)
+                        })
+                        .on_click(on_reset),
+                )
+            })
+            .child(
+                h_flex()
+                    .gap_1()
+                    .rounded_sm()
+                    .map(|this| {
+                        if is_outlined {
+                            this.overflow_hidden()
+                                .bg(cx.theme().colors().surface_background)
+                                .border_1()
+                                .border_color(cx.theme().colors().border_variant)
+                        } else {
+                            this.px_1().bg(cx.theme().colors().editor_background)
+                        }
+                    })
+                    .map(|decrement| {
+                        let decrement_handler = {
+                            let value = self.value;
+                            let on_change = self.on_change.clone();
+                            let min = self.min_value;
+                            move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
+                                let step = get_step(click.modifiers());
+                                let new_value = value - step;
+                                let new_value = if new_value < min { min } else { new_value };
+                                on_change(&new_value, window, cx);
+                                window.focus_prev();
+                            }
+                        };
+
+                        if is_outlined {
+                            decrement.child(
+                                h_flex()
+                                    .id("decrement_button")
+                                    .p_1p5()
+                                    .size_full()
+                                    .justify_center()
+                                    .hover(|s| s.bg(cx.theme().colors().element_hover))
+                                    .border_r_1()
+                                    .border_color(cx.theme().colors().border_variant)
+                                    .child(Icon::new(IconName::Dash).size(IconSize::Small))
+                                    .when_some(tab_index.as_mut(), |this, tab_index| {
+                                        *tab_index += 1;
+                                        this.tab_index(*tab_index - 1).focus(|style| {
+                                            style.bg(cx.theme().colors().element_hover)
+                                        })
+                                    })
+                                    .on_click(decrement_handler),
+                            )
+                        } else {
+                            decrement.child(
+                                IconButton::new("decrement", IconName::Dash)
+                                    .shape(shape)
+                                    .icon_size(icon_size)
+                                    .when_some(tab_index.as_mut(), |this, tab_index| {
+                                        *tab_index += 1;
+                                        this.tab_index(*tab_index - 1)
+                                    })
+                                    .on_click(decrement_handler),
+                            )
+                        }
+                    })
+                    .child(
+                        h_flex()
+                            .h_8()
+                            .min_w_16()
+                            .w_full()
+                            .border_1()
+                            .border_color(cx.theme().colors().border_transparent)
+                            .in_focus(|this| this.border_color(cx.theme().colors().border_focused))
+                            .child(match *self.mode.read(cx) {
+                                NumericStepperMode::Read => h_flex()
+                                    .id("numeric_stepper_label")
+                                    .flex_1()
+                                    .justify_center()
+                                    .child(Label::new((self.format)(&self.value)).mx_3())
+                                    .when_some(tab_index.as_mut(), |this, tab_index| {
+                                        *tab_index += 1;
+                                        this.tab_index(*tab_index - 1).focus(|style| {
+                                            style.bg(cx.theme().colors().element_hover)
+                                        })
+                                    })
+                                    .on_click({
+                                        let _mode = self.mode.clone();
+                                        move |click, _, _cx| {
+                                            if click.click_count() == 2 || click.is_keyboard() {
+                                                // Edit mode is disabled until we implement center text alignment for editor
+                                                // mode.write(cx, NumericStepperMode::Edit);
+                                            }
+                                        }
+                                    })
+                                    .into_any_element(),
+                                NumericStepperMode::Edit => h_flex()
+                                    .flex_1()
+                                    .child(window.use_state(cx, {
+                                        |window, cx| {
+                                            let previous_focus_handle = window.focused(cx);
+                                            let mut editor = Editor::single_line(window, cx);
+                                            let mut style = EditorStyle::default();
+                                            style.text.text_align = gpui::TextAlign::Right;
+                                            editor.set_style(style, window, cx);
+
+                                            editor.set_text(format!("{}", self.value), window, cx);
+                                            cx.on_focus_out(&editor.focus_handle(cx), window, {
+                                                let mode = self.mode.clone();
+                                                let min = self.min_value;
+                                                let max = self.max_value;
+                                                let on_change = self.on_change.clone();
+                                                move |this, _, window, cx| {
+                                                    if let Ok(new_value) =
+                                                        this.text(cx).parse::<T>()
+                                                    {
+                                                        let new_value = if new_value < min {
+                                                            min
+                                                        } else if new_value > max {
+                                                            max
+                                                        } else {
+                                                            new_value
+                                                        };
+
+                                                        if let Some(previous) =
+                                                            previous_focus_handle.as_ref()
+                                                        {
+                                                            window.focus(previous);
+                                                        }
+                                                        on_change(&new_value, window, cx);
+                                                    };
+                                                    mode.write(cx, NumericStepperMode::Read);
+                                                }
+                                            })
+                                            .detach();
+
+                                            window.focus(&editor.focus_handle(cx));
+
+                                            editor
+                                        }
+                                    }))
+                                    .on_action::<menu::Confirm>({
+                                        move |_, window, _| {
+                                            window.blur();
+                                        }
+                                    })
+                                    .into_any_element(),
+                            }),
+                    )
+                    .map(|increment| {
+                        let increment_handler = {
+                            let value = self.value;
+                            let on_change = self.on_change.clone();
+                            let max = self.max_value;
+                            move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
+                                let step = get_step(click.modifiers());
+                                let new_value = value + step;
+                                let new_value = if new_value > max { max } else { new_value };
+                                on_change(&new_value, window, cx);
+                            }
+                        };
+
+                        if is_outlined {
+                            increment.child(
+                                h_flex()
+                                    .id("increment_button")
+                                    .p_1p5()
+                                    .size_full()
+                                    .justify_center()
+                                    .hover(|s| s.bg(cx.theme().colors().element_hover))
+                                    .border_l_1()
+                                    .border_color(cx.theme().colors().border_variant)
+                                    .child(Icon::new(IconName::Plus).size(IconSize::Small))
+                                    .when_some(tab_index.as_mut(), |this, tab_index| {
+                                        *tab_index += 1;
+                                        this.tab_index(*tab_index - 1).focus(|style| {
+                                            style.bg(cx.theme().colors().element_hover)
+                                        })
+                                    })
+                                    .on_click(increment_handler),
+                            )
+                        } else {
+                            increment.child(
+                                IconButton::new("increment", IconName::Plus)
+                                    .shape(shape)
+                                    .icon_size(icon_size)
+                                    .when_some(tab_index.as_mut(), |this, tab_index| {
+                                        *tab_index += 1;
+                                        this.tab_index(*tab_index - 1)
+                                    })
+                                    .on_click(increment_handler),
+                            )
+                        }
+                    }),
+            )
+    }
+}
+
+impl Component for NumericStepper<usize> {
+    fn scope() -> ComponentScope {
+        ComponentScope::Input
+    }
+
+    fn name() -> &'static str {
+        "Numeric Stepper"
+    }
+
+    fn sort_name() -> &'static str {
+        Self::name()
+    }
+
+    fn description() -> Option<&'static str> {
+        Some("A button used to increment or decrement a numeric value.")
+    }
+
+    fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let first_stepper = window.use_state(cx, |_, _| 100usize);
+        let second_stepper = window.use_state(cx, |_, _| 100.0);
+        Some(
+            v_flex()
+                .gap_6()
+                .children(vec![example_group_with_title(
+                    "Styles",
+                    vec![
+                        single_example(
+                            "Default",
+                            NumericStepper::new(
+                                "numeric-stepper-component-preview",
+                                *first_stepper.read(cx),
+                                window,
+                                cx,
+                            )
+                            .on_change({
+                                let first_stepper = first_stepper.clone();
+                                move |value, _, cx| first_stepper.write(cx, *value)
+                            })
+                            .into_any_element(),
+                        ),
+                        single_example(
+                            "Outlined",
+                            NumericStepper::new(
+                                "numeric-stepper-with-border-component-preview",
+                                *second_stepper.read(cx),
+                                window,
+                                cx,
+                            )
+                            .on_change({
+                                let second_stepper = second_stepper.clone();
+                                move |value, _, cx| second_stepper.write(cx, *value)
+                            })
+                            .min(1.0)
+                            .max(100.0)
+                            .style(NumericStepperStyle::Outlined)
+                            .into_any_element(),
+                        ),
+                    ],
+                )])
+                .into_any_element(),
+        )
+    }
+}

crates/ui_input/src/ui_input.rs 🔗

@@ -4,10 +4,12 @@
 //!
 //! It can't be located in the `ui` crate because it depends on `editor`.
 //!
+mod numeric_stepper;
 
 use component::{example_group, single_example};
 use editor::{Editor, EditorElement, EditorStyle};
 use gpui::{App, Entity, FocusHandle, Focusable, FontStyle, Hsla, Length, TextStyle};
+pub use numeric_stepper::*;
 use settings::Settings;
 use std::sync::Arc;
 use theme::ThemeSettings;

crates/ui_macros/src/derive_register_component.rs 🔗

@@ -4,6 +4,7 @@ use syn::{DeriveInput, parse_macro_input};
 
 pub fn derive_register_component(input: TokenStream) -> TokenStream {
     let input = parse_macro_input!(input as DeriveInput);
+
     let name = input.ident;
     let register_fn_name = syn::Ident::new(
         &format!("__component_registry_internal_register_{}", name),