Skip to main content

fission_ir/
semantics.rs

1//! Accessibility and interaction semantics.
2//!
3//! The [`Semantics`] struct describes what a node *means* to assistive technology
4//! and to the event system. It carries a [`Role`] (button, text input, slider, ...),
5//! an optional human-readable label, a set of [`ActionEntry`]s that map input
6//! triggers to framework actions, and flags for focus, drag-and-drop, scrollability,
7//! and more.
8//!
9//! Semantics nodes appear in the IR as `Op::Semantics(semantics)`.
10
11use serde::{Deserialize, Serialize};
12
13/// The accessibility role of a node.
14///
15/// Roles tell screen readers and other assistive technology what kind of control a
16/// node represents. Choose the most specific role that applies.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub enum Role {
19    /// A clickable button that triggers an action.
20    Button,
21    /// A navigational link.
22    Link,
23    /// An actionable item inside a menu.
24    MenuItem,
25    /// A read-only text label.
26    Text,
27    /// An editable text field (single or multi-line).
28    TextInput,
29    /// A raster or vector image.
30    Image,
31    /// A toggle that is either checked or unchecked.
32    Checkbox,
33    /// A one-of-many selectable option in a radio group.
34    Radio,
35    /// A toggle switch (on/off).
36    Switch,
37    /// A modal or non-modal dialog overlay.
38    Dialog,
39    /// A continuous range input (e.g., volume control).
40    Slider,
41    /// A generic form input that does not fit the other roles.
42    Input,
43    /// A scrollable list container.
44    List,
45    /// An individual item inside a [`List`](Role::List).
46    ListItem,
47    /// A node with no specific semantic role. The default.
48    Generic,
49}
50
51/// How a focusable node responds to pointer focus.
52///
53/// `FocusPolicy` only changes pointer-driven focus assignment. Keyboard focus,
54/// accessibility focus, and semantic activation still work for focusable nodes.
55///
56/// # Example
57///
58/// A toolbar button can run its action without taking focus from an editor:
59///
60/// ```rust
61/// use fission_ir::semantics::{FocusPolicy, Role};
62/// use fission_ir::Semantics;
63///
64/// let semantics = Semantics {
65///     role: Role::Button,
66///     focusable: true,
67///     focus_policy: FocusPolicy::PreserveCurrentOnPointer,
68///     ..Semantics::default()
69/// };
70/// ```
71#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
72pub enum FocusPolicy {
73    /// Pointer-down focuses this node when it is focusable. This is the normal
74    /// behavior for buttons, text inputs, and other controls.
75    #[default]
76    FocusOnPointer,
77    /// Pointer-down keeps the currently focused node focused while still letting
78    /// this node receive pointer state and activation actions.
79    PreserveCurrentOnPointer,
80}
81
82/// What user interaction triggers an action.
83///
84/// Each [`ActionEntry`] pairs an `ActionTrigger` with an action ID so the event
85/// system knows which callback to invoke for a given input gesture.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
87pub enum ActionTrigger {
88    /// Primary activation: tap, click, or Enter key.
89    Default,
90    /// The user began dragging this node.
91    DragStart,
92    /// The drag position changed (fires continuously).
93    DragUpdate,
94    /// The user released the drag.
95    DragEnd,
96    /// The pointer entered the node's hit area.
97    HoverEnter,
98    /// The pointer left the node's hit area.
99    HoverExit,
100    /// A semantic cursor request applied while the pointer hovers this node.
101    ///
102    /// This is metadata, not a dispatched reducer action.
103    HoverCursor,
104    /// The node received keyboard focus.
105    Focus,
106    /// The node lost keyboard focus.
107    Blur,
108    /// A pointer-down happened outside the active text field.
109    TapOutside,
110    /// The node's value changed (for example, a slider moved).
111    Change,
112    /// Reserved legacy numeric text-change trigger.
113    ///
114    /// Fission 0.11 `TextInput` never emits this trigger and shells must not
115    /// interpret it. It remains in place to preserve serialized IR enum
116    /// discriminants for the variants that follow it.
117    #[deprecated(
118        since = "0.11.0",
119        note = "TextInput uses TextChanged and carries live edits in ActionInput"
120    )]
121    NumberChange,
122    /// Text editing was explicitly completed by the current input method.
123    EditingComplete,
124    /// The user submitted a text field.
125    Submit,
126    /// The caret or selection anchor position changed in a text field.
127    CursorChange,
128    /// A dragged payload was dropped onto this node.
129    Drop,
130    /// A drag entered this node's hit area (for drop targets).
131    DragEnter,
132    /// A drag left this node's hit area (for drop targets).
133    DragLeave,
134    /// Right-click or secondary mouse button.
135    SecondaryClick,
136    /// A text field changed.
137    ///
138    /// The bound action payload remains unchanged. The edited value, widget
139    /// identity, caret, and anchor are delivered as runtime action input.
140    TextChanged,
141    /// An interactive viewport began a pan or zoom gesture.
142    ViewportInteractionStart,
143    /// An interactive viewport's camera changed during a gesture.
144    ViewportInteractionUpdate,
145    /// An interactive viewport gesture ended; configured inertia may continue.
146    ViewportInteractionEnd,
147}
148
149#[cfg(test)]
150mod tests {
151    use super::ActionTrigger;
152
153    #[test]
154    fn text_changed_round_trips_through_ir_serialization() {
155        let encoded = serde_json::to_string(&ActionTrigger::TextChanged).unwrap();
156        assert_eq!(encoded, "\"TextChanged\"");
157        let decoded: ActionTrigger = serde_json::from_str(&encoded).unwrap();
158        assert_eq!(decoded, ActionTrigger::TextChanged);
159    }
160
161    #[test]
162    #[allow(deprecated)]
163    fn existing_action_trigger_discriminants_remain_stable() {
164        assert_eq!(ActionTrigger::Change as u8, 10);
165        assert_eq!(ActionTrigger::NumberChange as u8, 11);
166        assert_eq!(ActionTrigger::EditingComplete as u8, 12);
167        assert_eq!(ActionTrigger::SecondaryClick as u8, 18);
168        assert_eq!(ActionTrigger::TextChanged as u8, 19);
169        assert_eq!(ActionTrigger::ViewportInteractionStart as u8, 20);
170        assert_eq!(ActionTrigger::ViewportInteractionUpdate as u8, 21);
171        assert_eq!(ActionTrigger::ViewportInteractionEnd as u8, 22);
172    }
173}
174
175impl Default for ActionTrigger {
176    fn default() -> Self {
177        ActionTrigger::Default
178    }
179}
180
181/// Semantic cursor requests that shells map onto platform cursor icons.
182#[repr(u8)]
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
184pub enum MouseCursor {
185    #[default]
186    Default = 0,
187    Pointer = 1,
188    Text = 2,
189    Crosshair = 3,
190    Move = 4,
191    NotAllowed = 5,
192    Grab = 6,
193    Grabbing = 7,
194    Wait = 8,
195    Help = 9,
196    VerticalText = 10,
197}
198
199impl MouseCursor {
200    pub fn from_repr(value: u128) -> Option<Self> {
201        match value {
202            0 => Some(Self::Default),
203            1 => Some(Self::Pointer),
204            2 => Some(Self::Text),
205            3 => Some(Self::Crosshair),
206            4 => Some(Self::Move),
207            5 => Some(Self::NotAllowed),
208            6 => Some(Self::Grab),
209            7 => Some(Self::Grabbing),
210            8 => Some(Self::Wait),
211            9 => Some(Self::Help),
212            10 => Some(Self::VerticalText),
213            _ => None,
214        }
215    }
216}
217
218/// Preferred software keyboard / input modality for a text field.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
220pub enum TextInputType {
221    #[default]
222    Text,
223    Multiline,
224    Number,
225    EmailAddress,
226    Url,
227    Phone,
228    Name,
229}
230
231/// Preferred action for the return/submit key on software keyboards.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
233pub enum TextInputAction {
234    #[default]
235    Done,
236    Go,
237    Search,
238    Send,
239    Next,
240    Previous,
241    Continue,
242    Join,
243    Route,
244    EmergencyCall,
245    Newline,
246}
247
248/// Automatic capitalization strategy for inserted text.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
250pub enum TextCapitalization {
251    #[default]
252    None,
253    Characters,
254    Words,
255    Sentences,
256}
257
258/// Whether the framework should enforce `max_length` during editing.
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
260pub enum MaxLengthEnforcement {
261    None,
262    #[default]
263    Enforced,
264}
265
266/// Structured formatter primitives applied to inserted text.
267#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
268pub enum InputFormatter {
269    DigitsOnly,
270    AsciiOnly,
271    InternalLowercase,
272    Uppercase,
273    TrimWhitespace,
274    SingleLine,
275}
276
277/// A single action binding: a trigger, an action ID, and optional payload.
278///
279/// When the event system detects the input described by `trigger`, it dispatches
280/// the action identified by `action_id`. If the action carries data (e.g., drag
281/// coordinates), `payload_data` holds the serialized payload.
282///
283/// # Example
284///
285/// ```rust
286/// use fission_ir::semantics::{ActionEntry, ActionTrigger};
287///
288/// let entry = ActionEntry {
289///     trigger: ActionTrigger::Default,
290///     action_id: 42,
291///     payload_data: None,
292/// };
293/// ```
294#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
295pub struct ActionEntry {
296    /// Which input gesture triggers this action.
297    pub trigger: ActionTrigger,
298    /// The raw 128-bit action ID dispatched to the widget's action handler.
299    pub action_id: u128,
300    /// Optional serialized payload. `None` for actions with no data.
301    pub payload_data: Option<Vec<u8>>,
302}
303
304/// Canvas-specific semantic target used by the shared gesture controller.
305///
306/// This keeps stable document identity and geometry in backend-neutral IR while
307/// action payloads remain entirely application-defined.
308#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
309pub struct CanvasTarget {
310    pub canvas_id: u128,
311    pub kind: CanvasTargetKind,
312    pub selection_policy: CanvasSelectionPolicy,
313    pub snap_spacing: Option<f32>,
314    pub snap_threshold: f32,
315}
316
317impl std::hash::Hash for CanvasTarget {
318    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
319        self.canvas_id.hash(state);
320        self.kind.hash(state);
321        self.selection_policy.hash(state);
322        self.snap_spacing.map(f32::to_bits).hash(state);
323        self.snap_threshold.to_bits().hash(state);
324    }
325}
326
327/// Declarative selection behavior requested by an infinite canvas.
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
329pub enum CanvasSelectionPolicy {
330    None,
331    Single,
332    Toggle,
333    Marquee,
334}
335
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
337pub enum CanvasTargetKind {
338    Node {
339        node_id: u128,
340        bounds: [f32; 4],
341    },
342    ResizeHandle {
343        node_id: u128,
344        handle: u8,
345        bounds: [f32; 4],
346    },
347    Edge {
348        edge_id: u128,
349        points: Vec<[f32; 2]>,
350        cubic: bool,
351        hit_tolerance: f32,
352    },
353    Marquee,
354}
355
356impl std::hash::Hash for CanvasTargetKind {
357    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
358        std::mem::discriminant(self).hash(state);
359        match self {
360            Self::Node { node_id, bounds } => {
361                node_id.hash(state);
362                bounds.iter().for_each(|value| value.to_bits().hash(state));
363            }
364            Self::ResizeHandle {
365                node_id,
366                handle,
367                bounds,
368            } => {
369                node_id.hash(state);
370                handle.hash(state);
371                bounds.iter().for_each(|value| value.to_bits().hash(state));
372            }
373            Self::Edge {
374                edge_id,
375                points,
376                cubic,
377                hit_tolerance,
378            } => {
379                edge_id.hash(state);
380                for point in points {
381                    point[0].to_bits().hash(state);
382                    point[1].to_bits().hash(state);
383                }
384                cubic.hash(state);
385                hit_tolerance.to_bits().hash(state);
386            }
387            Self::Marquee => {}
388        }
389    }
390}
391
392impl ActionEntry {
393    /// Creates a non-dispatched cursor request consumed by hover handling.
394    pub fn hover_cursor(cursor: MouseCursor) -> Self {
395        Self {
396            trigger: ActionTrigger::HoverCursor,
397            action_id: cursor as u128,
398            payload_data: None,
399        }
400    }
401
402    /// Returns the semantic cursor encoded by this entry, if any.
403    pub fn as_hover_cursor(&self) -> Option<MouseCursor> {
404        (self.trigger == ActionTrigger::HoverCursor)
405            .then(|| MouseCursor::from_repr(self.action_id))
406            .flatten()
407    }
408}
409
410/// Accessibility and interaction metadata for a node.
411///
412/// `Semantics` is the IR's way of describing *what a node means* rather than how it
413/// looks or where it is positioned. It is consumed by:
414///
415/// * Assistive technology (screen readers, switch control) via the accessibility tree.
416/// * The event/focus system, which uses `focusable`, `actions`, and `disabled` to
417///   route input.
418/// * The drag-and-drop subsystem, which reads `draggable` and `drag_payload`.
419///
420/// Most fields default to "inert" values (see [`Default`] impl), so you only need to
421/// set the fields that matter for a given widget.
422///
423/// # Example
424///
425/// ```rust
426/// use fission_ir::Semantics;
427/// use fission_ir::semantics::Role;
428///
429/// let sem = Semantics {
430///     role: Role::Button,
431///     label: Some("Submit".into()),
432///     focusable: true,
433///     ..Semantics::default()
434/// };
435/// ```
436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
437pub struct Semantics {
438    /// The accessibility role. Defaults to [`Role::Generic`].
439    pub role: Role,
440    /// A human-readable label for assistive technology (e.g., "Close" for a button).
441    pub label: Option<String>,
442    /// Stable semantic identifier for tooling and automation.
443    pub identifier: Option<String>,
444    /// The current value as a string (e.g., the text in an input field).
445    pub value: Option<String>,
446    /// The set of actions this node responds to.
447    pub actions: ActionSet,
448    /// Structured InfiniteCanvas target metadata for contextual gesture input.
449    #[serde(default)]
450    pub canvas_target: Option<CanvasTarget>,
451    /// Optional raw action dispatch scope inherited by descendant actions.
452    #[serde(default)]
453    pub action_scope_id: Option<u128>,
454    /// Whether this node can receive keyboard focus.
455    pub focusable: bool,
456    /// How pointer-down should affect focus for this node.
457    #[serde(default)]
458    pub focus_policy: FocusPolicy,
459    /// Whether this text input supports multiple lines.
460    pub multiline: bool,
461    /// Whether the value should be obscured (password fields).
462    pub masked: bool,
463    /// An optional input mask that restricts which characters are accepted.
464    pub input_mask: Option<InputMask>,
465    /// The byte range of IME pre-edit (composition) text, if any.
466    pub ime_preedit_range: Option<(usize, usize)>,
467    /// The active byte range within [`Semantics::ime_preedit_range`], if the
468    /// platform IME exposes a pre-edit cursor or marked sub-range.
469    #[serde(default)]
470    pub ime_preedit_cursor_range: Option<(usize, usize)>,
471    /// Editable or selectable text selection as byte offsets `(anchor, focus)`.
472    #[serde(default)]
473    pub text_selection: Option<(usize, usize)>,
474    /// Whether this read-only text node supports pointer/keyboard selection.
475    #[serde(default)]
476    pub selectable_text: bool,
477    /// Whether this node can open a framework-managed context menu.
478    #[serde(default)]
479    pub context_menu: bool,
480    /// For checkboxes, radios, and switches: `Some(true)` = checked or selected,
481    /// `Some(false)` = unchecked or unselected, and `None` = no checked state.
482    pub checked: Option<bool>,
483    /// Whether the node is disabled (grayed out, non-interactive).
484    pub disabled: bool,
485    /// Whether the node can be focused and selected but not edited.
486    pub read_only: bool,
487    /// Whether this node should receive focus automatically when mounted.
488    pub autofocus: bool,
489    /// Whether this node can be dragged.
490    pub draggable: bool,
491    /// Whether the node scrolls horizontally.
492    pub scrollable_x: bool,
493    /// Whether the node scrolls vertically.
494    pub scrollable_y: bool,
495    /// Minimum value for range inputs (sliders).
496    pub min_value: Option<f32>,
497    /// Maximum value for range inputs (sliders).
498    pub max_value: Option<f32>,
499    /// Current numeric value for range inputs (sliders).
500    pub current_value: Option<f32>,
501    /// When `true`, this node creates a new focus scope (like a dialog or panel).
502    pub is_focus_scope: bool,
503    /// When `true`, Tab traversal does not leave this subtree.
504    pub is_focus_barrier: bool,
505    /// Serialized payload attached to a drag operation.
506    pub drag_payload: Option<Vec<u8>>,
507    /// An identifier for hero/shared-element transitions.
508    pub hero_tag: Option<String>,
509    /// Explicit tab order index. InternalLower values receive focus first. `None` means
510    /// the node follows document order.
511    pub focus_index: Option<i32>,
512    /// Preferred keyboard/input modality for text entry.
513    pub text_input_type: TextInputType,
514    /// Preferred submit/return key action.
515    pub text_input_action: TextInputAction,
516    /// Automatic capitalization strategy for inserted text.
517    pub text_capitalization: TextCapitalization,
518    /// Maximum number of Unicode scalar values allowed in the field.
519    pub max_length: Option<usize>,
520    /// Whether `max_length` should be enforced during editing.
521    pub max_length_enforcement: MaxLengthEnforcement,
522    /// Structured input formatters applied to inserted text.
523    pub input_formatters: Vec<InputFormatter>,
524    /// Hint to the platform IME whether autocorrect should be enabled.
525    pub autocorrect: bool,
526    /// Hint to the platform IME whether suggestions should be enabled.
527    pub enable_suggestions: bool,
528    /// Hint to the platform IME whether spell checking should be enabled.
529    pub spell_check: bool,
530    /// Hint to the platform IME whether smart dashes should be enabled.
531    pub smart_dashes: bool,
532    /// Hint to the platform IME whether smart quotes should be enabled.
533    pub smart_quotes: bool,
534    /// Platform autofill categories associated with this field.
535    pub autofill_hints: Vec<String>,
536    /// Extra padding to keep around the caret/selection when auto-scrolling `[left, right, top, bottom]`.
537    pub scroll_padding: Option<[f32; 4]>,
538    /// When true, Tab key inserts spaces instead of moving focus.
539    pub capture_tab: bool,
540    /// When true, Enter copies leading whitespace from the current line.
541    pub auto_indent: bool,
542}
543
544impl std::hash::Hash for Semantics {
545    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
546        self.role.hash(state);
547        self.label.hash(state);
548        self.identifier.hash(state);
549        self.value.hash(state);
550        self.actions.hash(state);
551        self.canvas_target.hash(state);
552        self.action_scope_id.hash(state);
553        self.focusable.hash(state);
554        self.focus_policy.hash(state);
555        self.multiline.hash(state);
556        self.masked.hash(state);
557        self.input_mask.hash(state);
558        self.ime_preedit_range.hash(state);
559        self.ime_preedit_cursor_range.hash(state);
560        self.text_selection.hash(state);
561        self.selectable_text.hash(state);
562        self.context_menu.hash(state);
563        self.checked.hash(state);
564        self.disabled.hash(state);
565        self.read_only.hash(state);
566        self.autofocus.hash(state);
567        self.draggable.hash(state);
568        self.scrollable_x.hash(state);
569        self.scrollable_y.hash(state);
570        self.min_value.map(|f| f.to_bits()).hash(state);
571        self.max_value.map(|f| f.to_bits()).hash(state);
572        self.current_value.map(|f| f.to_bits()).hash(state);
573        self.is_focus_scope.hash(state);
574        self.is_focus_barrier.hash(state);
575        self.drag_payload.hash(state);
576        self.hero_tag.hash(state);
577        self.focus_index.hash(state);
578        self.text_input_type.hash(state);
579        self.text_input_action.hash(state);
580        self.text_capitalization.hash(state);
581        self.max_length.hash(state);
582        self.max_length_enforcement.hash(state);
583        self.input_formatters.hash(state);
584        self.autocorrect.hash(state);
585        self.enable_suggestions.hash(state);
586        self.spell_check.hash(state);
587        self.smart_dashes.hash(state);
588        self.smart_quotes.hash(state);
589        self.autofill_hints.hash(state);
590        self.scroll_padding
591            .map(|padding| padding.map(f32::to_bits))
592            .hash(state);
593        self.capture_tab.hash(state);
594        self.auto_indent.hash(state);
595    }
596}
597
598impl Default for Semantics {
599    fn default() -> Self {
600        Self {
601            role: Role::Generic,
602            label: None,
603            identifier: None,
604            value: None,
605            actions: ActionSet::default(),
606            canvas_target: None,
607            action_scope_id: None,
608            focusable: false,
609            focus_policy: FocusPolicy::FocusOnPointer,
610            multiline: false,
611            masked: false,
612            input_mask: None,
613            ime_preedit_range: None,
614            ime_preedit_cursor_range: None,
615            text_selection: None,
616            selectable_text: false,
617            context_menu: false,
618            checked: None,
619            disabled: false,
620            read_only: false,
621            autofocus: false,
622            draggable: false,
623            scrollable_x: false,
624            scrollable_y: false,
625            min_value: None,
626            max_value: None,
627            current_value: None,
628            is_focus_scope: false,
629            is_focus_barrier: false,
630            drag_payload: None,
631            hero_tag: None,
632            focus_index: None,
633            text_input_type: TextInputType::Text,
634            text_input_action: TextInputAction::Done,
635            text_capitalization: TextCapitalization::None,
636            max_length: None,
637            max_length_enforcement: MaxLengthEnforcement::Enforced,
638            input_formatters: Vec::new(),
639            autocorrect: true,
640            enable_suggestions: true,
641            spell_check: true,
642            smart_dashes: true,
643            smart_quotes: true,
644            autofill_hints: Vec::new(),
645            scroll_padding: None,
646            capture_tab: false,
647            auto_indent: false,
648        }
649    }
650}
651
652/// A collection of [`ActionEntry`]s attached to a semantics node.
653///
654/// `ActionSet` is a simple wrapper around a `Vec<ActionEntry>`. It exists as a
655/// named type so that serialization and hashing are straightforward.
656#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
657pub struct ActionSet {
658    /// The action entries. Order does not matter for dispatch; the event system
659    /// matches on [`ActionTrigger`].
660    pub entries: Vec<ActionEntry>,
661}
662
663/// Restricts which characters a text input accepts.
664///
665/// Apply an `InputMask` to a [`Semantics`] node to filter keystrokes before they
666/// reach the text editing logic.
667#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
668pub enum InputMask {
669    /// Accept only ASCII digits (`0`-`9`).
670    Numeric,
671    /// Accept only ASCII letters and digits (`a`-`z`, `A`-`Z`, `0`-`9`).
672    Alphanumeric,
673}
674
675impl InputMask {
676    /// Returns `true` if `ch` is accepted by this mask.
677    ///
678    /// # Example
679    ///
680    /// ```rust
681    /// use fission_ir::semantics::InputMask;
682    /// assert!(InputMask::Numeric.is_valid_char('5'));
683    /// assert!(!InputMask::Numeric.is_valid_char('a'));
684    /// ```
685    pub fn is_valid_char(&self, ch: char) -> bool {
686        match self {
687            InputMask::Numeric => ch.is_ascii_digit(),
688            InputMask::Alphanumeric => ch.is_ascii_alphanumeric(),
689        }
690    }
691}