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