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