Skip to main content

tessera_ui/
accessibility.rs

1//! # Accessibility Support
2//!
3//! This module provides accessibility infrastructure for Tessera UI using
4//! AccessKit. It enables screen readers and other assistive technologies to
5//! interact with Tessera applications.
6//!
7//! ## Usage
8//!
9//! Accessibility metadata is typically attached through semantics and
10//! interaction modifiers in higher-level component crates. Framework internals
11//! may also write accessibility state directly while building node-local
12//! modifier chains.
13
14mod tree_builder;
15
16use accesskit::{Action, NodeId as AccessKitNodeId, Role, Toggled};
17
18use crate::Px;
19
20#[cfg(test)]
21pub(crate) use tree_builder::build_tree_update;
22pub(crate) use tree_builder::{AccessibilityIndex, dispatch_action};
23
24/// A stable identifier for accessibility nodes.
25///
26/// Published nodes use their stable component instance identity, preserving
27/// focus and action targets across value updates and local rebuilds.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub struct AccessibilityId(pub u64);
30
31impl AccessibilityId {
32    /// Creates a new accessibility ID from a u64.
33    pub fn new(id: u64) -> Self {
34        Self(id)
35    }
36
37    /// Converts to AccessKit's NodeId.
38    pub fn to_accesskit_id(self) -> AccessKitNodeId {
39        AccessKitNodeId(self.0)
40    }
41
42    /// Creates from AccessKit's NodeId.
43    pub fn from_accesskit_id(id: AccessKitNodeId) -> Self {
44        Self(id.0)
45    }
46
47    /// Converts an arena node index into a frame-local identifier.
48    ///
49    /// This conversion does not preserve identity across tree rebuilds. Use
50    /// identifiers from the published accessibility tree for action requests.
51    pub fn from_component_node_id(node_id: indextree::NodeId) -> Self {
52        // NodeId implements Into<usize>, giving us the 1-based index
53        let index: usize = node_id.into();
54        Self(index as u64)
55    }
56}
57
58/// Padding applied to semantic bounds without affecting layout.
59#[derive(Debug, Clone, Copy)]
60pub struct AccessibilityPadding {
61    /// Left padding in physical pixels.
62    pub left: Px,
63    /// Top padding in physical pixels.
64    pub top: Px,
65    /// Right padding in physical pixels.
66    pub right: Px,
67    /// Bottom padding in physical pixels.
68    pub bottom: Px,
69}
70
71impl AccessibilityPadding {
72    /// Creates zero padding.
73    pub const fn zero() -> Self {
74        Self {
75            left: Px::ZERO,
76            top: Px::ZERO,
77            right: Px::ZERO,
78            bottom: Px::ZERO,
79        }
80    }
81}
82
83/// Semantic information for an accessibility node.
84///
85/// This structure contains all the metadata that assistive technologies need
86/// to understand and interact with a UI component.
87#[derive(Debug, Clone)]
88pub struct AccessibilityNode {
89    /// The role of this node (button, text input, etc.)
90    pub role: Option<Role>,
91    /// A human-readable label for this node
92    pub label: Option<String>,
93    /// A detailed description of this node
94    pub description: Option<String>,
95    /// The current value (for text inputs, sliders, etc.)
96    pub value: Option<String>,
97    /// Numeric value (for sliders, progress bars, etc.)
98    pub numeric_value: Option<f64>,
99    /// Minimum numeric value
100    pub min_numeric_value: Option<f64>,
101    /// Maximum numeric value
102    pub max_numeric_value: Option<f64>,
103    /// Physical orientation of a range-based control.
104    pub orientation: Option<accesskit::Orientation>,
105    /// Whether this node can receive focus
106    pub focusable: bool,
107    /// Whether this node is currently focused
108    pub focused: bool,
109    /// Toggled/checked state (for checkboxes, switches, radio buttons)
110    pub toggled: Option<Toggled>,
111    /// Validation failure associated with the current value.
112    pub invalid: Option<accesskit::Invalid>,
113    /// Whether this item is selected, when selection applies to its role.
114    pub selected: Option<bool>,
115    /// Whether a disclosure control is expanded.
116    pub expanded: Option<bool>,
117    /// Kind of popup opened by this control.
118    pub has_popup: Option<accesskit::HasPopup>,
119    /// Whether this node is disabled
120    pub disabled: bool,
121    /// Whether this node is hidden from accessibility
122    pub hidden: bool,
123    /// Whether this dialog blocks interaction with the rest of the window.
124    pub modal: bool,
125    /// Supported actions
126    pub actions: Vec<Action>,
127    /// Custom accessibility key provided by the component
128    pub key: Option<String>,
129    /// Whether to merge child semantics into this node. When false, child
130    /// semantics are ignored, similar to Compose's `clearAndSetSemantics`.
131    pub merge_descendants: bool,
132    /// Optional padding applied to the semantic bounds without affecting
133    /// layout.
134    pub bounds_padding: Option<AccessibilityPadding>,
135    /// Optional state description announced in addition to the label.
136    pub state_description: Option<String>,
137    /// Optional custom role description for custom controls.
138    pub role_description: Option<String>,
139    /// Optional contextual help, inherited as a description by focusable
140    /// descendants that do not supply their own description.
141    pub tooltip: Option<String>,
142    /// Live region politeness.
143    pub live: Option<accesskit::Live>,
144    /// Optional heading level (1-based). When set and no role is provided,
145    /// role will default to `Heading`.
146    pub heading_level: Option<u32>,
147    /// Optional scroll x value and range.
148    pub scroll_x: Option<(f64, f64, f64)>,
149    /// Optional scroll y value and range.
150    pub scroll_y: Option<(f64, f64, f64)>,
151    /// Optional numeric step for range-based controls.
152    pub numeric_value_step: Option<f64>,
153    /// Optional numeric jump value for range-based controls.
154    pub numeric_value_jump: Option<f64>,
155    /// Optional collection info: row_count, column_count, hierarchical.
156    pub collection_info: Option<(usize, usize, bool)>,
157    /// Optional collection item info: row_index, row_span, col_index, col_span,
158    /// heading.
159    pub collection_item_info: Option<(usize, usize, usize, usize, bool)>,
160    /// Optional editable text flag.
161    pub is_editable_text: bool,
162}
163
164impl AccessibilityNode {
165    /// Creates a new empty accessibility node.
166    pub fn new() -> Self {
167        Self::default()
168    }
169
170    /// Sets the role of this node.
171    pub fn with_role(mut self, role: Role) -> Self {
172        self.role = Some(role);
173        self
174    }
175
176    /// Sets the label of this node.
177    pub fn with_label(mut self, label: impl Into<String>) -> Self {
178        self.label = Some(label.into());
179        self
180    }
181
182    /// Sets the description of this node.
183    pub fn with_description(mut self, description: impl Into<String>) -> Self {
184        self.description = Some(description.into());
185        self
186    }
187
188    /// Sets the value of this node.
189    pub fn with_value(mut self, value: impl Into<String>) -> Self {
190        self.value = Some(value.into());
191        self
192    }
193
194    /// Sets the numeric value of this node.
195    pub fn with_numeric_value(mut self, value: f64) -> Self {
196        self.numeric_value = Some(value);
197        self
198    }
199
200    /// Sets the numeric range of this node.
201    pub fn with_numeric_range(mut self, min: f64, max: f64) -> Self {
202        self.min_numeric_value = Some(min);
203        self.max_numeric_value = Some(max);
204        self
205    }
206
207    /// Marks this node as focusable.
208    pub fn focusable(mut self) -> Self {
209        self.focusable = true;
210        self
211    }
212
213    /// Marks this node as focused.
214    pub fn focused(mut self) -> Self {
215        self.focused = true;
216        self
217    }
218
219    /// Sets the toggled/checked state of this node.
220    pub fn with_toggled(mut self, toggled: Toggled) -> Self {
221        self.toggled = Some(toggled);
222        self
223    }
224
225    /// Marks this node as disabled.
226    pub fn disabled(mut self) -> Self {
227        self.disabled = true;
228        self
229    }
230
231    /// Marks this node as hidden from accessibility.
232    pub fn hidden(mut self) -> Self {
233        self.hidden = true;
234        self
235    }
236
237    /// Adds an action that this node supports.
238    pub fn with_action(mut self, action: Action) -> Self {
239        self.actions.push(action);
240        self
241    }
242
243    /// Adds multiple actions that this node supports.
244    pub fn with_actions(mut self, actions: impl IntoIterator<Item = Action>) -> Self {
245        self.actions.extend(actions);
246        self
247    }
248
249    /// Sets an application-defined semantic key for snapshots and inspection.
250    pub fn with_key(mut self, key: impl Into<String>) -> Self {
251        self.key = Some(key.into());
252        self
253    }
254}
255
256impl Default for AccessibilityNode {
257    fn default() -> Self {
258        Self {
259            role: None,
260            label: None,
261            description: None,
262            value: None,
263            numeric_value: None,
264            min_numeric_value: None,
265            max_numeric_value: None,
266            orientation: None,
267            focusable: false,
268            focused: false,
269            toggled: None,
270            invalid: None,
271            selected: None,
272            expanded: None,
273            has_popup: None,
274            disabled: false,
275            hidden: false,
276            modal: false,
277            actions: Vec::new(),
278            key: None,
279            merge_descendants: true,
280            bounds_padding: None,
281            state_description: None,
282            role_description: None,
283            tooltip: None,
284            live: None,
285            heading_level: None,
286            scroll_x: None,
287            scroll_y: None,
288            numeric_value_step: None,
289            numeric_value_jump: None,
290            collection_info: None,
291            collection_item_info: None,
292            is_editable_text: false,
293        }
294    }
295}
296
297pub(crate) enum StoredSemantics {
298    Description(Box<SemanticDescription>),
299    Extended(Box<AccessibilityNode>),
300}
301
302pub(crate) struct SemanticDescription {
303    role: Option<Role>,
304    label: Option<String>,
305    description: Option<String>,
306    key: Option<String>,
307    merge_descendants: bool,
308    tooltip: Option<String>,
309}
310
311impl StoredSemantics {
312    pub(crate) fn write(storage: &mut Option<Self>, node: AccessibilityNode) {
313        match node {
314            AccessibilityNode {
315                role,
316                label,
317                description,
318                key,
319                merge_descendants,
320                tooltip,
321                value: None,
322                numeric_value: None,
323                min_numeric_value: None,
324                max_numeric_value: None,
325                orientation: None,
326                focusable: false,
327                focused: false,
328                toggled: None,
329                invalid: None,
330                selected: None,
331                expanded: None,
332                has_popup: None,
333                disabled: false,
334                hidden: false,
335                modal: false,
336                actions,
337                bounds_padding: None,
338                state_description: None,
339                role_description: None,
340                live: None,
341                heading_level: None,
342                scroll_x: None,
343                scroll_y: None,
344                numeric_value_step: None,
345                numeric_value_jump: None,
346                collection_info: None,
347                collection_item_info: None,
348                is_editable_text: false,
349            } if actions.is_empty() => {
350                let description = SemanticDescription {
351                    role,
352                    label,
353                    description,
354                    key,
355                    merge_descendants,
356                    tooltip,
357                };
358                match storage {
359                    Some(Self::Description(previous)) => **previous = description,
360                    _ => *storage = Some(Self::Description(Box::new(description))),
361                }
362            }
363            node => match storage {
364                Some(Self::Extended(previous)) => **previous = node,
365                _ => *storage = Some(Self::Extended(Box::new(node))),
366            },
367        }
368    }
369
370    pub(crate) fn descendant_context(&self) -> (bool, Option<&str>) {
371        match self {
372            Self::Description(node) => (node.merge_descendants, node.tooltip.as_deref()),
373            Self::Extended(node) => (node.merge_descendants, node.tooltip.as_deref()),
374        }
375    }
376
377    pub(crate) fn extended(&self) -> Option<&AccessibilityNode> {
378        match self {
379            Self::Description(_) => None,
380            Self::Extended(node) => Some(node),
381        }
382    }
383
384    pub(crate) fn snapshot(&self) -> AccessibilityNode {
385        match self {
386            Self::Description(node) => AccessibilityNode {
387                role: node.role,
388                label: node.label.clone(),
389                description: node.description.clone(),
390                key: node.key.clone(),
391                merge_descendants: node.merge_descendants,
392                tooltip: node.tooltip.clone(),
393                ..AccessibilityNode::default()
394            },
395            Self::Extended(node) => node.as_ref().clone(),
396        }
397    }
398}
399
400/// Handler for accessibility actions.
401///
402/// When an assistive technology requests an action (like clicking a button),
403/// this handler is invoked.
404pub type AccessibilityActionHandler = Box<dyn Fn(Action) + Send + Sync>;