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}
164
165#[derive(Clone, Debug, PartialEq)]
166pub struct SemanticNode {
167    pub id: SemanticNodeId,
168    pub role: Role,
169    pub label: Option<String>,
170    pub value: Option<String>,
171    pub description: Option<String>,
172    pub bounds: Rect,
173    pub state: SemanticState,
174    pub actions: Vec<SemanticActionKind>,
175    pub children: Vec<SemanticNodeId>,
176}
177
178#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
179pub struct SemanticsDiagnostics {
180    pub nodes_created: u64,
181    pub nodes_removed: u64,
182    pub nodes_updated: u64,
183    pub property_updates: u64,
184    pub geometry_updates: u64,
185    pub native_updates: u64,
186    pub actions_received: u64,
187    /// Monotonically increases only when the retained semantic graph changes.
188    /// Native adapters use this to avoid rebuilding or even walking an
189    /// unchanged tree on paint/compositor-only frames.
190    pub revisions: u64,
191}
192
193/// Retained semantic arena. IDs are generational, so stale platform actions
194/// cannot address a node that later reuses the same storage slot.
195#[derive(Default)]
196pub struct SemanticsTree {
197    nodes: Arena<SemanticNode>,
198    root: Option<SemanticNodeId>,
199    diagnostics: SemanticsDiagnostics,
200}
201
202impl SemanticsTree {
203    #[must_use]
204    pub fn new() -> Self {
205        Self::default()
206    }
207    #[must_use]
208    pub const fn root(&self) -> Option<SemanticNodeId> {
209        self.root
210    }
211    pub fn set_root(&mut self, root: Option<SemanticNodeId>) {
212        if self.root != root {
213            self.root = root;
214            self.diagnostics.revisions = self.diagnostics.revisions.wrapping_add(1);
215        }
216    }
217    #[must_use]
218    pub fn node(&self, id: SemanticNodeId) -> Option<&SemanticNode> {
219        self.nodes.get(id.0)
220    }
221    #[must_use]
222    pub fn len(&self) -> usize {
223        self.nodes.len()
224    }
225    #[must_use]
226    pub fn is_empty(&self) -> bool {
227        self.nodes.is_empty()
228    }
229    #[must_use]
230    pub const fn diagnostics(&self) -> SemanticsDiagnostics {
231        self.diagnostics
232    }
233
234    /// Revision of the retained semantic graph.
235    ///
236    /// This is deliberately independent from paint and compositor state.  A
237    /// platform bridge can use it as a cheap dirty signal before performing
238    /// any projection work.
239    #[must_use]
240    pub const fn revision(&self) -> u64 {
241        self.diagnostics.revisions
242    }
243
244    pub fn insert(&mut self, node: SemanticNode) -> SemanticNodeId {
245        let id = SemanticNodeId(self.nodes.insert(node));
246        self.nodes.get_mut(id.0).expect("new semantic node").id = id;
247        self.diagnostics.nodes_created += 1;
248        self.diagnostics.revisions = self.diagnostics.revisions.wrapping_add(1);
249        id
250    }
251
252    pub fn update(&mut self, id: SemanticNodeId, node: SemanticNode) -> bool {
253        let Some(old) = self.nodes.get_mut(id.0) else {
254            return false;
255        };
256        let properties_changed = old.role != node.role
257            || old.label != node.label
258            || old.value != node.value
259            || old.description != node.description
260            || old.state != node.state
261            || old.actions != node.actions
262            || old.children != node.children;
263        let geometry_changed = old.bounds != node.bounds;
264        if properties_changed || geometry_changed {
265            *old = node;
266            self.diagnostics.nodes_updated += 1;
267            self.diagnostics.revisions = self.diagnostics.revisions.wrapping_add(1);
268        }
269        if properties_changed {
270            self.diagnostics.property_updates += 1;
271        }
272        if geometry_changed {
273            self.diagnostics.geometry_updates += 1;
274        }
275        true
276    }
277
278    pub fn remove(&mut self, id: SemanticNodeId) -> Option<SemanticNode> {
279        let node = self.nodes.remove(id.0)?;
280        self.diagnostics.nodes_removed += 1;
281        self.diagnostics.revisions = self.diagnostics.revisions.wrapping_add(1);
282        if self.root == Some(id) {
283            self.root = None;
284        }
285        Some(node)
286    }
287
288    pub fn note_action(&mut self) {
289        self.diagnostics.actions_received += 1;
290    }
291    pub fn note_native_update(&mut self) {
292        self.diagnostics.native_updates += 1;
293    }
294    pub fn iter(&self) -> impl Iterator<Item = (SemanticNodeId, &SemanticNode)> {
295        self.nodes
296            .iter()
297            .map(|(id, node)| (SemanticNodeId(id), node))
298    }
299
300    #[must_use]
301    pub fn debug_dump(&self) -> String {
302        self.debug_dump_limited(usize::MAX, usize::MAX, usize::MAX)
303    }
304
305    /// Produces a deterministic diagnostic dump with hard node, depth, and
306    /// text limits. Accessibility trees can be application-sized, so debug
307    /// tooling must not recurse or allocate in proportion to an unbounded
308    /// subtree. A truncation marker is included in the returned text when a
309    /// limit is reached.
310    #[must_use]
311    pub fn debug_dump_bounded(&self, max_nodes: usize) -> String {
312        self.debug_dump_limited(max_nodes, 256, 256)
313    }
314
315    /// Lower-level bounded dump used by retained diagnostics and visual
316    /// debuggers that need a tighter label/depth budget.
317    #[must_use]
318    pub fn debug_dump_limited(
319        &self,
320        max_nodes: usize,
321        max_depth: usize,
322        max_text_chars: usize,
323    ) -> String {
324        fn truncate(value: Option<&String>, max_chars: usize) -> Option<String> {
325            let value = value?;
326            if value.chars().count() <= max_chars {
327                return Some(value.clone());
328            }
329            let mut result = value.chars().take(max_chars).collect::<String>();
330            result.push('…');
331            Some(result)
332        }
333
334        // Sibling semantic roots share no parent, so walking only the
335        // designated root silently omits them. Visit every parentless
336        // node instead: the designated root first (single-root output is
337        // unchanged), then the rest in id order. Parentage is untouched;
338        // each node still prints once under its first-visited parent.
339        let mut output = String::new();
340        let mut parented = std::collections::HashSet::new();
341        for (_, node) in self.iter() {
342            parented.extend(node.children.iter().copied());
343        }
344        let mut extra: Vec<SemanticNodeId> = self
345            .iter()
346            .map(|(id, _)| id)
347            .filter(|id| Some(*id) != self.root && !parented.contains(id))
348            .collect();
349        extra.sort_by_key(|id| id.0);
350        let mut stack = Vec::new();
351        for id in extra.into_iter().rev() {
352            stack.push((id, 0usize));
353        }
354        if let Some(root) = self.root {
355            stack.push((root, 0usize));
356        }
357        let mut visited = 0usize;
358        while let Some((id, depth)) = stack.pop() {
359            if visited >= max_nodes {
360                output.push_str("… semantic dump truncated (node limit)\n");
361                break;
362            }
363            if depth > max_depth {
364                output.push_str(&format!(
365                    "{}… semantic dump truncated (depth limit)\n",
366                    "  ".repeat(max_depth.min(256))
367                ));
368                continue;
369            }
370            let Some(node) = self.node(id) else { continue };
371            visited = visited.saturating_add(1);
372            output.push_str(&format!(
373                "{}{:?}#{:?} label={:?} value={:?} bounds=({:.1},{:.1},{:.1},{:.1}) focused={} actions={:?}\n",
374                "  ".repeat(depth), node.role, node.id,
375                truncate(node.label.as_ref(), max_text_chars),
376                truncate(node.value.as_ref(), max_text_chars),
377                node.bounds.origin.x, node.bounds.origin.y, node.bounds.size.width,
378                node.bounds.size.height, node.state.focused, node.actions,
379            ));
380            for child in node.children.iter().rev() {
381                stack.push((*child, depth.saturating_add(1)));
382            }
383        }
384        output
385    }
386}