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