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}