Skip to main content

ferroui_controls/
masked_text_box.rs

1use crate::primitives::TemplatedControlImpl;
2use crate::utils::{ClipboardHelper, MaskedTextProvider};
3use ferroui_base::utilities::CultureInfo;
4use crate::{ControlImpl, TextBox, TextBoxImpl, TextBoxImplExt, TopLevel};
5use ferroui_base::input::platform::{ClipboardErrorKind, ClipboardExtensions};
6use ferroui_base::logging::{LogArea, LogEventLevel, Logger};
7use std::any::Any;
8use ferroui_base::input::{FocusChangedEventArgs, InputElementImpl, InputElementImplExt, Key, KeyEventArgs, KeyGesture, TextInputEventArgs};
9use ferroui_base::interactivity::InteractiveImpl;
10use ferroui_base::layout::LayoutableImpl;
11use ferroui_base::{
12    ferro_class, ferro_impl_classes, ferro_property, instantiate, DirectProperty, FerroObject, FerroObjectImpl,
13    FerroObjectImplExt, FerroProperty, FerroPropertyChangedEventArgs, Ref, StaticType, StyledElementImpl,
14    StyledProperty, StyledPropertyMetadata, StyledPropertyOptions, TypeInfo, VisualImpl,
15};
16use std::cell::{Cell, RefCell};
17use std::rc::Rc;
18
19/// A text box that restricts its input with a mask.
20#[repr(C)]
21pub struct MaskedTextBox {
22    base: TextBox,
23    mask_provider: RefCell<Option<Rc<RefCell<MaskedTextProvider>>>>,
24    ignore_text_changes: Cell<bool>,
25}
26
27ferro_class!(MaskedTextBox: TextBox);
28ferroui_base::ferro_class_info!(MaskedTextBox { new: MaskedTextBox::new });
29
30ferro_impl_classes!(MaskedTextBox: VisualImpl, LayoutableImpl, InteractiveImpl, ControlImpl, TemplatedControlImpl);
31
32impl FerroObjectImpl for MaskedTextBox {
33    fn constructed(this: &Self) {
34        // The static constructor runs before the instance constructors.
35        Self::parent_constructed(this);
36    }
37
38    fn on_property_changed(this: &Self, change: &FerroPropertyChangedEventArgs<'_>) {
39        let update_mask_provider = || {
40            let mask = this.mask();
41            let mut provider = match MaskedTextProvider::new_full(
42                mask.as_deref().unwrap_or(""),
43                this.culture(),
44                true,
45                this.prompt_char(),
46                this.password_char(),
47                this.ascii_only(),
48            ) {
49                Ok(provider) => provider,
50                Err(error) => panic!("{error}"),
51            };
52            provider.set_reset_on_space(this.reset_on_space());
53            provider.set_reset_on_prompt(this.reset_on_prompt());
54
55            let provider = Rc::new(RefCell::new(provider));
56            *this.mask_provider.borrow_mut() = Some(provider.clone());
57
58            if let Some(text) = this.text() {
59                provider.borrow_mut().set(&text);
60            }
61            this.refresh_text(Some(&provider), 0, false);
62        };
63
64        let property = change.property();
65        let provider = this.mask_provider();
66
67        if property == Self::mask_property().as_property() {
68            update_mask_provider();
69
70            if let Some(mask) = this.mask().filter(|mask| !mask.is_empty()) {
71                for c in mask.chars() {
72                    if !MaskedTextProvider::is_valid_mask_char(c) {
73                        panic!("Specified mask contains characters that are not valid.");
74                    }
75                }
76            }
77        } else if property == TextBox::password_char_property().as_property() {
78            if provider.is_some_and(|provider| provider.borrow().password_char() != this.password_char()) {
79                update_mask_provider();
80            }
81        } else if property == Self::prompt_char_property().as_property() {
82            if provider.is_some_and(|provider| provider.borrow().prompt_char() != this.prompt_char()) {
83                update_mask_provider();
84            }
85        } else if property == Self::reset_on_prompt_property().as_property() {
86            if let Some(provider) = provider {
87                provider.borrow_mut().set_reset_on_prompt(change.get_new_value::<bool>());
88            }
89        } else if property == Self::reset_on_space_property().as_property() {
90            if let Some(provider) = provider {
91                provider.borrow_mut().set_reset_on_space(change.get_new_value::<bool>());
92            }
93        } else if (property == Self::ascii_only_property().as_property()
94            && provider.as_ref().is_some_and(|provider| provider.borrow().ascii_only() != this.ascii_only()))
95            || (property == Self::culture_property().as_property()
96                && provider.as_ref().is_some_and(|provider| Some(provider.borrow().culture()) != this.culture().as_ref()))
97        {
98            update_mask_provider();
99        }
100
101        Self::parent_on_property_changed(this, change);
102    }
103}
104
105impl StyledElementImpl for MaskedTextBox {
106    fn style_key_override(_this: &Self) -> &'static TypeInfo {
107        <TextBox as StaticType>::TYPE
108    }
109}
110
111impl InputElementImpl for MaskedTextBox {
112    fn on_got_focus(this: &Self, e: &FocusChangedEventArgs) {
113        if this.hide_prompt_on_leave() {
114            if let Some(provider) = this.mask_provider() {
115                let text = provider.borrow().to_display_string();
116                this.set_text_from_internal_synchronization(Some(text));
117            }
118        }
119        Self::parent_on_got_focus(this, e);
120    }
121
122    /// The paste gesture reads the clipboard asynchronously: everything
123    /// after the read runs when the clipboard has delivered its text, from a
124    /// dispatcher job unless the clipboard completed at once. The event can
125    /// only be marked as handled in the latter case: afterwards its routing
126    /// is over.
127    ///
128    /// A clipboard that timed out is silently ignored and one that denied
129    /// access is logged; nothing is pasted then. Any other failure, and a
130    /// panic of the clipboard, is raised on the dispatcher.
131    fn on_key_down(this: &Self, e: &KeyEventArgs) {
132        let Some(provider) = this.mask_provider() else {
133            Self::parent_on_key_down(this, e);
134            return;
135        };
136
137        let keymap = this.get_platform_settings().map(|settings| settings.hotkey_configuration());
138
139        let matches = |gestures: &[KeyGesture]| gestures.iter().any(|g| g.matches(Some(e)));
140
141        if keymap.is_some_and(|keymap| matches(&keymap.paste)) {
142            let Some(clipboard) = TopLevel::get_top_level(Some(this)).and_then(|top_level| top_level.clipboard())
143            else {
144                return;
145            };
146
147            let target = this.to_ref();
148            // Set when the clipboard delivers its text before the operation
149            // is started has returned: only then is the event still being
150            // routed.
151            let completed: Rc<Cell<bool>> = Rc::new(Cell::new(false));
152            let completed_in_operation = completed.clone();
153
154            ClipboardHelper::start(async move {
155                let mut text: Option<String> = None;
156                match clipboard.try_get_text_async().await {
157                    Ok(value) => text = value,
158                    Err(ex) if ex.kind() == ClipboardErrorKind::Timeout => {
159                        // Silently ignore.
160                    }
161                    Err(uex) if uex.kind() == ClipboardErrorKind::AccessDenied => {
162                        if let Some(logger) = Logger::try_get(LogEventLevel::Warning, LogArea::CONTROL) {
163                            let source: &dyn Any = &target;
164                            logger.log_with_values(
165                                Some(source),
166                                "Failed to read text from clipboard: {Error}",
167                                &[&uex],
168                            );
169                        }
170                    }
171                    Err(ex) => return Err(ex),
172                }
173
174                let Some(text) = text else {
175                    return Ok(());
176                };
177
178                // The provider is read again: it may have been replaced
179                // while the clipboard was read.
180                let Some(provider) = target.mask_provider() else {
181                    return Ok(());
182                };
183
184                for item in text.chars() {
185                    let mut index = target.get_next_character_position(target.caret_index());
186                    let inserted = provider.borrow_mut().insert_at_char(item, index);
187                    if inserted {
188                        index += 1;
189                        target.set_current_value(TextBox::caret_index_property(), index);
190                    }
191                }
192
193                let text = provider.borrow().to_display_string();
194                target.set_text_from_edit(Some(text));
195                completed_in_operation.set(true);
196                Ok(())
197            });
198
199            if completed.get() {
200                e.set_handled(true);
201            }
202            return;
203        }
204
205        if e.key != Key::Back {
206            Self::parent_on_key_down(this, e);
207        }
208
209        match e.key {
210            Key::Delete => {
211                if this.text_length().is_some_and(|length| this.caret_index() < length) {
212                    let removed = provider.borrow_mut().remove_at(this.caret_index());
213                    if removed {
214                        this.refresh_text(Some(&provider), this.caret_index(), true);
215                    }
216
217                    e.set_handled(true);
218                }
219            }
220            Key::Space => {
221                let reset_on_space = provider.borrow().reset_on_space();
222                if !reset_on_space || this.selected_text().is_empty() {
223                    let inserted = provider.borrow_mut().insert_at(" ", this.caret_index());
224                    if inserted {
225                        this.refresh_text(Some(&provider), this.caret_index(), true);
226                    }
227                }
228
229                e.set_handled(true);
230            }
231            Key::Back => {
232                if this.caret_index() > 0 {
233                    provider.borrow_mut().remove_at(this.caret_index() - 1);
234                }
235                this.refresh_text(Some(&provider), this.caret_index() - 1, true);
236                e.set_handled(true);
237            }
238            _ => {}
239        }
240    }
241
242    fn on_lost_focus(this: &Self, e: &FocusChangedEventArgs) {
243        if this.hide_prompt_on_leave() {
244            if let Some(provider) = this.mask_provider() {
245                let text = provider.borrow().to_string_include(!this.hide_prompt_on_leave(), true);
246                this.set_text_from_internal_synchronization(Some(text));
247            }
248        }
249        Self::parent_on_lost_focus(this, e);
250    }
251
252    fn on_text_input(this: &Self, e: &TextInputEventArgs) {
253        this.ignore_text_changes.set(true);
254
255        // Clears the flag when the scope ends, also by a panic of a handler.
256        struct Restore<'a>(&'a Cell<bool>);
257
258        impl Drop for Restore<'_> {
259            fn drop(&mut self) {
260                self.0.set(false);
261            }
262        }
263
264        let _restore = Restore(&this.ignore_text_changes);
265
266        if this.is_read_only() {
267            e.set_handled(true);
268            Self::parent_on_text_input(this, e);
269            return;
270        }
271        let Some(provider) = this.mask_provider() else {
272            Self::parent_on_text_input(this, e);
273            return;
274        };
275
276        let (reset_on_space, reset_on_prompt, prompt_char) = {
277            let provider = provider.borrow();
278            (provider.reset_on_space(), provider.reset_on_prompt(), provider.prompt_char())
279        };
280        let text = e.text.as_deref();
281        let mut prompt_buffer = [0u8; 4];
282        let prompt: &str = prompt_char.encode_utf8(&mut prompt_buffer);
283
284        if (reset_on_space && text == Some(" ") || reset_on_prompt && text == Some(prompt))
285            && !this.selected_text().is_empty()
286        {
287            let (selection_start, selection_end) = (this.selection_start(), this.selection_end());
288            let removed = if selection_start > selection_end {
289                provider.borrow_mut().remove_at_range(selection_end, selection_start - 1)
290            } else {
291                provider.borrow_mut().remove_at_range(selection_start, selection_end - 1)
292            };
293            if removed {
294                this.set_selected_text(Some(""));
295            }
296        }
297
298        if this.text_length().is_some_and(|length| this.caret_index() < length) {
299            this.set_current_value(
300                TextBox::caret_index_property(),
301                this.get_next_character_position(this.caret_index()),
302            );
303
304            let input = text.expect("the text of a text input cannot be null");
305            let inserted = provider.borrow_mut().insert_at(input, this.caret_index());
306            if inserted {
307                this.set_caret_index(this.caret_index() + 1);
308            }
309            let next_pos = this.get_next_character_position(this.caret_index());
310            if next_pos != 0 && this.text_length() != Some(this.caret_index()) {
311                this.set_current_value(TextBox::caret_index_property(), next_pos);
312            }
313        }
314
315        this.refresh_text(Some(&provider), this.caret_index(), true);
316
317        e.set_handled(true);
318
319        Self::parent_on_text_input(this, e);
320    }
321}
322
323impl TextBoxImpl for MaskedTextBox {
324    fn coerce_text(this: &Self, mut text: Option<String>) -> Option<String> {
325        if !this.ignore_text_changes.get() {
326            if let Some(provider) = this.mask_provider() {
327                let mut provider = provider.borrow_mut();
328                match text.as_deref() {
329                    None | Some("") => provider.clear(),
330                    Some(text) => {
331                        provider.set(text);
332                    }
333                }
334
335                text = Some(provider.to_display_string());
336            }
337        }
338
339        Self::parent_coerce_text(this, text)
340    }
341}
342
343ferroui_base::ferro_properties! { impl MaskedTextBox {
344    ferro_property!(
345        /// Defines the `AsciiOnly` property.
346        pub fn ascii_only_property() -> StyledProperty<bool> {
347            FerroProperty::register::<MaskedTextBox, _>("AsciiOnly", false)
348        }
349    );
350
351    ferro_property!(
352        /// Defines the `Culture` property.
353        pub fn culture_property() -> StyledProperty<Option<CultureInfo>> {
354            FerroProperty::register::<MaskedTextBox, _>("Culture", Some(CultureInfo::current_culture()))
355        }
356    );
357
358    ferro_property!(
359        /// Defines the `HidePromptOnLeave` property.
360        pub fn hide_prompt_on_leave_property() -> StyledProperty<bool> {
361            FerroProperty::register::<MaskedTextBox, _>("HidePromptOnLeave", false)
362        }
363    );
364
365    ferro_property!(
366        /// Defines the `MaskCompleted` property.
367        pub fn mask_completed_property() -> DirectProperty<MaskedTextBox, Option<bool>> {
368            FerroProperty::register_direct::<MaskedTextBox, _>("MaskCompleted", |o| o.mask_completed(), None, None)
369        }
370    );
371
372    ferro_property!(
373        /// Defines the `MaskFull` property.
374        pub fn mask_full_property() -> DirectProperty<MaskedTextBox, Option<bool>> {
375            FerroProperty::register_direct::<MaskedTextBox, _>("MaskFull", |o| o.mask_full(), None, None)
376        }
377    );
378
379    ferro_property!(
380        /// Defines the `Mask` property.
381        pub fn mask_property() -> StyledProperty<Option<String>> {
382            FerroProperty::register::<MaskedTextBox, _>("Mask", Some(String::new()))
383        }
384    );
385
386    ferro_property!(
387        /// Defines the `PromptChar` property.
388        pub fn prompt_char_property() -> StyledProperty<char> {
389            FerroProperty::register_with::<MaskedTextBox, _>(
390                "PromptChar",
391                StyledPropertyOptions::new('_').coerce(Self::coerce_prompt_char),
392            )
393        }
394    );
395
396    ferro_property!(
397        /// Defines the `ResetOnPrompt` property.
398        pub fn reset_on_prompt_property() -> StyledProperty<bool> {
399            FerroProperty::register::<MaskedTextBox, _>("ResetOnPrompt", true)
400        }
401    );
402
403    ferro_property!(
404        /// Defines the `ResetOnSpace` property.
405        pub fn reset_on_space_property() -> StyledProperty<bool> {
406            FerroProperty::register::<MaskedTextBox, _>("ResetOnSpace", true)
407        }
408    );
409} }
410
411impl MaskedTextBox {
412    fn static_constructor() {
413        TextBox::password_char_property().override_metadata::<MaskedTextBox>(
414            StyledPropertyMetadata::new(Some('\0')).with_coerce(Self::coerce_password_char),
415        );
416    }
417
418    fn coerce_password_char(sender: &FerroObject, base_value: char) -> char {
419        if !MaskedTextProvider::is_valid_password_char(base_value) {
420            panic!("'{base_value}' is not a valid value for PasswordChar.");
421        }
422        let textbox = sender.downcast_ref::<MaskedTextBox>().expect("the sender is not a masked text box");
423        if let Some(mask_provider) = textbox.mask_provider() {
424            if base_value == mask_provider.borrow().prompt_char() {
425                // Prompt and password chars must be different.
426                panic!("PasswordChar and PromptChar values cannot be the same.");
427            }
428        }
429
430        base_value
431    }
432
433    fn coerce_prompt_char(sender: &FerroObject, base_value: char) -> char {
434        if !MaskedTextProvider::is_valid_input_char(base_value) {
435            panic!("'{base_value}' is not a valid value for PromptChar.");
436        }
437        if base_value == sender.get_value(TextBox::password_char_property()) {
438            panic!("PasswordChar and PromptChar values cannot be the same.");
439        }
440
441        base_value
442    }
443
444    /// Creates the class data; see [`ferroui_base::FerroObject::construct`].
445    pub fn construct() -> Self {
446        Self { base: TextBox::construct(), mask_provider: RefCell::new(None), ignore_text_changes: Cell::new(false) }
447    }
448
449    /// Initializes a new instance of the `MaskedTextBox` class.
450    pub fn new() -> Ref<Self> {
451        instantiate(Self::construct())
452    }
453
454    /// Constructs the masked text box with the settings of the specified
455    /// provider.
456    pub fn with_provider(masked_text_provider: &MaskedTextProvider) -> Ref<Self> {
457        let this = Self::new();
458        // These values are explicitly provided by a constructor parameter.
459        this.set_ascii_only(masked_text_provider.ascii_only());
460        this.set_culture(Some(masked_text_provider.culture().clone()));
461        this.set_mask(Some(masked_text_provider.mask()));
462        this.set_password_char(masked_text_provider.password_char());
463        this.set_prompt_char(masked_text_provider.prompt_char());
464        this
465    }
466
467    /// Whether the masked text box is restricted to accept only ASCII
468    /// characters. Default value is false.
469    pub fn ascii_only(&self) -> bool {
470        self.get_value(Self::ascii_only_property())
471    }
472
473    pub fn set_ascii_only(&self, value: bool) {
474        self.set_value(Self::ascii_only_property(), value)
475    }
476
477    /// The culture information associated with the masked text box.
478    pub fn culture(&self) -> Option<CultureInfo> {
479        self.get_value(Self::culture_property())
480    }
481
482    pub fn set_culture(&self, value: Option<CultureInfo>) {
483        self.set_value(Self::culture_property(), value)
484    }
485
486    /// Whether the prompt character is hidden when the masked text box loses
487    /// focus.
488    pub fn hide_prompt_on_leave(&self) -> bool {
489        self.get_value(Self::hide_prompt_on_leave_property())
490    }
491
492    pub fn set_hide_prompt_on_leave(&self, value: bool) {
493        self.set_value(Self::hide_prompt_on_leave_property(), value)
494    }
495
496    /// The mask to apply to the text box.
497    pub fn mask(&self) -> Option<String> {
498        self.get_value(Self::mask_property())
499    }
500
501    pub fn set_mask(&self, value: Option<&str>) {
502        self.set_value(Self::mask_property(), value.map(str::to_owned))
503    }
504
505    /// Specifies whether the test string required input positions, as
506    /// specified by the mask, have all been assigned.
507    pub fn mask_completed(&self) -> Option<bool> {
508        self.mask_provider().map(|provider| provider.borrow().mask_completed())
509    }
510
511    /// Specifies whether all inputs (required and optional) have been
512    /// provided into the mask successfully.
513    pub fn mask_full(&self) -> Option<bool> {
514        self.mask_provider().map(|provider| provider.borrow().mask_full())
515    }
516
517    /// The mask provider for the specified mask.
518    pub fn mask_provider(&self) -> Option<Rc<RefCell<MaskedTextProvider>>> {
519        self.mask_provider.borrow().clone()
520    }
521
522    /// The character used to represent the absence of user input in the
523    /// masked text box.
524    pub fn prompt_char(&self) -> char {
525        self.get_value(Self::prompt_char_property())
526    }
527
528    pub fn set_prompt_char(&self, value: char) {
529        self.set_value(Self::prompt_char_property(), value)
530    }
531
532    /// Whether selected characters should be reset when the prompt character
533    /// is pressed.
534    pub fn reset_on_prompt(&self) -> bool {
535        self.get_value(Self::reset_on_prompt_property())
536    }
537
538    pub fn set_reset_on_prompt(&self, value: bool) {
539        self.set_value(Self::reset_on_prompt_property(), value)
540    }
541
542    /// Whether selected characters should be reset when the space character
543    /// is pressed.
544    pub fn reset_on_space(&self) -> bool {
545        self.get_value(Self::reset_on_space_property())
546    }
547
548    pub fn set_reset_on_space(&self, value: bool) {
549        self.set_value(Self::reset_on_space_property(), value)
550    }
551
552    /// The length of the text in UTF-16 code units; `None` without a text.
553    fn text_length(&self) -> Option<i32> {
554        self.text().map(|text| text.encode_utf16().count() as i32)
555    }
556
557    fn get_next_character_position(&self, start_position: i32) -> i32 {
558        if let Some(provider) = self.mask_provider() {
559            let position = provider.borrow().find_edit_position_from(start_position, true);
560            if self.caret_index() != -1 {
561                return position;
562            }
563        }
564        start_position
565    }
566
567    fn refresh_text(&self, provider: Option<&Rc<RefCell<MaskedTextProvider>>>, position: i32, is_edit: bool) {
568        if let Some(provider) = provider {
569            let text = provider.borrow().to_display_string();
570            if is_edit {
571                self.set_text_from_edit(Some(text));
572            } else {
573                self.set_text_from_internal_synchronization(Some(text));
574            }
575            self.set_current_value(TextBox::caret_index_property(), position);
576        }
577    }
578}