From ea6ec8aa5ddb0eb8db932c16f275fab39367fdf3 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Mon, 25 Nov 2024 19:32:56 +0800 Subject: [PATCH] ui: Add `number_input` (#433) https://github.com/user-attachments/assets/ae6f4e48-b0fa-4f8c-bff5-809c70848b06 --- crates/story/src/input_story.rs | 105 +++++++++++++++++++- crates/ui/src/input/input.rs | 7 +- crates/ui/src/lib.rs | 2 + crates/ui/src/number_input.rs | 168 ++++++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 crates/ui/src/number_input.rs diff --git a/crates/story/src/input_story.rs b/crates/story/src/input_story.rs index c465008e..263e979e 100644 --- a/crates/story/src/input_story.rs +++ b/crates/story/src/input_story.rs @@ -3,6 +3,7 @@ use gpui::{ ParentElement as _, Render, SharedString, Styled, View, ViewContext, VisualContext, WindowContext, }; +use regex::Regex; use crate::section; use ui::{ @@ -10,6 +11,7 @@ use ui::{ checkbox::Checkbox, h_flex, input::{InputEvent, OtpInput, TextInput}, + number_input::{NumberInput, NumberInputEvent}, prelude::FluentBuilder as _, scroll::ScrollbarAxis, v_flex, FocusableCycle, IconName, Sizable, StyledExt, @@ -29,6 +31,10 @@ pub fn init(cx: &mut AppContext) { pub struct InputStory { input1: View, input2: View, + number_input1_value: i64, + number_input1: View, + number_input2: View, + number_input2_value: u64, mash_input: View, disabled_input: View, prefix_input1: View, @@ -72,13 +78,30 @@ impl InputStory { ); input }); - cx.subscribe(&input1, Self::on_input_event).detach(); let input2 = cx.new_view(|cx| TextInput::new(cx).placeholder("Enter text here...")); - cx.subscribe(&input2, Self::on_input_event).detach(); + let number_input1_value = 1; + let number_input1 = cx.new_view(|cx| { + let input = NumberInput::new(cx).placeholder("Number Input", cx); + input.set_value(number_input1_value.to_string(), cx); + input + }); + cx.subscribe(&number_input1, Self::on_number_input1_event) + .detach(); + + let number_input2 = cx.new_view(|cx| { + NumberInput::new(cx) + .placeholder("Unsized Integer Number Input", cx) + .pattern(Regex::new(r"^\d+$").unwrap(), cx) + .small(cx) + }); + + cx.subscribe(&number_input2, Self::on_number_input2_event) + .detach(); + let mask_input = cx.new_view(|cx| { let mut input = TextInput::new(cx).cleanable(); input.set_masked(true, cx); @@ -120,6 +143,10 @@ impl InputStory { Self { input1, input2, + number_input1, + number_input1_value, + number_input2, + number_input2_value: 0, mash_input: mask_input, disabled_input: cx.new_view(|cx| { let mut input = TextInput::new(cx); @@ -186,6 +213,70 @@ impl InputStory { }; } + fn on_number_input1_event( + &mut self, + _: View, + event: &NumberInputEvent, + cx: &mut ViewContext, + ) { + match event { + NumberInputEvent::Input(input_event) => match input_event { + InputEvent::Change(text) => println!("Change: {}", text), + InputEvent::PressEnter => println!("PressEnter"), + InputEvent::Focus => println!("Focus"), + InputEvent::Blur => println!("Blur"), + }, + NumberInputEvent::Step(step_action) => match step_action { + ui::number_input::StepAction::Decrement => { + self.number_input1_value = self.number_input1_value - 1; + self.number_input1.update(cx, |input, cx| { + input.set_value(self.number_input1_value.to_string(), cx); + }); + } + ui::number_input::StepAction::Increment => { + self.number_input1_value = self.number_input1_value + 1; + self.number_input1.update(cx, |input, cx| { + input.set_value(self.number_input1_value.to_string(), cx); + }); + } + }, + } + } + + fn on_number_input2_event( + &mut self, + _: View, + event: &NumberInputEvent, + cx: &mut ViewContext, + ) { + match event { + NumberInputEvent::Input(input_event) => match input_event { + InputEvent::Change(text) => println!("Change: {}", text), + InputEvent::PressEnter => println!("PressEnter"), + InputEvent::Focus => println!("Focus"), + InputEvent::Blur => println!("Blur"), + }, + NumberInputEvent::Step(step_action) => match step_action { + ui::number_input::StepAction::Decrement => { + if self.number_input2_value.le(&0) { + return; + } + + self.number_input2_value = self.number_input2_value - 1; + self.number_input2.update(cx, |input, cx| { + input.set_value(self.number_input2_value.to_string(), cx); + }); + } + ui::number_input::StepAction::Increment => { + self.number_input2_value = self.number_input2_value + 1; + self.number_input2.update(cx, |input, cx| { + input.set_value(self.number_input2_value.to_string(), cx); + }); + } + }, + } + } + fn toggle_opt_masked(&mut self, _: &bool, cx: &mut ViewContext) { self.otp_masked = !self.otp_masked; self.otp_input @@ -241,7 +332,15 @@ impl Render for InputStory { .child( section("Normal Input", cx) .child(self.input1.clone()) - .child(self.input2.clone()), + .child(self.input2.clone()) + .child( + v_flex() + .gap_y_4() + .w_full() + .child("Number Input") + .child(self.number_input1.clone()) + .child(self.number_input2.clone()), + ), ) .child( section("Input State", cx) diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index cef18677..9848d892 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -285,7 +285,7 @@ impl TextInput { self } - /// Set the placeholder text without ownership. + /// Set the placeholder text of the input field with reference. pub fn set_placeholder(&mut self, placeholder: impl Into) { self.placeholder = placeholder.into(); } @@ -302,6 +302,11 @@ impl TextInput { self } + /// Set the regular expression pattern of the input field with reference. + pub fn set_pattern(&mut self, pattern: regex::Regex) { + self.pattern = Some(pattern); + } + /// Set the validation function of the input field. pub fn validate(mut self, f: impl Fn(&str) -> bool + 'static) -> Self { self.validate = Some(Box::new(f)); diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 3944e1ef..f5c6de8e 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -28,6 +28,7 @@ pub mod link; pub mod list; pub mod modal; pub mod notification; +pub mod number_input; pub mod popover; pub mod popup_menu; pub mod prelude; @@ -82,6 +83,7 @@ pub fn init(cx: &mut gpui::AppContext) { drawer::init(cx); dropdown::init(cx); input::init(cx); + number_input::init(cx); list::init(cx); modal::init(cx); popover::init(cx); diff --git a/crates/ui/src/number_input.rs b/crates/ui/src/number_input.rs new file mode 100644 index 00000000..4a76c937 --- /dev/null +++ b/crates/ui/src/number_input.rs @@ -0,0 +1,168 @@ +use gpui::{ + actions, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, IntoElement, + KeyBinding, ParentElement, Render, SharedString, Styled, Subscription, View, ViewContext, + VisualContext, +}; +use regex::Regex; + +use crate::{ + button::{Button, ButtonStyled as _}, + h_flex, + input::{InputEvent, TextInput}, + prelude::FluentBuilder, + theme::ActiveTheme, + IconName, Sizable, Size, StyledExt, +}; + +actions!(number_input, [Increment, Decrement]); + +const KEY_CONTENT: &str = "NumberInput"; + +pub fn init(cx: &mut AppContext) { + cx.bind_keys(vec![ + KeyBinding::new("up", Increment, Some(KEY_CONTENT)), + KeyBinding::new("down", Decrement, Some(KEY_CONTENT)), + ]); +} + +pub struct NumberInput { + input: View, + _subscriptions: Vec, +} + +impl NumberInput { + pub fn new(cx: &mut ViewContext) -> Self { + // Default pattern for the number input. + let pattern = Regex::new(r"^-?(\d+)?\.?(\d+)?$").unwrap(); + + let input = cx.new_view(|cx| TextInput::new(cx).pattern(pattern).appearance(false)); + + let _subscriptions = vec![cx.subscribe(&input, |_, _, event: &InputEvent, cx| { + cx.emit(NumberInputEvent::Input(event.clone())); + })]; + + Self { + input, + _subscriptions, + } + } + + pub fn placeholder( + self, + placeholder: impl Into, + cx: &mut ViewContext, + ) -> Self { + self.input + .update(cx, |input, _| input.set_placeholder(placeholder)); + self + } + + pub fn set_placeholder(&self, text: impl Into, cx: &mut ViewContext) { + self.input.update(cx, |input, _| { + input.set_placeholder(text); + }); + } + + pub fn pattern(self, pattern: regex::Regex, cx: &mut ViewContext) -> Self { + self.input.update(cx, |input, _| input.set_pattern(pattern)); + self + } + + pub fn set_size(self, size: Size, cx: &mut ViewContext) -> Self { + self.input.update(cx, |input, cx| input.set_size(size, cx)); + self + } + + pub fn small(self, cx: &mut ViewContext) -> Self { + self.set_size(Size::Small, cx) + } + + pub fn xsmall(self, cx: &mut ViewContext) -> Self { + self.set_size(Size::XSmall, cx) + } + + pub fn large(self, cx: &mut ViewContext) -> Self { + self.set_size(Size::Large, cx) + } + + pub fn set_value(&self, text: impl Into, cx: &mut ViewContext) { + self.input.update(cx, |input, cx| input.set_text(text, cx)) + } + + pub fn set_disabled(&self, disabled: bool, cx: &mut ViewContext) { + self.input + .update(cx, |input, cx| input.set_disabled(disabled, cx)); + } + + pub fn increment(&mut self, cx: &mut ViewContext) { + self.handle_increment(&Increment, cx); + } + + pub fn decrement(&mut self, cx: &mut ViewContext) { + self.handle_decrement(&Decrement, cx); + } + + fn handle_increment(&mut self, _: &Increment, cx: &mut ViewContext) { + self.on_step(StepAction::Increment, cx); + } + + fn handle_decrement(&mut self, _: &Decrement, cx: &mut ViewContext) { + self.on_step(StepAction::Decrement, cx); + } + + fn on_step(&mut self, action: StepAction, cx: &mut ViewContext) { + cx.emit(NumberInputEvent::Step(action)); + } +} + +impl FocusableView for NumberInput { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + self.input.focus_handle(cx) + } +} + +pub enum StepAction { + Decrement, + Increment, +} + +pub enum NumberInputEvent { + Input(InputEvent), + Step(StepAction), +} + +impl EventEmitter for NumberInput {} + +impl Render for NumberInput { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let focused = self.input.focus_handle(cx).is_focused(cx); + + h_flex() + .key_context(KEY_CONTENT) + .on_action(cx.listener(Self::handle_increment)) + .on_action(cx.listener(Self::handle_decrement)) + .flex_1() + .px_1() + .gap_x_3() + .bg(cx.theme().background) + .border_color(cx.theme().border) + .border_1() + .rounded_md() + .when(focused, |this| this.outline(cx)) + .child( + Button::new("minus") + .ghost() + .xsmall() + .icon(IconName::Minus) + .on_click(cx.listener(|this, _, cx| this.on_step(StepAction::Decrement, cx))), + ) + .child(self.input.clone()) + .child( + Button::new("plus") + .ghost() + .xsmall() + .icon(IconName::Plus) + .on_click(cx.listener(|this, _, cx| this.on_step(StepAction::Increment, cx))), + ) + } +}