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}