From e5f05a21ce1d8a150551d39e3502ef6472856df6 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:35:30 -0400 Subject: [PATCH] settings ui: Improve numeric stepper component interface (#36513) 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 + Sub + 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 Co-authored-by: Gaauwe Rombouts --- 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 + .../src/derive_register_component.rs | 1 + 13 files changed, 617 insertions(+), 289 deletions(-) delete mode 100644 crates/ui/src/components/numeric_stepper.rs create mode 100644 crates/ui_input/src/numeric_stepper.rs diff --git a/Cargo.lock b/Cargo.lock index 72ae0c8fbbd4e57bd063444eb531338e0b6c9031..c8af92a969373c30a36ba7af19e3fc04e6675b25 100644 --- a/Cargo.lock +++ b/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", diff --git a/crates/editor/src/editor_settings_controls.rs b/crates/editor/src/editor_settings_controls.rs index 20393f6cbb0ddca9d8561c7023fd0d9e5e863a9d..5426087c4d7752d2d4830ec5c59d257373495590 100644 --- a/crates/editor/src/editor_settings_controls.rs +++ b/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 } } diff --git a/crates/gpui/src/app/context.rs b/crates/gpui/src/app/context.rs index a95c225bcbb95e53a1fecbcb251dc8eef7b7c7a8..41d6cac82b7c179040d61ddfd22b003c143a5fb9 100644 --- a/crates/gpui/src/app/context.rs +++ b/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) + '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( &mut self, diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index 820eeb175e43ebc76f4a9969173aef27996b838c..0261e7e0f361336f5fbc87a3310e71bde79510e9 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -2809,6 +2809,12 @@ impl From for u32 { } } +impl From<&Pixels> for u32 { + fn from(pixels: &Pixels) -> Self { + pixels.0 as u32 + } +} + impl From for Pixels { fn from(pixels: u32) -> Self { Pixels(pixels as f32) diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 218ae5fcdfbb60b2dd99c8a656d95c3962edc98c..dafe623dfada7ba7b21140fc36c7c824e8b5f3f6 100644 --- a/crates/gpui/src/interactive.rs +++ b/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. diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml index 3354b985449e0e389f6c02f748ddf26630ff193c..f51a04cc7452ec1616401218e01e904039183751 100644 --- a/crates/onboarding/Cargo.toml +++ b/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 diff --git a/crates/onboarding/src/editing_page.rs b/crates/onboarding/src/editing_page.rs index 60ae00564a32b06f5ad42446de939e81da927778..0aa1579f582c81ea002156a2193f5c9c70981f88 100644 --- a/crates/onboarding/src/editing_page.rs +++ b/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 { + window.with_id(id, |window| { + let optimistic_font_size: gpui::Entity> = 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; pub struct FontPickerDelegate { diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index d43dc29fe7d23249e4fbee40533afaffe312c91d..fae444c0ef81d0f7b631769112f4286f8e75ea23 100644 --- a/crates/ui/src/components.rs +++ b/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::*; diff --git a/crates/ui/src/components/numeric_stepper.rs b/crates/ui/src/components/numeric_stepper.rs deleted file mode 100644 index 2ddb86d9a0d595edffc76319b415f9f68f9c6b9c..0000000000000000000000000000000000000000 --- a/crates/ui/src/components/numeric_stepper.rs +++ /dev/null @@ -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, - on_increment: Box, - /// Whether to reserve space for the reset button. - reserve_space_for_reset: bool, - on_reset: Option>, - tab_index: Option, -} - -impl NumericStepper { - pub fn new( - id: impl Into, - value: impl Into, - 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 { - 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(), - ) - } -} diff --git a/crates/ui_input/Cargo.toml b/crates/ui_input/Cargo.toml index 0f337597f0fbac925d2cc2a41fdf6a07ebf831b1..97f250c6ae97b82d30814f1c90bfd2002427b0da 100644 --- a/crates/ui_input/Cargo.toml +++ b/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 diff --git a/crates/ui_input/src/numeric_stepper.rs b/crates/ui_input/src/numeric_stepper.rs new file mode 100644 index 0000000000000000000000000000000000000000..7a5d66422fa0a6d74dbaa05ea96dedcdf3292111 --- /dev/null +++ b/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 + + Sub + + 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 { + id: ElementId, + value: T, + style: NumericStepperStyle, + focus_handle: FocusHandle, + mode: Entity, + format: Box String>, + large_step: T, + small_step: T, + step: T, + min_value: T, + max_value: T, + on_reset: Option>, + on_change: Rc, + tab_index: Option, +} + +impl NumericStepper { + pub fn new(id: impl Into, 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 IntoElement for NumericStepper { + type Element = gpui::Component; + + fn into_element(self) -> Self::Element { + gpui::Component::new(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; + + 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::() + { + 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::({ + 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 { + 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 { + 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(), + ) + } +} diff --git a/crates/ui_input/src/ui_input.rs b/crates/ui_input/src/ui_input.rs index 79bddf6a182f1fefa495e635bd0dd348211fdc94..f2b9d8e195e23f2ebbd5c6e3f9b7c7ddaed7f73f 100644 --- a/crates/ui_input/src/ui_input.rs +++ b/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; diff --git a/crates/ui_macros/src/derive_register_component.rs b/crates/ui_macros/src/derive_register_component.rs index 27248e2aacdee89757ee1cce1dbd42360a155cd7..64ab132cc01748f0899a788534e65a9129dc2712 100644 --- a/crates/ui_macros/src/derive_register_component.rs +++ b/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),