From c0e0d8259441c72a9fe3c46db1dcf103576ea37c Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 14 May 2025 22:27:46 +0800 Subject: [PATCH] input: Add mask pattern to format display. (#854) - Add `mask_pattern` to Input, NumberInput. - Fix Input `set_text` to move cursor to end. https://github.com/user-attachments/assets/2d7e362a-3f80-47a4-94e3-efd729bffcac --- Cargo.lock | 6 +- crates/story/src/input_story.rs | 52 ++- crates/story/src/main.rs | 37 +- crates/story/src/number_input_story.rs | 127 +++--- crates/ui/src/input/input.rs | 69 ++- crates/ui/src/input/mask_pattern.rs | 562 +++++++++++++++++++++++++ crates/ui/src/input/mod.rs | 2 + crates/ui/src/input/number_input.rs | 17 +- 8 files changed, 795 insertions(+), 77 deletions(-) create mode 100644 crates/ui/src/input/mask_pattern.rs diff --git a/Cargo.lock b/Cargo.lock index 50a2b652..ecb90647 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -605,7 +605,7 @@ dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.12.1", "lazy_static", "lazycell", "log", @@ -628,7 +628,7 @@ dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -4225,7 +4225,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.2.0", "proc-macro2", "quote", "syn 2.0.96", diff --git a/crates/story/src/input_story.rs b/crates/story/src/input_story.rs index 9ef5f276..28e37825 100644 --- a/crates/story/src/input_story.rs +++ b/crates/story/src/input_story.rs @@ -8,7 +8,7 @@ use crate::section; use gpui_component::{ button::{Button, ButtonVariant, ButtonVariants as _}, h_flex, - input::{InputEvent, TextInput}, + input::{InputEvent, MaskPattern, TextInput}, v_flex, ContextModal, FocusableCycle, Icon, IconName, Sizable, }; @@ -34,6 +34,9 @@ pub struct InputStory { both_input1: Entity, large_input: Entity, small_input: Entity, + phone_input: Entity, + mask_input2: Entity, + currency_input: Entity, _subscriptions: Vec, } @@ -118,9 +121,19 @@ impl InputStory { .placeholder("This input have prefix and suffix.") }); + let phone_input = cx.new(|cx| TextInput::new(window, cx).mask_pattern("(999)-999-9999")); + let mask_input2 = cx.new(|cx| TextInput::new(window, cx).mask_pattern("AAA-###-AAA")); + let currency_input = cx.new(|cx| { + TextInput::new(window, cx).mask_pattern(MaskPattern::Number { + separator: Some(','), + fraction: Some(3), + }) + }); + let _subscriptions = vec![ cx.subscribe_in(&input1, window, Self::on_input_event), cx.subscribe_in(&input2, window, Self::on_input_event), + cx.subscribe_in(&phone_input, window, Self::on_input_event), ]; Self { @@ -148,6 +161,9 @@ impl InputStory { prefix_input1, suffix_input1, both_input1, + phone_input, + mask_input2, + currency_input, _subscriptions, } } @@ -228,6 +244,40 @@ impl Render for InputStory { .child(self.both_input1.clone()) .child(self.suffix_input1.clone()), ) + .child( + section("Currency Input with thousands separator") + .max_w_md() + .child(self.currency_input.clone()) + .child( + div().child(format!("Value: {:?}", self.currency_input.read(cx).text())), + ), + ) + .child( + section("Input with mask pattern: (999)-999-9999") + .max_w_md() + .child(self.phone_input.clone()) + .child( + v_flex() + .child(format!("Value: {:?}", self.phone_input.read(cx).text())) + .child(format!( + "Unmask Value: {:?}", + self.phone_input.read(cx).unmask_text() + )), + ), + ) + .child( + section("Input with mask pattern: AAA-###-AAA") + .max_w_md() + .child(self.mask_input2.clone()) + .child( + v_flex() + .child(format!("Value: {:?}", self.mask_input2.read(cx).text())) + .child(format!( + "Unmask Value: {:?}", + self.mask_input2.read(cx).unmask_text() + )), + ), + ) .child( section("Input Size") .max_w_md() diff --git a/crates/story/src/main.rs b/crates/story/src/main.rs index 280732a4..040caf49 100644 --- a/crates/story/src/main.rs +++ b/crates/story/src/main.rs @@ -19,7 +19,7 @@ pub struct Gallery { } impl Gallery { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { + pub fn new(init_story: Option<&str>, window: &mut Window, cx: &mut Context) -> Self { let search_input = cx.new(|cx| { TextInput::new(window, cx) .appearance(false) @@ -82,7 +82,7 @@ impl Gallery { ), ]; - Self { + let mut this = Self { search_input, stories, active_group_index: Some(0), @@ -90,11 +90,31 @@ impl Gallery { collapsed: false, sidebar_state: ResizableState::new(cx), _subscriptions, + }; + + if let Some(init_story) = init_story { + this.set_active_story(init_story, cx); } + + this } - fn view(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Self::new(window, cx)) + fn set_active_story(&mut self, name: &str, cx: &mut App) { + let group_index = 1; + let Some(story_index) = self.stories.get(group_index).and_then(|(_, stories)| { + stories + .iter() + .position(|story| story.read(cx).name.to_lowercase().replace("story", "") == name) + }) else { + return; + }; + + self.active_group_index = Some(group_index); + self.active_index = Some(story_index); + } + + fn view(init_story: Option<&str>, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(init_story, window, cx)) } } @@ -271,10 +291,17 @@ impl Render for Gallery { fn main() { let app = Application::new().with_assets(Assets); + // Parse `cargo run -- ` + let name = std::env::args().nth(1); + app.run(move |cx| { story::init(cx); cx.activate(true); - story::create_new_window("Gallery of GPUI Component", Gallery::view, cx); + story::create_new_window( + "Gallery of GPUI Component", + move |window, cx| Gallery::view(name.as_deref(), window, cx), + cx, + ); }); } diff --git a/crates/story/src/number_input_story.rs b/crates/story/src/number_input_story.rs index ea2adaaa..f74ec19d 100644 --- a/crates/story/src/number_input_story.rs +++ b/crates/story/src/number_input_story.rs @@ -6,7 +6,7 @@ use regex::Regex; use crate::section; use gpui_component::{ - input::{InputEvent, NumberInput, NumberInputEvent, StepAction}, + input::{InputEvent, MaskPattern, NumberInput, NumberInputEvent, StepAction}, v_flex, FocusableCycle, Sizable, }; @@ -26,6 +26,8 @@ pub struct NumberInputStory { number_input1: Entity, number_input2: Entity, number_input2_value: u64, + number_input3: Entity, + number_input3_value: f64, _subscriptions: Vec, } @@ -68,9 +70,23 @@ impl NumberInputStory { .small() }); + let number_input3 = cx.new(|cx| { + NumberInput::new(window, cx) + .placeholder("Number Input with mask pattern", window, cx) + .mask_pattern( + MaskPattern::Number { + separator: Some(','), + fraction: Some(2), + }, + window, + cx, + ) + }); + let _subscriptions = vec![ - cx.subscribe_in(&number_input1, window, Self::on_number_input1_event), - cx.subscribe_in(&number_input2, window, Self::on_number_input2_event), + cx.subscribe_in(&number_input1, window, Self::on_number_input_event), + cx.subscribe_in(&number_input2, window, Self::on_number_input_event), + cx.subscribe_in(&number_input3, window, Self::on_number_input_event), ]; Self { @@ -78,6 +94,8 @@ impl NumberInputStory { number_input1_value, number_input2, number_input2_value: 0, + number_input3, + number_input3_value: 0.0, _subscriptions, } } @@ -90,9 +108,9 @@ impl NumberInputStory { self.cycle_focus(false, window, cx); } - fn on_number_input1_event( + fn on_number_input_event( &mut self, - _: &Entity, + this: &Entity, event: &NumberInputEvent, window: &mut Window, cx: &mut Context, @@ -100,8 +118,18 @@ impl NumberInputStory { match event { NumberInputEvent::Input(input_event) => match input_event { InputEvent::Change(text) => { - if let Ok(value) = text.parse::() { - self.number_input1_value = value; + if this == &self.number_input1 { + if let Ok(value) = text.parse::() { + self.number_input1_value = value; + } + } else if this == &self.number_input2 { + if let Ok(value) = text.parse::() { + self.number_input2_value = value; + } + } else if this == &self.number_input3 { + if let Ok(value) = text.parse::() { + self.number_input3_value = value; + } } println!("Change: {}", text); } @@ -113,58 +141,40 @@ impl NumberInputStory { }, NumberInputEvent::Step(step_action) => match step_action { 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(), window, cx); - }); + if this == &self.number_input1 { + self.number_input1_value = self.number_input1_value - 1; + this.update(cx, |input, cx| { + input.set_value(self.number_input1_value.to_string(), window, cx); + }); + } else if this == &self.number_input2 { + self.number_input2_value = self.number_input2_value - 1; + this.update(cx, |input, cx| { + input.set_value(self.number_input2_value.to_string(), window, cx); + }); + } else if this == &self.number_input3 { + self.number_input3_value = self.number_input3_value - 1.0; + this.update(cx, |input, cx| { + input.set_value(self.number_input3_value.to_string(), window, cx); + }); + } } 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(), window, cx); - }); - } - }, - } - } - - fn on_number_input2_event( - &mut self, - _: &Entity, - event: &NumberInputEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - NumberInputEvent::Input(input_event) => match input_event { - InputEvent::Change(text) => { - if let Ok(value) = text.parse::() { - self.number_input2_value = value; + if this == &self.number_input1 { + self.number_input1_value = self.number_input1_value + 1; + this.update(cx, |input, cx| { + input.set_value(self.number_input1_value.to_string(), window, cx); + }); + } else if this == &self.number_input2 { + self.number_input2_value = self.number_input2_value + 1; + this.update(cx, |input, cx| { + input.set_value(self.number_input2_value.to_string(), window, cx); + }); + } else if this == &self.number_input3 { + self.number_input3_value = self.number_input3_value + 1.0; + this.update(cx, |input, cx| { + input.set_value(self.number_input3_value.to_string(), window, cx); + }); } - println!("Change: {}", text); - } - InputEvent::PressEnter { secondary } => { - println!("PressEnter secondary: {}", secondary); - } - InputEvent::Focus => println!("Focus"), - InputEvent::Blur => println!("Blur"), - }, - NumberInputEvent::Step(step_action) => match step_action { - 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(), window, cx); - }); - } - 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(), window, cx); - }); } }, } @@ -202,5 +212,10 @@ impl Render for NumberInputStory { .max_w_md() .child(self.number_input2.clone()), ) + .child( + section("With mask pattern") + .max_w_md() + .child(self.number_input3.clone()), + ) } } diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 410ad2e6..8dd8c9eb 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -26,6 +26,7 @@ use gpui::{ use super::blink_cursor::BlinkCursor; use super::change::Change; use super::element::TextElement; +use super::mask_pattern::MaskPattern; use super::number_input; use crate::button::{Button, ButtonVariants as _}; @@ -243,7 +244,7 @@ pub struct TextInput { /// For special case, e.g.: NumberInput + - button pub(super) no_gap: bool, pub(super) height: Option, - pattern: Option, + pub(super) pattern: Option, validate: Option bool + 'static>>, pub(crate) scroll_handle: ScrollHandle, scrollbar_state: Rc>, @@ -252,6 +253,8 @@ pub struct TextInput { /// To remember the horizontal column (x-coordinate) of the cursor position. preferred_x_offset: Option, _subscriptions: Vec, + /// The mask pattern for formatting the input text + pub(crate) mask_pattern: MaskPattern, } impl EventEmitter for TextInput {} @@ -320,6 +323,7 @@ impl TextInput { scroll_size: gpui::size(px(0.), px(0.)), preferred_x_offset: None, _subscriptions, + mask_pattern: MaskPattern::default(), } } @@ -502,7 +506,7 @@ impl TextInput { self.replace_text(text, window, cx); self.history.ignore = false; // Ensure cursor to start when set text - self.selected_range = 0..0; + self.selected_range = self.text.len()..self.text.len(); cx.notify(); } @@ -553,19 +557,19 @@ impl TextInput { cx.notify(); } - /// Set with masked state. + /// Set with password masked state. pub fn masked(mut self, masked: bool) -> Self { self.masked = masked; self } - /// Set the masked state of the input field. + /// Set the password masked state of the input field. pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context) { self.masked = masked; cx.notify(); } - /// Set to enable toggle button for mask state. + /// Set to enable toggle button for password mask state. pub fn mask_toggle(mut self) -> Self { self.mask_toggle = true; self @@ -713,6 +717,11 @@ impl TextInput { &self.text } + /// Return the text without mask. + pub fn unmask_text(&self) -> SharedString { + self.mask_pattern.unmask(&self.text).into() + } + pub fn disabled(&self) -> bool { self.disabled } @@ -1543,6 +1552,10 @@ impl TextInput { } } + if !self.mask_pattern.is_valid(new_text) { + return false; + } + self.pattern .as_ref() .map(|p| p.is_match(new_text)) @@ -1577,6 +1590,36 @@ impl TextInput { ), ) } + + /// Set the mask pattern for formatting the input text. + /// + /// The pattern can contain: + /// - 9: Any digit or dot + /// - A: Any letter + /// - *: Any character + /// - Other characters will be treated as literal mask characters + /// + /// Example: "(999)999-999" for phone numbers + pub fn mask_pattern(mut self, pattern: impl Into) -> Self { + self.mask_pattern = pattern.into(); + if let Some(placeholder) = self.mask_pattern.placeholder() { + self.placeholder = placeholder.into(); + } + self + } + + pub fn set_mask_pattern( + &mut self, + pattern: impl Into, + _: &mut Window, + cx: &mut Context, + ) { + self.mask_pattern = pattern.into(); + if let Some(placeholder) = self.mask_pattern.placeholder() { + self.placeholder = placeholder.into(); + } + cx.notify(); + } } impl Sizable for TextInput { @@ -1644,18 +1687,23 @@ impl EntityInputHandler for TextInput { let pending_text: SharedString = (self.text[0..range.start].to_owned() + new_text + &self.text[range.end..]).into(); + // Check if the new text is valid if !self.is_valid_input(&pending_text) { return; } - self.push_history(&range, new_text, window, cx); - self.text = pending_text; - self.selected_range = range.start + new_text.len()..range.start + new_text.len(); + let mask_text = self.mask_pattern.mask(&pending_text); + let new_text_len = (new_text.len() + mask_text.len()).saturating_sub(pending_text.len()); + let new_pos = (range.start + new_text_len).min(mask_text.len()); + + self.push_history(&range, &new_text, window, cx); + self.text = mask_text; + self.selected_range = new_pos..new_pos; self.marked_range.take(); self.update_preferred_x_offset(cx); self.update_scroll_offset(None, cx); self.check_to_auto_grow(window, cx); - cx.emit(InputEvent::Change(self.text.clone())); + cx.emit(InputEvent::Change(self.unmask_text())); cx.notify(); } @@ -1690,7 +1738,7 @@ impl EntityInputHandler for TextInput { .map(|range_utf16| self.range_from_utf16(range_utf16)) .map(|new_range| new_range.start + range.start..new_range.end + range.end) .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); - cx.emit(InputEvent::Change(self.text.clone())); + cx.emit(InputEvent::Change(self.unmask_text())); cx.notify(); } @@ -1835,7 +1883,6 @@ impl Render for TextInput { .on_action(cx.listener(Self::cut)) .on_action(cx.listener(Self::undo)) .on_action(cx.listener(Self::redo)) - .on_action(cx.listener(Self::redo)) .on_key_down(cx.listener(Self::on_key_down)) .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) diff --git a/crates/ui/src/input/mask_pattern.rs b/crates/ui/src/input/mask_pattern.rs new file mode 100644 index 00000000..bf0ca800 --- /dev/null +++ b/crates/ui/src/input/mask_pattern.rs @@ -0,0 +1,562 @@ +use gpui::SharedString; + +#[derive(Clone, PartialEq, Debug)] +pub enum MaskToken { + /// 0 Digit, equivalent to `[0]` + // Digit0, + /// Digit, equivalent to `[0-9]` + Digit, + /// Letter, equivalent to `[a-zA-Z]` + Letter, + /// Letter or digit, equivalent to `[a-zA-Z0-9]` + LetterOrDigit, + /// Separator + Sep(char), + /// Any character + Any, +} + +#[allow(unused)] +impl MaskToken { + /// Check if the token is any character. + pub fn is_any(&self) -> bool { + matches!(self, MaskToken::Any) + } + + /// Check if the token is a match for the given character. + /// + /// The separator is always a match any input character. + fn is_match(&self, ch: char) -> bool { + match self { + MaskToken::Digit => ch.is_ascii_digit(), + MaskToken::Letter => ch.is_ascii_alphabetic(), + MaskToken::LetterOrDigit => ch.is_ascii_alphanumeric(), + MaskToken::Any => true, + MaskToken::Sep(c) => *c == ch, + } + } + + /// Is the token a separator (Can be ignored) + fn is_sep(&self) -> bool { + matches!(self, MaskToken::Sep(_)) + } + + /// Check if the token is a number. + pub fn is_number(&self) -> bool { + matches!(self, MaskToken::Digit) + } + + pub fn placeholder(&self) -> char { + match self { + MaskToken::Sep(c) => *c, + _ => '_', + } + } + + fn mask_char(&self, ch: char) -> char { + match self { + MaskToken::Digit | MaskToken::LetterOrDigit | MaskToken::Letter => ch, + MaskToken::Sep(c) => *c, + MaskToken::Any => ch, + } + } + + fn unmask_char(&self, ch: char) -> Option { + match self { + MaskToken::Digit => Some(ch), + MaskToken::Letter => Some(ch), + MaskToken::LetterOrDigit => Some(ch), + MaskToken::Any => Some(ch), + _ => None, + } + } +} + +#[derive(Clone, Default)] +pub enum MaskPattern { + #[default] + None, + Pattern { + pattern: SharedString, + tokens: Vec, + }, + Number { + /// Group separator, e.g. "," or " " + separator: Option, + /// Number of fraction digits, e.g. 2 for 123.45 + fraction: Option, + }, +} + +impl From<&str> for MaskPattern { + fn from(pattern: &str) -> Self { + Self::new(pattern) + } +} + +impl MaskPattern { + /// Create a new mask pattern + /// + /// - `9` - Digit + /// - `A` - Letter + /// - `#` - Letter or Digit + /// - `*` - Any character + /// - other characters - Separator + /// + /// For example: + /// + /// - `(999)999-9999` - US phone number: (123)456-7890 + /// - `99999-9999` - ZIP code: 12345-6789 + /// - `AAAA-99-####` - Custom pattern: ABCD-12-3AB4 + /// - `*999*` - Custom pattern: (123) or [123] + pub fn new(pattern: &str) -> Self { + let tokens = pattern + .chars() + .map(|ch| match ch { + // '0' => MaskToken::Digit0, + '9' => MaskToken::Digit, + 'A' => MaskToken::Letter, + '#' => MaskToken::LetterOrDigit, + '*' => MaskToken::Any, + _ => MaskToken::Sep(ch), + }) + .collect(); + + Self::Pattern { + pattern: pattern.to_owned().into(), + tokens, + } + } + + #[allow(unused)] + fn tokens(&self) -> Option<&Vec> { + match self { + Self::Pattern { tokens, .. } => Some(tokens), + Self::Number { .. } => None, + Self::None => None, + } + } + + /// Create a new mask pattern with group separator, e.g. "," or " " + pub fn number(sep: Option) -> Self { + Self::Number { + separator: sep, + fraction: None, + } + } + + pub fn placeholder(&self) -> Option { + match self { + Self::Pattern { tokens, .. } => { + Some(tokens.iter().map(|token| token.placeholder()).collect()) + } + Self::Number { .. } => None, + Self::None => None, + } + } + + /// Return true if the mask pattern is None or no any pattern. + pub fn is_none(&self) -> bool { + match self { + Self::Pattern { tokens, .. } => tokens.is_empty(), + Self::Number { .. } => false, + Self::None => true, + } + } + + /// Check is the mask text is valid. + /// + /// If the mask pattern is None, always return true. + pub fn is_valid(&self, mask_text: &str) -> bool { + if self.is_none() { + return true; + } + + let mut text_index = 0; + let mask_text_chars: Vec = mask_text.chars().collect(); + match self { + Self::Pattern { tokens, .. } => { + for token in tokens { + if text_index >= mask_text_chars.len() { + break; + } + + let ch = mask_text_chars[text_index]; + if token.is_match(ch) { + text_index += 1; + } + } + text_index == mask_text.len() + } + Self::Number { separator, .. } => { + if mask_text.is_empty() { + return true; + } + + // check if the text is valid number + let mut parts = mask_text.split('.'); + let int_part = parts.next().unwrap_or(""); + let frac_part = parts.next(); + + if int_part.is_empty() { + return false; + } + + // check if the integer part is valid + if !int_part + .chars() + .all(|ch| ch.is_ascii_digit() || Some(ch) == *separator) + { + return false; + } + + // check if the fraction part is valid + if let Some(frac) = frac_part { + if !frac + .chars() + .all(|ch| ch.is_ascii_digit() || Some(ch) == *separator) + { + return false; + } + } + + true + } + Self::None => true, + } + } + + /// Check if valid input char at the given position. + pub fn is_valid_at(&self, ch: char, pos: usize) -> bool { + if self.is_none() { + return true; + } + + match self { + Self::Pattern { tokens, .. } => { + if let Some(token) = tokens.get(pos) { + if token.is_match(ch) { + return true; + } + + if token.is_sep() { + // If next token is match, it's valid + if let Some(next_token) = tokens.get(pos + 1) { + if next_token.is_match(ch) { + return true; + } + } + } + } + + false + } + Self::Number { .. } => true, + Self::None => true, + } + } + + /// Format the text according to the mask pattern + /// + /// For example: + /// + /// - pattern: (999)999-999 + /// - text: 123456789 + /// - mask_text: (123)456-789 + pub fn mask(&self, text: &str) -> SharedString { + if self.is_none() { + return text.to_owned().into(); + } + + match self { + Self::Number { + separator, + fraction, + } => { + if let Some(sep) = *separator { + // Remove the existing group separator + let text = text.replace(sep, ""); + + let mut parts = text.split('.'); + let int_part = parts.next().unwrap_or(""); + + // Limit the fraction part to the given range, if not enough, pad with 0 + let frac_part = parts.next().map(|part| { + part.chars() + .take(fraction.unwrap_or(usize::MAX)) + .collect::() + }); + + // Reverse the integer part for easier grouping + let chars: Vec = int_part.chars().rev().collect(); + let mut result = String::new(); + for (i, ch) in chars.iter().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(sep); + } + result.push(*ch); + } + let int_with_sep: String = result.chars().rev().collect(); + + let final_str = if let Some(frac) = frac_part { + if fraction == &Some(0) { + int_with_sep + } else { + format!("{}.{}", int_with_sep, frac) + } + } else { + int_with_sep + }; + return final_str.into(); + } + + text.to_owned().into() + } + Self::Pattern { tokens, .. } => { + let mut result = String::new(); + let mut text_index = 0; + let text_chars: Vec = text.chars().collect(); + for (pos, token) in tokens.iter().enumerate() { + if text_index >= text_chars.len() { + break; + } + let ch = text_chars[text_index]; + // Break if expected char is not match + if !token.is_sep() && !self.is_valid_at(ch, pos) { + break; + } + let mask_ch = token.mask_char(ch); + result.push(mask_ch); + if ch == mask_ch { + text_index += 1; + continue; + } + } + result.into() + } + Self::None => text.to_owned().into(), + } + } + + /// Extract original text from masked text + pub fn unmask(&self, mask_text: &str) -> String { + match self { + Self::Number { separator, .. } => { + if let Some(sep) = *separator { + let mut result = String::new(); + for ch in mask_text.chars() { + if ch == sep { + continue; + } + result.push(ch); + } + + if result.contains('.') { + result = result.trim_end_matches('0').to_string(); + } + return result; + } + + return mask_text.to_owned(); + } + Self::Pattern { tokens, .. } => { + let mut result = String::new(); + let mask_text_chars: Vec = mask_text.chars().collect(); + for (text_index, token) in tokens.iter().enumerate() { + if text_index >= mask_text_chars.len() { + break; + } + let ch = mask_text_chars[text_index]; + let unmask_ch = token.unmask_char(ch); + if let Some(ch) = unmask_ch { + result.push(ch); + } + } + result + } + Self::None => mask_text.to_owned(), + } + } +} + +#[cfg(test)] +mod tests { + use crate::input::mask_pattern::{MaskPattern, MaskToken}; + + #[test] + fn test_is_match() { + assert_eq!(MaskToken::Sep('(').is_match('('), true); + assert_eq!(MaskToken::Sep('-').is_match('('), false); + assert_eq!(MaskToken::Sep('-').is_match('3'), false); + + assert_eq!(MaskToken::Digit.is_match('0'), true); + assert_eq!(MaskToken::Digit.is_match('9'), true); + assert_eq!(MaskToken::Digit.is_match('a'), false); + assert_eq!(MaskToken::Digit.is_match('C'), false); + + assert_eq!(MaskToken::Letter.is_match('a'), true); + assert_eq!(MaskToken::Letter.is_match('Z'), true); + assert_eq!(MaskToken::Letter.is_match('3'), false); + assert_eq!(MaskToken::Letter.is_match('-'), false); + + assert_eq!(MaskToken::LetterOrDigit.is_match('0'), true); + assert_eq!(MaskToken::LetterOrDigit.is_match('9'), true); + assert_eq!(MaskToken::LetterOrDigit.is_match('a'), true); + assert_eq!(MaskToken::LetterOrDigit.is_match('Z'), true); + assert_eq!(MaskToken::LetterOrDigit.is_match('3'), true); + + assert_eq!(MaskToken::Any.is_match('a'), true); + assert_eq!(MaskToken::Any.is_match('3'), true); + assert_eq!(MaskToken::Any.is_match('-'), true); + assert_eq!(MaskToken::Any.is_match(' '), true); + } + + #[test] + fn test_mask_none() { + let mask = MaskPattern::None; + assert_eq!(mask.is_none(), true); + assert_eq!(mask.is_valid("1124124ASLDJKljk"), true); + assert_eq!(mask.mask("hello-world"), "hello-world"); + assert_eq!(mask.unmask("hello-world"), "hello-world"); + } + + #[test] + fn test_mask_pattern1() { + let mask = MaskPattern::new("(AA)999-999"); + assert_eq!( + mask.tokens(), + Some(&vec![ + MaskToken::Sep('('), + MaskToken::Letter, + MaskToken::Letter, + MaskToken::Sep(')'), + MaskToken::Digit, + MaskToken::Digit, + MaskToken::Digit, + MaskToken::Sep('-'), + MaskToken::Digit, + MaskToken::Digit, + MaskToken::Digit, + ]) + ); + + assert_eq!(mask.is_valid_at('(', 0), true); + assert_eq!(mask.is_valid_at('H', 0), true); + assert_eq!(mask.is_valid_at('3', 0), false); + assert_eq!(mask.is_valid_at('-', 0), false); + assert_eq!(mask.is_valid_at(')', 1), false); + assert_eq!(mask.is_valid_at('H', 1), true); + assert_eq!(mask.is_valid_at('1', 1), false); + assert_eq!(mask.is_valid_at('e', 2), true); + assert_eq!(mask.is_valid_at(')', 3), true); + assert_eq!(mask.is_valid_at('1', 3), true); + assert_eq!(mask.is_valid_at('2', 4), true); + + assert_eq!(mask.is_valid("(AB)123-456"), true); + + assert_eq!(mask.mask("AB123456"), "(AB)123-456"); + assert_eq!(mask.mask("(AB)123-456"), "(AB)123-456"); + assert_eq!(mask.mask("(AB123456"), "(AB)123-456"); + assert_eq!(mask.mask("AB123-456"), "(AB)123-456"); + assert_eq!(mask.mask("AB123-"), "(AB)123-"); + assert_eq!(mask.mask("AB123--"), "(AB)123-"); + assert_eq!(mask.mask("AB123-4"), "(AB)123-4"); + + let unmasked_text = mask.unmask("(AB)123-456"); + assert_eq!(unmasked_text, "AB123456"); + + assert_eq!(mask.is_valid("12AB345"), false); + assert_eq!(mask.is_valid("(11)123-456"), false); + assert_eq!(mask.is_valid("##"), false); + assert_eq!(mask.is_valid("(AB)123456"), true); + } + + #[test] + fn test_mask_pattern2() { + let mask = MaskPattern::new("999-999-******"); + assert_eq!( + mask.tokens(), + Some(&vec![ + MaskToken::Digit, + MaskToken::Digit, + MaskToken::Digit, + MaskToken::Sep('-'), + MaskToken::Digit, + MaskToken::Digit, + MaskToken::Digit, + MaskToken::Sep('-'), + MaskToken::Any, + MaskToken::Any, + MaskToken::Any, + MaskToken::Any, + MaskToken::Any, + MaskToken::Any, + ]) + ); + + let text = "123456A(111)"; + let masked_text = mask.mask(text); + assert_eq!(masked_text, "123-456-A(111)"); + let unmasked_text = mask.unmask(&masked_text); + assert_eq!(unmasked_text, "123456A(111)"); + assert_eq!(mask.is_valid(&masked_text), true); + } + + #[test] + fn test_number_with_group_separator() { + // Use comma as group separator + let mask = MaskPattern::number(Some(',')); + assert_eq!(mask.mask("1234567"), "1,234,567"); + assert_eq!(mask.mask("1,234,567"), "1,234,567"); + assert_eq!(mask.unmask("1,234,567"), "1234567"); + let mask = MaskPattern::number(Some(',')); + assert_eq!(mask.mask("1234567.89"), "1,234,567.89"); + assert_eq!(mask.unmask("1,234,567.89"), "1234567.89"); + + // Use space as group separator + let mask = MaskPattern::number(Some(' ')); + assert_eq!(mask.mask("1234567"), "1 234 567"); + assert_eq!(mask.unmask("1 234 567"), "1234567"); + let mask = MaskPattern::number(Some(' ')); + assert_eq!(mask.mask("1234567.89"), "1 234 567.89"); + assert_eq!(mask.unmask("1 234 567.89"), "1234567.89"); + + // No group separator + let mask = MaskPattern::number(None); + assert_eq!(mask.mask("1234567"), "1234567"); + assert_eq!(mask.unmask("1234567"), "1234567"); + let mask = MaskPattern::number(None); + assert_eq!(mask.mask("1234567.89"), "1234567.89"); + assert_eq!(mask.unmask("1234567.89"), "1234567.89"); + } + + #[test] + fn test_number_with_fraction_digits() { + let mask = MaskPattern::Number { + separator: Some(','), + fraction: Some(4), + }; + + assert_eq!(mask.mask("1234567"), "1,234,567"); + assert_eq!(mask.unmask("1,234,567"), "1234567"); + assert_eq!(mask.mask("1234567."), "1,234,567."); + assert_eq!(mask.mask("1234567.89"), "1,234,567.89"); + assert_eq!(mask.unmask("1,234,567.890"), "1234567.89"); + assert_eq!(mask.mask("1234567.891"), "1,234,567.891"); + assert_eq!(mask.mask("1234567.891234"), "1,234,567.8912"); + + let mask = MaskPattern::Number { + separator: Some(','), + fraction: None, + }; + + assert_eq!(mask.mask("1234567.1234567"), "1,234,567.1234567"); + + let mask = MaskPattern::Number { + separator: Some(','), + fraction: Some(0), + }; + + assert_eq!(mask.mask("1234567.1234567"), "1,234,567"); + } +} diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index fbcd7a63..1ee04380 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -3,10 +3,12 @@ mod change; mod clear_button; mod element; mod input; +mod mask_pattern; mod number_input; mod otp_input; pub(crate) use clear_button::*; pub use input::*; +pub use mask_pattern::MaskPattern; pub use number_input::{NumberInput, NumberInputEvent, StepAction}; pub use otp_input::*; diff --git a/crates/ui/src/input/number_input.rs b/crates/ui/src/input/number_input.rs index a5bfbe5a..1e1de1fa 100644 --- a/crates/ui/src/input/number_input.rs +++ b/crates/ui/src/input/number_input.rs @@ -12,6 +12,8 @@ use crate::{ ActiveTheme, IconName, Sizable, Size, StyleSized, StyledExt as _, }; +use super::MaskPattern; + actions!(number_input, [Increment, Decrement]); const KEY_CONTENT: &str = "NumberInput"; @@ -93,6 +95,19 @@ impl NumberInput { self } + pub fn mask_pattern( + self, + mask_pattern: MaskPattern, + window: &mut Window, + cx: &mut Context, + ) -> Self { + self.input.update(cx, |input, cx| { + input.pattern = None; + input.set_mask_pattern(mask_pattern, window, cx) + }); + self + } + pub fn set_value( &self, text: impl Into, @@ -100,7 +115,7 @@ impl NumberInput { cx: &mut Context, ) { self.input - .update(cx, |input, cx| input.set_text(text, window, cx)) + .update(cx, |input, cx| input.set_text(text, window, cx)); } pub fn set_disabled(&self, disabled: bool, window: &mut Window, cx: &mut Context) {