Skip to main content

teksilo_widgets/primitives/text_input_field/
mask.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Input-mask grammar (Qt-compatible subset).
5//!
6//! Adopts the well-known `QLineEdit::setInputMask` grammar so masks
7//! that work in any Qt-based desktop app port over verbatim. Used by
8//! [`TextInputField`](super::super::text_input_field::TextInputField)
9//! to constrain typed input, render the empty-state template
10//! (`__/__/____` for `99/99/9999`), and auto-insert literal separators
11//! between editable positions.
12//!
13//! # Grammar
14//!
15//! | Char | Meaning |
16//! | --- | --- |
17//! | `9` | Required digit |
18//! | `0` | Optional digit |
19//! | `A` | Required ASCII letter |
20//! | `a` | Optional ASCII letter |
21//! | `N` | Required alphanumeric |
22//! | `n` | Optional alphanumeric |
23//! | `X` | Any required character |
24//! | `x` | Any optional character |
25//! | `H` | Required hex digit |
26//! | `h` | Optional hex digit |
27//! | `>` | Uppercase the following editable chars (toggle) |
28//! | `<` | Lowercase the following editable chars (toggle) |
29//! | `!` | Cancel the case lock from `>` / `<` |
30//! | `\X` | Literal `X` (escape) |
31//!
32//! Anything else is a fixed separator: `99/99/9999`, `(999) 999-9999`,
33//! `>AA` for force-uppercase 2-letter codes.
34//!
35//! # Storage model
36//!
37//! The bound `Signal<String>` observes the **formatted** text including
38//! literal separators (matches Qt's QLineEdit semantics): typing `12302026`
39//! into `99/99/9999` produces `"12/30/2026"`, not `"12302026"`. Apps that
40//! need raw digits strip separators trivially with `.replace('/', "")`.
41
42use std::fmt::Write as _;
43
44/// Per-position case lock applied during typing.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum CaseLock {
47    None,
48    Upper,
49    Lower,
50}
51
52/// One position in the parsed mask. Either the user provides a
53/// character there ([`Editable`](MaskPosition::Editable)) or it's a
54/// fixed literal that the field paints automatically and the caret
55/// skips over.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum MaskPosition {
58    /// Editable slot. `class` constrains accepted chars; `required`
59    /// distinguishes mandatory vs optional positions; `case` applies
60    /// to letter / alphanumeric / any classes.
61    Editable {
62        class: MaskClass,
63        required: bool,
64        case: CaseLock,
65    },
66    /// Fixed literal painted by the field; caret skips over it.
67    Fixed(char),
68}
69
70/// Character classes that an editable position accepts.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum MaskClass {
73    /// `9` / `0` — ASCII digit `0..9`.
74    Digit,
75    /// `A` / `a` — ASCII letter `a..z`/`A..Z`.
76    Letter,
77    /// `N` / `n` — letter or digit.
78    Alphanumeric,
79    /// `X` / `x` — any non-control character.
80    Any,
81    /// `H` / `h` — hex digit `0..9`/`a..f`/`A..F`.
82    HexDigit,
83}
84
85impl MaskClass {
86    /// Does `c` fit this class?
87    pub fn accepts(self, c: char) -> bool {
88        match self {
89            Self::Digit => c.is_ascii_digit(),
90            Self::Letter => c.is_ascii_alphabetic(),
91            Self::Alphanumeric => c.is_ascii_alphanumeric(),
92            Self::Any => !c.is_control(),
93            Self::HexDigit => c.is_ascii_hexdigit(),
94        }
95    }
96}
97
98impl MaskPosition {
99    /// Editable shorthand.
100    pub fn is_editable(&self) -> bool {
101        matches!(self, Self::Editable { .. })
102    }
103}
104
105/// Parsed mask: a sequence of positions, ready for rendering and
106/// per-character routing.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct InputMask {
109    positions: Vec<MaskPosition>,
110}
111
112impl InputMask {
113    /// Parse a Qt-grammar mask string. Errors only on a trailing
114    /// backslash with nothing to escape; any unrecognized printable
115    /// char becomes a fixed literal. (Qt is similarly permissive.)
116    pub fn parse(mask: &str) -> Result<Self, MaskError> {
117        let mut positions = Vec::with_capacity(mask.len());
118        let mut chars = mask.chars().peekable();
119        let mut case = CaseLock::None;
120
121        while let Some(c) = chars.next() {
122            match c {
123                '\\' => {
124                    let Some(next) = chars.next() else {
125                        return Err(MaskError::TrailingBackslash);
126                    };
127                    positions.push(MaskPosition::Fixed(next));
128                }
129                '>' => case = CaseLock::Upper,
130                '<' => case = CaseLock::Lower,
131                '!' => case = CaseLock::None,
132                '9' => positions.push(MaskPosition::Editable {
133                    class: MaskClass::Digit,
134                    required: true,
135                    case,
136                }),
137                '0' => positions.push(MaskPosition::Editable {
138                    class: MaskClass::Digit,
139                    required: false,
140                    case,
141                }),
142                'A' => positions.push(MaskPosition::Editable {
143                    class: MaskClass::Letter,
144                    required: true,
145                    case,
146                }),
147                'a' => positions.push(MaskPosition::Editable {
148                    class: MaskClass::Letter,
149                    required: false,
150                    case,
151                }),
152                'N' => positions.push(MaskPosition::Editable {
153                    class: MaskClass::Alphanumeric,
154                    required: true,
155                    case,
156                }),
157                'n' => positions.push(MaskPosition::Editable {
158                    class: MaskClass::Alphanumeric,
159                    required: false,
160                    case,
161                }),
162                'X' => positions.push(MaskPosition::Editable {
163                    class: MaskClass::Any,
164                    required: true,
165                    case,
166                }),
167                'x' => positions.push(MaskPosition::Editable {
168                    class: MaskClass::Any,
169                    required: false,
170                    case,
171                }),
172                'H' => positions.push(MaskPosition::Editable {
173                    class: MaskClass::HexDigit,
174                    required: true,
175                    case,
176                }),
177                'h' => positions.push(MaskPosition::Editable {
178                    class: MaskClass::HexDigit,
179                    required: false,
180                    case,
181                }),
182                other => positions.push(MaskPosition::Fixed(other)),
183            }
184        }
185
186        Ok(Self { positions })
187    }
188
189    /// Total number of positions (editable + fixed).
190    pub fn len(&self) -> usize {
191        self.positions.len()
192    }
193
194    /// `true` if the parsed mask has no positions.
195    pub fn is_empty(&self) -> bool {
196        self.positions.is_empty()
197    }
198
199    /// Iterator over positions in document order.
200    pub fn positions(&self) -> impl Iterator<Item = &MaskPosition> {
201        self.positions.iter()
202    }
203
204    /// Position at index `i`, or `None` if out of bounds.
205    pub fn get(&self, i: usize) -> Option<&MaskPosition> {
206        self.positions.get(i)
207    }
208
209    /// Render the mask as the empty-state template: every editable
210    /// position becomes `placeholder_char`, every fixed position keeps
211    /// its literal. So `99/99/9999` with `_` → `"__/__/____"`.
212    pub fn empty_template(&self, placeholder_char: char) -> String {
213        let mut s = String::with_capacity(self.positions.len());
214        for pos in &self.positions {
215            match pos {
216                MaskPosition::Editable { .. } => s.push(placeholder_char),
217                MaskPosition::Fixed(c) => s.push(*c),
218            }
219        }
220        s
221    }
222
223    /// Apply the mask to `raw` input. Walks `raw` and the mask in
224    /// lockstep:
225    /// - At a fixed-separator position, the separator is appended
226    ///   automatically (the user doesn't need to type it). If the next
227    ///   `raw` char matches the separator it's consumed; otherwise it
228    ///   stays for the next editable position.
229    /// - At an editable position, the next `raw` char is consumed
230    ///   if it fits the class, else dropped.
231    /// - Case lock is applied to letter/alphanumeric/any chars.
232    /// - When `raw` is exhausted, the rest of the formatted string
233    ///   uses `placeholder_char` for editable positions and the
234    ///   literal for fixed ones — giving a partial template like
235    ///   `"12/__/____"`.
236    ///
237    /// The result is the **fully-templated** string (with placeholder
238    /// characters for unfilled positions), suitable for direct
239    /// rendering. To get just the user's input keep raw and don't
240    /// call this; to get a string with separators but no placeholders,
241    /// truncate at the position past the last filled editable slot.
242    pub fn format(&self, raw: &str, placeholder_char: char) -> FormattedMask {
243        let mut buf = String::with_capacity(self.positions.len());
244        let mut raw_iter = raw.chars().peekable();
245        let mut last_filled_index: Option<usize> = None;
246
247        for (i, pos) in self.positions.iter().enumerate() {
248            match pos {
249                MaskPosition::Fixed(sep) => {
250                    let _ = write!(buf, "{}", sep);
251                    // If the user typed the separator themselves,
252                    // consume it so subsequent chars route to the
253                    // next editable slot (don't double-eat user input).
254                    if raw_iter.peek() == Some(sep) {
255                        raw_iter.next();
256                    }
257                }
258                MaskPosition::Editable { class, case, .. } => {
259                    // Find the next raw char that fits the class.
260                    // Skip raw chars that don't fit (consistent with
261                    // Qt: if the user pastes `abc12` into `99`, the
262                    // letters drop and the digits land).
263                    let mut filled = false;
264                    while let Some(&c) = raw_iter.peek() {
265                        raw_iter.next();
266                        if class.accepts(c) {
267                            let cased = match case {
268                                CaseLock::None => c,
269                                CaseLock::Upper => c.to_ascii_uppercase(),
270                                CaseLock::Lower => c.to_ascii_lowercase(),
271                            };
272                            buf.push(cased);
273                            last_filled_index = Some(i);
274                            filled = true;
275                            break;
276                        }
277                    }
278                    if !filled {
279                        buf.push(placeholder_char);
280                    }
281                }
282            }
283        }
284
285        FormattedMask {
286            full: buf,
287            last_filled_index,
288            mask_len: self.positions.len(),
289        }
290    }
291
292    /// Strip placeholder characters from the tail of a formatted
293    /// string. Returns the prefix containing only filled positions
294    /// and their preceding separators. `"12/__/____"` → `"12/"`,
295    /// `"12/30/____"` → `"12/30/"`, `"____"` → `""`.
296    pub fn strip_trailing_placeholders(&self, formatted: &FormattedMask) -> String {
297        let Some(last) = formatted.last_filled_index else {
298            return String::new();
299        };
300        // Count chars up to and including position `last`. The
301        // formatted buffer is one char per position (mask is ASCII;
302        // non-ASCII separators are still single chars in our model).
303        formatted.full.chars().take(last + 1).collect()
304    }
305
306    /// Position-aware char filter: returns `true` iff `c` would be
307    /// accepted at editable position `pos_index`. Fixed positions
308    /// never accept input directly (the caret should skip over them).
309    /// Out-of-range indices reject. Used by composites that want to
310    /// gate keystrokes per-position.
311    pub fn accepts_at(&self, pos_index: usize, c: char) -> bool {
312        match self.positions.get(pos_index) {
313            Some(MaskPosition::Editable { class, .. }) => class.accepts(c),
314            _ => false,
315        }
316    }
317}
318
319/// Result of [`InputMask::format`]: the fully-templated string plus
320/// metadata about how much of the user's input was consumed.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct FormattedMask {
323    /// Fully-templated string with placeholder chars at unfilled
324    /// editable positions. Always `mask.len()` chars long.
325    pub full: String,
326    /// Index of the last editable position that received a user
327    /// character. `None` if no editable position was filled.
328    pub last_filled_index: Option<usize>,
329    /// Total length of the parsed mask (number of positions).
330    pub mask_len: usize,
331}
332
333impl FormattedMask {
334    /// Is every editable position filled?
335    pub fn is_complete(&self, mask: &InputMask) -> bool {
336        let last_editable = mask
337            .positions()
338            .enumerate()
339            .filter(|(_, p)| p.is_editable())
340            .map(|(i, _)| i)
341            .last();
342        match (self.last_filled_index, last_editable) {
343            (Some(filled), Some(target)) => filled == target,
344            (None, None) => true,
345            _ => false,
346        }
347    }
348}
349
350/// Mask-parse errors. Currently only the trailing-backslash case;
351/// any other character becomes a fixed literal (Qt's permissive
352/// behaviour) so most accidental "weird" masks just produce odd
353/// templates rather than parse failures.
354#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
355pub enum MaskError {
356    /// `\` at the end of the mask string with no character to escape.
357    #[error("trailing `\\` in mask string")]
358    TrailingBackslash,
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn parse(s: &str) -> InputMask {
366        InputMask::parse(s).unwrap()
367    }
368
369    #[test]
370    fn parse_date_mask() {
371        let m = parse("99/99/9999");
372        assert_eq!(m.len(), 10);
373        // Editable positions at 0,1,3,4,6,7,8,9 — separator at 2,5
374        assert!(matches!(
375            m.get(0),
376            Some(MaskPosition::Editable {
377                class: MaskClass::Digit,
378                required: true,
379                ..
380            })
381        ));
382        assert!(matches!(m.get(2), Some(MaskPosition::Fixed('/'))));
383        assert!(matches!(m.get(5), Some(MaskPosition::Fixed('/'))));
384    }
385
386    #[test]
387    fn parse_phone_mask() {
388        let m = parse("(999) 999-9999");
389        // Fixed '(' ')' ' ' '-', plus 10 editable digits.
390        let editable_count = m.positions().filter(|p| p.is_editable()).count();
391        assert_eq!(editable_count, 10);
392    }
393
394    #[test]
395    fn parse_uppercase_letters() {
396        let m = parse(">AA");
397        match m.get(0) {
398            Some(MaskPosition::Editable {
399                class: MaskClass::Letter,
400                case: CaseLock::Upper,
401                ..
402            }) => {}
403            other => panic!("expected uppercase letter at 0, got {other:?}"),
404        }
405    }
406
407    #[test]
408    fn parse_escape() {
409        let m = parse(r"\99");
410        // `\9` is a literal '9'; the second `9` is a digit class.
411        assert!(matches!(m.get(0), Some(MaskPosition::Fixed('9'))));
412        assert!(matches!(
413            m.get(1),
414            Some(MaskPosition::Editable {
415                class: MaskClass::Digit,
416                ..
417            })
418        ));
419    }
420
421    #[test]
422    fn parse_trailing_backslash_errors() {
423        assert_eq!(InputMask::parse(r"99\"), Err(MaskError::TrailingBackslash));
424    }
425
426    #[test]
427    fn empty_template_is_underscore_for_editable() {
428        let m = parse("99/99/9999");
429        assert_eq!(m.empty_template('_'), "__/__/____");
430        assert_eq!(m.empty_template('·'), "··/··/····");
431    }
432
433    #[test]
434    fn format_partial_fills_then_placeholders() {
435        let m = parse("99/99/9999");
436        let f = m.format("1", '_');
437        assert_eq!(f.full, "1_/__/____");
438        assert_eq!(f.last_filled_index, Some(0));
439
440        let f = m.format("12", '_');
441        assert_eq!(f.full, "12/__/____");
442        assert_eq!(f.last_filled_index, Some(1));
443
444        let f = m.format("123", '_');
445        // `1` → pos0, `2` → pos1, fixed `/` at pos2, `3` → pos3.
446        assert_eq!(f.full, "12/3_/____");
447        assert_eq!(f.last_filled_index, Some(3));
448    }
449
450    #[test]
451    fn format_consumes_user_typed_separators() {
452        // User types `12/30/2026` (with separators); the mask should
453        // not double-eat the separators.
454        let m = parse("99/99/9999");
455        let f = m.format("12/30/2026", '_');
456        assert_eq!(f.full, "12/30/2026");
457        assert!(f.is_complete(&m));
458    }
459
460    #[test]
461    fn format_drops_chars_that_dont_fit_class() {
462        let m = parse("99/99/9999");
463        let f = m.format("abc12def30ghi2026", '_');
464        assert_eq!(f.full, "12/30/2026");
465    }
466
467    #[test]
468    fn format_uppercase_lock_applies() {
469        let m = parse(">AA");
470        let f = m.format("us", '_');
471        assert_eq!(f.full, "US");
472    }
473
474    #[test]
475    fn format_complete_for_full_input() {
476        let m = parse("99/99/9999");
477        let f = m.format("12302026", '_');
478        assert_eq!(f.full, "12/30/2026");
479        assert!(f.is_complete(&m));
480    }
481
482    #[test]
483    fn format_empty_input_all_placeholders() {
484        let m = parse("99/99/9999");
485        let f = m.format("", '_');
486        assert_eq!(f.full, "__/__/____");
487        assert_eq!(f.last_filled_index, None);
488        assert!(!f.is_complete(&m));
489    }
490
491    #[test]
492    fn strip_trailing_placeholders_truncates_template() {
493        let m = parse("99/99/9999");
494        let f = m.format("12", '_');
495        assert_eq!(m.strip_trailing_placeholders(&f), "12");
496        let f = m.format("123", '_');
497        assert_eq!(m.strip_trailing_placeholders(&f), "12/3");
498        let f = m.format("12302026", '_');
499        assert_eq!(m.strip_trailing_placeholders(&f), "12/30/2026");
500    }
501
502    #[test]
503    fn accepts_at_position() {
504        let m = parse("99/99/9999");
505        assert!(m.accepts_at(0, '5')); // editable digit
506        assert!(!m.accepts_at(0, 'a')); // letter at digit pos
507        assert!(!m.accepts_at(2, '/')); // fixed: never accepts
508        assert!(!m.accepts_at(99, '5')); // out of range
509    }
510}