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
2321
    pub fn new() -> Self {
66
2321
        let root_id = A11yNodeId(0);
67
2321
        Self {
68
2321
            root_id,
69
2321
            tree: None,
70
2321
            last_tree_update: None,
71
2321
            tree_initialized: false,
72
2321
        }
73
2321
    }
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
2275
    pub fn update_tree(
80
2275
        root_id: A11yNodeId,
81
2275
        layout_results: &std::collections::BTreeMap<DomId, DomLayoutResult>,
82
2275
        window_title: &AzString,
83
2275
        window_size: LogicalSize,
84
2275
        focused_node: Option<azul_core::dom::DomNodeId>,
85
2275
        hidpi_factor: f32,
86
2275
        dirty_text_overrides: &std::collections::BTreeMap<(DomId, NodeId), String>,
87
2275
        cursor_info: Option<CursorA11yInfo>,
88
2275
    ) -> TreeUpdate {
89
2275
        let mut nodes = Vec::new();
90
2275
        let mut root_children = Vec::new();
91

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

            
95
        // Map to collect children for each parent
96
2275
        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
2275
        let mut root_node = Node::new(Role::Window);
100
2275
        root_node.set_label(window_title.as_str());
101
2275
        nodes.push((root_id, root_node));
102

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

            
108
            // First pass: Create a11y nodes for each DOM node
109
15295
            for (dom_idx, node_data) in node_data_slice.iter().enumerate() {
110
15295
                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
15295
                let should_create_node = a11y_info.is_some()
116
15295
                    || node_data.is_contenteditable()
117
15015
                    || node_data.is_focusable()
118
13300
                    || !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
15295
                if !should_create_node {
130
                    continue;
131
15295
                }
132

            
133
                // Generate stable A11yNodeId: offset by 1 to avoid collision with root_id(0)
134
15295
                let a11y_node_id = A11yNodeId(((dom_id.inner as u64) << 32) | ((dom_idx as u64) + 1));
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
15295
                let dom_node_id = NodeId::new(dom_idx);
139
15295
                let layout_info = layout_result.layout_tree.dom_to_layout
140
15295
                    .get(&dom_node_id)
141
15295
                    .and_then(|indices| indices.first())
142
15295
                    .and_then(|&layout_idx| {
143
15260
                        let hot = layout_result.layout_tree.get(layout_idx)?;
144
15260
                        let abs_pos = layout_result.calculated_positions
145
15260
                            .get(layout_idx).copied();
146
15260
                        Some((hot, layout_idx, abs_pos))
147
15260
                    });
148

            
149
15295
                let a11y_info_ref = a11y_info.as_ref().map(|b| b.as_ref());
150
15295
                let mut node = match layout_info {
151
15260
                    Some((layout_node, _layout_idx, abs_pos)) => {
152
15260
                        Self::build_node(node_data, layout_node, abs_pos, a11y_info_ref, hidpi_factor, window_size)
153
                    }
154
                    None => {
155
35
                        let role = if let Some(info) = a11y_info_ref {
156
                            Self::map_role(&info.role)
157
                        } else {
158
35
                            Self::node_type_to_role(&node_data.node_type)
159
                        };
160
35
                        let mut builder = Node::new(role);
161
35
                        if let NodeType::Text(text) = &node_data.node_type {
162
                            builder.set_label(text.as_str());
163
35
                        }
164
35
                        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
15295
                    let hierarchy_item = &node_hierarchy[dom_idx];
178
15295
                    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
15295
                    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
15295
                        let mut text = String::new();
185
15295
                        let mut has_non_text = false;
186

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

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

            
213
                            // If cursor/selection is in this node, expose to screen readers
214
280
                            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
280
                            }
244
3395
                        } else if !has_non_text_children {
245
3360
                            // Only promote text when there are NO interactive children.
246
3360
                            // Otherwise VoiceOver reads the label instead of navigating children.
247
3360
                            node.set_label(text_content.as_str());
248
3360
                        }
249
11620
                    }
250
                }
251

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

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

            
263
15295
                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
15295
                let mut current_parent = hierarchy_item.parent_id();
268
15295
                let mut accessible_parent_id = None;
269
15295
                let mut iterations = 0;
270

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

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

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

            
297
        // Third pass: Set children on all nodes (including root)
298
17570
        for (node_id, node) in nodes.iter_mut() {
299
17570
            if *node_id == root_id {
300
2275
                // Root window node gets top-level DOM nodes as children
301
2275
                node.set_children(root_children.clone());
302
15295
            } else if let Some(children) = parent_children_map.get(node_id) {
303
10850
                node.set_children(children.clone());
304
10850
            }
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
2275
        let focus = focused_node
311
2275
            .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
2275
            .unwrap_or_else(|| {
316
                // Fallback: first non-container node
317
2275
                nodes.iter()
318
4550
                    .find(|(id, node)| {
319
4550
                        *id != root_id && !matches!(node.role(), Role::GenericContainer | Role::Window)
320
4550
                    })
321
2275
                    .map(|(id, _)| *id)
322
2275
                    .unwrap_or(root_id)
323
2275
            });
324

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

            
333
2275
        tree_update
334
2275
    }
335

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

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

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

            
362
        // === Label and Value ===
363
        // Priority: explicit a11y info > DOM attributes > text content
364
15260
        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
15260
        }
375

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

            
388
        // === States from AccessibilityInfo ===
389
15260
        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
15260
        }
406

            
407
        // === Heading level ===
408
15260
        match &node_data.node_type {
409
35
            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
15225
            _ => {}
416
        }
417

            
418
        // Wire up HTML attributes to accesskit properties
419
15260
        for attr in node_data.attributes().as_ref() {
420
1890
            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
1890
                _ => {}
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
15260
        if let (Some(pos), Some(size)) = (abs_pos, layout_node.used_size) {
468
10990
            let bp = layout_node.box_props.unpack();
469
10990
            let pad_left = bp.padding.left + bp.border.left;
470
10990
            let pad_top = bp.padding.top + bp.border.top;
471
10990
            let pad_right = bp.padding.right + bp.border.right;
472
10990
            let pad_bottom = bp.padding.bottom + bp.border.bottom;
473

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

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

            
483
10990
            if x1 > x0 && y1 > y0 {
484
9975
                builder.set_bounds(Rect { x0, y0, x1, y1 });
485
9975
            }
486
4270
        }
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
15260
        if node_data.is_focusable() || node_data.is_contenteditable() {
491
1995
            builder.add_action(Action::Focus);
492
13265
        }
493
15260
        if node_data.has_activation_behavior() {
494
1715
            builder.add_action(Action::Click);
495
13545
        }
496

            
497
15260
        builder
498
15260
    }
499

            
500
    /// Maps an HTML `NodeType` to an accesskit `Role`.
501
    ///
502
    /// Every role used here must pass accesskit's `common_filter` (i.e. NOT be
503
    /// `GenericContainer` or `TextRun`) or VoiceOver will skip the node entirely.
504
    /// Use `Group` for structural containers, `Paragraph` for text blocks, `Label`
505
    /// for inline text, and semantic roles for everything else.
506
15015
    const fn node_type_to_role(node_type: &NodeType) -> Role {
507
15015
        match node_type {
508
            // === Text content ===
509
3675
            NodeType::Text(_) => Role::Label,
510
35
            NodeType::P => Role::Paragraph,
511
            NodeType::Pre => Role::Code,
512
            NodeType::BlockQuote => Role::Blockquote,
513
            NodeType::Code => Role::Code,
514
            NodeType::Em | NodeType::I => Role::Emphasis,
515
            NodeType::Strong | NodeType::B => Role::Strong,
516
            NodeType::Mark => Role::Mark,
517
            NodeType::Del => Role::ContentDeletion,
518
            NodeType::Ins => Role::ContentInsertion,
519
            NodeType::Abbr | NodeType::Acronym => Role::Abbr,
520
            NodeType::Q => Role::Blockquote,
521
            NodeType::Time => Role::Time,
522
            NodeType::Cite | NodeType::Dfn | NodeType::Var
523
            | NodeType::Samp | NodeType::Kbd => Role::Label,
524
            NodeType::Small | NodeType::Big | NodeType::Sub
525
            | NodeType::Sup | NodeType::U | NodeType::S => Role::Label,
526
            NodeType::Ruby => Role::Ruby,
527
            NodeType::Rt => Role::RubyAnnotation,
528
            NodeType::Br => Role::LineBreak,
529
            NodeType::Hr => Role::Splitter,
530

            
531
            // === Structural containers ===
532
            // Group (not GenericContainer) so VoiceOver can navigate into them
533
1015
            NodeType::Body => Role::Group,
534
1540
            NodeType::Div => Role::Group,
535
1820
            NodeType::Span => Role::Group,
536
            NodeType::Html => Role::Group,
537

            
538
            // === Semantic sections ===
539
            NodeType::Article => Role::Article,
540
            NodeType::Section => Role::Section,
541
            NodeType::Nav => Role::Navigation,
542
            NodeType::Main => Role::Main,
543
            NodeType::Header => Role::Header,
544
            NodeType::Footer => Role::Footer,
545
            NodeType::Aside => Role::Complementary,
546
            NodeType::Address => Role::Group,
547
            NodeType::Figure => Role::Figure,
548
            NodeType::FigCaption => Role::FigureCaption,
549
            NodeType::Details => Role::Details,
550
            NodeType::Summary => Role::DisclosureTriangle,
551
            NodeType::Dialog => Role::Dialog,
552

            
553
            // === Headings ===
554
            NodeType::H1 | NodeType::H2 | NodeType::H3
555
35
            | NodeType::H4 | NodeType::H5 | NodeType::H6 => Role::Heading,
556

            
557
            // === Lists ===
558
            NodeType::Ul | NodeType::Ol | NodeType::Dir => Role::List,
559
            NodeType::Li => Role::ListItem,
560
            NodeType::Dl => Role::DescriptionList,
561
            NodeType::Dt => Role::Term,
562
            NodeType::Dd => Role::Definition,
563
            NodeType::Menu => Role::Menu,
564
            NodeType::MenuItem => Role::MenuItem,
565

            
566
            // === Tables ===
567
1120
            NodeType::Table => Role::Table,
568
            NodeType::Caption => Role::Caption,
569
            NodeType::THead | NodeType::TBody | NodeType::TFoot => Role::RowGroup,
570
1155
            NodeType::Tr => Role::Row,
571
            NodeType::Th => Role::ColumnHeader,
572
2905
            NodeType::Td => Role::Cell,
573
            NodeType::ColGroup | NodeType::Col => Role::GenericContainer,
574

            
575
            // === Forms ===
576
            NodeType::Form => Role::Form,
577
            NodeType::FieldSet => Role::Group,
578
            NodeType::Legend => Role::Legend,
579
            NodeType::Label => Role::Label,
580
            NodeType::Input => Role::TextInput,
581
            NodeType::Button => Role::Button,
582
            NodeType::Select => Role::ComboBox,
583
            NodeType::OptGroup => Role::Group,
584
            NodeType::SelectOption => Role::ListBoxOption,
585
            NodeType::TextArea => Role::MultilineTextInput,
586
            NodeType::Output => Role::Status,
587
            NodeType::Progress => Role::ProgressIndicator,
588
            NodeType::Meter => Role::Meter,
589
            NodeType::DataList => Role::ListBox,
590

            
591
            // === Links ===
592
1715
            NodeType::A => Role::Link,
593

            
594
            // === Embedded content ===
595
            NodeType::Image(_) => Role::Image,
596
            NodeType::Icon(_) => Role::Image,
597
            NodeType::Canvas => Role::Canvas,
598
            NodeType::Audio => Role::Audio,
599
            NodeType::Video => Role::Video,
600
            NodeType::Svg => Role::SvgRoot,
601
            NodeType::Object | NodeType::Embed => Role::EmbeddedObject,
602

            
603
            // === Everything else: Group (visible to VoiceOver) ===
604
            _ => Role::Group,
605
        }
606
15015
    }
607

            
608
    /// Maps Azul's AccessibilityRole to accesskit's Role.
609
    fn map_role(role: &AccessibilityRole) -> Role {
610
        match role {
611
            AccessibilityRole::TitleBar => Role::TitleBar,
612
            AccessibilityRole::MenuBar => Role::MenuBar,
613
            AccessibilityRole::ScrollBar => Role::ScrollBar,
614
            AccessibilityRole::Grip => Role::Splitter,
615
            AccessibilityRole::Sound => Role::Audio,
616
            AccessibilityRole::Cursor => Role::Caret,
617
            AccessibilityRole::Caret => Role::Caret,
618
            AccessibilityRole::Alert => Role::Alert,
619
            AccessibilityRole::Window => Role::Window,
620
            AccessibilityRole::Client => Role::GenericContainer,
621
            AccessibilityRole::MenuPopup => Role::Menu,
622
            AccessibilityRole::MenuItem => Role::MenuItem,
623
            AccessibilityRole::Tooltip => Role::Tooltip,
624
            AccessibilityRole::Application => Role::Application,
625
            AccessibilityRole::Document => Role::Document,
626
            AccessibilityRole::Pane => Role::Pane,
627
            AccessibilityRole::Chart => Role::Figure,
628
            AccessibilityRole::Dialog => Role::Dialog,
629
            AccessibilityRole::Border => Role::GenericContainer,
630
            AccessibilityRole::Grouping => Role::Group,
631
            AccessibilityRole::Separator => Role::GenericContainer,
632
            AccessibilityRole::Toolbar => Role::Toolbar,
633
            AccessibilityRole::StatusBar => Role::Status,
634
            AccessibilityRole::Table => Role::Table,
635
            AccessibilityRole::ColumnHeader => Role::ColumnHeader,
636
            AccessibilityRole::RowHeader => Role::RowHeader,
637
            AccessibilityRole::Column => Role::GenericContainer, // No Column in accesskit 0.17
638
            AccessibilityRole::Row => Role::Row,
639
            AccessibilityRole::Cell => Role::Cell,
640
            AccessibilityRole::Link => Role::Link,
641
            AccessibilityRole::HelpBalloon => Role::Tooltip,
642
            AccessibilityRole::Character => Role::GenericContainer,
643
            AccessibilityRole::List => Role::List,
644
            AccessibilityRole::ListItem => Role::ListItem,
645
            AccessibilityRole::Outline => Role::Tree,
646
            AccessibilityRole::OutlineItem => Role::TreeItem,
647
            AccessibilityRole::PageTab => Role::Tab,
648
            AccessibilityRole::PropertyPage => Role::TabPanel,
649
            AccessibilityRole::Indicator => Role::Meter,
650
            AccessibilityRole::Graphic => Role::Image,
651
            // StaticText -> Label in accesskit 0.17
652
            AccessibilityRole::StaticText => Role::Label,
653
            AccessibilityRole::Text => Role::TextInput,
654
            AccessibilityRole::PushButton => Role::Button,
655
            AccessibilityRole::CheckButton => Role::CheckBox,
656
            AccessibilityRole::RadioButton => Role::RadioButton,
657
            AccessibilityRole::ComboBox => Role::ComboBox,
658
            AccessibilityRole::DropList => Role::ListBox,
659
            AccessibilityRole::ProgressBar => Role::ProgressIndicator,
660
            AccessibilityRole::Dial => Role::Meter,
661
            AccessibilityRole::HotkeyField => Role::TextInput,
662
            AccessibilityRole::Slider => Role::Slider,
663
            AccessibilityRole::SpinButton => Role::SpinButton,
664
            AccessibilityRole::Diagram => Role::Figure,
665
            AccessibilityRole::Animation => Role::GenericContainer,
666
            AccessibilityRole::Equation => Role::Math,
667
            AccessibilityRole::ButtonDropdown => Role::Button,
668
            // No MenuButton in accesskit 0.17
669
            AccessibilityRole::ButtonMenu => Role::Button,
670
            AccessibilityRole::ButtonDropdownGrid => Role::Button,
671
            AccessibilityRole::Whitespace => Role::GenericContainer,
672
            AccessibilityRole::PageTabList => Role::TabList,
673
            AccessibilityRole::Clock => Role::Timer,
674
            AccessibilityRole::SplitButton => Role::Button,
675
            AccessibilityRole::IpAddress => Role::TextInput,
676
            AccessibilityRole::Unknown => Role::Unknown,
677
            AccessibilityRole::Nothing => Role::GenericContainer,
678
        }
679
    }
680

            
681
    /// Handles an action request from an assistive technology.
682
    ///
683
    /// Translates the accesskit ActionRequest into a (DomNodeId, Action) pair
684
    /// that can be used to generate synthetic events in the Azul event system.
685
    pub fn handle_action_request(
686
        &self,
687
        request: ActionRequest,
688
    ) -> Option<(DomNodeId, AccessibilityAction)> {
689
        // Decode the A11yNodeId back into DomId + NodeId.
690
        //
691
        // The A11yNodeId encodes both values in a single u64:
692
        //
693
        //   - Upper 32 bits: DomId (which DOM tree the node belongs to)
694
        //   - Lower 32 bits: NodeId (index within that DOM tree)
695
        //
696
        // This encoding matches the format used in update_tree().
697
        let dom_id = DomId {
698
            inner: (request.target_node.0 >> 32) as usize,
699
        };
700
        let node_id = NodeId::new(((request.target_node.0 & 0xFFFF_FFFF) - 1) as usize);
701
        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
702
        let dom_node_id = DomNodeId {
703
            dom: dom_id,
704
            node: hierarchy_id,
705
        };
706

            
707
        Some((dom_node_id, map_accesskit_action(request)?))
708
    }
709
}
710

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

            
718
    let action = match request.action {
719
        Action::Click => AccessibilityAction::Default,
720
        Action::Focus => AccessibilityAction::Focus,
721
        Action::Blur => AccessibilityAction::Blur,
722
        Action::Collapse => AccessibilityAction::Collapse,
723
        Action::Expand => AccessibilityAction::Expand,
724
        Action::ScrollIntoView => AccessibilityAction::ScrollIntoView,
725
        Action::Increment => AccessibilityAction::Increment,
726
        Action::Decrement => AccessibilityAction::Decrement,
727
        Action::ShowContextMenu => AccessibilityAction::ShowContextMenu,
728
        Action::HideTooltip => AccessibilityAction::HideTooltip,
729
        Action::ShowTooltip => AccessibilityAction::ShowTooltip,
730
        Action::ScrollUp => AccessibilityAction::ScrollUp,
731
        Action::ScrollDown => AccessibilityAction::ScrollDown,
732
        Action::ScrollLeft => AccessibilityAction::ScrollLeft,
733
        Action::ScrollRight => AccessibilityAction::ScrollRight,
734
        Action::SetSequentialFocusNavigationStartingPoint => {
735
            AccessibilityAction::SetSequentialFocusNavigationStartingPoint
736
        }
737
        Action::ReplaceSelectedText => {
738
            let accesskit::ActionData::Value(value) = request.data? else {
739
                return None;
740
            };
741
            AccessibilityAction::ReplaceSelectedText(AzString::from(value.as_ref()))
742
        }
743
        Action::ScrollToPoint => {
744
            let accesskit::ActionData::ScrollToPoint(point) = request.data? else {
745
                return None;
746
            };
747
            AccessibilityAction::ScrollToPoint(LogicalPosition {
748
                x: point.x as f32,
749
                y: point.y as f32,
750
            })
751
        }
752
        Action::SetScrollOffset => {
753
            let accesskit::ActionData::SetScrollOffset(point) = request.data? else {
754
                return None;
755
            };
756
            AccessibilityAction::SetScrollOffset(LogicalPosition {
757
                x: point.x as f32,
758
                y: point.y as f32,
759
            })
760
        }
761
        Action::SetTextSelection => {
762
            let accesskit::ActionData::SetTextSelection(selection) = request.data? else {
763
                return None;
764
            };
765
            AccessibilityAction::SetTextSelection(TextSelectionStartEnd {
766
                selection_start: selection.anchor.character_index,
767
                selection_end: selection.focus.character_index,
768
            })
769
        }
770
        Action::SetValue => match request.data? {
771
            accesskit::ActionData::Value(value) => {
772
                AccessibilityAction::SetValue(AzString::from(value.as_ref()))
773
            }
774
            accesskit::ActionData::NumericValue(value) => {
775
                AccessibilityAction::SetNumericValue(FloatValue::new(value as f32))
776
            }
777
            _ => return None,
778
        },
779
        Action::CustomAction => {
780
            let accesskit::ActionData::CustomAction(id) = request.data? else {
781
                return None;
782
            };
783
            AccessibilityAction::CustomAction(id)
784
        }
785
    };
786

            
787
    Some(action)
788
}
789

            
790
/// Stub implementation when accessibility feature is disabled.
791
#[cfg(not(feature = "a11y"))]
792
pub struct A11yManager {
793
    _private: (),
794
}
795

            
796
#[cfg(not(feature = "a11y"))]
797
impl A11yManager {
798
    /// Creates a new stub `A11yManager` (no-op when accessibility is disabled).
799
    pub fn new() -> Self {
800
        Self { _private: () }
801
    }
802
}