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}