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
20pub(crate) use tree_builder::{build_tree_update, dispatch_action};
21
22/// A stable identifier for accessibility nodes.
23///
24/// This ID is generated based on the component's position in the tree and
25/// optional user-provided keys. It remains stable across frames as long as the
26/// UI structure doesn't change.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub struct AccessibilityId(pub u64);
29
30impl AccessibilityId {
31 /// Creates a new accessibility ID from a u64.
32 pub fn new(id: u64) -> Self {
33 Self(id)
34 }
35
36 /// Converts to AccessKit's NodeId.
37 pub fn to_accesskit_id(self) -> AccessKitNodeId {
38 AccessKitNodeId(self.0)
39 }
40
41 /// Creates from AccessKit's NodeId.
42 pub fn from_accesskit_id(id: AccessKitNodeId) -> Self {
43 Self(id.0)
44 }
45
46 /// Generates a stable ID from an indextree NodeId.
47 ///
48 /// indextree uses an arena-based implementation where NodeIds contain:
49 /// - A 1-based index into the arena
50 /// - A stamp for detecting node reuse
51 ///
52 /// In Tessera's immediate-mode model, the component tree is cleared and
53 /// rebuilt each frame, so there's no node reuse within a frame. This
54 /// makes the index stable for the current tree state, which is exactly
55 /// what AccessKit requires (IDs only need to be stable within the current
56 /// tree).
57 ///
58 /// # Stability Guarantee
59 ///
60 /// The ID is stable within a single frame as long as the UI structure
61 /// doesn't change. This matches AccessKit's requirement perfectly.
62 pub fn from_component_node_id(node_id: indextree::NodeId) -> Self {
63 // NodeId implements Into<usize>, giving us the 1-based index
64 let index: usize = node_id.into();
65 Self(index as u64)
66 }
67}
68
69/// Padding applied to semantic bounds without affecting layout.
70#[derive(Debug, Clone, Copy)]
71pub struct AccessibilityPadding {
72 /// Left padding in physical pixels.
73 pub left: Px,
74 /// Top padding in physical pixels.
75 pub top: Px,
76 /// Right padding in physical pixels.
77 pub right: Px,
78 /// Bottom padding in physical pixels.
79 pub bottom: Px,
80}
81
82impl AccessibilityPadding {
83 /// Creates zero padding.
84 pub const fn zero() -> Self {
85 Self {
86 left: Px::ZERO,
87 top: Px::ZERO,
88 right: Px::ZERO,
89 bottom: Px::ZERO,
90 }
91 }
92}
93
94/// Semantic information for an accessibility node.
95///
96/// This structure contains all the metadata that assistive technologies need
97/// to understand and interact with a UI component.
98#[derive(Debug, Clone)]
99pub struct AccessibilityNode {
100 /// The role of this node (button, text input, etc.)
101 pub role: Option<Role>,
102 /// A human-readable label for this node
103 pub label: Option<String>,
104 /// A detailed description of this node
105 pub description: Option<String>,
106 /// The current value (for text inputs, sliders, etc.)
107 pub value: Option<String>,
108 /// Numeric value (for sliders, progress bars, etc.)
109 pub numeric_value: Option<f64>,
110 /// Minimum numeric value
111 pub min_numeric_value: Option<f64>,
112 /// Maximum numeric value
113 pub max_numeric_value: Option<f64>,
114 /// Whether this node can receive focus
115 pub focusable: bool,
116 /// Whether this node is currently focused
117 pub focused: bool,
118 /// Toggled/checked state (for checkboxes, switches, radio buttons)
119 pub toggled: Option<Toggled>,
120 /// Whether this node is disabled
121 pub disabled: bool,
122 /// Whether this node is hidden from accessibility
123 pub hidden: bool,
124 /// Supported actions
125 pub actions: Vec<Action>,
126 /// Custom accessibility key provided by the component
127 pub key: Option<String>,
128 /// Whether to merge child semantics into this node. When false, child
129 /// semantics are ignored, similar to Compose's `clearAndSetSemantics`.
130 pub merge_descendants: bool,
131 /// Optional padding applied to the semantic bounds without affecting
132 /// layout.
133 pub bounds_padding: Option<AccessibilityPadding>,
134 /// Optional state description announced in addition to the label.
135 pub state_description: Option<String>,
136 /// Optional custom role description for custom controls.
137 pub role_description: Option<String>,
138 /// Optional tooltip text exposed as a name override.
139 pub tooltip: Option<String>,
140 /// Live region politeness.
141 pub live: Option<accesskit::Live>,
142 /// Optional heading level (1-based). When set and no role is provided,
143 /// role will default to `Heading`.
144 pub heading_level: Option<u32>,
145 /// Optional scroll x value and range.
146 pub scroll_x: Option<(f64, f64, f64)>,
147 /// Optional scroll y value and range.
148 pub scroll_y: Option<(f64, f64, f64)>,
149 /// Optional numeric step for range-based controls.
150 pub numeric_value_step: Option<f64>,
151 /// Optional numeric jump value for range-based controls.
152 pub numeric_value_jump: Option<f64>,
153 /// Optional collection info: row_count, column_count, hierarchical.
154 pub collection_info: Option<(usize, usize, bool)>,
155 /// Optional collection item info: row_index, row_span, col_index, col_span,
156 /// heading.
157 pub collection_item_info: Option<(usize, usize, usize, usize, bool)>,
158 /// Optional editable text flag.
159 pub is_editable_text: bool,
160}
161
162impl AccessibilityNode {
163 /// Creates a new empty accessibility node.
164 pub fn new() -> Self {
165 Self::default()
166 }
167
168 /// Sets the role of this node.
169 pub fn with_role(mut self, role: Role) -> Self {
170 self.role = Some(role);
171 self
172 }
173
174 /// Sets the label of this node.
175 pub fn with_label(mut self, label: impl Into<String>) -> Self {
176 self.label = Some(label.into());
177 self
178 }
179
180 /// Sets the description of this node.
181 pub fn with_description(mut self, description: impl Into<String>) -> Self {
182 self.description = Some(description.into());
183 self
184 }
185
186 /// Sets the value of this node.
187 pub fn with_value(mut self, value: impl Into<String>) -> Self {
188 self.value = Some(value.into());
189 self
190 }
191
192 /// Sets the numeric value of this node.
193 pub fn with_numeric_value(mut self, value: f64) -> Self {
194 self.numeric_value = Some(value);
195 self
196 }
197
198 /// Sets the numeric range of this node.
199 pub fn with_numeric_range(mut self, min: f64, max: f64) -> Self {
200 self.min_numeric_value = Some(min);
201 self.max_numeric_value = Some(max);
202 self
203 }
204
205 /// Marks this node as focusable.
206 pub fn focusable(mut self) -> Self {
207 self.focusable = true;
208 self
209 }
210
211 /// Marks this node as focused.
212 pub fn focused(mut self) -> Self {
213 self.focused = true;
214 self
215 }
216
217 /// Sets the toggled/checked state of this node.
218 pub fn with_toggled(mut self, toggled: Toggled) -> Self {
219 self.toggled = Some(toggled);
220 self
221 }
222
223 /// Marks this node as disabled.
224 pub fn disabled(mut self) -> Self {
225 self.disabled = true;
226 self
227 }
228
229 /// Marks this node as hidden from accessibility.
230 pub fn hidden(mut self) -> Self {
231 self.hidden = true;
232 self
233 }
234
235 /// Adds an action that this node supports.
236 pub fn with_action(mut self, action: Action) -> Self {
237 self.actions.push(action);
238 self
239 }
240
241 /// Adds multiple actions that this node supports.
242 pub fn with_actions(mut self, actions: impl IntoIterator<Item = Action>) -> Self {
243 self.actions.extend(actions);
244 self
245 }
246
247 /// Sets a custom accessibility key for stable ID generation.
248 pub fn with_key(mut self, key: impl Into<String>) -> Self {
249 self.key = Some(key.into());
250 self
251 }
252}
253
254impl Default for AccessibilityNode {
255 fn default() -> Self {
256 Self {
257 role: None,
258 label: None,
259 description: None,
260 value: None,
261 numeric_value: None,
262 min_numeric_value: None,
263 max_numeric_value: None,
264 focusable: false,
265 focused: false,
266 toggled: None,
267 disabled: false,
268 hidden: false,
269 actions: Vec::new(),
270 key: None,
271 merge_descendants: true,
272 bounds_padding: None,
273 state_description: None,
274 role_description: None,
275 tooltip: None,
276 live: None,
277 heading_level: None,
278 scroll_x: None,
279 scroll_y: None,
280 numeric_value_step: None,
281 numeric_value_jump: None,
282 collection_info: None,
283 collection_item_info: None,
284 is_editable_text: false,
285 }
286 }
287}
288
289/// Handler for accessibility actions.
290///
291/// When an assistive technology requests an action (like clicking a button),
292/// this handler is invoked.
293pub type AccessibilityActionHandler = Box<dyn Fn(Action) + Send + Sync>;