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/// Where a hyperlink should open its destination.
52///
53/// Shells map the standard variants to the host's native navigation model. HTML
54/// renderers use the corresponding `_self`, `_blank`, `_parent`, and `_top`
55/// targets, while [`Named`](Self::Named) preserves an application-provided
56/// browsing-context name.
57#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
58pub enum LinkTarget {
59    /// Reuse the active browsing context or native application window.
60    #[default]
61    Current,
62    /// Open a new browser tab/window or the closest native equivalent.
63    NewWindow,
64    /// Navigate the parent browsing context.
65    Parent,
66    /// Navigate the top-level browsing context.
67    Top,
68    /// Navigate a named browsing context.
69    Named(String),
70}
71
72impl LinkTarget {
73    /// Returns the HTML `target` value represented by this target.
74    pub fn as_html_target(&self) -> &str {
75        match self {
76            Self::Current => "_self",
77            Self::NewWindow => "_blank",
78            Self::Parent => "_parent",
79            Self::Top => "_top",
80            Self::Named(name) => name,
81        }
82    }
83}
84
85/// Declarative hyperlink metadata understood by every shell.
86///
87/// This lives on semantic nodes rather than on one concrete `Link` widget so
88/// application and third-party widgets can expose genuine navigation without
89/// inheriting Fission's visual link treatment.
90#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
91pub struct Hyperlink {
92    /// Destination URL or logical application route.
93    pub href: String,
94    /// Browsing context in which the destination opens.
95    #[serde(default)]
96    pub target: LinkTarget,
97    /// Optional HTML relationship tokens.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub rel: Option<String>,
100    /// Optional download filename; presence requests download behavior.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub download: Option<String>,
103}
104
105impl Hyperlink {
106    pub fn new(href: impl Into<String>) -> Self {
107        Self {
108            href: href.into(),
109            target: LinkTarget::Current,
110            rel: None,
111            download: None,
112        }
113    }
114
115    pub fn target(mut self, target: LinkTarget) -> Self {
116        self.target = target;
117        self
118    }
119
120    pub fn rel(mut self, rel: impl Into<String>) -> Self {
121        self.rel = Some(rel.into());
122        self
123    }
124
125    pub fn download(mut self, filename: impl Into<String>) -> Self {
126        self.download = Some(filename.into());
127        self
128    }
129}
130
131/// Action requested from an HTML popover invocation target.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
133pub enum PopoverAction {
134    /// Toggle the target popover's visibility.
135    #[default]
136    Toggle,
137    /// Show the target popover.
138    Show,
139    /// Hide the target popover.
140    Hide,
141}
142
143impl PopoverAction {
144    pub fn as_html_action(self) -> &'static str {
145        match self {
146            Self::Toggle => "toggle",
147            Self::Show => "show",
148            Self::Hide => "hide",
149        }
150    }
151}
152
153/// Declarative relationship between an invoker and an HTML popover.
154#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
155pub struct PopoverTarget {
156    /// DOM id of the popover controlled by this invoker.
157    pub id: String,
158    /// Visibility operation requested on activation.
159    #[serde(default)]
160    pub action: PopoverAction,
161}
162
163/// How a focusable node responds to pointer focus.
164///
165/// `FocusPolicy` only changes pointer-driven focus assignment. Keyboard focus,
166/// accessibility focus, and semantic activation still work for focusable nodes.
167///
168/// # Example
169///
170/// A toolbar button can run its action without taking focus from an editor:
171///
172/// ```rust
173/// use fission_ir::semantics::{FocusPolicy, Role};
174/// use fission_ir::Semantics;
175///
176/// let semantics = Semantics {
177///     role: Role::Button,
178///     focusable: true,
179///     focus_policy: FocusPolicy::PreserveCurrentOnPointer,
180///     ..Semantics::default()
181/// };
182/// ```
183#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
184pub enum FocusPolicy {
185    /// Pointer-down focuses this node when it is focusable. This is the normal
186    /// behavior for buttons, text inputs, and other controls.
187    #[default]
188    FocusOnPointer,
189    /// Pointer-down keeps the currently focused node focused while still letting
190    /// this node receive pointer state and activation actions.
191    PreserveCurrentOnPointer,
192}
193
194/// What user interaction triggers an action.
195///
196/// Each [`ActionEntry`] pairs an `ActionTrigger` with an action ID so the event
197/// system knows which callback to invoke for a given input gesture.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
199pub enum ActionTrigger {
200    /// Primary activation: tap, click, or Enter key.
201    Default,
202    /// The user began dragging this node.
203    DragStart,
204    /// The drag position changed (fires continuously).
205    DragUpdate,
206    /// The user released the drag.
207    DragEnd,
208    /// The pointer entered the node's hit area.
209    HoverEnter,
210    /// The pointer left the node's hit area.
211    HoverExit,
212    /// A semantic cursor request applied while the pointer hovers this node.
213    ///
214    /// This is metadata, not a dispatched reducer action.
215    HoverCursor,
216    /// The node received keyboard focus.
217    Focus,
218    /// The node lost keyboard focus.
219    Blur,
220    /// A pointer-down happened outside the active text field.
221    TapOutside,
222    /// The node's value changed (for example, a slider moved).
223    Change,
224    /// Reserved legacy numeric text-change trigger.
225    ///
226    /// Fission 0.11 `TextInput` never emits this trigger and shells must not
227    /// interpret it. It remains in place to preserve serialized IR enum
228    /// discriminants for the variants that follow it.
229    #[deprecated(
230        since = "0.11.0",
231        note = "TextInput uses TextChanged and carries live edits in ActionInput"
232    )]
233    NumberChange,
234    /// Text editing was explicitly completed by the current input method.
235    EditingComplete,
236    /// The user submitted a text field.
237    Submit,
238    /// The caret or selection anchor position changed in a text field.
239    CursorChange,
240    /// A dragged payload was dropped onto this node.
241    Drop,
242    /// A drag entered this node's hit area (for drop targets).
243    DragEnter,
244    /// A drag left this node's hit area (for drop targets).
245    DragLeave,
246    /// Right-click or secondary mouse button.
247    SecondaryClick,
248    /// A text field changed.
249    ///
250    /// The bound action payload remains unchanged. The edited value, widget
251    /// identity, caret, and anchor are delivered as runtime action input.
252    TextChanged,
253    /// An interactive viewport began a pan or zoom gesture.
254    ViewportInteractionStart,
255    /// An interactive viewport's camera changed during a gesture.
256    ViewportInteractionUpdate,
257    /// An interactive viewport gesture ended; configured inertia may continue.
258    ViewportInteractionEnd,
259    /// A text field's validation state was requested or changed.
260    Validation,
261}
262
263#[cfg(test)]
264mod tests {
265    use super::ActionTrigger;
266
267    #[test]
268    fn text_changed_round_trips_through_ir_serialization() {
269        let encoded = serde_json::to_string(&ActionTrigger::TextChanged).unwrap();
270        assert_eq!(encoded, "\"TextChanged\"");
271        let decoded: ActionTrigger = serde_json::from_str(&encoded).unwrap();
272        assert_eq!(decoded, ActionTrigger::TextChanged);
273    }
274
275    #[test]
276    #[allow(deprecated)]
277    fn existing_action_trigger_discriminants_remain_stable() {
278        assert_eq!(ActionTrigger::Change as u8, 10);
279        assert_eq!(ActionTrigger::NumberChange as u8, 11);
280        assert_eq!(ActionTrigger::EditingComplete as u8, 12);
281        assert_eq!(ActionTrigger::SecondaryClick as u8, 18);
282        assert_eq!(ActionTrigger::TextChanged as u8, 19);
283        assert_eq!(ActionTrigger::ViewportInteractionStart as u8, 20);
284        assert_eq!(ActionTrigger::ViewportInteractionUpdate as u8, 21);
285        assert_eq!(ActionTrigger::ViewportInteractionEnd as u8, 22);
286    }
287}
288
289impl Default for ActionTrigger {
290    fn default() -> Self {
291        ActionTrigger::Default
292    }
293}
294
295/// Semantic cursor requests that shells map onto platform cursor icons.
296#[repr(u8)]
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
298pub enum MouseCursor {
299    #[default]
300    Default = 0,
301    Pointer = 1,
302    Text = 2,
303    Crosshair = 3,
304    Move = 4,
305    NotAllowed = 5,
306    Grab = 6,
307    Grabbing = 7,
308    Wait = 8,
309    Help = 9,
310    VerticalText = 10,
311}
312
313impl MouseCursor {
314    pub fn from_repr(value: u128) -> Option<Self> {
315        match value {
316            0 => Some(Self::Default),
317            1 => Some(Self::Pointer),
318            2 => Some(Self::Text),
319            3 => Some(Self::Crosshair),
320            4 => Some(Self::Move),
321            5 => Some(Self::NotAllowed),
322            6 => Some(Self::Grab),
323            7 => Some(Self::Grabbing),
324            8 => Some(Self::Wait),
325            9 => Some(Self::Help),
326            10 => Some(Self::VerticalText),
327            _ => None,
328        }
329    }
330}
331
332/// Preferred software keyboard / input modality for a text field.
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
334pub enum TextInputType {
335    #[default]
336    Text,
337    Multiline,
338    Number,
339    EmailAddress,
340    Url,
341    Phone,
342    Name,
343}
344
345/// Editable multiline wrapping and submission behavior.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
347pub enum TextWrapMode {
348    /// Wrap visually without inserting line breaks into submitted text.
349    #[default]
350    Soft,
351    /// Wrap visually and allow HTML textarea targets to submit hard line breaks.
352    Hard,
353    /// Do not wrap; overflow scrolls horizontally.
354    NoWrap,
355}
356
357/// Preferred action for the return/submit key on software keyboards.
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
359pub enum TextInputAction {
360    #[default]
361    Done,
362    Go,
363    Search,
364    Send,
365    Next,
366    Previous,
367    Continue,
368    Join,
369    Route,
370    EmergencyCall,
371    Newline,
372}
373
374/// Automatic capitalization strategy for inserted text.
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
376pub enum TextCapitalization {
377    #[default]
378    None,
379    Characters,
380    Words,
381    Sentences,
382}
383
384/// Whether the framework should enforce `max_length` during editing.
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
386pub enum MaxLengthEnforcement {
387    None,
388    Enforced,
389    /// Allow the active composing value to exceed the limit and enforce it
390    /// once the input method commits.
391    #[default]
392    AfterComposition,
393}
394
395/// Structured formatter primitives applied to inserted text.
396#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
397pub enum InputFormatter {
398    DigitsOnly,
399    AsciiOnly,
400    InternalLowercase,
401    Uppercase,
402    TrimWhitespace,
403    SingleLine,
404}
405
406/// Marks a semantic subtree as one coordinated read-only text selection region.
407#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
408pub struct SelectionRegionSemantics {
409    /// Excluded regions prevent an ancestor region from selecting this subtree.
410    pub excluded: bool,
411    /// Text inserted between selectable descendants when copying or exposing
412    /// the region as one accessibility value.
413    pub separator: String,
414}
415
416/// Declarative validity of an editable field.
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
418pub enum TextFieldValidationState {
419    #[default]
420    Unvalidated,
421    Valid,
422    Invalid,
423}
424
425/// A single action binding: a trigger, an action ID, and optional payload.
426///
427/// When the event system detects the input described by `trigger`, it dispatches
428/// the action identified by `action_id`. If the action carries data (e.g., drag
429/// coordinates), `payload_data` holds the serialized payload.
430///
431/// # Example
432///
433/// ```rust
434/// use fission_ir::semantics::{ActionEntry, ActionTrigger};
435///
436/// let entry = ActionEntry {
437///     trigger: ActionTrigger::Default,
438///     action_id: 42,
439///     payload_data: None,
440/// };
441/// ```
442#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
443pub struct ActionEntry {
444    /// Which input gesture triggers this action.
445    pub trigger: ActionTrigger,
446    /// The raw 128-bit action ID dispatched to the widget's action handler.
447    pub action_id: u128,
448    /// Optional serialized payload. `None` for actions with no data.
449    pub payload_data: Option<Vec<u8>>,
450}
451
452/// Canvas-specific semantic target used by the shared gesture controller.
453///
454/// This keeps stable document identity and geometry in backend-neutral IR while
455/// action payloads remain entirely application-defined.
456#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
457pub struct CanvasTarget {
458    pub canvas_id: u128,
459    pub kind: CanvasTargetKind,
460    pub selection_policy: CanvasSelectionPolicy,
461    pub snap_spacing: Option<f32>,
462    pub snap_threshold: f32,
463}
464
465impl std::hash::Hash for CanvasTarget {
466    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
467        self.canvas_id.hash(state);
468        self.kind.hash(state);
469        self.selection_policy.hash(state);
470        self.snap_spacing.map(f32::to_bits).hash(state);
471        self.snap_threshold.to_bits().hash(state);
472    }
473}
474
475/// Declarative selection behavior requested by an infinite canvas.
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
477pub enum CanvasSelectionPolicy {
478    None,
479    Single,
480    Toggle,
481    Marquee,
482}
483
484#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
485pub enum CanvasTargetKind {
486    Node {
487        node_id: u128,
488        bounds: [f32; 4],
489    },
490    ResizeHandle {
491        node_id: u128,
492        handle: u8,
493        bounds: [f32; 4],
494    },
495    Edge {
496        edge_id: u128,
497        points: Vec<[f32; 2]>,
498        cubic: bool,
499        hit_tolerance: f32,
500    },
501    Marquee,
502}
503
504impl std::hash::Hash for CanvasTargetKind {
505    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
506        std::mem::discriminant(self).hash(state);
507        match self {
508            Self::Node { node_id, bounds } => {
509                node_id.hash(state);
510                bounds.iter().for_each(|value| value.to_bits().hash(state));
511            }
512            Self::ResizeHandle {
513                node_id,
514                handle,
515                bounds,
516            } => {
517                node_id.hash(state);
518                handle.hash(state);
519                bounds.iter().for_each(|value| value.to_bits().hash(state));
520            }
521            Self::Edge {
522                edge_id,
523                points,
524                cubic,
525                hit_tolerance,
526            } => {
527                edge_id.hash(state);
528                for point in points {
529                    point[0].to_bits().hash(state);
530                    point[1].to_bits().hash(state);
531                }
532                cubic.hash(state);
533                hit_tolerance.to_bits().hash(state);
534            }
535            Self::Marquee => {}
536        }
537    }
538}
539
540impl ActionEntry {
541    /// Creates a non-dispatched cursor request consumed by hover handling.
542    pub fn hover_cursor(cursor: MouseCursor) -> Self {
543        Self {
544            trigger: ActionTrigger::HoverCursor,
545            action_id: cursor as u128,
546            payload_data: None,
547        }
548    }
549
550    /// Returns the semantic cursor encoded by this entry, if any.
551    pub fn as_hover_cursor(&self) -> Option<MouseCursor> {
552        (self.trigger == ActionTrigger::HoverCursor)
553            .then(|| MouseCursor::from_repr(self.action_id))
554            .flatten()
555    }
556}
557
558/// Accessibility and interaction metadata for a node.
559///
560/// `Semantics` is the IR's way of describing *what a node means* rather than how it
561/// looks or where it is positioned. It is consumed by:
562///
563/// * Assistive technology (screen readers, switch control) via the accessibility tree.
564/// * The event/focus system, which uses `focusable`, `actions`, and `disabled` to
565///   route input.
566/// * The drag-and-drop subsystem, which reads `draggable` and `drag_payload`.
567///
568/// Most fields default to "inert" values (see [`Default`] impl), so you only need to
569/// set the fields that matter for a given widget.
570///
571/// # Example
572///
573/// ```rust
574/// use fission_ir::Semantics;
575/// use fission_ir::semantics::Role;
576///
577/// let sem = Semantics {
578///     role: Role::Button,
579///     label: Some("Submit".into()),
580///     focusable: true,
581///     ..Semantics::default()
582/// };
583/// ```
584#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
585pub struct Semantics {
586    /// The accessibility role. Defaults to [`Role::Generic`].
587    pub role: Role,
588    /// A human-readable label for assistive technology (e.g., "Close" for a button).
589    pub label: Option<String>,
590    /// Stable semantic identifier for tooling and automation.
591    pub identifier: Option<String>,
592    /// The current value as a string (e.g., the text in an input field).
593    pub value: Option<String>,
594    /// Optional hyperlink destination for this semantic region.
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub hyperlink: Option<Hyperlink>,
597    /// Optional HTML popover invocation metadata.
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub popover_target: Option<PopoverTarget>,
600    /// The set of actions this node responds to.
601    pub actions: ActionSet,
602    /// Structured InfiniteCanvas target metadata for contextual gesture input.
603    #[serde(default)]
604    pub canvas_target: Option<CanvasTarget>,
605    /// Optional raw action dispatch scope inherited by descendant actions.
606    #[serde(default)]
607    pub action_scope_id: Option<u128>,
608    /// Whether this node can receive keyboard focus.
609    pub focusable: bool,
610    /// How pointer-down should affect focus for this node.
611    #[serde(default)]
612    pub focus_policy: FocusPolicy,
613    /// Whether this text input supports multiple lines.
614    pub multiline: bool,
615    /// Editable multiline wrapping and submission behavior.
616    #[serde(default)]
617    pub text_wrap_mode: TextWrapMode,
618    /// Whether the value should be obscured (password fields).
619    pub masked: bool,
620    /// An optional input mask that restricts which characters are accepted.
621    pub input_mask: Option<InputMask>,
622    /// The byte range of IME pre-edit (composition) text, if any.
623    pub ime_preedit_range: Option<(usize, usize)>,
624    /// The active byte range within [`Semantics::ime_preedit_range`], if the
625    /// platform IME exposes a pre-edit cursor or marked sub-range.
626    #[serde(default)]
627    pub ime_preedit_cursor_range: Option<(usize, usize)>,
628    /// Editable or selectable text selection as byte offsets `(anchor, focus)`.
629    #[serde(default)]
630    pub text_selection: Option<(usize, usize)>,
631    /// Whether this read-only text node supports pointer/keyboard selection.
632    #[serde(default)]
633    pub selectable_text: bool,
634    /// Coordinated selection metadata for this semantic subtree.
635    #[serde(default, skip_serializing_if = "Option::is_none")]
636    pub selection_region: Option<SelectionRegionSemantics>,
637    /// Whether this node can open a framework-managed context menu.
638    #[serde(default)]
639    pub context_menu: bool,
640    /// For checkboxes, radios, and switches: `Some(true)` = checked or selected,
641    /// `Some(false)` = unchecked or unselected, and `None` = no checked state.
642    pub checked: Option<bool>,
643    /// Whether the node is disabled (grayed out, non-interactive).
644    pub disabled: bool,
645    /// Whether the node can be focused and selected but not edited.
646    pub read_only: bool,
647    /// Whether this node should receive focus automatically when mounted.
648    pub autofocus: bool,
649    /// Whether this node can be dragged.
650    pub draggable: bool,
651    /// Whether the node scrolls horizontally.
652    pub scrollable_x: bool,
653    /// Whether the node scrolls vertically.
654    pub scrollable_y: bool,
655    /// Minimum value for range inputs (sliders).
656    pub min_value: Option<f32>,
657    /// Maximum value for range inputs (sliders).
658    pub max_value: Option<f32>,
659    /// Current numeric value for range inputs (sliders).
660    pub current_value: Option<f32>,
661    /// When `true`, this node creates a new focus scope (like a dialog or panel).
662    pub is_focus_scope: bool,
663    /// When `true`, Tab traversal does not leave this subtree.
664    pub is_focus_barrier: bool,
665    /// Serialized payload attached to a drag operation.
666    pub drag_payload: Option<Vec<u8>>,
667    /// An identifier for hero/shared-element transitions.
668    pub hero_tag: Option<String>,
669    /// Explicit tab order index. InternalLower values receive focus first. `None` means
670    /// the node follows document order.
671    pub focus_index: Option<i32>,
672    /// Preferred keyboard/input modality for text entry.
673    pub text_input_type: TextInputType,
674    /// Preferred submit/return key action.
675    pub text_input_action: TextInputAction,
676    /// Automatic capitalization strategy for inserted text.
677    pub text_capitalization: TextCapitalization,
678    /// Maximum number of user-perceived grapheme clusters allowed in the field.
679    pub max_length: Option<usize>,
680    /// Whether `max_length` should be enforced during editing.
681    pub max_length_enforcement: MaxLengthEnforcement,
682    /// Structured input formatters applied to inserted text.
683    pub input_formatters: Vec<InputFormatter>,
684    /// Name submitted by semantic form targets.
685    #[serde(default)]
686    pub text_field_name: Option<String>,
687    /// Logical form membership for coordinated validation/submission.
688    #[serde(default)]
689    pub text_form_id: Option<String>,
690    /// Autofill session/group shared by related fields.
691    #[serde(default)]
692    pub autofill_group: Option<String>,
693    /// Whether a non-empty value is required.
694    #[serde(default)]
695    pub required: bool,
696    /// Minimum number of user-perceived graphemes.
697    #[serde(default)]
698    pub min_length: Option<usize>,
699    /// Pattern constraint for targets with pattern-validation support.
700    #[serde(default)]
701    pub validation_pattern: Option<String>,
702    /// Application-authoritative field validity.
703    #[serde(default)]
704    pub validation_state: TextFieldValidationState,
705    /// Accessible validation message, independent of visual decoration.
706    #[serde(default)]
707    pub validation_message: Option<String>,
708    /// Hint to the platform IME whether autocorrect should be enabled.
709    pub autocorrect: bool,
710    /// Hint to the platform IME whether suggestions should be enabled.
711    pub enable_suggestions: bool,
712    /// Hint to the platform IME whether spell checking should be enabled.
713    pub spell_check: bool,
714    /// Hint to the platform IME whether smart dashes should be enabled.
715    pub smart_dashes: bool,
716    /// Hint to the platform IME whether smart quotes should be enabled.
717    pub smart_quotes: bool,
718    /// Platform autofill categories associated with this field.
719    pub autofill_hints: Vec<String>,
720    /// Extra padding to keep around the caret/selection when auto-scrolling `[left, right, top, bottom]`.
721    pub scroll_padding: Option<[f32; 4]>,
722    /// When true, Tab key inserts spaces instead of moving focus.
723    pub capture_tab: bool,
724    /// When true, Enter copies leading whitespace from the current line.
725    pub auto_indent: bool,
726}
727
728impl std::hash::Hash for Semantics {
729    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
730        self.role.hash(state);
731        self.label.hash(state);
732        self.identifier.hash(state);
733        self.value.hash(state);
734        self.hyperlink.hash(state);
735        self.popover_target.hash(state);
736        self.actions.hash(state);
737        self.canvas_target.hash(state);
738        self.action_scope_id.hash(state);
739        self.focusable.hash(state);
740        self.focus_policy.hash(state);
741        self.multiline.hash(state);
742        self.text_wrap_mode.hash(state);
743        self.masked.hash(state);
744        self.input_mask.hash(state);
745        self.ime_preedit_range.hash(state);
746        self.ime_preedit_cursor_range.hash(state);
747        self.text_selection.hash(state);
748        self.selectable_text.hash(state);
749        self.selection_region.hash(state);
750        self.context_menu.hash(state);
751        self.checked.hash(state);
752        self.disabled.hash(state);
753        self.read_only.hash(state);
754        self.autofocus.hash(state);
755        self.draggable.hash(state);
756        self.scrollable_x.hash(state);
757        self.scrollable_y.hash(state);
758        self.min_value.map(|f| f.to_bits()).hash(state);
759        self.max_value.map(|f| f.to_bits()).hash(state);
760        self.current_value.map(|f| f.to_bits()).hash(state);
761        self.is_focus_scope.hash(state);
762        self.is_focus_barrier.hash(state);
763        self.drag_payload.hash(state);
764        self.hero_tag.hash(state);
765        self.focus_index.hash(state);
766        self.text_input_type.hash(state);
767        self.text_input_action.hash(state);
768        self.text_capitalization.hash(state);
769        self.max_length.hash(state);
770        self.max_length_enforcement.hash(state);
771        self.input_formatters.hash(state);
772        self.text_field_name.hash(state);
773        self.text_form_id.hash(state);
774        self.autofill_group.hash(state);
775        self.required.hash(state);
776        self.min_length.hash(state);
777        self.validation_pattern.hash(state);
778        self.validation_state.hash(state);
779        self.validation_message.hash(state);
780        self.autocorrect.hash(state);
781        self.enable_suggestions.hash(state);
782        self.spell_check.hash(state);
783        self.smart_dashes.hash(state);
784        self.smart_quotes.hash(state);
785        self.autofill_hints.hash(state);
786        self.scroll_padding
787            .map(|padding| padding.map(f32::to_bits))
788            .hash(state);
789        self.capture_tab.hash(state);
790        self.auto_indent.hash(state);
791    }
792}
793
794impl Default for Semantics {
795    fn default() -> Self {
796        Self {
797            role: Role::Generic,
798            label: None,
799            identifier: None,
800            value: None,
801            hyperlink: None,
802            popover_target: None,
803            actions: ActionSet::default(),
804            canvas_target: None,
805            action_scope_id: None,
806            focusable: false,
807            focus_policy: FocusPolicy::FocusOnPointer,
808            multiline: false,
809            text_wrap_mode: TextWrapMode::Soft,
810            masked: false,
811            input_mask: None,
812            ime_preedit_range: None,
813            ime_preedit_cursor_range: None,
814            text_selection: None,
815            selectable_text: false,
816            selection_region: None,
817            context_menu: false,
818            checked: None,
819            disabled: false,
820            read_only: false,
821            autofocus: false,
822            draggable: false,
823            scrollable_x: false,
824            scrollable_y: false,
825            min_value: None,
826            max_value: None,
827            current_value: None,
828            is_focus_scope: false,
829            is_focus_barrier: false,
830            drag_payload: None,
831            hero_tag: None,
832            focus_index: None,
833            text_input_type: TextInputType::Text,
834            text_input_action: TextInputAction::Done,
835            text_capitalization: TextCapitalization::None,
836            max_length: None,
837            max_length_enforcement: MaxLengthEnforcement::AfterComposition,
838            input_formatters: Vec::new(),
839            text_field_name: None,
840            text_form_id: None,
841            autofill_group: None,
842            required: false,
843            min_length: None,
844            validation_pattern: None,
845            validation_state: TextFieldValidationState::Unvalidated,
846            validation_message: None,
847            autocorrect: true,
848            enable_suggestions: true,
849            spell_check: true,
850            smart_dashes: true,
851            smart_quotes: true,
852            autofill_hints: Vec::new(),
853            scroll_padding: None,
854            capture_tab: false,
855            auto_indent: false,
856        }
857    }
858}
859
860/// A collection of [`ActionEntry`]s attached to a semantics node.
861///
862/// `ActionSet` is a simple wrapper around a `Vec<ActionEntry>`. It exists as a
863/// named type so that serialization and hashing are straightforward.
864#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
865pub struct ActionSet {
866    /// The action entries. Order does not matter for dispatch; the event system
867    /// matches on [`ActionTrigger`].
868    pub entries: Vec<ActionEntry>,
869}
870
871/// Restricts which characters a text input accepts.
872///
873/// Apply an `InputMask` to a [`Semantics`] node to filter keystrokes before they
874/// reach the text editing logic.
875#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
876pub enum InputMask {
877    /// Accept only ASCII digits (`0`-`9`).
878    Numeric,
879    /// Accept only ASCII letters and digits (`a`-`z`, `A`-`Z`, `0`-`9`).
880    Alphanumeric,
881}
882
883impl InputMask {
884    /// Returns `true` if `ch` is accepted by this mask.
885    ///
886    /// # Example
887    ///
888    /// ```rust
889    /// use fission_ir::semantics::InputMask;
890    /// assert!(InputMask::Numeric.is_valid_char('5'));
891    /// assert!(!InputMask::Numeric.is_valid_char('a'));
892    /// ```
893    pub fn is_valid_char(&self, ch: char) -> bool {
894        match self {
895            InputMask::Numeric => ch.is_ascii_digit(),
896            InputMask::Alphanumeric => ch.is_ascii_alphanumeric(),
897        }
898    }
899}