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 (sliders, text inputs, etc.).
111    Change,
112    /// A text field changed and requests numeric `f32` payload dispatch.
113    ///
114    /// This is intentionally separate from [`Change`] so a numeric keyboard
115    /// hint alone does not change the generic text-input payload contract.
116    NumberChange,
117    /// Text editing was explicitly completed by the current input method.
118    EditingComplete,
119    /// The user submitted a text field.
120    Submit,
121    /// The caret or selection anchor position changed in a text field.
122    CursorChange,
123    /// A dragged payload was dropped onto this node.
124    Drop,
125    /// A drag entered this node's hit area (for drop targets).
126    DragEnter,
127    /// A drag left this node's hit area (for drop targets).
128    DragLeave,
129    /// Right-click or secondary mouse button.
130    SecondaryClick,
131}
132
133impl Default for ActionTrigger {
134    fn default() -> Self {
135        ActionTrigger::Default
136    }
137}
138
139/// Semantic cursor requests that shells map onto platform cursor icons.
140#[repr(u8)]
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
142pub enum MouseCursor {
143    #[default]
144    Default = 0,
145    Pointer = 1,
146    Text = 2,
147    Crosshair = 3,
148    Move = 4,
149    NotAllowed = 5,
150    Grab = 6,
151    Grabbing = 7,
152    Wait = 8,
153    Help = 9,
154    VerticalText = 10,
155}
156
157impl MouseCursor {
158    pub fn from_repr(value: u128) -> Option<Self> {
159        match value {
160            0 => Some(Self::Default),
161            1 => Some(Self::Pointer),
162            2 => Some(Self::Text),
163            3 => Some(Self::Crosshair),
164            4 => Some(Self::Move),
165            5 => Some(Self::NotAllowed),
166            6 => Some(Self::Grab),
167            7 => Some(Self::Grabbing),
168            8 => Some(Self::Wait),
169            9 => Some(Self::Help),
170            10 => Some(Self::VerticalText),
171            _ => None,
172        }
173    }
174}
175
176/// Preferred software keyboard / input modality for a text field.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
178pub enum TextInputType {
179    #[default]
180    Text,
181    Multiline,
182    Number,
183    EmailAddress,
184    Url,
185    Phone,
186    Name,
187}
188
189/// Preferred action for the return/submit key on software keyboards.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
191pub enum TextInputAction {
192    #[default]
193    Done,
194    Go,
195    Search,
196    Send,
197    Next,
198    Previous,
199    Continue,
200    Join,
201    Route,
202    EmergencyCall,
203    Newline,
204}
205
206/// Automatic capitalization strategy for inserted text.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
208pub enum TextCapitalization {
209    #[default]
210    None,
211    Characters,
212    Words,
213    Sentences,
214}
215
216/// Whether the framework should enforce `max_length` during editing.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
218pub enum MaxLengthEnforcement {
219    None,
220    #[default]
221    Enforced,
222}
223
224/// Structured formatter primitives applied to inserted text.
225#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
226pub enum InputFormatter {
227    DigitsOnly,
228    AsciiOnly,
229    InternalLowercase,
230    Uppercase,
231    TrimWhitespace,
232    SingleLine,
233}
234
235/// A single action binding: a trigger, an action ID, and optional payload.
236///
237/// When the event system detects the input described by `trigger`, it dispatches
238/// the action identified by `action_id`. If the action carries data (e.g., drag
239/// coordinates), `payload_data` holds the serialized payload.
240///
241/// # Example
242///
243/// ```rust
244/// use fission_ir::semantics::{ActionEntry, ActionTrigger};
245///
246/// let entry = ActionEntry {
247///     trigger: ActionTrigger::Default,
248///     action_id: 42,
249///     payload_data: None,
250/// };
251/// ```
252#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
253pub struct ActionEntry {
254    /// Which input gesture triggers this action.
255    pub trigger: ActionTrigger,
256    /// The raw 128-bit action ID dispatched to the widget's action handler.
257    pub action_id: u128,
258    /// Optional serialized payload. `None` for actions with no data.
259    pub payload_data: Option<Vec<u8>>,
260}
261
262impl ActionEntry {
263    /// Creates a non-dispatched cursor request consumed by hover handling.
264    pub fn hover_cursor(cursor: MouseCursor) -> Self {
265        Self {
266            trigger: ActionTrigger::HoverCursor,
267            action_id: cursor as u128,
268            payload_data: None,
269        }
270    }
271
272    /// Returns the semantic cursor encoded by this entry, if any.
273    pub fn as_hover_cursor(&self) -> Option<MouseCursor> {
274        (self.trigger == ActionTrigger::HoverCursor)
275            .then(|| MouseCursor::from_repr(self.action_id))
276            .flatten()
277    }
278}
279
280/// Accessibility and interaction metadata for a node.
281///
282/// `Semantics` is the IR's way of describing *what a node means* rather than how it
283/// looks or where it is positioned. It is consumed by:
284///
285/// * Assistive technology (screen readers, switch control) via the accessibility tree.
286/// * The event/focus system, which uses `focusable`, `actions`, and `disabled` to
287///   route input.
288/// * The drag-and-drop subsystem, which reads `draggable` and `drag_payload`.
289///
290/// Most fields default to "inert" values (see [`Default`] impl), so you only need to
291/// set the fields that matter for a given widget.
292///
293/// # Example
294///
295/// ```rust
296/// use fission_ir::Semantics;
297/// use fission_ir::semantics::Role;
298///
299/// let sem = Semantics {
300///     role: Role::Button,
301///     label: Some("Submit".into()),
302///     focusable: true,
303///     ..Semantics::default()
304/// };
305/// ```
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
307pub struct Semantics {
308    /// The accessibility role. Defaults to [`Role::Generic`].
309    pub role: Role,
310    /// A human-readable label for assistive technology (e.g., "Close" for a button).
311    pub label: Option<String>,
312    /// Stable semantic identifier for tooling and automation.
313    pub identifier: Option<String>,
314    /// The current value as a string (e.g., the text in an input field).
315    pub value: Option<String>,
316    /// The set of actions this node responds to.
317    pub actions: ActionSet,
318    /// Optional raw action dispatch scope inherited by descendant actions.
319    #[serde(default)]
320    pub action_scope_id: Option<u128>,
321    /// Whether this node can receive keyboard focus.
322    pub focusable: bool,
323    /// How pointer-down should affect focus for this node.
324    #[serde(default)]
325    pub focus_policy: FocusPolicy,
326    /// Whether this text input supports multiple lines.
327    pub multiline: bool,
328    /// Whether the value should be obscured (password fields).
329    pub masked: bool,
330    /// An optional input mask that restricts which characters are accepted.
331    pub input_mask: Option<InputMask>,
332    /// The byte range of IME pre-edit (composition) text, if any.
333    pub ime_preedit_range: Option<(usize, usize)>,
334    /// The active byte range within [`Semantics::ime_preedit_range`], if the
335    /// platform IME exposes a pre-edit cursor or marked sub-range.
336    #[serde(default)]
337    pub ime_preedit_cursor_range: Option<(usize, usize)>,
338    /// Editable or selectable text selection as byte offsets `(anchor, focus)`.
339    #[serde(default)]
340    pub text_selection: Option<(usize, usize)>,
341    /// Whether this read-only text node supports pointer/keyboard selection.
342    #[serde(default)]
343    pub selectable_text: bool,
344    /// Whether this node can open a framework-managed context menu.
345    #[serde(default)]
346    pub context_menu: bool,
347    /// For checkboxes, radios, and switches: `Some(true)` = checked or selected,
348    /// `Some(false)` = unchecked or unselected, and `None` = no checked state.
349    pub checked: Option<bool>,
350    /// Whether the node is disabled (grayed out, non-interactive).
351    pub disabled: bool,
352    /// Whether the node can be focused and selected but not edited.
353    pub read_only: bool,
354    /// Whether this node should receive focus automatically when mounted.
355    pub autofocus: bool,
356    /// Whether this node can be dragged.
357    pub draggable: bool,
358    /// Whether the node scrolls horizontally.
359    pub scrollable_x: bool,
360    /// Whether the node scrolls vertically.
361    pub scrollable_y: bool,
362    /// Minimum value for range inputs (sliders).
363    pub min_value: Option<f32>,
364    /// Maximum value for range inputs (sliders).
365    pub max_value: Option<f32>,
366    /// Current numeric value for range inputs (sliders).
367    pub current_value: Option<f32>,
368    /// When `true`, this node creates a new focus scope (like a dialog or panel).
369    pub is_focus_scope: bool,
370    /// When `true`, Tab traversal does not leave this subtree.
371    pub is_focus_barrier: bool,
372    /// Serialized payload attached to a drag operation.
373    pub drag_payload: Option<Vec<u8>>,
374    /// An identifier for hero/shared-element transitions.
375    pub hero_tag: Option<String>,
376    /// Explicit tab order index. InternalLower values receive focus first. `None` means
377    /// the node follows document order.
378    pub focus_index: Option<i32>,
379    /// Preferred keyboard/input modality for text entry.
380    pub text_input_type: TextInputType,
381    /// Preferred submit/return key action.
382    pub text_input_action: TextInputAction,
383    /// Automatic capitalization strategy for inserted text.
384    pub text_capitalization: TextCapitalization,
385    /// Maximum number of Unicode scalar values allowed in the field.
386    pub max_length: Option<usize>,
387    /// Whether `max_length` should be enforced during editing.
388    pub max_length_enforcement: MaxLengthEnforcement,
389    /// Structured input formatters applied to inserted text.
390    pub input_formatters: Vec<InputFormatter>,
391    /// Hint to the platform IME whether autocorrect should be enabled.
392    pub autocorrect: bool,
393    /// Hint to the platform IME whether suggestions should be enabled.
394    pub enable_suggestions: bool,
395    /// Hint to the platform IME whether spell checking should be enabled.
396    pub spell_check: bool,
397    /// Hint to the platform IME whether smart dashes should be enabled.
398    pub smart_dashes: bool,
399    /// Hint to the platform IME whether smart quotes should be enabled.
400    pub smart_quotes: bool,
401    /// Platform autofill categories associated with this field.
402    pub autofill_hints: Vec<String>,
403    /// Extra padding to keep around the caret/selection when auto-scrolling `[left, right, top, bottom]`.
404    pub scroll_padding: Option<[f32; 4]>,
405    /// When true, Tab key inserts spaces instead of moving focus.
406    pub capture_tab: bool,
407    /// When true, Enter copies leading whitespace from the current line.
408    pub auto_indent: bool,
409}
410
411impl std::hash::Hash for Semantics {
412    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
413        self.role.hash(state);
414        self.label.hash(state);
415        self.identifier.hash(state);
416        self.value.hash(state);
417        self.actions.hash(state);
418        self.action_scope_id.hash(state);
419        self.focusable.hash(state);
420        self.focus_policy.hash(state);
421        self.multiline.hash(state);
422        self.masked.hash(state);
423        self.input_mask.hash(state);
424        self.ime_preedit_range.hash(state);
425        self.ime_preedit_cursor_range.hash(state);
426        self.text_selection.hash(state);
427        self.selectable_text.hash(state);
428        self.context_menu.hash(state);
429        self.checked.hash(state);
430        self.disabled.hash(state);
431        self.read_only.hash(state);
432        self.autofocus.hash(state);
433        self.draggable.hash(state);
434        self.scrollable_x.hash(state);
435        self.scrollable_y.hash(state);
436        self.min_value.map(|f| f.to_bits()).hash(state);
437        self.max_value.map(|f| f.to_bits()).hash(state);
438        self.current_value.map(|f| f.to_bits()).hash(state);
439        self.is_focus_scope.hash(state);
440        self.is_focus_barrier.hash(state);
441        self.drag_payload.hash(state);
442        self.hero_tag.hash(state);
443        self.focus_index.hash(state);
444        self.text_input_type.hash(state);
445        self.text_input_action.hash(state);
446        self.text_capitalization.hash(state);
447        self.max_length.hash(state);
448        self.max_length_enforcement.hash(state);
449        self.input_formatters.hash(state);
450        self.autocorrect.hash(state);
451        self.enable_suggestions.hash(state);
452        self.spell_check.hash(state);
453        self.smart_dashes.hash(state);
454        self.smart_quotes.hash(state);
455        self.autofill_hints.hash(state);
456        self.scroll_padding
457            .map(|padding| padding.map(f32::to_bits))
458            .hash(state);
459        self.capture_tab.hash(state);
460        self.auto_indent.hash(state);
461    }
462}
463
464impl Default for Semantics {
465    fn default() -> Self {
466        Self {
467            role: Role::Generic,
468            label: None,
469            identifier: None,
470            value: None,
471            actions: ActionSet::default(),
472            action_scope_id: None,
473            focusable: false,
474            focus_policy: FocusPolicy::FocusOnPointer,
475            multiline: false,
476            masked: false,
477            input_mask: None,
478            ime_preedit_range: None,
479            ime_preedit_cursor_range: None,
480            text_selection: None,
481            selectable_text: false,
482            context_menu: false,
483            checked: None,
484            disabled: false,
485            read_only: false,
486            autofocus: false,
487            draggable: false,
488            scrollable_x: false,
489            scrollable_y: false,
490            min_value: None,
491            max_value: None,
492            current_value: None,
493            is_focus_scope: false,
494            is_focus_barrier: false,
495            drag_payload: None,
496            hero_tag: None,
497            focus_index: None,
498            text_input_type: TextInputType::Text,
499            text_input_action: TextInputAction::Done,
500            text_capitalization: TextCapitalization::None,
501            max_length: None,
502            max_length_enforcement: MaxLengthEnforcement::Enforced,
503            input_formatters: Vec::new(),
504            autocorrect: true,
505            enable_suggestions: true,
506            spell_check: true,
507            smart_dashes: true,
508            smart_quotes: true,
509            autofill_hints: Vec::new(),
510            scroll_padding: None,
511            capture_tab: false,
512            auto_indent: false,
513        }
514    }
515}
516
517/// A collection of [`ActionEntry`]s attached to a semantics node.
518///
519/// `ActionSet` is a simple wrapper around a `Vec<ActionEntry>`. It exists as a
520/// named type so that serialization and hashing are straightforward.
521#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
522pub struct ActionSet {
523    /// The action entries. Order does not matter for dispatch; the event system
524    /// matches on [`ActionTrigger`].
525    pub entries: Vec<ActionEntry>,
526}
527
528/// Restricts which characters a text input accepts.
529///
530/// Apply an `InputMask` to a [`Semantics`] node to filter keystrokes before they
531/// reach the text editing logic.
532#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
533pub enum InputMask {
534    /// Accept only ASCII digits (`0`-`9`).
535    Numeric,
536    /// Accept only ASCII letters and digits (`a`-`z`, `A`-`Z`, `0`-`9`).
537    Alphanumeric,
538}
539
540impl InputMask {
541    /// Returns `true` if `ch` is accepted by this mask.
542    ///
543    /// # Example
544    ///
545    /// ```rust
546    /// use fission_ir::semantics::InputMask;
547    /// assert!(InputMask::Numeric.is_valid_char('5'));
548    /// assert!(!InputMask::Numeric.is_valid_char('a'));
549    /// ```
550    pub fn is_valid_char(&self, ch: char) -> bool {
551        match self {
552            InputMask::Numeric => ch.is_ascii_digit(),
553            InputMask::Alphanumeric => ch.is_ascii_alphanumeric(),
554        }
555    }
556}