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}