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",
"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",

View file

@ -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<TextInput>,
large_input: Entity<TextInput>,
small_input: Entity<TextInput>,
phone_input: Entity<TextInput>,
mask_input2: Entity<TextInput>,
currency_input: Entity<TextInput>,
_subscriptions: Vec<Subscription>,
}
@ -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()

View file

@ -19,7 +19,7 @@ pub struct 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| {
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<Self> {
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<Self> {
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 -- <story_name>`
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,
);
});
}

View file

@ -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<NumberInput>,
number_input2: Entity<NumberInput>,
number_input2_value: u64,
number_input3: Entity<NumberInput>,
number_input3_value: f64,
_subscriptions: Vec<Subscription>,
}
@ -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<NumberInput>,
this: &Entity<NumberInput>,
event: &NumberInputEvent,
window: &mut Window,
cx: &mut Context<Self>,
@ -100,8 +118,18 @@ impl NumberInputStory {
match event {
NumberInputEvent::Input(input_event) => match input_event {
InputEvent::Change(text) => {
if let Ok(value) = text.parse::<i64>() {
self.number_input1_value = value;
if this == &self.number_input1 {
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);
}
@ -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<NumberInput>,
event: &NumberInputEvent,
window: &mut Window,
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;
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()),
)
}
}

View file

@ -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<gpui::DefiniteLength>,
pattern: Option<regex::Regex>,
pub(super) pattern: Option<regex::Regex>,
validate: Option<Box<dyn Fn(&str) -> bool + 'static>>,
pub(crate) scroll_handle: ScrollHandle,
scrollbar_state: Rc<Cell<ScrollbarState>>,
@ -252,6 +253,8 @@ pub struct TextInput {
/// To remember the horizontal column (x-coordinate) of the cursor position.
preferred_x_offset: Option<Pixels>,
_subscriptions: Vec<Subscription>,
/// The mask pattern for formatting the input text
pub(crate) mask_pattern: MaskPattern,
}
impl EventEmitter<InputEvent> 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>) {
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<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 {
@ -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))

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 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::*;

View file

@ -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 {
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<SharedString>,
@ -100,7 +115,7 @@ impl NumberInput {
cx: &mut Context<Self>,
) {
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>) {