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:
parent
1535df796e
commit
c0e0d82594
8 changed files with 795 additions and 77 deletions
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
562
crates/ui/src/input/mask_pattern.rs
Normal file
562
crates/ui/src/input/mask_pattern.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue