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 toggle switch (on/off).
30 Switch,
31 /// A modal or non-modal dialog overlay.
32 Dialog,
33 /// A continuous range input (e.g., volume control).
34 Slider,
35 /// A generic form input that does not fit the other roles.
36 Input,
37 /// A scrollable list container.
38 List,
39 /// An individual item inside a [`List`](Role::List).
40 ListItem,
41 /// A node with no specific semantic role. The default.
42 Generic,
43}
44
45/// How a focusable node responds to pointer focus.
46///
47/// `FocusPolicy` only changes pointer-driven focus assignment. Keyboard focus,
48/// accessibility focus, and semantic activation still work for focusable nodes.
49///
50/// # Example
51///
52/// A toolbar button can run its action without taking focus from an editor:
53///
54/// ```rust
55/// use fission_ir::semantics::{FocusPolicy, Role};
56/// use fission_ir::Semantics;
57///
58/// let semantics = Semantics {
59/// role: Role::Button,
60/// focusable: true,
61/// focus_policy: FocusPolicy::PreserveCurrentOnPointer,
62/// ..Semantics::default()
63/// };
64/// ```
65#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub enum FocusPolicy {
67 /// Pointer-down focuses this node when it is focusable. This is the normal
68 /// behavior for buttons, text inputs, and other controls.
69 #[default]
70 FocusOnPointer,
71 /// Pointer-down keeps the currently focused node focused while still letting
72 /// this node receive pointer state and activation actions.
73 PreserveCurrentOnPointer,
74}
75
76/// What user interaction triggers an action.
77///
78/// Each [`ActionEntry`] pairs an `ActionTrigger` with an action ID so the event
79/// system knows which callback to invoke for a given input gesture.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
81pub enum ActionTrigger {
82 /// Primary activation: tap, click, or Enter key.
83 Default,
84 /// The user began dragging this node.
85 DragStart,
86 /// The drag position changed (fires continuously).
87 DragUpdate,
88 /// The user released the drag.
89 DragEnd,
90 /// The pointer entered the node's hit area.
91 HoverEnter,
92 /// The pointer left the node's hit area.
93 HoverExit,
94 /// A semantic cursor request applied while the pointer hovers this node.
95 ///
96 /// This is metadata, not a dispatched reducer action.
97 HoverCursor,
98 /// The node received keyboard focus.
99 Focus,
100 /// The node lost keyboard focus.
101 Blur,
102 /// A pointer-down happened outside the active text field.
103 TapOutside,
104 /// The node's value changed (sliders, text inputs, etc.).
105 Change,
106 /// A text field changed and requests numeric `f32` payload dispatch.
107 ///
108 /// This is intentionally separate from [`Change`] so a numeric keyboard
109 /// hint alone does not change the generic text-input payload contract.
110 NumberChange,
111 /// Text editing was explicitly completed by the current input method.
112 EditingComplete,
113 /// The user submitted a text field.
114 Submit,
115 /// The caret or selection anchor position changed in a text field.
116 CursorChange,
117 /// A dragged payload was dropped onto this node.
118 Drop,
119 /// A drag entered this node's hit area (for drop targets).
120 DragEnter,
121 /// A drag left this node's hit area (for drop targets).
122 DragLeave,
123 /// Right-click or secondary mouse button.
124 SecondaryClick,
125}
126
127impl Default for ActionTrigger {
128 fn default() -> Self {
129 ActionTrigger::Default
130 }
131}
132
133/// Semantic cursor requests that shells map onto platform cursor icons.
134#[repr(u8)]
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
136pub enum MouseCursor {
137 #[default]
138 Default = 0,
139 Pointer = 1,
140 Text = 2,
141 Crosshair = 3,
142 Move = 4,
143 NotAllowed = 5,
144 Grab = 6,
145 Grabbing = 7,
146 Wait = 8,
147 Help = 9,
148 VerticalText = 10,
149}
150
151impl MouseCursor {
152 pub fn from_repr(value: u128) -> Option<Self> {
153 match value {
154 0 => Some(Self::Default),
155 1 => Some(Self::Pointer),
156 2 => Some(Self::Text),
157 3 => Some(Self::Crosshair),
158 4 => Some(Self::Move),
159 5 => Some(Self::NotAllowed),
160 6 => Some(Self::Grab),
161 7 => Some(Self::Grabbing),
162 8 => Some(Self::Wait),
163 9 => Some(Self::Help),
164 10 => Some(Self::VerticalText),
165 _ => None,
166 }
167 }
168}
169
170/// Preferred software keyboard / input modality for a text field.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
172pub enum TextInputType {
173 #[default]
174 Text,
175 Multiline,
176 Number,
177 EmailAddress,
178 Url,
179 Phone,
180 Name,
181}
182
183/// Preferred action for the return/submit key on software keyboards.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
185pub enum TextInputAction {
186 #[default]
187 Done,
188 Go,
189 Search,
190 Send,
191 Next,
192 Previous,
193 Continue,
194 Join,
195 Route,
196 EmergencyCall,
197 Newline,
198}
199
200/// Automatic capitalization strategy for inserted text.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
202pub enum TextCapitalization {
203 #[default]
204 None,
205 Characters,
206 Words,
207 Sentences,
208}
209
210/// Whether the framework should enforce `max_length` during editing.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
212pub enum MaxLengthEnforcement {
213 None,
214 #[default]
215 Enforced,
216}
217
218/// Structured formatter primitives applied to inserted text.
219#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
220pub enum InputFormatter {
221 DigitsOnly,
222 AsciiOnly,
223 InternalLowercase,
224 Uppercase,
225 TrimWhitespace,
226 SingleLine,
227}
228
229/// A single action binding: a trigger, an action ID, and optional payload.
230///
231/// When the event system detects the input described by `trigger`, it dispatches
232/// the action identified by `action_id`. If the action carries data (e.g., drag
233/// coordinates), `payload_data` holds the serialized payload.
234///
235/// # Example
236///
237/// ```rust
238/// use fission_ir::semantics::{ActionEntry, ActionTrigger};
239///
240/// let entry = ActionEntry {
241/// trigger: ActionTrigger::Default,
242/// action_id: 42,
243/// payload_data: None,
244/// };
245/// ```
246#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
247pub struct ActionEntry {
248 /// Which input gesture triggers this action.
249 pub trigger: ActionTrigger,
250 /// The raw 128-bit action ID dispatched to the widget's action handler.
251 pub action_id: u128,
252 /// Optional serialized payload. `None` for actions with no data.
253 pub payload_data: Option<Vec<u8>>,
254}
255
256impl ActionEntry {
257 /// Creates a non-dispatched cursor request consumed by hover handling.
258 pub fn hover_cursor(cursor: MouseCursor) -> Self {
259 Self {
260 trigger: ActionTrigger::HoverCursor,
261 action_id: cursor as u128,
262 payload_data: None,
263 }
264 }
265
266 /// Returns the semantic cursor encoded by this entry, if any.
267 pub fn as_hover_cursor(&self) -> Option<MouseCursor> {
268 (self.trigger == ActionTrigger::HoverCursor)
269 .then(|| MouseCursor::from_repr(self.action_id))
270 .flatten()
271 }
272}
273
274/// Accessibility and interaction metadata for a node.
275///
276/// `Semantics` is the IR's way of describing *what a node means* rather than how it
277/// looks or where it is positioned. It is consumed by:
278///
279/// * Assistive technology (screen readers, switch control) via the accessibility tree.
280/// * The event/focus system, which uses `focusable`, `actions`, and `disabled` to
281/// route input.
282/// * The drag-and-drop subsystem, which reads `draggable` and `drag_payload`.
283///
284/// Most fields default to "inert" values (see [`Default`] impl), so you only need to
285/// set the fields that matter for a given widget.
286///
287/// # Example
288///
289/// ```rust
290/// use fission_ir::Semantics;
291/// use fission_ir::semantics::Role;
292///
293/// let sem = Semantics {
294/// role: Role::Button,
295/// label: Some("Submit".into()),
296/// focusable: true,
297/// ..Semantics::default()
298/// };
299/// ```
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301pub struct Semantics {
302 /// The accessibility role. Defaults to [`Role::Generic`].
303 pub role: Role,
304 /// A human-readable label for assistive technology (e.g., "Close" for a button).
305 pub label: Option<String>,
306 /// Stable semantic identifier for tooling and automation.
307 pub identifier: Option<String>,
308 /// The current value as a string (e.g., the text in an input field).
309 pub value: Option<String>,
310 /// The set of actions this node responds to.
311 pub actions: ActionSet,
312 /// Optional raw action dispatch scope inherited by descendant actions.
313 #[serde(default)]
314 pub action_scope_id: Option<u128>,
315 /// Whether this node can receive keyboard focus.
316 pub focusable: bool,
317 /// How pointer-down should affect focus for this node.
318 #[serde(default)]
319 pub focus_policy: FocusPolicy,
320 /// Whether this text input supports multiple lines.
321 pub multiline: bool,
322 /// Whether the value should be obscured (password fields).
323 pub masked: bool,
324 /// An optional input mask that restricts which characters are accepted.
325 pub input_mask: Option<InputMask>,
326 /// The byte range of IME pre-edit (composition) text, if any.
327 pub ime_preedit_range: Option<(usize, usize)>,
328 /// The active byte range within [`Semantics::ime_preedit_range`], if the
329 /// platform IME exposes a pre-edit cursor or marked sub-range.
330 #[serde(default)]
331 pub ime_preedit_cursor_range: Option<(usize, usize)>,
332 /// Editable text selection as byte offsets `(anchor, focus)`.
333 #[serde(default)]
334 pub text_selection: Option<(usize, usize)>,
335 /// For checkboxes and switches: `Some(true)` = checked, `Some(false)` = unchecked,
336 /// `None` = not a toggle.
337 pub checked: Option<bool>,
338 /// Whether the node is disabled (grayed out, non-interactive).
339 pub disabled: bool,
340 /// Whether the node can be focused and selected but not edited.
341 pub read_only: bool,
342 /// Whether this node should receive focus automatically when mounted.
343 pub autofocus: bool,
344 /// Whether this node can be dragged.
345 pub draggable: bool,
346 /// Whether the node scrolls horizontally.
347 pub scrollable_x: bool,
348 /// Whether the node scrolls vertically.
349 pub scrollable_y: bool,
350 /// Minimum value for range inputs (sliders).
351 pub min_value: Option<f32>,
352 /// Maximum value for range inputs (sliders).
353 pub max_value: Option<f32>,
354 /// Current numeric value for range inputs (sliders).
355 pub current_value: Option<f32>,
356 /// When `true`, this node creates a new focus scope (like a dialog or panel).
357 pub is_focus_scope: bool,
358 /// When `true`, Tab traversal does not leave this subtree.
359 pub is_focus_barrier: bool,
360 /// Serialized payload attached to a drag operation.
361 pub drag_payload: Option<Vec<u8>>,
362 /// An identifier for hero/shared-element transitions.
363 pub hero_tag: Option<String>,
364 /// Explicit tab order index. InternalLower values receive focus first. `None` means
365 /// the node follows document order.
366 pub focus_index: Option<i32>,
367 /// Preferred keyboard/input modality for text entry.
368 pub text_input_type: TextInputType,
369 /// Preferred submit/return key action.
370 pub text_input_action: TextInputAction,
371 /// Automatic capitalization strategy for inserted text.
372 pub text_capitalization: TextCapitalization,
373 /// Maximum number of Unicode scalar values allowed in the field.
374 pub max_length: Option<usize>,
375 /// Whether `max_length` should be enforced during editing.
376 pub max_length_enforcement: MaxLengthEnforcement,
377 /// Structured input formatters applied to inserted text.
378 pub input_formatters: Vec<InputFormatter>,
379 /// Hint to the platform IME whether autocorrect should be enabled.
380 pub autocorrect: bool,
381 /// Hint to the platform IME whether suggestions should be enabled.
382 pub enable_suggestions: bool,
383 /// Hint to the platform IME whether spell checking should be enabled.
384 pub spell_check: bool,
385 /// Hint to the platform IME whether smart dashes should be enabled.
386 pub smart_dashes: bool,
387 /// Hint to the platform IME whether smart quotes should be enabled.
388 pub smart_quotes: bool,
389 /// Platform autofill categories associated with this field.
390 pub autofill_hints: Vec<String>,
391 /// Extra padding to keep around the caret/selection when auto-scrolling `[left, right, top, bottom]`.
392 pub scroll_padding: Option<[f32; 4]>,
393 /// When true, Tab key inserts spaces instead of moving focus.
394 pub capture_tab: bool,
395 /// When true, Enter copies leading whitespace from the current line.
396 pub auto_indent: bool,
397}
398
399impl std::hash::Hash for Semantics {
400 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
401 self.role.hash(state);
402 self.label.hash(state);
403 self.identifier.hash(state);
404 self.value.hash(state);
405 self.actions.hash(state);
406 self.action_scope_id.hash(state);
407 self.focusable.hash(state);
408 self.focus_policy.hash(state);
409 self.multiline.hash(state);
410 self.masked.hash(state);
411 self.input_mask.hash(state);
412 self.ime_preedit_range.hash(state);
413 self.ime_preedit_cursor_range.hash(state);
414 self.text_selection.hash(state);
415 self.checked.hash(state);
416 self.disabled.hash(state);
417 self.read_only.hash(state);
418 self.autofocus.hash(state);
419 self.draggable.hash(state);
420 self.scrollable_x.hash(state);
421 self.scrollable_y.hash(state);
422 self.min_value.map(|f| f.to_bits()).hash(state);
423 self.max_value.map(|f| f.to_bits()).hash(state);
424 self.current_value.map(|f| f.to_bits()).hash(state);
425 self.is_focus_scope.hash(state);
426 self.is_focus_barrier.hash(state);
427 self.drag_payload.hash(state);
428 self.hero_tag.hash(state);
429 self.focus_index.hash(state);
430 self.text_input_type.hash(state);
431 self.text_input_action.hash(state);
432 self.text_capitalization.hash(state);
433 self.max_length.hash(state);
434 self.max_length_enforcement.hash(state);
435 self.input_formatters.hash(state);
436 self.autocorrect.hash(state);
437 self.enable_suggestions.hash(state);
438 self.spell_check.hash(state);
439 self.smart_dashes.hash(state);
440 self.smart_quotes.hash(state);
441 self.autofill_hints.hash(state);
442 self.scroll_padding
443 .map(|padding| padding.map(f32::to_bits))
444 .hash(state);
445 self.capture_tab.hash(state);
446 self.auto_indent.hash(state);
447 }
448}
449
450impl Default for Semantics {
451 fn default() -> Self {
452 Self {
453 role: Role::Generic,
454 label: None,
455 identifier: None,
456 value: None,
457 actions: ActionSet::default(),
458 action_scope_id: None,
459 focusable: false,
460 focus_policy: FocusPolicy::FocusOnPointer,
461 multiline: false,
462 masked: false,
463 input_mask: None,
464 ime_preedit_range: None,
465 ime_preedit_cursor_range: None,
466 text_selection: None,
467 checked: None,
468 disabled: false,
469 read_only: false,
470 autofocus: false,
471 draggable: false,
472 scrollable_x: false,
473 scrollable_y: false,
474 min_value: None,
475 max_value: None,
476 current_value: None,
477 is_focus_scope: false,
478 is_focus_barrier: false,
479 drag_payload: None,
480 hero_tag: None,
481 focus_index: None,
482 text_input_type: TextInputType::Text,
483 text_input_action: TextInputAction::Done,
484 text_capitalization: TextCapitalization::None,
485 max_length: None,
486 max_length_enforcement: MaxLengthEnforcement::Enforced,
487 input_formatters: Vec::new(),
488 autocorrect: true,
489 enable_suggestions: true,
490 spell_check: true,
491 smart_dashes: true,
492 smart_quotes: true,
493 autofill_hints: Vec::new(),
494 scroll_padding: None,
495 capture_tab: false,
496 auto_indent: false,
497 }
498 }
499}
500
501/// A collection of [`ActionEntry`]s attached to a semantics node.
502///
503/// `ActionSet` is a simple wrapper around a `Vec<ActionEntry>`. It exists as a
504/// named type so that serialization and hashing are straightforward.
505#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
506pub struct ActionSet {
507 /// The action entries. Order does not matter for dispatch; the event system
508 /// matches on [`ActionTrigger`].
509 pub entries: Vec<ActionEntry>,
510}
511
512/// Restricts which characters a text input accepts.
513///
514/// Apply an `InputMask` to a [`Semantics`] node to filter keystrokes before they
515/// reach the text editing logic.
516#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
517pub enum InputMask {
518 /// Accept only ASCII digits (`0`-`9`).
519 Numeric,
520 /// Accept only ASCII letters and digits (`a`-`z`, `A`-`Z`, `0`-`9`).
521 Alphanumeric,
522}
523
524impl InputMask {
525 /// Returns `true` if `ch` is accepted by this mask.
526 ///
527 /// # Example
528 ///
529 /// ```rust
530 /// use fission_ir::semantics::InputMask;
531 /// assert!(InputMask::Numeric.is_valid_char('5'));
532 /// assert!(!InputMask::Numeric.is_valid_char('a'));
533 /// ```
534 pub fn is_valid_char(&self, ch: char) -> bool {
535 match self {
536 InputMask::Numeric => ch.is_ascii_digit(),
537 InputMask::Alphanumeric => ch.is_ascii_alphanumeric(),
538 }
539 }
540}