1
//! Accessibility Manager for integrating with `accesskit`.
2
//!
3
//! This module provides the `A11yManager` which:
4
//!
5
//! - Maintains the accessibility tree state
6
//! - Generates `TreeUpdate`s after each layout pass
7
//! - Handles `ActionRequest`s from assistive technologies
8
//!
9
//! The manager translates between Azul's internal DOM representation and
10
//! the platform-agnostic `accesskit` tree format.
11

            
12
#[cfg(feature = "a11y")]
13
use std::collections::HashMap;
14

            
15
#[cfg(feature = "a11y")]
16
use accesskit::{Action, ActionRequest, Node, NodeId as A11yNodeId, Rect, Role, Tree, TreeUpdate};
17
use azul_core::{
18
    dom::{
19
        AccessibilityAction, AccessibilityInfo, AccessibilityRole, AccessibilityState, DomId,
20
        DomNodeId, NodeData, NodeId, NodeType, TextSelectionStartEnd,
21
    },
22
    geom::{LogicalPosition, LogicalSize},
23
    styled_dom::NodeHierarchyItemId,
24
};
25
use azul_css::AzString;
26

            
27
use crate::{solver3::layout_tree::LayoutNodeHot, window::DomLayoutResult};
28

            
29
/// Cursor/selection info passed to the a11y tree builder.
30
/// Used to set text_selection on contenteditable nodes so screen readers
31
/// can announce the cursor position and selection range.
32
#[cfg(feature = "a11y")]
33
pub struct CursorA11yInfo {
34
    pub dom_id: DomId,
35
    pub node_id: NodeId,
36
    /// Byte offset of the selection anchor (start of selection, or cursor pos if no range)
37
    pub anchor_offset: usize,
38
    /// Byte offset of the selection focus (end of selection, or same as anchor for cursor)
39
    pub focus_offset: usize,
40
}
41

            
42
/// Manager for accessibility tree state and updates.
43
///
44
/// The `A11yManager` sits within `LayoutWindow` and is responsible for:
45
///
46
/// 1. Maintaining the current accessibility tree state
47
/// 2. Generating `TreeUpdate`s by comparing layout results with the stored tree
48
/// 3. Translating `ActionRequest`s from screen readers into synthetic Azul events
49
#[cfg(feature = "a11y")]
50
pub struct A11yManager {
51
    /// The root node ID of the accessibility tree (represents the window).
52
    pub root_id: A11yNodeId,
53
    /// The current accessibility tree state.
54
    pub tree: Option<Tree>,
55
    /// The last generated tree update (for platform adapter consumption).
56
    pub last_tree_update: Option<TreeUpdate>,
57
    /// Whether the full tree has been sent to the platform adapter at least once.
58
    /// After initialization, incremental updates can use `tree: None`.
59
    pub tree_initialized: bool,
60
}
61

            
62
#[cfg(feature = "a11y")]
63
impl A11yManager {
64
    /// Creates a new `A11yManager` with an empty tree containing only a root window node.
65
3487
    pub fn new() -> Self {
66
3487
        let root_id = A11yNodeId(0);
67
3487
        Self {
68
3487
            root_id,
69
3487
            tree: None,
70
3487
            last_tree_update: None,
71
3487
            tree_initialized: false,
72
3487
        }
73
3487
    }
74

            
75
    /// Updates the accessibility tree based on the current layout state.
76
    ///
77
    /// This should be called after each layout pass to synchronize the
78
    /// accessibility tree with the visual representation.
79
3432
    pub fn update_tree(
80
3432
        root_id: A11yNodeId,
81
3432
        layout_results: &std::collections::BTreeMap<DomId, DomLayoutResult>,
82
3432
        window_title: &AzString,
83
3432
        window_size: LogicalSize,
84
3432
        focused_node: Option<azul_core::dom::DomNodeId>,
85
3432
        hidpi_factor: f32,
86
3432
        dirty_text_overrides: &std::collections::BTreeMap<(DomId, NodeId), String>,
87
3432
        cursor_info: Option<CursorA11yInfo>,
88
3432
    ) -> TreeUpdate {
89
3432
        let mut nodes = Vec::new();
90
3432
        let mut root_children = Vec::new();
91

            
92
        // Map from (DomId, NodeId) to A11yNodeId for building parent-child relationships
93
3432
        let mut node_id_map: HashMap<(u32, u32), A11yNodeId> = HashMap::new();
94

            
95
        // Map to collect children for each parent
96
3432
        let mut parent_children_map: HashMap<A11yNodeId, Vec<A11yNodeId>> = HashMap::new();
97

            
98
        // Create root window node and add it to the nodes list
99
3432
        let mut root_node = Node::new(Role::Window);
100
3432
        root_node.set_label(window_title.as_str());
101
3432
        nodes.push((root_id, root_node));
102

            
103
7172
        for (dom_id, layout_result) in layout_results {
104
3740
            let styled_dom = &layout_result.styled_dom;
105
3740
            let node_hierarchy = styled_dom.node_hierarchy.as_ref();
106
3740
            let node_data_slice = styled_dom.node_data.as_ref();
107

            
108
            // First pass: Create a11y nodes for each DOM node
109
41668
            for (dom_idx, node_data) in node_data_slice.iter().enumerate() {
110
41668
                let a11y_info = node_data.get_accessibility_info();
111

            
112
                // Include every node that has a meaningful role.
113
                // The only types we skip are metadata (Head, Meta, Script, Style, etc.)
114
                // and pseudo-elements that don't represent real content.
115
41668
                let should_create_node = a11y_info.is_some()
116
41668
                    || node_data.is_contenteditable()
117
41316
                    || node_data.is_focusable()
118
39160
                    || !matches!(node_data.node_type,
119
                        NodeType::Head | NodeType::Meta | NodeType::Link
120
                        | NodeType::Script | NodeType::Style | NodeType::Base
121
                        | NodeType::Before | NodeType::After | NodeType::Marker
122
                        | NodeType::Placeholder | NodeType::Source | NodeType::Track
123
                        | NodeType::Param | NodeType::Col | NodeType::ColGroup
124
                        | NodeType::Wbr | NodeType::Rp | NodeType::Rtc
125
                        | NodeType::Bdo | NodeType::Bdi | NodeType::Data
126
                        | NodeType::Map | NodeType::Area | NodeType::VirtualView
127
                    );
128

            
129
41668
                if !should_create_node {
130
308
                    continue;
131
41360
                }
132

            
133
                // Generate stable A11yNodeId: offset by 1 to avoid collision with root_id(0)
134
41360
                let a11y_node_id = Self::encode_a11y_node_id(dom_id.inner, dom_idx);
135

            
136
                // Get layout info: absolute position from calculated_positions,
137
                // size from layout node. Uses dom_to_layout to map DOM → layout index.
138
41360
                let dom_node_id = NodeId::new(dom_idx);
139
41360
                let layout_info = layout_result.layout_tree.dom_to_layout
140
41360
                    .get(&dom_node_id)
141
41360
                    .and_then(|indices| indices.first())
142
41360
                    .and_then(|&layout_idx| {
143
41316
                        let hot = layout_result.layout_tree.get(layout_idx)?;
144
41316
                        let abs_pos = layout_result.calculated_positions
145
41316
                            .get(layout_idx).copied();
146
41316
                        Some((hot, layout_idx, abs_pos))
147
41316
                    });
148

            
149
41360
                let a11y_info_ref = a11y_info.as_ref().map(|b| b.as_ref());
150
41360
                let mut node = match layout_info {
151
41316
                    Some((layout_node, _layout_idx, abs_pos)) => {
152
41316
                        Self::build_node(node_data, layout_node, abs_pos, a11y_info_ref, hidpi_factor, window_size)
153
                    }
154
                    None => {
155
44
                        let role = if let Some(info) = a11y_info_ref {
156
                            Self::map_role(&info.role)
157
                        } else {
158
44
                            Self::node_type_to_role(&node_data.node_type)
159
                        };
160
44
                        let mut builder = Node::new(role);
161
44
                        if let NodeType::Text(text) = &node_data.node_type {
162
                            builder.set_label(text.as_str());
163
44
                        }
164
44
                        builder
165
                    }
166
                };
167

            
168
                // Collect child text and promote to this node's label or value.
169
                // Only do this when all children are text nodes — if the node has
170
                // interactive children (links, buttons, inputs), DON'T set a group
171
                // label, so VoiceOver navigates into the children individually.
172
                //
173
                // For edited contenteditable nodes, dirty_text_overrides has the
174
                // current text (from the relayout path) instead of the stale
175
                // StyledDom text.
176
                {
177
41360
                    let hierarchy_item = &node_hierarchy[dom_idx];
178
41360
                    let dom_node_id_key = (*dom_id, NodeId::new(dom_idx));
179

            
180
                    // Use dirty text override if this node was edited since last RefreshDom
181
41360
                    let (text_content, has_non_text_children) = if let Some(override_text) = dirty_text_overrides.get(&dom_node_id_key) {
182
                        (override_text.clone(), false)
183
                    } else {
184
41360
                        let mut text = String::new();
185
41360
                        let mut has_non_text = false;
186

            
187
41360
                        let mut child = hierarchy_item.first_child_id(NodeId::new(dom_idx));
188
79288
                        while let Some(child_id) = child {
189
37928
                            if let Some(child_data) = node_data_slice.get(child_id.index()) {
190
37928
                                if let NodeType::Text(t) = &child_data.node_type {
191
14564
                                    if !text.is_empty() { text.push(' '); }
192
14564
                                    text.push_str(t.as_str());
193
23364
                                } else {
194
23364
                                    has_non_text = true;
195
23364
                                }
196
                            }
197
37928
                            if child_id.index() >= node_hierarchy.len() { break; }
198
37928
                            child = node_hierarchy[child_id.index()].next_sibling_id();
199
                        }
200
41360
                        (text, has_non_text)
201
                    };
202

            
203
41360
                    if !text_content.is_empty() {
204
14564
                        if node_data.is_contenteditable()
205
14212
                            || matches!(node_data.node_type, NodeType::TextArea | NodeType::Input)
206
                        {
207
352
                            node.set_value(text_content.as_str());
208
                            // Add text editing actions for contenteditable/input nodes
209
352
                            node.add_action(Action::SetTextSelection);
210
352
                            node.add_action(Action::ReplaceSelectedText);
211
352
                            node.add_action(Action::SetValue);
212

            
213
                            // If cursor/selection is in this node, expose to screen readers
214
352
                            if let Some(ref ci) = cursor_info {
215
                                if ci.dom_id == *dom_id && ci.node_id == NodeId::new(dom_idx) {
216
                                    let char_lengths: Vec<u8> = text_content.chars()
217
                                        .map(|c| c.len_utf16() as u8)
218
                                        .collect();
219
                                    node.set_character_lengths(char_lengths.clone());
220

            
221
                                    let byte_to_char_idx = |byte_off: usize| -> usize {
222
                                        text_content
223
                                            .char_indices()
224
                                            .take_while(|(b, _)| *b < byte_off)
225
                                            .count()
226
                                            .min(char_lengths.len())
227
                                    };
228

            
229
                                    let anchor_idx = byte_to_char_idx(ci.anchor_offset);
230
                                    let focus_idx = byte_to_char_idx(ci.focus_offset);
231

            
232
                                    node.set_text_selection(accesskit::TextSelection {
233
                                        anchor: accesskit::TextPosition {
234
                                            node: a11y_node_id,
235
                                            character_index: anchor_idx,
236
                                        },
237
                                        focus: accesskit::TextPosition {
238
                                            node: a11y_node_id,
239
                                            character_index: focus_idx,
240
                                        },
241
                                    });
242
                                }
243
352
                            }
244
14212
                        } else if !has_non_text_children {
245
13860
                            // Only promote text when there are NO interactive children.
246
13860
                            // Otherwise VoiceOver reads the label instead of navigating children.
247
13860
                            node.set_label(text_content.as_str());
248
13860
                        }
249
26796
                    }
250
                }
251

            
252
41360
                node_id_map.insert((dom_id.inner as u32, dom_idx as u32), a11y_node_id);
253
41360
                nodes.push((a11y_node_id, node));
254
            }
255

            
256
            // Second pass: Build parent-child relationships using DOM hierarchy
257
41668
            for (dom_idx, _) in node_data_slice.iter().enumerate() {
258
41668
                let a11y_node_id = match node_id_map.get(&(dom_id.inner as u32, dom_idx as u32)) {
259
41360
                    Some(id) => *id,
260
308
                    None => continue,
261
                };
262

            
263
41360
                let hierarchy_item = &node_hierarchy[dom_idx];
264

            
265
                // Walk up the DOM tree to find the nearest accessible ancestor.
266
                // parent_id() decodes the 1-based encoding: 0 = None, n+1 = Some(NodeId(n))
267
41360
                let mut current_parent = hierarchy_item.parent_id();
268
41360
                let mut accessible_parent_id = None;
269
41360
                let mut iterations = 0;
270

            
271
41360
                while let Some(parent_node_id) = current_parent {
272
37620
                    iterations += 1;
273
37620
                    if iterations > 10_000 { break; }
274

            
275
37620
                    let parent_idx = parent_node_id.index();
276
37620
                    if let Some(parent_a11y_id) =
277
37620
                        node_id_map.get(&(dom_id.inner as u32, parent_idx as u32))
278
                    {
279
37620
                        accessible_parent_id = Some(*parent_a11y_id);
280
37620
                        break;
281
                    }
282
                    if parent_idx >= node_hierarchy.len() { break; }
283
                    current_parent = node_hierarchy[parent_idx].parent_id();
284
                }
285

            
286
41360
                if let Some(parent_id) = accessible_parent_id {
287
37620
                    parent_children_map
288
37620
                        .entry(parent_id)
289
37620
                        .or_insert_with(Vec::new)
290
37620
                        .push(a11y_node_id);
291
37620
                } else {
292
3740
                    root_children.push(a11y_node_id);
293
3740
                }
294
            }
295
        }
296

            
297
        // Third pass: Set children on all nodes (including root)
298
44792
        for (node_id, node) in nodes.iter_mut() {
299
44792
            if *node_id == root_id {
300
3432
                // Root window node gets top-level DOM nodes as children
301
3432
                node.set_children(root_children.clone());
302
41360
            } else if let Some(children) = parent_children_map.get(node_id) {
303
25300
                node.set_children(children.clone());
304
25300
            }
305
        }
306

            
307
        // Set focus to the currently focused DOM node (from FocusManager).
308
        // If no node is focused, fall back to the first visible content node.
309
        // VoiceOver navigates to the focused element on activation.
310
3432
        let focus = focused_node
311
3432
            .and_then(|dom_node_id| {
312
                let dom_idx = dom_node_id.node.into_crate_internal()?.index();
313
                node_id_map.get(&(dom_node_id.dom.inner as u32, dom_idx as u32)).copied()
314
            })
315
3432
            .unwrap_or_else(|| {
316
                // Fallback: first non-container node
317
3432
                nodes.iter()
318
6864
                    .find(|(id, node)| {
319
6864
                        *id != root_id && !matches!(node.role(), Role::GenericContainer | Role::Window)
320
6864
                    })
321
3432
                    .map(|(id, _)| *id)
322
3432
                    .unwrap_or(root_id)
323
3432
            });
324

            
325
        // Create the tree update
326
3432
        let tree_update = TreeUpdate {
327
3432
            nodes,
328
3432
            tree: Some(Tree::new(root_id)),
329
3432
            focus,
330
3432
            tree_id: accesskit::TreeId::ROOT,
331
3432
        };
332

            
333
3432
        tree_update
334
3432
    }
335

            
336
    /// Builds an accesskit Node from Azul's NodeData and layout information.
337
41316
    fn build_node(
338
41316
        node_data: &NodeData,
339
41316
        layout_node: &LayoutNodeHot,
340
41316
        abs_pos: Option<LogicalPosition>,
341
41316
        a11y_info: Option<&AccessibilityInfo>,
342
41316
        hidpi_factor: f32,
343
41316
        window_size: LogicalSize,
344
41316
    ) -> Node {
345
        // Set role based on NodeType or AccessibilityInfo.
346
41316
        let role = if node_data.is_contenteditable() {
347
352
            Role::MultilineTextInput
348
40964
        } else if let Some(info) = a11y_info {
349
            Self::map_role(&info.role)
350
        } else {
351
40964
            Self::node_type_to_role(&node_data.node_type)
352
        };
353

            
354
41316
        let mut builder = Node::new(role);
355

            
356
        // Set HTML tag name for screen readers that use it
357
41316
        let tag = node_data.node_type.get_path().to_string();
358
41316
        if !tag.is_empty() {
359
41316
            builder.set_html_tag(tag.as_str());
360
41316
        }
361

            
362
        // === Label and Value ===
363
        // Priority: explicit a11y info > DOM attributes > text content
364
41316
        if let Some(info) = a11y_info {
365
            if let Some(name) = info.accessibility_name.as_option() {
366
                builder.set_label(name.as_str());
367
            }
368
            if let Some(value) = info.accessibility_value.as_option() {
369
                builder.set_value(value.as_str());
370
            }
371
            if let Some(desc) = info.description.as_option() {
372
                builder.set_description(desc.as_str());
373
            }
374
41316
        }
375

            
376
        // DOM attribute overrides
377
41316
        if let Some(label) = node_data.get_accessible_label() {
378
            builder.set_label(label);
379
41316
        }
380
41316
        if let Some(value) = node_data.get_accessible_value() {
381
            builder.set_value(value);
382
41316
        }
383
        // Text node: set as label
384
41316
        if let NodeType::Text(text) = &node_data.node_type {
385
14564
            builder.set_label(text.as_str());
386
26752
        }
387

            
388
        // === States from AccessibilityInfo ===
389
41316
        if let Some(info) = a11y_info {
390
            for state in info.states.as_ref() {
391
                match state {
392
                    AccessibilityState::Unavailable => { builder.set_disabled(); }
393
                    AccessibilityState::Readonly => { builder.set_read_only(); }
394
                    AccessibilityState::CheckedTrue => { builder.set_toggled(accesskit::Toggled::True); }
395
                    AccessibilityState::CheckedFalse => { builder.set_toggled(accesskit::Toggled::False); }
396
                    AccessibilityState::Expanded => { builder.set_expanded(true); }
397
                    AccessibilityState::Collapsed => { builder.set_expanded(false); }
398
                    AccessibilityState::Focusable => { builder.add_action(Action::Focus); }
399
                    AccessibilityState::Selected => { builder.set_selected(true); }
400
                    AccessibilityState::Busy => { builder.set_busy(); }
401
                    AccessibilityState::Offscreen => { builder.set_hidden(); }
402
                    _ => {}
403
                }
404
            }
405
41316
        }
406

            
407
        // === Heading level ===
408
41316
        match &node_data.node_type {
409
44
            NodeType::H1 => { builder.set_level(1); }
410
            NodeType::H2 => { builder.set_level(2); }
411
            NodeType::H3 => { builder.set_level(3); }
412
            NodeType::H4 => { builder.set_level(4); }
413
            NodeType::H5 => { builder.set_level(5); }
414
            NodeType::H6 => { builder.set_level(6); }
415
41272
            _ => {}
416
        }
417

            
418
        // Wire up HTML attributes to accesskit properties
419
41316
        for attr in node_data.attributes().as_ref() {
420
6732
            match attr {
421
                azul_core::dom::AttributeType::AriaLabel(s) => {
422
                    builder.set_label(s.as_str());
423
                }
424
                azul_core::dom::AttributeType::Title(s)
425
                | azul_core::dom::AttributeType::Alt(s) => {
426
                    builder.set_description(s.as_str());
427
                }
428
                azul_core::dom::AttributeType::Placeholder(s) => {
429
                    builder.set_placeholder(s.as_str());
430
                }
431
                azul_core::dom::AttributeType::Value(s) => {
432
                    builder.set_value(s.as_str());
433
                }
434
                azul_core::dom::AttributeType::Disabled => {
435
                    builder.set_disabled();
436
                }
437
                azul_core::dom::AttributeType::Readonly => {
438
                    builder.set_read_only();
439
                }
440
                azul_core::dom::AttributeType::CheckedTrue => {
441
                    builder.set_toggled(accesskit::Toggled::True);
442
                }
443
                azul_core::dom::AttributeType::CheckedFalse => {
444
                    builder.set_toggled(accesskit::Toggled::False);
445
                }
446
                azul_core::dom::AttributeType::Required => {
447
                    builder.set_required();
448
                }
449
                azul_core::dom::AttributeType::Hidden => {
450
                    builder.set_hidden();
451
                }
452
                azul_core::dom::AttributeType::Lang(s) => {
453
                    builder.set_language(s.as_str());
454
                }
455
                azul_core::dom::AttributeType::ColSpan(n) => {
456
                    builder.set_column_span(*n as usize);
457
                }
458
                azul_core::dom::AttributeType::RowSpan(n) => {
459
                    builder.set_row_span(*n as usize);
460
                }
461
6732
                _ => {}
462
            }
463
        }
464

            
465
        // Set bounds: absolute position, offset by padding+border, scaled to physical pixels,
466
        // clipped to window viewport so VoiceOver highlights don't extend off-screen.
467
41316
        if let (Some(pos), Some(size)) = (abs_pos, layout_node.used_size) {
468
32604
            let bp = layout_node.box_props.unpack();
469
32604
            let pad_left = bp.padding.left + bp.border.left;
470
32604
            let pad_top = bp.padding.top + bp.border.top;
471
32604
            let pad_right = bp.padding.right + bp.border.right;
472
32604
            let pad_bottom = bp.padding.bottom + bp.border.bottom;
473

            
474
32604
            let s = hidpi_factor as f64;
475
32604
            let ww = window_size.width as f64 * s;
476
32604
            let wh = window_size.height as f64 * s;
477

            
478
32604
            let x0 = ((pos.x + pad_left) as f64 * s).max(0.0).min(ww);
479
32604
            let y0 = ((pos.y + pad_top) as f64 * s).max(0.0).min(wh);
480
32604
            let x1 = ((pos.x + size.width - pad_right) as f64 * s).max(0.0).min(ww);
481
32604
            let y1 = ((pos.y + size.height - pad_bottom) as f64 * s).max(0.0).min(wh);
482

            
483
32604
            if x1 > x0 && y1 > y0 {
484
21780
                builder.set_bounds(Rect { x0, y0, x1, y1 });
485
21780
            }
486
8712
        }
487

            
488
        // Add supported actions based on the DOM node's own properties.
489
        // VoiceOver uses these to determine what the user can do with the element.
490
41316
        if node_data.is_focusable() || node_data.is_contenteditable() {
491
2508
            builder.add_action(Action::Focus);
492
38808
        }
493
41316
        if node_data.has_activation_behavior() {
494
2904
            builder.add_action(Action::Click);
495
38412
        }
496

            
497
        // ARIA relations + live-region from AccessibilityInfo. aria-labelledby /
498
        // aria-describedby reference another node; encode its id the SAME way the
499
        // tree walk does (encode_a11y_node_id) so the relation resolves to a real
500
        // node. is_live_region maps to accesskit's Live property. These were all
501
        // previously dropped (screen readers got no labelled-by/described-by
502
        // relations and no live-region announcements).
503
41316
        if let Some(info) = a11y_info {
504
            if let azul_core::dom::OptionDomNodeId::Some(target) = info.labelled_by {
505
                if let Some(id) = Self::a11y_node_id_for(&target) {
506
                    builder.push_labelled_by(id);
507
                }
508
            }
509
            if let azul_core::dom::OptionDomNodeId::Some(target) = info.described_by {
510
                if let Some(id) = Self::a11y_node_id_for(&target) {
511
                    builder.push_described_by(id);
512
                }
513
            }
514
            if info.is_live_region {
515
                builder.set_live(accesskit::Live::Polite);
516
            }
517
41316
        }
518

            
519
41316
        builder
520
41316
    }
521

            
522
    /// Encode a `(DomId.inner, node index)` pair into the stable A11yNodeId used
523
    /// throughout the tree (offset by 1 so it never collides with `root_id` 0).
524
    /// Shared by the tree walk and the aria-labelledby/-describedby relation
525
    /// mapping, so a relation always resolves to the node the walk emitted.
526
41364
    fn encode_a11y_node_id(dom_inner: usize, node_idx: usize) -> A11yNodeId {
527
41364
        A11yNodeId(((dom_inner as u64) << 32) | ((node_idx as u64) + 1))
528
41364
    }
529

            
530
    /// Map an aria-labelledby/-describedby target `DomNodeId` to its A11yNodeId,
531
    /// or `None` if the node id can't be resolved.
532
    fn a11y_node_id_for(target: &DomNodeId) -> Option<A11yNodeId> {
533
        let idx = target.node.into_crate_internal()?.index();
534
        Some(Self::encode_a11y_node_id(target.dom.inner, idx))
535
    }
536

            
537
    /// Maps an HTML `NodeType` to an accesskit `Role`.
538
    ///
539
    /// Every role used here must pass accesskit's `common_filter` (i.e. NOT be
540
    /// `GenericContainer` or `TextRun`) or VoiceOver will skip the node entirely.
541
    /// Use `Group` for structural containers, `Paragraph` for text blocks, `Label`
542
    /// for inline text, and semantic roles for everything else.
543
41008
    const fn node_type_to_role(node_type: &NodeType) -> Role {
544
41008
        match node_type {
545
            // === Text content ===
546
14564
            NodeType::Text(_) => Role::Label,
547
44
            NodeType::P => Role::Paragraph,
548
            NodeType::Pre => Role::Code,
549
            NodeType::BlockQuote => Role::Blockquote,
550
            NodeType::Code => Role::Code,
551
            NodeType::Em | NodeType::I => Role::Emphasis,
552
            NodeType::Strong | NodeType::B => Role::Strong,
553
            NodeType::Mark => Role::Mark,
554
            NodeType::Del => Role::ContentDeletion,
555
            NodeType::Ins => Role::ContentInsertion,
556
            NodeType::Abbr | NodeType::Acronym => Role::Abbr,
557
            NodeType::Q => Role::Blockquote,
558
            NodeType::Time => Role::Time,
559
            NodeType::Cite | NodeType::Dfn | NodeType::Var
560
            | NodeType::Samp | NodeType::Kbd => Role::Label,
561
            NodeType::Small | NodeType::Big | NodeType::Sub
562
            | NodeType::Sup | NodeType::U | NodeType::S => Role::Label,
563
            NodeType::Ruby => Role::Ruby,
564
            NodeType::Rt => Role::RubyAnnotation,
565
            NodeType::Br => Role::LineBreak,
566
            NodeType::Hr => Role::Splitter,
567

            
568
            // === Structural containers ===
569
            // Group (not GenericContainer) so VoiceOver can navigate into them
570
1716
            NodeType::Body => Role::Group,
571
13552
            NodeType::Div => Role::Group,
572
2288
            NodeType::Span => Role::Group,
573
44
            NodeType::Html => Role::Group,
574

            
575
            // === Semantic sections ===
576
            NodeType::Article => Role::Article,
577
            NodeType::Section => Role::Section,
578
            NodeType::Nav => Role::Navigation,
579
            NodeType::Main => Role::Main,
580
            NodeType::Header => Role::Header,
581
            NodeType::Footer => Role::Footer,
582
            NodeType::Aside => Role::Complementary,
583
            NodeType::Address => Role::Group,
584
            NodeType::Figure => Role::Figure,
585
            NodeType::FigCaption => Role::FigureCaption,
586
            NodeType::Details => Role::Details,
587
            NodeType::Summary => Role::DisclosureTriangle,
588
            NodeType::Dialog => Role::Dialog,
589

            
590
            // === Headings ===
591
            NodeType::H1 | NodeType::H2 | NodeType::H3
592
44
            | NodeType::H4 | NodeType::H5 | NodeType::H6 => Role::Heading,
593

            
594
            // === Lists ===
595
            NodeType::Ul | NodeType::Ol | NodeType::Dir => Role::List,
596
            NodeType::Li => Role::ListItem,
597
            NodeType::Dl => Role::DescriptionList,
598
            NodeType::Dt => Role::Term,
599
            NodeType::Dd => Role::Definition,
600
            NodeType::Menu => Role::Menu,
601
            NodeType::MenuItem => Role::MenuItem,
602

            
603
            // === Tables ===
604
1408
            NodeType::Table => Role::Table,
605
            NodeType::Caption => Role::Caption,
606
            NodeType::THead | NodeType::TBody | NodeType::TFoot => Role::RowGroup,
607
1452
            NodeType::Tr => Role::Row,
608
            NodeType::Th => Role::ColumnHeader,
609
3652
            NodeType::Td => Role::Cell,
610
            NodeType::ColGroup | NodeType::Col => Role::GenericContainer,
611

            
612
            // === Forms ===
613
            NodeType::Form => Role::Form,
614
            NodeType::FieldSet => Role::Group,
615
            NodeType::Legend => Role::Legend,
616
            NodeType::Label => Role::Label,
617
            NodeType::Input => Role::TextInput,
618
            NodeType::Button => Role::Button,
619
            NodeType::Select => Role::ComboBox,
620
            NodeType::OptGroup => Role::Group,
621
            NodeType::SelectOption => Role::ListBoxOption,
622
            NodeType::TextArea => Role::MultilineTextInput,
623
            NodeType::Output => Role::Status,
624
            NodeType::Progress => Role::ProgressIndicator,
625
            NodeType::Meter => Role::Meter,
626
            NodeType::DataList => Role::ListBox,
627

            
628
            // === Links ===
629
2156
            NodeType::A => Role::Link,
630

            
631
            // === Embedded content ===
632
88
            NodeType::Image(_) => Role::Image,
633
            NodeType::Icon(_) => Role::Image,
634
            NodeType::Canvas => Role::Canvas,
635
            NodeType::Audio => Role::Audio,
636
            NodeType::Video => Role::Video,
637
            NodeType::Svg => Role::SvgRoot,
638
            NodeType::Object | NodeType::Embed => Role::EmbeddedObject,
639

            
640
            // === Everything else: Group (visible to VoiceOver) ===
641
            _ => Role::Group,
642
        }
643
41008
    }
644

            
645
    /// Maps Azul's AccessibilityRole to accesskit's Role.
646
    fn map_role(role: &AccessibilityRole) -> Role {
647
        match role {
648
            AccessibilityRole::TitleBar => Role::TitleBar,
649
            AccessibilityRole::MenuBar => Role::MenuBar,
650
            AccessibilityRole::ScrollBar => Role::ScrollBar,
651
            AccessibilityRole::Grip => Role::Splitter,
652
            AccessibilityRole::Sound => Role::Audio,
653
            AccessibilityRole::Cursor => Role::Caret,
654
            AccessibilityRole::Caret => Role::Caret,
655
            AccessibilityRole::Alert => Role::Alert,
656
            AccessibilityRole::Window => Role::Window,
657
            AccessibilityRole::Client => Role::GenericContainer,
658
            AccessibilityRole::MenuPopup => Role::Menu,
659
            AccessibilityRole::MenuItem => Role::MenuItem,
660
            AccessibilityRole::Tooltip => Role::Tooltip,
661
            AccessibilityRole::Application => Role::Application,
662
            AccessibilityRole::Document => Role::Document,
663
            AccessibilityRole::Pane => Role::Pane,
664
            AccessibilityRole::Chart => Role::Figure,
665
            AccessibilityRole::Dialog => Role::Dialog,
666
            AccessibilityRole::Border => Role::GenericContainer,
667
            AccessibilityRole::Grouping => Role::Group,
668
            AccessibilityRole::Separator => Role::GenericContainer,
669
            AccessibilityRole::Toolbar => Role::Toolbar,
670
            AccessibilityRole::StatusBar => Role::Status,
671
            AccessibilityRole::Table => Role::Table,
672
            AccessibilityRole::ColumnHeader => Role::ColumnHeader,
673
            AccessibilityRole::RowHeader => Role::RowHeader,
674
            AccessibilityRole::Column => Role::GenericContainer, // No Column in accesskit 0.17
675
            AccessibilityRole::Row => Role::Row,
676
            AccessibilityRole::Cell => Role::Cell,
677
            AccessibilityRole::Link => Role::Link,
678
            AccessibilityRole::HelpBalloon => Role::Tooltip,
679
            AccessibilityRole::Character => Role::GenericContainer,
680
            AccessibilityRole::List => Role::List,
681
            AccessibilityRole::ListItem => Role::ListItem,
682
            AccessibilityRole::Outline => Role::Tree,
683
            AccessibilityRole::OutlineItem => Role::TreeItem,
684
            AccessibilityRole::PageTab => Role::Tab,
685
            AccessibilityRole::PropertyPage => Role::TabPanel,
686
            AccessibilityRole::Indicator => Role::Meter,
687
            AccessibilityRole::Graphic => Role::Image,
688
            // StaticText -> Label in accesskit 0.17
689
            AccessibilityRole::StaticText => Role::Label,
690
            AccessibilityRole::Text => Role::TextInput,
691
            AccessibilityRole::PushButton => Role::Button,
692
            AccessibilityRole::CheckButton => Role::CheckBox,
693
            AccessibilityRole::RadioButton => Role::RadioButton,
694
            AccessibilityRole::ComboBox => Role::ComboBox,
695
            AccessibilityRole::DropList => Role::ListBox,
696
            AccessibilityRole::ProgressBar => Role::ProgressIndicator,
697
            AccessibilityRole::Dial => Role::Meter,
698
            AccessibilityRole::HotkeyField => Role::TextInput,
699
            AccessibilityRole::Slider => Role::Slider,
700
            AccessibilityRole::SpinButton => Role::SpinButton,
701
            AccessibilityRole::Diagram => Role::Figure,
702
            AccessibilityRole::Animation => Role::GenericContainer,
703
            AccessibilityRole::Equation => Role::Math,
704
            AccessibilityRole::ButtonDropdown => Role::Button,
705
            // No MenuButton in accesskit 0.17
706
            AccessibilityRole::ButtonMenu => Role::Button,
707
            AccessibilityRole::ButtonDropdownGrid => Role::Button,
708
            AccessibilityRole::Whitespace => Role::GenericContainer,
709
            AccessibilityRole::PageTabList => Role::TabList,
710
            AccessibilityRole::Clock => Role::Timer,
711
            AccessibilityRole::SplitButton => Role::Button,
712
            AccessibilityRole::IpAddress => Role::TextInput,
713
            AccessibilityRole::Unknown => Role::Unknown,
714
            AccessibilityRole::Nothing => Role::GenericContainer,
715
        }
716
    }
717

            
718
    /// Handles an action request from an assistive technology.
719
    ///
720
    /// Translates the accesskit ActionRequest into a (DomNodeId, Action) pair
721
    /// that can be used to generate synthetic events in the Azul event system.
722
    pub fn handle_action_request(
723
        &self,
724
        request: ActionRequest,
725
    ) -> Option<(DomNodeId, AccessibilityAction)> {
726
        // Decode the A11yNodeId back into DomId + NodeId.
727
        //
728
        // The A11yNodeId encodes both values in a single u64:
729
        //
730
        //   - Upper 32 bits: DomId (which DOM tree the node belongs to)
731
        //   - Lower 32 bits: NodeId (index within that DOM tree)
732
        //
733
        // This encoding matches the format used in update_tree().
734
        let dom_id = DomId {
735
            inner: (request.target_node.0 >> 32) as usize,
736
        };
737
        let node_id = NodeId::new(((request.target_node.0 & 0xFFFF_FFFF) - 1) as usize);
738
        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
739
        let dom_node_id = DomNodeId {
740
            dom: dom_id,
741
            node: hierarchy_id,
742
        };
743

            
744
        Some((dom_node_id, map_accesskit_action(request)?))
745
    }
746
}
747

            
748
/// Maps an accesskit `Action` and optional `ActionData` to an Azul `AccessibilityAction`.
749
///
750
/// Returns `None` if the action requires data that was not provided or is invalid.
751
#[cfg(feature = "a11y")]
752
fn map_accesskit_action(request: ActionRequest) -> Option<AccessibilityAction> {
753
    use azul_css::{props::basic::FloatValue, AzString};
754

            
755
    let action = match request.action {
756
        Action::Click => AccessibilityAction::Default,
757
        Action::Focus => AccessibilityAction::Focus,
758
        Action::Blur => AccessibilityAction::Blur,
759
        Action::Collapse => AccessibilityAction::Collapse,
760
        Action::Expand => AccessibilityAction::Expand,
761
        Action::ScrollIntoView => AccessibilityAction::ScrollIntoView,
762
        Action::Increment => AccessibilityAction::Increment,
763
        Action::Decrement => AccessibilityAction::Decrement,
764
        Action::ShowContextMenu => AccessibilityAction::ShowContextMenu,
765
        Action::HideTooltip => AccessibilityAction::HideTooltip,
766
        Action::ShowTooltip => AccessibilityAction::ShowTooltip,
767
        Action::ScrollUp => AccessibilityAction::ScrollUp,
768
        Action::ScrollDown => AccessibilityAction::ScrollDown,
769
        Action::ScrollLeft => AccessibilityAction::ScrollLeft,
770
        Action::ScrollRight => AccessibilityAction::ScrollRight,
771
        Action::SetSequentialFocusNavigationStartingPoint => {
772
            AccessibilityAction::SetSequentialFocusNavigationStartingPoint
773
        }
774
        Action::ReplaceSelectedText => {
775
            let accesskit::ActionData::Value(value) = request.data? else {
776
                return None;
777
            };
778
            AccessibilityAction::ReplaceSelectedText(AzString::from(value.as_ref()))
779
        }
780
        Action::ScrollToPoint => {
781
            let accesskit::ActionData::ScrollToPoint(point) = request.data? else {
782
                return None;
783
            };
784
            AccessibilityAction::ScrollToPoint(LogicalPosition {
785
                x: point.x as f32,
786
                y: point.y as f32,
787
            })
788
        }
789
        Action::SetScrollOffset => {
790
            let accesskit::ActionData::SetScrollOffset(point) = request.data? else {
791
                return None;
792
            };
793
            AccessibilityAction::SetScrollOffset(LogicalPosition {
794
                x: point.x as f32,
795
                y: point.y as f32,
796
            })
797
        }
798
        Action::SetTextSelection => {
799
            let accesskit::ActionData::SetTextSelection(selection) = request.data? else {
800
                return None;
801
            };
802
            AccessibilityAction::SetTextSelection(TextSelectionStartEnd {
803
                selection_start: selection.anchor.character_index,
804
                selection_end: selection.focus.character_index,
805
            })
806
        }
807
        Action::SetValue => match request.data? {
808
            accesskit::ActionData::Value(value) => {
809
                AccessibilityAction::SetValue(AzString::from(value.as_ref()))
810
            }
811
            accesskit::ActionData::NumericValue(value) => {
812
                AccessibilityAction::SetNumericValue(FloatValue::new(value as f32))
813
            }
814
            _ => return None,
815
        },
816
        Action::CustomAction => {
817
            let accesskit::ActionData::CustomAction(id) = request.data? else {
818
                return None;
819
            };
820
            AccessibilityAction::CustomAction(id)
821
        }
822
    };
823

            
824
    Some(action)
825
}
826

            
827
/// Stub implementation when accessibility feature is disabled.
828
#[cfg(not(feature = "a11y"))]
829
pub struct A11yManager {
830
    _private: (),
831
}
832

            
833
#[cfg(not(feature = "a11y"))]
834
impl A11yManager {
835
    /// Creates a new stub `A11yManager` (no-op when accessibility is disabled).
836
    pub fn new() -> Self {
837
        Self { _private: () }
838
    }
839
}
840

            
841
#[cfg(all(test, feature = "a11y"))]
842
mod a11y_relation_tests {
843
    use super::A11yManager;
844
    use accesskit::NodeId as A11yNodeId;
845

            
846
    /// The a11y node-id encoding must stay in lockstep with the tree walk:
847
    /// `(dom.inner << 32) | (idx + 1)`. labelled_by/described_by relations encode
848
    /// their targets the same way, so any drift here would point a relation at
849
    /// the wrong (or a nonexistent) node.
850
    #[test]
851
1
    fn a11y_node_id_encoding_is_stable_and_offset() {
852
1
        assert_eq!(A11yManager::encode_a11y_node_id(0, 0), A11yNodeId(1));
853
1
        assert_eq!(A11yManager::encode_a11y_node_id(0, 5), A11yNodeId(6));
854
1
        assert_eq!(
855
1
            A11yManager::encode_a11y_node_id(2, 3),
856
            A11yNodeId((2u64 << 32) | 4)
857
        );
858
        // Never collides with the root window node (id 0).
859
1
        assert_ne!(A11yManager::encode_a11y_node_id(0, 0), A11yNodeId(0));
860
1
    }
861
}