Skip to main content

incular_semantics/
lib.rs

1//! Renderer- and platform-neutral retained accessibility semantics.
2//!
3//! This crate owns the semantic data model and its generational retained tree.
4//! Widget trees map their persistent identities to these nodes, while runtimes
5//! and native adapters communicate exclusively through `SemanticNodeId` and
6//! `SemanticAction`.
7
8use incular_core::{Arena, ArenaId, Rect};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11pub struct SemanticNodeId(pub ArenaId);
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14pub enum Role {
15    Button,
16    Text,
17    TextField,
18    TextArea,
19    List,
20    ListItem,
21    ScrollView,
22    GenericContainer,
23    Checkbox,
24    Radio,
25    /// A two-state toggle distinct from a momentary button.
26    Switch,
27    Slider,
28    Menu,
29    Dialog,
30    Image,
31    Heading,
32    Link,
33    Group,
34    Tab,
35    TabList,
36    TabPanel,
37    MenuItem,
38    ProgressBar,
39    Meter,
40    SearchField,
41}
42
43pub type SemanticRole = Role;
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
46pub enum SemanticActionKind {
47    Focus,
48    Activate,
49    SetText,
50    SetSelection,
51    Increment,
52    Decrement,
53    ScrollForward,
54    ScrollBackward,
55}
56
57/// A request received from a native accessibility adapter. Text offsets use
58/// the editing model's UTF-8 byte indexing convention.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum SemanticAction {
61    Focus,
62    Activate,
63    SetText(String),
64    SetSelection { base: usize, extent: usize },
65    Increment,
66    Decrement,
67    ScrollForward,
68    ScrollBackward,
69}
70
71impl SemanticAction {
72    #[must_use]
73    pub const fn kind(&self) -> SemanticActionKind {
74        match self {
75            Self::Focus => SemanticActionKind::Focus,
76            Self::Activate => SemanticActionKind::Activate,
77            Self::SetText(_) => SemanticActionKind::SetText,
78            Self::SetSelection { .. } => SemanticActionKind::SetSelection,
79            Self::Increment => SemanticActionKind::Increment,
80            Self::Decrement => SemanticActionKind::Decrement,
81            Self::ScrollForward => SemanticActionKind::ScrollForward,
82            Self::ScrollBackward => SemanticActionKind::ScrollBackward,
83        }
84    }
85}
86
87#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
88pub struct TextSelection {
89    pub base: usize,
90    pub extent: usize,
91}
92
93/// Value of a checkable control. `None` in [`SemanticState::checked`] means the
94/// node is not checkable; an indeterminate control is still checkable.
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
96pub enum CheckedState {
97    #[default]
98    Unchecked,
99    Checked,
100    Indeterminate,
101}
102
103impl CheckedState {
104    #[must_use]
105    pub const fn is_checked(self) -> bool {
106        matches!(self, Self::Checked)
107    }
108}
109
110impl From<bool> for CheckedState {
111    fn from(checked: bool) -> Self {
112        if checked {
113            Self::Checked
114        } else {
115            Self::Unchecked
116        }
117    }
118}
119
120impl std::fmt::Display for CheckedState {
121    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        formatter.write_str(match self {
123            Self::Unchecked => "false",
124            Self::Checked => "true",
125            Self::Indeterminate => "mixed",
126        })
127    }
128}
129
130#[derive(Clone, Debug, Default, PartialEq)]
131pub struct SemanticState {
132    pub enabled: bool,
133    pub focused: bool,
134    pub focusable: bool,
135    pub selected: bool,
136    pub checked: Option<CheckedState>,
137    pub expanded: Option<bool>,
138    pub read_only: bool,
139    pub editable: bool,
140    /// Whether a text field's value is visually obscured (for example a
141    /// password field).  Keeping this in the portable tree lets native
142    /// accessibility adapters make the same privacy decision as the widget
143    /// layer without exposing the clear-text paint representation.
144    pub obscured: bool,
145    pub multiline: bool,
146    pub selection: Option<TextSelection>,
147    pub item_index: Option<usize>,
148    pub set_size: Option<usize>,
149    /// Indicates that the node's value is still being produced.
150    pub busy: bool,
151    /// Validation state exposed independently from enabled/read-only state.
152    pub invalid: bool,
153    /// Whether the user must provide a value.
154    pub required: bool,
155    /// Numeric range/value metadata used by sliders, meters, and progress
156    /// indicators. Values stay floating point because Flutter controls do.
157    pub numeric_value: Option<f64>,
158    pub numeric_min: Option<f64>,
159    pub numeric_max: Option<f64>,
160    pub numeric_step: Option<f64>,
161    /// Heading level in the portable tree (1 is the most prominent level).
162    pub heading_level: Option<u8>,
163    /// Flutter's `hasToggledState`/`isToggled`, for on/off controls such as
164    /// switches; `None` when the node has no toggled state.
165    pub toggled: Option<bool>,
166    /// Flutter's `isHidden`: present in the tree but not currently visible,
167    /// such as an off-screen item a screen reader may scroll to.
168    pub hidden: bool,
169    /// Flutter's `isLiveRegion`: changes are announced politely.
170    pub live_region: bool,
171    /// Flutter's `tooltip`.
172    pub tooltip: Option<String>,
173    /// Flutter's `linkUrl`: where a link node navigates.
174    pub link_url: Option<String>,
175    /// Flutter's `identifier`: a stable platform identifier (Android
176    /// resource-id, iOS accessibilityIdentifier) for UI automation.
177    pub identifier: Option<String>,
178}
179
180#[derive(Clone, Debug, PartialEq)]
181pub struct SemanticNode {
182    pub id: SemanticNodeId,
183    pub role: Role,
184    pub label: Option<String>,
185    pub value: Option<String>,
186    pub description: Option<String>,
187    pub bounds: Rect,
188    pub state: SemanticState,
189    pub actions: Vec<SemanticActionKind>,
190    pub children: Vec<SemanticNodeId>,
191}
192
193#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
194pub struct SemanticsDiagnostics {
195    pub nodes_created: u64,
196    pub nodes_removed: u64,
197    pub nodes_updated: u64,
198    pub property_updates: u64,
199    pub geometry_updates: u64,
200    pub native_updates: u64,
201    pub actions_received: u64,
202    /// Monotonically increases only when the retained semantic graph changes.
203    /// Native adapters use this to avoid rebuilding or even walking an
204    /// unchanged tree on paint/compositor-only frames.
205    pub revisions: u64,
206}
207
208/// A retained node and the graph revision that last inserted or changed it.
209struct RetainedNode {
210    node: SemanticNode,
211    changed_at: u64,
212}
213
214/// Retained semantic arena. IDs are generational, so stale platform actions
215/// cannot address a node that later reuses the same storage slot.
216#[derive(Default)]
217pub struct SemanticsTree {
218    nodes: Arena<RetainedNode>,
219    root: Option<SemanticNodeId>,
220    diagnostics: SemanticsDiagnostics,
221}
222
223impl SemanticsTree {
224    #[must_use]
225    pub fn new() -> Self {
226        Self::default()
227    }
228    #[must_use]
229    pub const fn root(&self) -> Option<SemanticNodeId> {
230        self.root
231    }
232    pub fn set_root(&mut self, root: Option<SemanticNodeId>) {
233        if self.root != root {
234            self.root = root;
235            self.diagnostics.revisions = self.diagnostics.revisions.wrapping_add(1);
236        }
237    }
238    #[must_use]
239    pub fn node(&self, id: SemanticNodeId) -> Option<&SemanticNode> {
240        self.nodes.get(id.0).map(|retained| &retained.node)
241    }
242    #[must_use]
243    pub fn len(&self) -> usize {
244        self.nodes.len()
245    }
246    #[must_use]
247    pub fn is_empty(&self) -> bool {
248        self.nodes.is_empty()
249    }
250    #[must_use]
251    pub const fn diagnostics(&self) -> SemanticsDiagnostics {
252        self.diagnostics
253    }
254
255    /// Revision of the retained semantic graph.
256    ///
257    /// This is deliberately independent from paint and compositor state.  A
258    /// platform bridge can use it as a cheap dirty signal before performing
259    /// any projection work.
260    #[must_use]
261    pub const fn revision(&self) -> u64 {
262        self.diagnostics.revisions
263    }
264
265    pub fn insert(&mut self, node: SemanticNode) -> SemanticNodeId {
266        self.diagnostics.revisions = self.diagnostics.revisions.wrapping_add(1);
267        let id = SemanticNodeId(self.nodes.insert(RetainedNode {
268            node,
269            changed_at: self.diagnostics.revisions,
270        }));
271        self.nodes.get_mut(id.0).expect("new semantic node").node.id = id;
272        self.diagnostics.nodes_created += 1;
273        id
274    }
275
276    pub fn update(&mut self, id: SemanticNodeId, node: SemanticNode) -> bool {
277        let Some(retained) = self.nodes.get_mut(id.0) else {
278            return false;
279        };
280        let old = &mut retained.node;
281        let properties_changed = old.role != node.role
282            || old.label != node.label
283            || old.value != node.value
284            || old.description != node.description
285            || old.state != node.state
286            || old.actions != node.actions
287            || old.children != node.children;
288        let geometry_changed = old.bounds != node.bounds;
289        if properties_changed || geometry_changed {
290            *old = node;
291            self.diagnostics.nodes_updated += 1;
292            self.diagnostics.revisions = self.diagnostics.revisions.wrapping_add(1);
293            retained.changed_at = self.diagnostics.revisions;
294        }
295        if properties_changed {
296            self.diagnostics.property_updates += 1;
297        }
298        if geometry_changed {
299            self.diagnostics.geometry_updates += 1;
300        }
301        true
302    }
303
304    /// Updates only `bounds` and `children` of a retained node, leaving its
305    /// role, text, state and actions untouched and uncopied. Reports the same
306    /// revision and diagnostics changes as [`Self::update`] would for a node
307    /// whose other properties are unchanged. Returns `false` for a stale id.
308    pub fn update_geometry(
309        &mut self,
310        id: SemanticNodeId,
311        bounds: Rect,
312        children: &[SemanticNodeId],
313    ) -> bool {
314        let Some(retained) = self.nodes.get_mut(id.0) else {
315            return false;
316        };
317        let old = &mut retained.node;
318        let children_changed = old.children != children;
319        let geometry_changed = old.bounds != bounds;
320        if !children_changed && !geometry_changed {
321            return true;
322        }
323        old.bounds = bounds;
324        if children_changed {
325            old.children.clear();
326            old.children.extend_from_slice(children);
327            self.diagnostics.property_updates += 1;
328        }
329        if geometry_changed {
330            self.diagnostics.geometry_updates += 1;
331        }
332        self.diagnostics.nodes_updated += 1;
333        self.diagnostics.revisions = self.diagnostics.revisions.wrapping_add(1);
334        retained.changed_at = self.diagnostics.revisions;
335        true
336    }
337
338    pub fn remove(&mut self, id: SemanticNodeId) -> Option<SemanticNode> {
339        let node = self.nodes.remove(id.0)?.node;
340        self.diagnostics.nodes_removed += 1;
341        self.diagnostics.revisions = self.diagnostics.revisions.wrapping_add(1);
342        if self.root == Some(id) {
343            self.root = None;
344        }
345        Some(node)
346    }
347
348    pub fn note_action(&mut self) {
349        self.diagnostics.actions_received += 1;
350    }
351    pub fn note_native_update(&mut self) {
352        self.diagnostics.native_updates += 1;
353    }
354    pub fn iter(&self) -> impl Iterator<Item = (SemanticNodeId, &SemanticNode)> {
355        self.nodes
356            .iter()
357            .map(|(id, retained)| (SemanticNodeId(id), &retained.node))
358    }
359
360    /// Nodes inserted or changed after graph revision `revision`, in arena
361    /// order. A projection last synchronized at `revision` reads these
362    /// instead of copying and comparing the whole tree. Removals are not
363    /// listed; [`SemanticsDiagnostics::nodes_removed`] tells whether any
364    /// happened.
365    pub fn changed_since(
366        &self,
367        revision: u64,
368    ) -> impl Iterator<Item = (SemanticNodeId, &SemanticNode)> {
369        self.nodes
370            .iter()
371            .filter(move |(_, retained)| retained.changed_at > revision)
372            .map(|(id, retained)| (SemanticNodeId(id), &retained.node))
373    }
374
375    #[must_use]
376    pub fn debug_dump(&self) -> String {
377        self.debug_dump_limited(usize::MAX, usize::MAX, usize::MAX)
378    }
379
380    /// Produces a deterministic diagnostic dump with hard node, depth, and
381    /// text limits. Accessibility trees can be application-sized, so debug
382    /// tooling must not recurse or allocate in proportion to an unbounded
383    /// subtree. A truncation marker is included in the returned text when a
384    /// limit is reached.
385    #[must_use]
386    pub fn debug_dump_bounded(&self, max_nodes: usize) -> String {
387        self.debug_dump_limited(max_nodes, 256, 256)
388    }
389
390    /// Lower-level bounded dump used by retained diagnostics and visual
391    /// debuggers that need a tighter label/depth budget.
392    #[must_use]
393    pub fn debug_dump_limited(
394        &self,
395        max_nodes: usize,
396        max_depth: usize,
397        max_text_chars: usize,
398    ) -> String {
399        fn truncate(value: Option<&String>, max_chars: usize) -> Option<String> {
400            let value = value?;
401            if value.chars().count() <= max_chars {
402                return Some(value.clone());
403            }
404            let mut result = value.chars().take(max_chars).collect::<String>();
405            result.push('…');
406            Some(result)
407        }
408
409        // Sibling semantic roots share no parent, so walking only the
410        // designated root silently omits them. Visit every parentless
411        // node instead: the designated root first (single-root output is
412        // unchanged), then the rest in id order. Parentage is untouched;
413        // each node still prints once under its first-visited parent.
414        let mut output = String::new();
415        let mut parented = std::collections::HashSet::new();
416        for (_, node) in self.iter() {
417            parented.extend(node.children.iter().copied());
418        }
419        let mut extra: Vec<SemanticNodeId> = self
420            .iter()
421            .map(|(id, _)| id)
422            .filter(|id| Some(*id) != self.root && !parented.contains(id))
423            .collect();
424        extra.sort_by_key(|id| id.0);
425        let mut stack = Vec::new();
426        for id in extra.into_iter().rev() {
427            stack.push((id, 0usize));
428        }
429        if let Some(root) = self.root {
430            stack.push((root, 0usize));
431        }
432        let mut visited = 0usize;
433        while let Some((id, depth)) = stack.pop() {
434            if visited >= max_nodes {
435                output.push_str("… semantic dump truncated (node limit)\n");
436                break;
437            }
438            if depth > max_depth {
439                output.push_str(&format!(
440                    "{}… semantic dump truncated (depth limit)\n",
441                    "  ".repeat(max_depth.min(256))
442                ));
443                continue;
444            }
445            let Some(node) = self.node(id) else { continue };
446            visited = visited.saturating_add(1);
447            output.push_str(&format!(
448                "{}{:?}#{:?} label={:?} value={:?} bounds=({:.1},{:.1},{:.1},{:.1}) focused={} actions={:?}\n",
449                "  ".repeat(depth), node.role, node.id,
450                truncate(node.label.as_ref(), max_text_chars),
451                truncate(node.value.as_ref(), max_text_chars),
452                node.bounds.origin.x, node.bounds.origin.y, node.bounds.size.width,
453                node.bounds.size.height, node.state.focused, node.actions,
454            ));
455            for child in node.children.iter().rev() {
456                stack.push((*child, depth.saturating_add(1)));
457            }
458        }
459        output
460    }
461}