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