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
This commit is contained in:
Jason Lee 2025-05-14 22:27:46 +08:00 committed by GitHub
parent 1535df796e
commit c0e0d82594
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 795 additions and 77 deletions

6
Cargo.lock generated
View file

@ -605,7 +605,7 @@ dependencies = [
"bitflags 2.6.0", "bitflags 2.6.0",
"cexpr", "cexpr",
"clang-sys", "clang-sys",
"itertools 0.11.0", "itertools 0.12.1",
"lazy_static", "lazy_static",
"lazycell", "lazycell",
"log", "log",
@ -628,7 +628,7 @@ dependencies = [
"bitflags 2.6.0", "bitflags 2.6.0",
"cexpr", "cexpr",
"clang-sys", "clang-sys",
"itertools 0.11.0", "itertools 0.13.0",
"log", "log",
"prettyplease", "prettyplease",
"proc-macro2", "proc-macro2",
@ -4225,7 +4225,7 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
dependencies = [ dependencies = [
"proc-macro-crate 1.3.1", "proc-macro-crate 3.2.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.96", "syn 2.0.96",

View file

@ -8,7 +8,7 @@ use crate::section;
use gpui_component::{ use gpui_component::{
button::{Button, ButtonVariant, ButtonVariants as _}, button::{Button, ButtonVariant, ButtonVariants as _},
h_flex, h_flex,
input::{InputEvent, TextInput}, input::{InputEvent, MaskPattern, TextInput},
v_flex, ContextModal, FocusableCycle, Icon, IconName, Sizable, v_flex, ContextModal, FocusableCycle, Icon, IconName, Sizable,
}; };
@ -34,6 +34,9 @@ pub struct InputStory {
both_input1: Entity<TextInput>, both_input1: Entity<TextInput>,
large_input: Entity<TextInput>, large_input: Entity<TextInput>,
small_input: Entity<TextInput>, small_input: Entity<TextInput>,
phone_input: Entity<TextInput>,
mask_input2: Entity<TextInput>,
currency_input: Entity<TextInput>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@ -118,9 +121,19 @@ impl InputStory {
.placeholder("This input have prefix and suffix.") .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![ let _subscriptions = vec![
cx.subscribe_in(&input1, window, Self::on_input_event), cx.subscribe_in(&input1, window, Self::on_input_event),
cx.subscribe_in(&input2, 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 { Self {
@ -148,6 +161,9 @@ impl InputStory {
prefix_input1, prefix_input1,
suffix_input1, suffix_input1,
both_input1, both_input1,
phone_input,
mask_input2,
currency_input,
_subscriptions, _subscriptions,
} }
} }
@ -228,6 +244,40 @@ impl Render for InputStory {
.child(self.both_input1.clone()) .child(self.both_input1.clone())
.child(self.suffix_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( .child(
section("Input Size") section("Input Size")
.max_w_md() .max_w_md()

View file

@ -19,7 +19,7 @@ pub struct Gallery {
} }
impl Gallery { impl Gallery {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(init_story: Option<&str>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let search_input = cx.new(|cx| { let search_input = cx.new(|cx| {
TextInput::new(window, cx) TextInput::new(window, cx)
.appearance(false) .appearance(false)
@ -82,7 +82,7 @@ impl Gallery {
), ),
]; ];
Self { let mut this = Self {
search_input, search_input,
stories, stories,
active_group_index: Some(0), active_group_index: Some(0),
@ -90,11 +90,31 @@ impl Gallery {
collapsed: false, collapsed: false,
sidebar_state: ResizableState::new(cx), sidebar_state: ResizableState::new(cx),
_subscriptions, _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<Self> { fn set_active_story(&mut self, name: &str, cx: &mut App) {
cx.new(|cx| Self::new(window, cx)) 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<Self> {
cx.new(|cx| Self::new(init_story, window, cx))
} }
} }
@ -271,10 +291,17 @@ impl Render for Gallery {
fn main() { fn main() {
let app = Application::new().with_assets(Assets); let app = Application::new().with_assets(Assets);
// Parse `cargo run -- <story_name>`
let name = std::env::args().nth(1);
app.run(move |cx| { app.run(move |cx| {
story::init(cx); story::init(cx);
cx.activate(true); 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,
);
}); });
} }

View file

@ -6,7 +6,7 @@ use regex::Regex;
use crate::section; use crate::section;
use gpui_component::{ use gpui_component::{
input::{InputEvent, NumberInput, NumberInputEvent, StepAction}, input::{InputEvent, MaskPattern, NumberInput, NumberInputEvent, StepAction},
v_flex, FocusableCycle, Sizable, v_flex, FocusableCycle, Sizable,
}; };
@ -26,6 +26,8 @@ pub struct NumberInputStory {
number_input1: Entity<NumberInput>, number_input1: Entity<NumberInput>,
number_input2: Entity<NumberInput>, number_input2: Entity<NumberInput>,
number_input2_value: u64, number_input2_value: u64,
number_input3: Entity<NumberInput>,
number_input3_value: f64,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@ -68,9 +70,23 @@ impl NumberInputStory {
.small() .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![ let _subscriptions = vec![
cx.subscribe_in(&number_input1, window, Self::on_number_input1_event), cx.subscribe_in(&number_input1, window, Self::on_number_input_event),
cx.subscribe_in(&number_input2, window, Self::on_number_input2_event), cx.subscribe_in(&number_input2, window, Self::on_number_input_event),
cx.subscribe_in(&number_input3, window, Self::on_number_input_event),
]; ];
Self { Self {
@ -78,6 +94,8 @@ impl NumberInputStory {
number_input1_value, number_input1_value,
number_input2, number_input2,
number_input2_value: 0, number_input2_value: 0,
number_input3,
number_input3_value: 0.0,
_subscriptions, _subscriptions,
} }
} }
@ -90,9 +108,9 @@ impl NumberInputStory {
self.cycle_focus(false, window, cx); self.cycle_focus(false, window, cx);
} }
fn on_number_input1_event( fn on_number_input_event(
&mut self, &mut self,
_: &Entity<NumberInput>, this: &Entity<NumberInput>,
event: &NumberInputEvent, event: &NumberInputEvent,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
@ -100,8 +118,18 @@ impl NumberInputStory {
match event { match event {
NumberInputEvent::Input(input_event) => match input_event { NumberInputEvent::Input(input_event) => match input_event {
InputEvent::Change(text) => { InputEvent::Change(text) => {
if let Ok(value) = text.parse::<i64>() { if this == &self.number_input1 {
self.number_input1_value = value; if let Ok(value) = text.parse::<i64>() {
self.number_input1_value = value;
}
} else if this == &self.number_input2 {
if let Ok(value) = text.parse::<u64>() {
self.number_input2_value = value;
}
} else if this == &self.number_input3 {
if let Ok(value) = text.parse::<f64>() {
self.number_input3_value = value;
}
} }
println!("Change: {}", text); println!("Change: {}", text);
} }
@ -113,58 +141,40 @@ impl NumberInputStory {
}, },
NumberInputEvent::Step(step_action) => match step_action { NumberInputEvent::Step(step_action) => match step_action {
StepAction::Decrement => { StepAction::Decrement => {
self.number_input1_value = self.number_input1_value - 1; if this == &self.number_input1 {
self.number_input1.update(cx, |input, cx| { self.number_input1_value = self.number_input1_value - 1;
input.set_value(self.number_input1_value.to_string(), window, cx); 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 => { StepAction::Increment => {
self.number_input1_value = self.number_input1_value + 1; if this == &self.number_input1 {
self.number_input1.update(cx, |input, cx| { self.number_input1_value = self.number_input1_value + 1;
input.set_value(self.number_input1_value.to_string(), window, cx); 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);
fn on_number_input2_event( });
&mut self, } else if this == &self.number_input3 {
_: &Entity<NumberInput>, self.number_input3_value = self.number_input3_value + 1.0;
event: &NumberInputEvent, this.update(cx, |input, cx| {
window: &mut Window, input.set_value(self.number_input3_value.to_string(), window, cx);
cx: &mut Context<Self>, });
) {
match event {
NumberInputEvent::Input(input_event) => match input_event {
InputEvent::Change(text) => {
if let Ok(value) = text.parse::<u64>() {
self.number_input2_value = value;
} }
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() .max_w_md()
.child(self.number_input2.clone()), .child(self.number_input2.clone()),
) )
.child(
section("With mask pattern")
.max_w_md()
.child(self.number_input3.clone()),
)
} }
} }

View file

@ -26,6 +26,7 @@ use gpui::{
use super::blink_cursor::BlinkCursor; use super::blink_cursor::BlinkCursor;
use super::change::Change; use super::change::Change;
use super::element::TextElement; use super::element::TextElement;
use super::mask_pattern::MaskPattern;
use super::number_input; use super::number_input;
use crate::button::{Button, ButtonVariants as _}; use crate::button::{Button, ButtonVariants as _};
@ -243,7 +244,7 @@ pub struct TextInput {
/// For special case, e.g.: NumberInput + - button /// For special case, e.g.: NumberInput + - button
pub(super) no_gap: bool, pub(super) no_gap: bool,
pub(super) height: Option<gpui::DefiniteLength>, pub(super) height: Option<gpui::DefiniteLength>,
pattern: Option<regex::Regex>, pub(super) pattern: Option<regex::Regex>,
validate: Option<Box<dyn Fn(&str) -> bool + 'static>>, validate: Option<Box<dyn Fn(&str) -> bool + 'static>>,
pub(crate) scroll_handle: ScrollHandle, pub(crate) scroll_handle: ScrollHandle,
scrollbar_state: Rc<Cell<ScrollbarState>>, scrollbar_state: Rc<Cell<ScrollbarState>>,
@ -252,6 +253,8 @@ pub struct TextInput {
/// To remember the horizontal column (x-coordinate) of the cursor position. /// To remember the horizontal column (x-coordinate) of the cursor position.
preferred_x_offset: Option<Pixels>, preferred_x_offset: Option<Pixels>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
/// The mask pattern for formatting the input text
pub(crate) mask_pattern: MaskPattern,
} }
impl EventEmitter<InputEvent> for TextInput {} impl EventEmitter<InputEvent> for TextInput {}
@ -320,6 +323,7 @@ impl TextInput {
scroll_size: gpui::size(px(0.), px(0.)), scroll_size: gpui::size(px(0.), px(0.)),
preferred_x_offset: None, preferred_x_offset: None,
_subscriptions, _subscriptions,
mask_pattern: MaskPattern::default(),
} }
} }
@ -502,7 +506,7 @@ impl TextInput {
self.replace_text(text, window, cx); self.replace_text(text, window, cx);
self.history.ignore = false; self.history.ignore = false;
// Ensure cursor to start when set text // Ensure cursor to start when set text
self.selected_range = 0..0; self.selected_range = self.text.len()..self.text.len();
cx.notify(); cx.notify();
} }
@ -553,19 +557,19 @@ impl TextInput {
cx.notify(); cx.notify();
} }
/// Set with masked state. /// Set with password masked state.
pub fn masked(mut self, masked: bool) -> Self { pub fn masked(mut self, masked: bool) -> Self {
self.masked = masked; self.masked = masked;
self 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>) { pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context<Self>) {
self.masked = masked; self.masked = masked;
cx.notify(); 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 { pub fn mask_toggle(mut self) -> Self {
self.mask_toggle = true; self.mask_toggle = true;
self self
@ -713,6 +717,11 @@ impl TextInput {
&self.text &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 { pub fn disabled(&self) -> bool {
self.disabled self.disabled
} }
@ -1543,6 +1552,10 @@ impl TextInput {
} }
} }
if !self.mask_pattern.is_valid(new_text) {
return false;
}
self.pattern self.pattern
.as_ref() .as_ref()
.map(|p| p.is_match(new_text)) .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<MaskPattern>) -> 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<MaskPattern>,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.mask_pattern = pattern.into();
if let Some(placeholder) = self.mask_pattern.placeholder() {
self.placeholder = placeholder.into();
}
cx.notify();
}
} }
impl Sizable for TextInput { impl Sizable for TextInput {
@ -1644,18 +1687,23 @@ impl EntityInputHandler for TextInput {
let pending_text: SharedString = let pending_text: SharedString =
(self.text[0..range.start].to_owned() + new_text + &self.text[range.end..]).into(); (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) { if !self.is_valid_input(&pending_text) {
return; return;
} }
self.push_history(&range, new_text, window, cx); let mask_text = self.mask_pattern.mask(&pending_text);
self.text = pending_text; let new_text_len = (new_text.len() + mask_text.len()).saturating_sub(pending_text.len());
self.selected_range = range.start + new_text.len()..range.start + new_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.marked_range.take();
self.update_preferred_x_offset(cx); self.update_preferred_x_offset(cx);
self.update_scroll_offset(None, cx); self.update_scroll_offset(None, cx);
self.check_to_auto_grow(window, cx); self.check_to_auto_grow(window, cx);
cx.emit(InputEvent::Change(self.text.clone())); cx.emit(InputEvent::Change(self.unmask_text()));
cx.notify(); cx.notify();
} }
@ -1690,7 +1738,7 @@ impl EntityInputHandler for TextInput {
.map(|range_utf16| self.range_from_utf16(range_utf16)) .map(|range_utf16| self.range_from_utf16(range_utf16))
.map(|new_range| new_range.start + range.start..new_range.end + range.end) .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()); .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(); cx.notify();
} }
@ -1835,7 +1883,6 @@ impl Render for TextInput {
.on_action(cx.listener(Self::cut)) .on_action(cx.listener(Self::cut))
.on_action(cx.listener(Self::undo)) .on_action(cx.listener(Self::undo))
.on_action(cx.listener(Self::redo)) .on_action(cx.listener(Self::redo))
.on_action(cx.listener(Self::redo))
.on_key_down(cx.listener(Self::on_key_down)) .on_key_down(cx.listener(Self::on_key_down))
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))

View file

@ -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<char> {
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<MaskToken>,
},
Number {
/// Group separator, e.g. "," or " "
separator: Option<char>,
/// Number of fraction digits, e.g. 2 for 123.45
fraction: Option<usize>,
},
}
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<MaskToken>> {
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<char>) -> Self {
Self::Number {
separator: sep,
fraction: None,
}
}
pub fn placeholder(&self) -> Option<String> {
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<char> = 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::<String>()
});
// Reverse the integer part for easier grouping
let chars: Vec<char> = 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<char> = 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<char> = 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");
}
}

View file

@ -3,10 +3,12 @@ mod change;
mod clear_button; mod clear_button;
mod element; mod element;
mod input; mod input;
mod mask_pattern;
mod number_input; mod number_input;
mod otp_input; mod otp_input;
pub(crate) use clear_button::*; pub(crate) use clear_button::*;
pub use input::*; pub use input::*;
pub use mask_pattern::MaskPattern;
pub use number_input::{NumberInput, NumberInputEvent, StepAction}; pub use number_input::{NumberInput, NumberInputEvent, StepAction};
pub use otp_input::*; pub use otp_input::*;

View file

@ -12,6 +12,8 @@ use crate::{
ActiveTheme, IconName, Sizable, Size, StyleSized, StyledExt as _, ActiveTheme, IconName, Sizable, Size, StyleSized, StyledExt as _,
}; };
use super::MaskPattern;
actions!(number_input, [Increment, Decrement]); actions!(number_input, [Increment, Decrement]);
const KEY_CONTENT: &str = "NumberInput"; const KEY_CONTENT: &str = "NumberInput";
@ -93,6 +95,19 @@ impl NumberInput {
self self
} }
pub fn mask_pattern(
self,
mask_pattern: MaskPattern,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
self.input.update(cx, |input, cx| {
input.pattern = None;
input.set_mask_pattern(mask_pattern, window, cx)
});
self
}
pub fn set_value( pub fn set_value(
&self, &self,
text: impl Into<SharedString>, text: impl Into<SharedString>,
@ -100,7 +115,7 @@ impl NumberInput {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.input 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<Self>) { pub fn set_disabled(&self, disabled: bool, window: &mut Window, cx: &mut Context<Self>) {