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 text selection as byte offsets `(anchor, focus)`.
335    #[serde(default)]
336    pub text_selection: Option<(usize, usize)>,
337    /// For checkboxes, radios, and switches: `Some(true)` = checked or selected,
338    /// `Some(false)` = unchecked or unselected, and `None` = no checked state.
339    pub checked: Option<bool>,
340    /// Whether the node is disabled (grayed out, non-interactive).
341    pub disabled: bool,
342    /// Whether the node can be focused and selected but not edited.
343    pub read_only: bool,
344    /// Whether this node should receive focus automatically when mounted.
345    pub autofocus: bool,
346    /// Whether this node can be dragged.
347    pub draggable: bool,
348    /// Whether the node scrolls horizontally.
349    pub scrollable_x: bool,
350    /// Whether the node scrolls vertically.
351    pub scrollable_y: bool,
352    /// Minimum value for range inputs (sliders).
353    pub min_value: Option<f32>,
354    /// Maximum value for range inputs (sliders).
355    pub max_value: Option<f32>,
356    /// Current numeric value for range inputs (sliders).
357    pub current_value: Option<f32>,
358    /// When `true`, this node creates a new focus scope (like a dialog or panel).
359    pub is_focus_scope: bool,
360    /// When `true`, Tab traversal does not leave this subtree.
361    pub is_focus_barrier: bool,
362    /// Serialized payload attached to a drag operation.
363    pub drag_payload: Option<Vec<u8>>,
364    /// An identifier for hero/shared-element transitions.
365    pub hero_tag: Option<String>,
366    /// Explicit tab order index. InternalLower values receive focus first. `None` means
367    /// the node follows document order.
368    pub focus_index: Option<i32>,
369    /// Preferred keyboard/input modality for text entry.
370    pub text_input_type: TextInputType,
371    /// Preferred submit/return key action.
372    pub text_input_action: TextInputAction,
373    /// Automatic capitalization strategy for inserted text.
374    pub text_capitalization: TextCapitalization,
375    /// Maximum number of Unicode scalar values allowed in the field.
376    pub max_length: Option<usize>,
377    /// Whether `max_length` should be enforced during editing.
378    pub max_length_enforcement: MaxLengthEnforcement,
379    /// Structured input formatters applied to inserted text.
380    pub input_formatters: Vec<InputFormatter>,
381    /// Hint to the platform IME whether autocorrect should be enabled.
382    pub autocorrect: bool,
383    /// Hint to the platform IME whether suggestions should be enabled.
384    pub enable_suggestions: bool,
385    /// Hint to the platform IME whether spell checking should be enabled.
386    pub spell_check: bool,
387    /// Hint to the platform IME whether smart dashes should be enabled.
388    pub smart_dashes: bool,
389    /// Hint to the platform IME whether smart quotes should be enabled.
390    pub smart_quotes: bool,
391    /// Platform autofill categories associated with this field.
392    pub autofill_hints: Vec<String>,
393    /// Extra padding to keep around the caret/selection when auto-scrolling `[left, right, top, bottom]`.
394    pub scroll_padding: Option<[f32; 4]>,
395    /// When true, Tab key inserts spaces instead of moving focus.
396    pub capture_tab: bool,
397    /// When true, Enter copies leading whitespace from the current line.
398    pub auto_indent: bool,
399}
400
401impl std::hash::Hash for Semantics {
402    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
403        self.role.hash(state);
404        self.label.hash(state);
405        self.identifier.hash(state);
406        self.value.hash(state);
407        self.actions.hash(state);
408        self.action_scope_id.hash(state);
409        self.focusable.hash(state);
410        self.focus_policy.hash(state);
411        self.multiline.hash(state);
412        self.masked.hash(state);
413        self.input_mask.hash(state);
414        self.ime_preedit_range.hash(state);
415        self.ime_preedit_cursor_range.hash(state);
416        self.text_selection.hash(state);
417        self.checked.hash(state);
418        self.disabled.hash(state);
419        self.read_only.hash(state);
420        self.autofocus.hash(state);
421        self.draggable.hash(state);
422        self.scrollable_x.hash(state);
423        self.scrollable_y.hash(state);
424        self.min_value.map(|f| f.to_bits()).hash(state);
425        self.max_value.map(|f| f.to_bits()).hash(state);
426        self.current_value.map(|f| f.to_bits()).hash(state);
427        self.is_focus_scope.hash(state);
428        self.is_focus_barrier.hash(state);
429        self.drag_payload.hash(state);
430        self.hero_tag.hash(state);
431        self.focus_index.hash(state);
432        self.text_input_type.hash(state);
433        self.text_input_action.hash(state);
434        self.text_capitalization.hash(state);
435        self.max_length.hash(state);
436        self.max_length_enforcement.hash(state);
437        self.input_formatters.hash(state);
438        self.autocorrect.hash(state);
439        self.enable_suggestions.hash(state);
440        self.spell_check.hash(state);
441        self.smart_dashes.hash(state);
442        self.smart_quotes.hash(state);
443        self.autofill_hints.hash(state);
444        self.scroll_padding
445            .map(|padding| padding.map(f32::to_bits))
446            .hash(state);
447        self.capture_tab.hash(state);
448        self.auto_indent.hash(state);
449    }
450}
451
452impl Default for Semantics {
453    fn default() -> Self {
454        Self {
455            role: Role::Generic,
456            label: None,
457            identifier: None,
458            value: None,
459            actions: ActionSet::default(),
460            action_scope_id: None,
461            focusable: false,
462            focus_policy: FocusPolicy::FocusOnPointer,
463            multiline: false,
464            masked: false,
465            input_mask: None,
466            ime_preedit_range: None,
467            ime_preedit_cursor_range: None,
468            text_selection: None,
469            checked: None,
470            disabled: false,
471            read_only: false,
472            autofocus: false,
473            draggable: false,
474            scrollable_x: false,
475            scrollable_y: false,
476            min_value: None,
477            max_value: None,
478            current_value: None,
479            is_focus_scope: false,
480            is_focus_barrier: false,
481            drag_payload: None,
482            hero_tag: None,
483            focus_index: None,
484            text_input_type: TextInputType::Text,
485            text_input_action: TextInputAction::Done,
486            text_capitalization: TextCapitalization::None,
487            max_length: None,
488            max_length_enforcement: MaxLengthEnforcement::Enforced,
489            input_formatters: Vec::new(),
490            autocorrect: true,
491            enable_suggestions: true,
492            spell_check: true,
493            smart_dashes: true,
494            smart_quotes: true,
495            autofill_hints: Vec::new(),
496            scroll_padding: None,
497            capture_tab: false,
498            auto_indent: false,
499        }
500    }
501}
502
503/// A collection of [`ActionEntry`]s attached to a semantics node.
504///
505/// `ActionSet` is a simple wrapper around a `Vec<ActionEntry>`. It exists as a
506/// named type so that serialization and hashing are straightforward.
507#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
508pub struct ActionSet {
509    /// The action entries. Order does not matter for dispatch; the event system
510    /// matches on [`ActionTrigger`].
511    pub entries: Vec<ActionEntry>,
512}
513
514/// Restricts which characters a text input accepts.
515///
516/// Apply an `InputMask` to a [`Semantics`] node to filter keystrokes before they
517/// reach the text editing logic.
518#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
519pub enum InputMask {
520    /// Accept only ASCII digits (`0`-`9`).
521    Numeric,
522    /// Accept only ASCII letters and digits (`a`-`z`, `A`-`Z`, `0`-`9`).
523    Alphanumeric,
524}
525
526impl InputMask {
527    /// Returns `true` if `ch` is accepted by this mask.
528    ///
529    /// # Example
530    ///
531    /// ```rust
532    /// use fission_ir::semantics::InputMask;
533    /// assert!(InputMask::Numeric.is_valid_char('5'));
534    /// assert!(!InputMask::Numeric.is_valid_char('a'));
535    /// ```
536    pub fn is_valid_char(&self, ch: char) -> bool {
537        match self {
538            InputMask::Numeric => ch.is_ascii_digit(),
539            InputMask::Alphanumeric => ch.is_ascii_alphanumeric(),
540        }
541    }
542}