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
};
24
use azul_css::AzString;
25

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

            
28
/// Is this DOM node exposed to assistive technology?
29
///
30
/// ONE definition, deliberately. It used to be an inline condition inside
31
/// [`A11yManager::update_tree`], which is fine while accesskit is the only
32
/// consumer — but accesskit ships no `UIKit` and no Android backend, so the iOS
33
/// and Android bridges have to build their element lists themselves, and the
34
/// E2E `accessibility_action` op has to decide whether the node a test is
35
/// activating is one a screen reader could ever reach. Three consumers guessing
36
/// separately is three different answers to "can a screen reader see this?".
37
///
38
/// Included: anything carrying explicit `AccessibilityInfo`, anything
39
/// contenteditable, anything focusable, and every node type that is not pure
40
/// metadata (`<head>`, `<meta>`, `<script>`, …) or a pseudo-element.
41
#[must_use]
42
233050
pub fn is_exposed_to_accessibility(node_data: &NodeData) -> bool {
43
233050
    node_data.get_accessibility_info().is_some()
44
233050
        || node_data.is_contenteditable()
45
231898
        || node_data.is_focusable()
46
216473
        || !matches!(
47
216680
            node_data.node_type,
48
            NodeType::Head
49
                | NodeType::Meta
50
                | NodeType::Link
51
                | NodeType::Script
52
                | NodeType::Style
53
                | NodeType::Base
54
                | NodeType::Before
55
                | NodeType::After
56
                | NodeType::Marker
57
                | NodeType::Placeholder
58
                | NodeType::Source
59
                | NodeType::Track
60
                | NodeType::Param
61
                | NodeType::Col
62
                | NodeType::ColGroup
63
                | NodeType::Wbr
64
                | NodeType::Rp
65
                | NodeType::Rtc
66
                | NodeType::Bdo
67
                | NodeType::Bdi
68
                | NodeType::Data
69
                | NodeType::Map
70
                | NodeType::Area
71
                | NodeType::VirtualView
72
        )
73
233050
}
74

            
75
/// Cursor/selection info passed to the a11y tree builder.
76
/// Used to set `text_selection` on contenteditable nodes so screen readers
77
/// can announce the cursor position and selection range.
78
#[cfg(feature = "a11y")]
79
#[derive(Debug, Clone, Copy)]
80
pub struct CursorA11yInfo {
81
    pub dom_id: DomId,
82
    pub node_id: NodeId,
83
    /// Byte offset of the selection anchor (start of selection, or cursor pos if no range)
84
    pub anchor_offset: usize,
85
    /// Byte offset of the selection focus (end of selection, or same as anchor for cursor)
86
    pub focus_offset: usize,
87
}
88

            
89
/// Manager for accessibility tree state and updates.
90
///
91
/// The `A11yManager` sits within `LayoutWindow` and is responsible for:
92
///
93
/// 1. Maintaining the current accessibility tree state
94
/// 2. Generating `TreeUpdate`s by comparing layout results with the stored tree
95
/// 3. Translating `ActionRequest`s from screen readers into synthetic Azul events
96
#[cfg(feature = "a11y")]
97
#[derive(Debug)]
98
pub struct A11yManager {
99
    /// The root node ID of the accessibility tree (represents the window).
100
    pub root_id: A11yNodeId,
101
    /// The current accessibility tree state.
102
    pub tree: Option<Tree>,
103
    /// The last generated tree update (for platform adapter consumption).
104
    pub last_tree_update: Option<TreeUpdate>,
105
    /// Whether the full tree has been sent to the platform adapter at least once.
106
    /// After initialization, incremental updates can use `tree: None`.
107
    pub tree_initialized: bool,
108
}
109

            
110
#[cfg(feature = "a11y")]
111
impl Default for A11yManager {
112
1
    fn default() -> Self {
113
1
        Self::new()
114
1
    }
115
}
116

            
117
#[cfg(feature = "a11y")]
118
impl A11yManager {
119
    /// Creates a new `A11yManager` with an empty tree containing only a root window node.
120
5581
    #[must_use] pub const fn new() -> Self {
121
5581
        let root_id = A11yNodeId(0);
122
5581
        Self {
123
5581
            root_id,
124
5581
            tree: None,
125
5581
            last_tree_update: None,
126
5581
            tree_initialized: false,
127
5581
        }
128
5581
    }
129

            
130
    /// Updates the accessibility tree based on the current layout state.
131
    ///
132
    /// This should be called after each layout pass to synchronize the
133
    /// accessibility tree with the visual representation.
134
    #[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
135
    #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
136
4377
    #[must_use] pub fn update_tree(
137
4377
        root_id: A11yNodeId,
138
4377
        layout_results: &std::collections::BTreeMap<DomId, DomLayoutResult>,
139
4377
        scroll_manager: &crate::managers::scroll_state::ScrollManager,
140
4377
        window_title: &AzString,
141
4377
        window_size: LogicalSize,
142
4377
        focused_node: Option<DomNodeId>,
143
4377
        hidpi_factor: f32,
144
4377
        dirty_text_overrides: &std::collections::BTreeMap<(DomId, NodeId), String>,
145
4377
        cursor_info: Option<CursorA11yInfo>,
146
4377
    ) -> TreeUpdate {
147
4377
        let mut nodes = Vec::new();
148
4377
        let mut root_children = Vec::new();
149

            
150
        // Map from (DomId, NodeId) to A11yNodeId for building parent-child relationships
151
4377
        let mut node_id_map: HashMap<(u32, u32), A11yNodeId> = HashMap::new();
152

            
153
        // Map to collect children for each parent
154
4377
        let mut parent_children_map: HashMap<A11yNodeId, Vec<A11yNodeId>> = HashMap::new();
155

            
156
        // Create root window node and add it to the nodes list
157
4377
        let mut root_node = Node::new(Role::Window);
158
4377
        root_node.set_label(window_title.as_str());
159
4377
        nodes.push((root_id, root_node));
160

            
161
8924
        for (dom_id, layout_result) in layout_results {
162
4547
            let styled_dom = &layout_result.styled_dom;
163
4547
            let node_hierarchy = styled_dom.node_hierarchy.as_ref();
164
4547
            let node_data_slice = styled_dom.node_data.as_ref();
165

            
166
            // First pass: Create a11y nodes for each DOM node
167
233047
            for (dom_idx, node_data) in node_data_slice.iter().enumerate() {
168
233047
                let a11y_info = node_data.get_accessibility_info();
169

            
170
                // Include every node that has a meaningful role — see
171
                // `is_exposed_to_accessibility`, which is the single definition
172
                // shared with every other a11y surface.
173
233047
                let should_create_node = is_exposed_to_accessibility(node_data);
174

            
175
233047
                if !should_create_node {
176
207
                    continue;
177
232840
                }
178

            
179
                // Generate stable A11yNodeId: offset by 1 to avoid collision with root_id(0)
180
232840
                let a11y_node_id = Self::encode_a11y_node_id(dom_id.inner, dom_idx);
181

            
182
                // Get layout info: absolute position from calculated_positions,
183
                // size from layout node. Uses dom_to_layout to map DOM → layout index.
184
232840
                let dom_node_id = NodeId::new(dom_idx);
185
232840
                let layout_info = layout_result.layout_tree.dom_to_layout
186
232840
                    .get(&dom_node_id)
187
232840
                    .and_then(|indices| indices.first())
188
232840
                    .and_then(|&layout_idx| {
189
195134
                        let hot = layout_result.layout_tree.get(layout_idx)?;
190
195134
                        let abs_pos = layout_result.calculated_positions
191
195134
                            .get(layout_idx.index()).copied();
192
195134
                        Some((hot, layout_idx, abs_pos))
193
195134
                    });
194

            
195
232840
                let a11y_info_ref = a11y_info;
196
232840
                let mut node = if let Some((layout_node, _layout_idx, abs_pos)) = layout_info {
197
195134
                    Self::build_node(node_data, layout_node, abs_pos, a11y_info_ref, hidpi_factor, window_size)
198
                } else {
199
37706
                    let role = a11y_info_ref.map_or_else(|| Self::node_type_to_role(&node_data.node_type), |info| Self::map_role(&info.role));
200
37706
                    let mut builder = Node::new(role);
201
37706
                    if let NodeType::Text(text) = &node_data.node_type {
202
24286
                        builder.set_label(text.as_str());
203
24286
                    }
204
37706
                    builder
205
                };
206

            
207
                // MWA-B10: advertise the scroll surface. The INBOUND handler
208
                // (LayoutWindow::process_accessibility_action) has handled
209
                // ScrollUp/Down/Left/Right/SetScrollOffset/ScrollIntoView all
210
                // along — but the tree never declared any scroll action or
211
                // offset, so screen readers had nothing to invoke.
212
15
                if let Some((offset, max_x, max_y)) =
213
232840
                    scroll_manager.a11y_scroll_info(*dom_id, NodeId::new(dom_idx))
214
                {
215
15
                    node.set_scroll_x(f64::from(offset.x));
216
15
                    node.set_scroll_x_min(0.0);
217
15
                    node.set_scroll_x_max(f64::from(max_x));
218
15
                    node.set_scroll_y(f64::from(offset.y));
219
15
                    node.set_scroll_y_min(0.0);
220
15
                    node.set_scroll_y_max(f64::from(max_y));
221
15
                    node.set_clips_children();
222
15
                    if max_y > 0.0 {
223
15
                        node.add_action(Action::ScrollUp);
224
15
                        node.add_action(Action::ScrollDown);
225
15
                    }
226
15
                    if max_x > 0.0 {
227
                        node.add_action(Action::ScrollLeft);
228
                        node.add_action(Action::ScrollRight);
229
15
                    }
230
15
                    node.add_action(Action::SetScrollOffset);
231
232825
                }
232

            
233
                // Collect child text and promote to this node's label or value.
234
                // Only do this when all children are text nodes — if the node has
235
                // interactive children (links, buttons, inputs), DON'T set a group
236
                // label, so VoiceOver navigates into the children individually.
237
                //
238
                // For edited contenteditable nodes, dirty_text_overrides has the
239
                // current text (from the relayout path) instead of the stale
240
                // StyledDom text.
241
                {
242
232840
                    let hierarchy_item = &node_hierarchy[dom_idx];
243
232840
                    let dom_node_id_key = (*dom_id, NodeId::new(dom_idx));
244

            
245
                    // Use dirty text override if this node was edited since last RefreshDom
246
232840
                    let (text_content, has_non_text_children) = dirty_text_overrides.get(&dom_node_id_key).map_or_else(|| {
247
232840
                        let mut text = String::new();
248
232840
                        let mut has_non_text = false;
249

            
250
232840
                        let mut child = hierarchy_item.first_child_id(NodeId::new(dom_idx));
251
461339
                        while let Some(child_id) = child {
252
228499
                            if let Some(child_data) = node_data_slice.get(child_id.index()) {
253
228499
                                if let NodeType::Text(t) = &child_data.node_type {
254
82709
                                    if !text.is_empty() { text.push(' '); }
255
82709
                                    text.push_str(t.as_str());
256
145790
                                } else {
257
145790
                                    has_non_text = true;
258
145790
                                }
259
                            }
260
228499
                            if child_id.index() >= node_hierarchy.len() { break; }
261
228499
                            child = node_hierarchy[child_id.index()].next_sibling_id();
262
                        }
263
232840
                        (text, has_non_text)
264
232840
                    }, |override_text| (override_text.clone(), false));
265

            
266
232840
                    if !text_content.is_empty() {
267
69467
                        if node_data.is_contenteditable()
268
68585
                            || matches!(node_data.node_type, NodeType::TextArea | NodeType::Input)
269
                        {
270
882
                            node.set_value(text_content.as_str());
271
                            // Add text editing actions for contenteditable/input nodes
272
882
                            node.add_action(Action::SetTextSelection);
273
882
                            node.add_action(Action::ReplaceSelectedText);
274
882
                            node.add_action(Action::SetValue);
275

            
276
                            // If cursor/selection is in this node, expose to screen readers
277
882
                            if let Some(ref ci) = cursor_info {
278
180
                                if ci.dom_id == *dom_id && ci.node_id == NodeId::new(dom_idx) {
279
                                    let char_lengths: Vec<u8> = text_content.chars()
280
                                        .map(|c| c.len_utf16() as u8)
281
                                        .collect();
282
                                    node.set_character_lengths(char_lengths.clone());
283

            
284
                                    let byte_to_char_idx = |byte_off: usize| -> usize {
285
                                        text_content
286
                                            .char_indices()
287
                                            .take_while(|(b, _)| *b < byte_off)
288
                                            .count()
289
                                            .min(char_lengths.len())
290
                                    };
291

            
292
                                    let anchor_idx = byte_to_char_idx(ci.anchor_offset);
293
                                    let focus_idx = byte_to_char_idx(ci.focus_offset);
294

            
295
                                    node.set_text_selection(accesskit::TextSelection {
296
                                        anchor: accesskit::TextPosition {
297
                                            node: a11y_node_id,
298
                                            character_index: anchor_idx,
299
                                        },
300
                                        focus: accesskit::TextPosition {
301
                                            node: a11y_node_id,
302
                                            character_index: focus_idx,
303
                                        },
304
                                    });
305
180
                                }
306
702
                            }
307
68585
                        } else if !has_non_text_children {
308
57496
                            // Only promote text when there are NO interactive children.
309
57496
                            // Otherwise VoiceOver reads the label instead of navigating children.
310
57496
                            node.set_label(text_content.as_str());
311
57599
                        }
312
163373
                    }
313
                }
314

            
315
232840
                node_id_map.insert((dom_id.inner as u32, dom_idx as u32), a11y_node_id);
316
232840
                nodes.push((a11y_node_id, node));
317
            }
318

            
319
            // Second pass: Build parent-child relationships using DOM hierarchy
320
233047
            for (dom_idx, _) in node_data_slice.iter().enumerate() {
321
233047
                let a11y_node_id = match node_id_map.get(&(dom_id.inner as u32, dom_idx as u32)) {
322
232840
                    Some(id) => *id,
323
207
                    None => continue,
324
                };
325

            
326
232840
                let hierarchy_item = &node_hierarchy[dom_idx];
327

            
328
                // Walk up the DOM tree to find the nearest accessible ancestor.
329
                // parent_id() decodes the 1-based encoding: 0 = None, n+1 = Some(NodeId(n))
330
232840
                let mut current_parent = hierarchy_item.parent_id();
331
232840
                let mut accessible_parent_id = None;
332
232840
                let mut iterations = 0;
333

            
334
232840
                while let Some(parent_node_id) = current_parent {
335
228292
                    iterations += 1;
336
228292
                    if iterations > 10_000 { break; }
337

            
338
228292
                    let parent_idx = parent_node_id.index();
339
228292
                    if let Some(parent_a11y_id) =
340
228292
                        node_id_map.get(&(dom_id.inner as u32, parent_idx as u32))
341
                    {
342
228292
                        accessible_parent_id = Some(*parent_a11y_id);
343
228292
                        break;
344
                    }
345
                    if parent_idx >= node_hierarchy.len() { break; }
346
                    current_parent = node_hierarchy[parent_idx].parent_id();
347
                }
348

            
349
232840
                if let Some(parent_id) = accessible_parent_id {
350
228292
                    parent_children_map
351
228292
                        .entry(parent_id)
352
228292
                        .or_default()
353
228292
                        .push(a11y_node_id);
354
228292
                } else {
355
4548
                    root_children.push(a11y_node_id);
356
4548
                }
357
            }
358
        }
359

            
360
        // Third pass: Set children on all nodes (including root)
361
241594
        for (node_id, node) in &mut nodes {
362
237217
            if *node_id == root_id {
363
4377
                // Root window node gets top-level DOM nodes as children
364
4377
                node.set_children(root_children.clone());
365
232840
            } else if let Some(children) = parent_children_map.get(node_id) {
366
131566
                node.set_children(children.clone());
367
132248
            }
368
        }
369

            
370
        // Set focus to the currently focused DOM node (from FocusManager).
371
        // If no node is focused, fall back to the first visible content node.
372
        // VoiceOver navigates to the focused element on activation.
373
4377
        let focus = focused_node
374
4377
            .and_then(|dom_node_id| {
375
79
                let dom_idx = dom_node_id.node.into_crate_internal()?.index();
376
78
                node_id_map.get(&(dom_node_id.dom.inner as u32, dom_idx as u32)).copied()
377
79
            })
378
4377
            .unwrap_or_else(|| {
379
                // Fallback: first non-container node
380
4300
                nodes.iter()
381
8563
                    .find(|(id, node)| {
382
8563
                        *id != root_id && !matches!(node.role(), Role::GenericContainer | Role::Window)
383
8563
                    })
384
4300
                    .map_or(root_id, |(id, _)| *id)
385
4300
            });
386

            
387
        // Create the tree update
388
        
389

            
390
4377
        TreeUpdate {
391
4377
            nodes,
392
4377
            tree: Some(Tree::new(root_id)),
393
4377
            focus,
394
4377
            tree_id: accesskit::TreeId::ROOT,
395
4377
        }
396
4377
    }
397

            
398
    /// MWA-B10: outbound twin of `map_accesskit_action` — declares a node's
399
    /// supported actions in the tree (payload-carrying variants map to their
400
    /// action KIND; the payload only exists on inbound requests).
401
27
    const fn map_action_to_accesskit(action: &AccessibilityAction) -> Action {
402
        use azul_core::a11y::AccessibilityAction as A;
403
27
        match action {
404
1
            A::Default => Action::Click,
405
1
            A::Focus => Action::Focus,
406
1
            A::Blur => Action::Blur,
407
1
            A::Collapse => Action::Collapse,
408
1
            A::Expand => Action::Expand,
409
1
            A::ScrollIntoView => Action::ScrollIntoView,
410
2
            A::Increment => Action::Increment,
411
2
            A::Decrement => Action::Decrement,
412
1
            A::ShowContextMenu => Action::ShowContextMenu,
413
1
            A::HideTooltip => Action::HideTooltip,
414
1
            A::ShowTooltip => Action::ShowTooltip,
415
1
            A::ScrollUp => Action::ScrollUp,
416
1
            A::ScrollDown => Action::ScrollDown,
417
1
            A::ScrollLeft => Action::ScrollLeft,
418
1
            A::ScrollRight => Action::ScrollRight,
419
1
            A::ReplaceSelectedText(_) => Action::ReplaceSelectedText,
420
1
            A::ScrollToPoint(_) => Action::ScrollToPoint,
421
1
            A::SetScrollOffset(_) => Action::SetScrollOffset,
422
1
            A::SetTextSelection(_) => Action::SetTextSelection,
423
            A::SetSequentialFocusNavigationStartingPoint => {
424
1
                Action::SetSequentialFocusNavigationStartingPoint
425
            }
426
3
            A::SetValue(_) | A::SetNumericValue(_) => Action::SetValue,
427
2
            A::CustomAction(_) => Action::CustomAction,
428
        }
429
27
    }
430

            
431
    /// Builds an accesskit Node from Azul's `NodeData` and layout information.
432
    #[allow(clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
433
    #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
434
195491
    fn build_node(
435
195491
        node_data: &NodeData,
436
195491
        layout_node: &LayoutNodeHot,
437
195491
        abs_pos: Option<LogicalPosition>,
438
195491
        a11y_info: Option<&AccessibilityInfo>,
439
195491
        hidpi_factor: f32,
440
195491
        window_size: LogicalSize,
441
195491
    ) -> Node {
442
        // Set role based on NodeType or AccessibilityInfo.
443
195491
        let role = if node_data.is_contenteditable() {
444
1153
            Role::MultilineTextInput
445
194338
        } else if let Some(info) = a11y_info {
446
6
            Self::map_role(&info.role)
447
        } else {
448
194332
            Self::node_type_to_role(&node_data.node_type)
449
        };
450

            
451
195491
        let mut builder = Node::new(role);
452

            
453
        // Set HTML tag name for screen readers that use it
454
195491
        let tag = node_data.node_type.get_path().to_string();
455
195491
        if !tag.is_empty() {
456
195491
            builder.set_html_tag(tag.as_str());
457
195491
        }
458

            
459
        // === Label and Value ===
460
        // Priority: explicit a11y info > DOM attributes > text content
461
195491
        if let Some(info) = a11y_info {
462
7
            if let Some(name) = info.accessibility_name.as_option() {
463
1
                builder.set_label(name.as_str());
464
6
            }
465
7
            if let Some(value) = info.accessibility_value.as_option() {
466
1
                builder.set_value(value.as_str());
467
6
            }
468
7
            if let Some(desc) = info.description.as_option() {
469
                builder.set_description(desc.as_str());
470
7
            }
471
195484
        }
472

            
473
        // DOM attribute overrides
474
195491
        if let Some(label) = node_data.get_accessible_label() {
475
2
            builder.set_label(label);
476
195489
        }
477
195491
        if let Some(value) = node_data.get_accessible_value() {
478
2
            builder.set_value(value);
479
195489
        }
480
        // Text node: set as label
481
195491
        if let NodeType::Text(text) = &node_data.node_type {
482
58426
            builder.set_label(text.as_str());
483
137065
        }
484

            
485
        // === States from AccessibilityInfo ===
486
195491
        if let Some(info) = a11y_info {
487
10
            for state in info.states.as_ref() {
488
10
                match state {
489
1
                    AccessibilityState::Unavailable => { builder.set_disabled(); }
490
1
                    AccessibilityState::Readonly => { builder.set_read_only(); }
491
1
                    AccessibilityState::CheckedTrue => { builder.set_toggled(accesskit::Toggled::True); }
492
1
                    AccessibilityState::CheckedFalse => { builder.set_toggled(accesskit::Toggled::False); }
493
1
                    AccessibilityState::Expanded => { builder.set_expanded(true); }
494
1
                    AccessibilityState::Collapsed => { builder.set_expanded(false); }
495
1
                    AccessibilityState::Focusable => { builder.add_action(Action::Focus); }
496
1
                    AccessibilityState::Selected => { builder.set_selected(true); }
497
1
                    AccessibilityState::Busy => { builder.set_busy(); }
498
1
                    AccessibilityState::Offscreen => { builder.set_hidden(); }
499
                    _ => {}
500
                }
501
            }
502
195484
        }
503

            
504
        // MWA-B10: declare user-supplied supported actions — the public
505
        // AccessibilityInfo.supported_actions field was never read, so
506
        // API-declared actions never reached assistive technology.
507
195491
        if let Some(info) = a11y_info {
508
7
            for action in info.supported_actions.as_ref() {
509
4
                builder.add_action(Self::map_action_to_accesskit(action));
510
4
            }
511
195484
        }
512

            
513
        // MWA-B10: every content node can be scrolled into view — the
514
        // inbound handler implements it; declaring it lets screen readers
515
        // use it for navigation.
516
195491
        builder.add_action(Action::ScrollIntoView);
517

            
518
        // === Heading level ===
519
195491
        match &node_data.node_type {
520
406
            NodeType::H1 => { builder.set_level(1); }
521
1
            NodeType::H2 => { builder.set_level(2); }
522
1
            NodeType::H3 => { builder.set_level(3); }
523
1
            NodeType::H4 => { builder.set_level(4); }
524
1
            NodeType::H5 => { builder.set_level(5); }
525
1
            NodeType::H6 => { builder.set_level(6); }
526
195080
            _ => {}
527
        }
528

            
529
        // Wire up HTML attributes to accesskit properties
530
195491
        for attr in node_data.attributes().as_ref() {
531
85600
            match attr {
532
2
                azul_core::dom::AttributeType::AriaLabel(s) => {
533
2
                    builder.set_label(s.as_str());
534
2
                }
535
1
                azul_core::dom::AttributeType::Title(s)
536
1
                | azul_core::dom::AttributeType::Alt(s) => {
537
1
                    builder.set_description(s.as_str());
538
1
                }
539
1
                azul_core::dom::AttributeType::Placeholder(s) => {
540
1
                    builder.set_placeholder(s.as_str());
541
1
                }
542
2
                azul_core::dom::AttributeType::Value(s) => {
543
2
                    builder.set_value(s.as_str());
544
2
                }
545
1
                azul_core::dom::AttributeType::Disabled => {
546
1
                    builder.set_disabled();
547
1
                }
548
1
                azul_core::dom::AttributeType::Readonly => {
549
1
                    builder.set_read_only();
550
1
                }
551
1
                azul_core::dom::AttributeType::CheckedTrue => {
552
1
                    builder.set_toggled(accesskit::Toggled::True);
553
1
                }
554
                azul_core::dom::AttributeType::CheckedFalse => {
555
                    builder.set_toggled(accesskit::Toggled::False);
556
                }
557
1
                azul_core::dom::AttributeType::Required => {
558
1
                    builder.set_required();
559
1
                }
560
1
                azul_core::dom::AttributeType::Hidden => {
561
1
                    builder.set_hidden();
562
1
                }
563
1
                azul_core::dom::AttributeType::Lang(s) => {
564
1
                    builder.set_language(s.as_str());
565
1
                }
566
3
                azul_core::dom::AttributeType::ColSpan(n) => {
567
3
                    builder.set_column_span(*n as usize);
568
3
                }
569
2
                azul_core::dom::AttributeType::RowSpan(n) => {
570
2
                    builder.set_row_span(*n as usize);
571
2
                }
572
85583
                _ => {}
573
            }
574
        }
575

            
576
        // Set bounds: absolute position, offset by padding+border, scaled to physical pixels,
577
        // clipped to window viewport so VoiceOver highlights don't extend off-screen.
578
195491
        if let (Some(pos), Some(size)) = (abs_pos, layout_node.used_size) {
579
142195
            let bp = layout_node.box_props.unpack();
580
142195
            let pad_left = bp.padding.left + bp.border.left;
581
142195
            let pad_top = bp.padding.top + bp.border.top;
582
142195
            let pad_right = bp.padding.right + bp.border.right;
583
142195
            let pad_bottom = bp.padding.bottom + bp.border.bottom;
584

            
585
142195
            let s = f64::from(hidpi_factor);
586
142195
            let ww = f64::from(window_size.width) * s;
587
142195
            let wh = f64::from(window_size.height) * s;
588

            
589
142195
            let x0 = (f64::from(pos.x + pad_left) * s).max(0.0).min(ww);
590
142195
            let y0 = (f64::from(pos.y + pad_top) * s).max(0.0).min(wh);
591
142195
            let x1 = (f64::from(pos.x + size.width - pad_right) * s).max(0.0).min(ww);
592
142195
            let y1 = (f64::from(pos.y + size.height - pad_bottom) * s).max(0.0).min(wh);
593

            
594
142195
            if x1 > x0 && y1 > y0 {
595
111655
                builder.set_bounds(Rect { x0, y0, x1, y1 });
596
111828
            }
597
53296
        }
598

            
599
        // Add supported actions based on the DOM node's own properties.
600
        // VoiceOver uses these to determine what the user can do with the element.
601
195491
        if node_data.is_focusable() || node_data.is_contenteditable() {
602
16370
            builder.add_action(Action::Focus);
603
179121
        }
604
195491
        if node_data.has_activation_behavior() {
605
20414
            builder.add_action(Action::Click);
606
175077
        }
607

            
608
        // ARIA relations + live-region from AccessibilityInfo. aria-labelledby /
609
        // aria-describedby reference another node; encode its id the SAME way the
610
        // tree walk does (encode_a11y_node_id) so the relation resolves to a real
611
        // node. is_live_region maps to accesskit's Live property. These were all
612
        // previously dropped (screen readers got no labelled-by/described-by
613
        // relations and no live-region announcements).
614
195491
        if let Some(info) = a11y_info {
615
7
            if let azul_core::dom::OptionDomNodeId::Some(target) = info.labelled_by {
616
2
                if let Some(id) = Self::a11y_node_id_for(&target) {
617
1
                    builder.push_labelled_by(id);
618
1
                }
619
5
            }
620
7
            if let azul_core::dom::OptionDomNodeId::Some(target) = info.described_by {
621
2
                if let Some(id) = Self::a11y_node_id_for(&target) {
622
1
                    builder.push_described_by(id);
623
1
                }
624
5
            }
625
7
            if info.is_live_region {
626
1
                builder.set_live(accesskit::Live::Polite);
627
6
            }
628
195484
        }
629

            
630
        // MWA-C-a11y: aria-live="polite|assertive" HTML attribute — arrives
631
        // as AriaProperty/Custom (no parsing existed; live regions were only
632
        // reachable through the explicit AccessibilityInfo.is_live_region
633
        // flag, so HTML-defined live regions never announced).
634
195491
        for attr in node_data.attributes() {
635
85600
            let (name, value) = match attr {
636
8
                azul_core::dom::AttributeType::AriaProperty(nv)
637
                | azul_core::dom::AttributeType::Custom(nv) => {
638
8
                    (nv.attr_name.as_str(), nv.value.as_str())
639
                }
640
85592
                _ => continue,
641
            };
642
8
            if name.eq_ignore_ascii_case("aria-live") {
643
8
                match value.to_ascii_lowercase().as_str() {
644
8
                    "polite" => builder.set_live(accesskit::Live::Polite),
645
6
                    "assertive" => builder.set_live(accesskit::Live::Assertive),
646
4
                    _ => builder.set_live(accesskit::Live::Off),
647
                }
648
            }
649
        }
650

            
651
195491
        builder
652
195491
    }
653

            
654
    /// Encode a `(DomId.inner, node index)` pair into the stable `A11yNodeId` used
655
    /// throughout the tree (offset by 1 so it never collides with `root_id` 0).
656
    /// Shared by the tree walk and the aria-labelledby/-describedby relation
657
    /// mapping, so a relation always resolves to the node the walk emitted.
658
232866
    const fn encode_a11y_node_id(dom_inner: usize, node_idx: usize) -> A11yNodeId {
659
232866
        A11yNodeId(((dom_inner as u64) << 32) | ((node_idx as u64) + 1))
660
232866
    }
661

            
662
    /// Map an aria-labelledby/-describedby target `DomNodeId` to its `A11yNodeId`,
663
    /// or `None` if the node id can't be resolved.
664
7
    fn a11y_node_id_for(target: &DomNodeId) -> Option<A11yNodeId> {
665
7
        let idx = target.node.into_crate_internal()?.index();
666
4
        Some(Self::encode_a11y_node_id(target.dom.inner, idx))
667
7
    }
668

            
669
    /// Maps an HTML `NodeType` to an accesskit `Role`.
670
    ///
671
    /// Every role used here must pass accesskit's `common_filter` (i.e. NOT be
672
    /// `GenericContainer` or `TextRun`) or `VoiceOver` will skip the node entirely.
673
    /// Use `Group` for structural containers, `Paragraph` for text blocks, `Label`
674
    /// for inline text, and semantic roles for everything else.
675
    // Exhaustive NodeType -> accessibility Role mapping table; many node types share
676
    // a Role, but one-arm-per-NodeType is intentional for readability/maintainability.
677
    #[allow(clippy::match_same_arms)]
678
232138
    const fn node_type_to_role(node_type: &NodeType) -> Role {
679
232138
        match node_type {
680
            // === Text content ===
681
82714
            NodeType::Text(_) => Role::Label,
682
49704
            NodeType::P => Role::Paragraph,
683
1
            NodeType::Pre => Role::Code,
684
1
            NodeType::BlockQuote => Role::Blockquote,
685
1
            NodeType::Code => Role::Code,
686
1
            NodeType::Em | NodeType::I => Role::Emphasis,
687
1
            NodeType::Strong | NodeType::B => Role::Strong,
688
1
            NodeType::Mark => Role::Mark,
689
1
            NodeType::Del => Role::ContentDeletion,
690
1
            NodeType::Ins => Role::ContentInsertion,
691
1
            NodeType::Abbr | NodeType::Acronym => Role::Abbr,
692
1
            NodeType::Q => Role::Blockquote,
693
1
            NodeType::Time => Role::Time,
694
            NodeType::Cite | NodeType::Dfn | NodeType::Var
695
            | NodeType::Samp | NodeType::Kbd => Role::Label,
696
            NodeType::Small | NodeType::Big | NodeType::Sub
697
            | NodeType::Sup | NodeType::U | NodeType::S => Role::Label,
698
1
            NodeType::Ruby => Role::Ruby,
699
1
            NodeType::Rt => Role::RubyAnnotation,
700
2
            NodeType::Br => Role::LineBreak,
701
2
            NodeType::Hr => Role::Splitter,
702

            
703
            // === Structural containers ===
704
            // Group (not GenericContainer) so VoiceOver can navigate into them
705
3487
            NodeType::Body => Role::Group,
706
61724
            NodeType::Div => Role::Group,
707
478
            NodeType::Span => Role::Group,
708
531
            NodeType::Html => Role::Group,
709

            
710
            // === Semantic sections ===
711
1
            NodeType::Article => Role::Article,
712
1
            NodeType::Section => Role::Section,
713
1
            NodeType::Nav => Role::Navigation,
714
1
            NodeType::Main => Role::Main,
715
1
            NodeType::Header => Role::Header,
716
1
            NodeType::Footer => Role::Footer,
717
1
            NodeType::Aside => Role::Complementary,
718
            NodeType::Address => Role::Group,
719
1
            NodeType::Figure => Role::Figure,
720
1
            NodeType::FigCaption => Role::FigureCaption,
721
1
            NodeType::Details => Role::Details,
722
1
            NodeType::Summary => Role::DisclosureTriangle,
723
1
            NodeType::Dialog => Role::Dialog,
724

            
725
            // === Headings ===
726
            NodeType::H1 | NodeType::H2 | NodeType::H3
727
419
            | NodeType::H4 | NodeType::H5 | NodeType::H6 => Role::Heading,
728

            
729
            // === Lists ===
730
435
            NodeType::Ul | NodeType::Ol | NodeType::Dir => Role::List,
731
1271
            NodeType::Li => Role::ListItem,
732
1
            NodeType::Dl => Role::DescriptionList,
733
1
            NodeType::Dt => Role::Term,
734
1
            NodeType::Dd => Role::Definition,
735
1
            NodeType::Menu => Role::Menu,
736
1
            NodeType::MenuItem => Role::MenuItem,
737

            
738
            // === Tables ===
739
299
            NodeType::Table => Role::Table,
740
1
            NodeType::Caption => Role::Caption,
741
12
            NodeType::THead | NodeType::TBody | NodeType::TFoot => Role::RowGroup,
742
308
            NodeType::Tr => Role::Row,
743
20
            NodeType::Th => Role::ColumnHeader,
744
751
            NodeType::Td => Role::Cell,
745
2
            NodeType::ColGroup | NodeType::Col => Role::GenericContainer,
746

            
747
            // === Forms ===
748
1
            NodeType::Form => Role::Form,
749
1
            NodeType::FieldSet => Role::Group,
750
1
            NodeType::Legend => Role::Legend,
751
1
            NodeType::Label => Role::Label,
752
3
            NodeType::Input => Role::TextInput,
753
14636
            NodeType::Button => Role::Button,
754
1
            NodeType::Select => Role::ComboBox,
755
            NodeType::OptGroup => Role::Group,
756
1
            NodeType::SelectOption => Role::ListBoxOption,
757
2
            NodeType::TextArea => Role::MultilineTextInput,
758
1
            NodeType::Output => Role::Status,
759
1
            NodeType::Progress => Role::ProgressIndicator,
760
1
            NodeType::Meter => Role::Meter,
761
1
            NodeType::DataList => Role::ListBox,
762

            
763
            // === Links ===
764
443
            NodeType::A => Role::Link,
765

            
766
            // === Embedded content ===
767
20
            NodeType::Image(_) => Role::Image,
768
14814
            NodeType::Icon(_) => Role::Image,
769
1
            NodeType::Canvas => Role::Canvas,
770
1
            NodeType::Audio => Role::Audio,
771
1
            NodeType::Video => Role::Video,
772
1
            NodeType::Svg => Role::SvgRoot,
773
2
            NodeType::Object | NodeType::Embed => Role::EmbeddedObject,
774

            
775
            // === Everything else: Group (visible to VoiceOver) ===
776
14
            _ => Role::Group,
777
        }
778
232138
    }
779

            
780
    /// Maps Azul's `AccessibilityRole` to accesskit's Role.
781
    // Exhaustive AccessibilityRole -> AccessKit Role mapping table (see node_type_to_role).
782
    #[allow(clippy::match_same_arms)]
783
    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
784
71
    const fn map_role(role: &AccessibilityRole) -> Role {
785
71
        match role {
786
1
            AccessibilityRole::TitleBar => Role::TitleBar,
787
1
            AccessibilityRole::MenuBar => Role::MenuBar,
788
1
            AccessibilityRole::ScrollBar => Role::ScrollBar,
789
1
            AccessibilityRole::Grip => Role::Splitter,
790
1
            AccessibilityRole::Sound => Role::Audio,
791
1
            AccessibilityRole::Cursor => Role::Caret,
792
1
            AccessibilityRole::Caret => Role::Caret,
793
1
            AccessibilityRole::Alert => Role::Alert,
794
1
            AccessibilityRole::Window => Role::Window,
795
1
            AccessibilityRole::Client => Role::GenericContainer,
796
1
            AccessibilityRole::MenuPopup => Role::Menu,
797
1
            AccessibilityRole::MenuItem => Role::MenuItem,
798
1
            AccessibilityRole::Tooltip => Role::Tooltip,
799
1
            AccessibilityRole::Application => Role::Application,
800
1
            AccessibilityRole::Document => Role::Document,
801
1
            AccessibilityRole::Pane => Role::Pane,
802
1
            AccessibilityRole::Chart => Role::Figure,
803
1
            AccessibilityRole::Dialog => Role::Dialog,
804
1
            AccessibilityRole::Border => Role::GenericContainer,
805
1
            AccessibilityRole::Grouping => Role::Group,
806
1
            AccessibilityRole::Separator => Role::GenericContainer,
807
1
            AccessibilityRole::Toolbar => Role::Toolbar,
808
1
            AccessibilityRole::StatusBar => Role::Status,
809
1
            AccessibilityRole::Table => Role::Table,
810
1
            AccessibilityRole::ColumnHeader => Role::ColumnHeader,
811
1
            AccessibilityRole::RowHeader => Role::RowHeader,
812
1
            AccessibilityRole::Column => Role::GenericContainer, // No Column in accesskit 0.17
813
1
            AccessibilityRole::Row => Role::Row,
814
1
            AccessibilityRole::Cell => Role::Cell,
815
1
            AccessibilityRole::Link => Role::Link,
816
1
            AccessibilityRole::HelpBalloon => Role::Tooltip,
817
1
            AccessibilityRole::Character => Role::GenericContainer,
818
1
            AccessibilityRole::List => Role::List,
819
1
            AccessibilityRole::ListItem => Role::ListItem,
820
1
            AccessibilityRole::Outline => Role::Tree,
821
1
            AccessibilityRole::OutlineItem => Role::TreeItem,
822
1
            AccessibilityRole::PageTab => Role::Tab,
823
1
            AccessibilityRole::PropertyPage => Role::TabPanel,
824
1
            AccessibilityRole::Indicator => Role::Meter,
825
1
            AccessibilityRole::Graphic => Role::Image,
826
            // StaticText -> Label in accesskit 0.17
827
1
            AccessibilityRole::StaticText => Role::Label,
828
3
            AccessibilityRole::Text => Role::TextInput,
829
2
            AccessibilityRole::PushButton => Role::Button,
830
3
            AccessibilityRole::CheckButton => Role::CheckBox,
831
1
            AccessibilityRole::RadioButton => Role::RadioButton,
832
1
            AccessibilityRole::ComboBox => Role::ComboBox,
833
1
            AccessibilityRole::DropList => Role::ListBox,
834
1
            AccessibilityRole::ProgressBar => Role::ProgressIndicator,
835
1
            AccessibilityRole::Dial => Role::Meter,
836
1
            AccessibilityRole::HotkeyField => Role::TextInput,
837
2
            AccessibilityRole::Slider => Role::Slider,
838
1
            AccessibilityRole::SpinButton => Role::SpinButton,
839
1
            AccessibilityRole::Diagram => Role::Figure,
840
1
            AccessibilityRole::Animation => Role::GenericContainer,
841
1
            AccessibilityRole::Equation => Role::Math,
842
1
            AccessibilityRole::ButtonDropdown => Role::Button,
843
            // No MenuButton in accesskit 0.17
844
1
            AccessibilityRole::ButtonMenu => Role::Button,
845
1
            AccessibilityRole::ButtonDropdownGrid => Role::Button,
846
1
            AccessibilityRole::Whitespace => Role::GenericContainer,
847
1
            AccessibilityRole::PageTabList => Role::TabList,
848
1
            AccessibilityRole::Clock => Role::Timer,
849
1
            AccessibilityRole::SplitButton => Role::Button,
850
1
            AccessibilityRole::IpAddress => Role::TextInput,
851
1
            AccessibilityRole::Unknown => Role::Unknown,
852
1
            AccessibilityRole::Nothing => Role::GenericContainer,
853
        }
854
71
    }
855

            
856
}
857

            
858
/// Decodes an `A11yNodeId` back into its `(DomId, NodeId)` components.
859
///
860
/// The `A11yNodeId` encodes both values in a single u64:
861
/// - Upper 32 bits: `DomId` (which DOM tree the node belongs to)
862
/// - Lower 32 bits: `NodeId + 1` (index within that DOM tree, offset by 1 to avoid
863
///   colliding with the accesskit root node id, matching the encoding in `update_tree`)
864
#[cfg(feature = "a11y")]
865
10
#[must_use] pub const fn decode_a11y_node_id(a11y_node_id: A11yNodeId) -> (DomId, NodeId) {
866
10
    let raw = a11y_node_id.0;
867
10
    let dom_id = DomId {
868
10
        inner: (raw >> 32) as usize,
869
10
    };
870
10
    let node_id = NodeId::new(((raw & 0xFFFF_FFFF).wrapping_sub(1)) as usize);
871
10
    (dom_id, node_id)
872
10
}
873

            
874
/// Maps an accesskit `ActionRequest` to an Azul `AccessibilityAction`.
875
///
876
/// Returns `None` if the action requires data that was not provided or is invalid.
877
#[cfg(feature = "a11y")]
878
#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
879
48
#[must_use] pub fn map_accesskit_action(request: ActionRequest) -> Option<AccessibilityAction> {
880
    use azul_css::{props::basic::FloatValue, AzString};
881

            
882
48
    let action = match request.action {
883
1
        Action::Click => AccessibilityAction::Default,
884
1
        Action::Focus => AccessibilityAction::Focus,
885
1
        Action::Blur => AccessibilityAction::Blur,
886
1
        Action::Collapse => AccessibilityAction::Collapse,
887
1
        Action::Expand => AccessibilityAction::Expand,
888
2
        Action::ScrollIntoView => AccessibilityAction::ScrollIntoView,
889
1
        Action::Increment => AccessibilityAction::Increment,
890
1
        Action::Decrement => AccessibilityAction::Decrement,
891
1
        Action::ShowContextMenu => AccessibilityAction::ShowContextMenu,
892
1
        Action::HideTooltip => AccessibilityAction::HideTooltip,
893
1
        Action::ShowTooltip => AccessibilityAction::ShowTooltip,
894
1
        Action::ScrollUp => AccessibilityAction::ScrollUp,
895
1
        Action::ScrollDown => AccessibilityAction::ScrollDown,
896
1
        Action::ScrollLeft => AccessibilityAction::ScrollLeft,
897
1
        Action::ScrollRight => AccessibilityAction::ScrollRight,
898
        Action::SetSequentialFocusNavigationStartingPoint => {
899
1
            AccessibilityAction::SetSequentialFocusNavigationStartingPoint
900
        }
901
        Action::ReplaceSelectedText => {
902
2
            let accesskit::ActionData::Value(value) = request.data? else {
903
1
                return None;
904
            };
905
            AccessibilityAction::ReplaceSelectedText(AzString::from(value.as_ref()))
906
        }
907
        Action::ScrollToPoint => {
908
3
            let accesskit::ActionData::ScrollToPoint(point) = request.data? else {
909
1
                return None;
910
            };
911
1
            AccessibilityAction::ScrollToPoint(LogicalPosition {
912
1
                x: point.x as f32,
913
1
                y: point.y as f32,
914
1
            })
915
        }
916
        Action::SetScrollOffset => {
917
3
            let accesskit::ActionData::SetScrollOffset(point) = request.data? else {
918
1
                return None;
919
            };
920
1
            AccessibilityAction::SetScrollOffset(LogicalPosition {
921
1
                x: point.x as f32,
922
1
                y: point.y as f32,
923
1
            })
924
        }
925
        Action::SetTextSelection => {
926
3
            let accesskit::ActionData::SetTextSelection(selection) = request.data? else {
927
1
                return None;
928
            };
929
1
            AccessibilityAction::SetTextSelection(TextSelectionStartEnd {
930
1
                selection_start: selection.anchor.character_index,
931
1
                selection_end: selection.focus.character_index,
932
1
            })
933
        }
934
14
        Action::SetValue => match request.data? {
935
4
            accesskit::ActionData::Value(value) => {
936
4
                AccessibilityAction::SetValue(AzString::from(value.as_ref()))
937
            }
938
8
            accesskit::ActionData::NumericValue(value) => {
939
8
                AccessibilityAction::SetNumericValue(FloatValue::new(value as f32))
940
            }
941
1
            _ => return None,
942
        },
943
        Action::CustomAction => {
944
6
            let accesskit::ActionData::CustomAction(id) = request.data? else {
945
1
                return None;
946
            };
947
4
            AccessibilityAction::CustomAction(id)
948
        }
949
    };
950

            
951
36
    Some(action)
952
48
}
953

            
954
/// Stub implementation when accessibility feature is disabled.
955
#[cfg(not(feature = "a11y"))]
956
#[derive(Debug)]
957
pub struct A11yManager {
958
    _private: (),
959
}
960

            
961
#[cfg(not(feature = "a11y"))]
962
impl A11yManager {
963
    /// Creates a new stub `A11yManager` (no-op when accessibility is disabled).
964
    pub fn new() -> Self {
965
        Self { _private: () }
966
    }
967
}
968

            
969
#[cfg(all(test, feature = "a11y"))]
970
mod a11y_relation_tests {
971
    use super::A11yManager;
972
    use accesskit::NodeId as A11yNodeId;
973

            
974
    /// The a11y node-id encoding must stay in lockstep with the tree walk:
975
    /// `(dom.inner << 32) | (idx + 1)`. `labelled_by/described_by` relations encode
976
    /// their targets the same way, so any drift here would point a relation at
977
    /// the wrong (or a nonexistent) node.
978
    #[test]
979
1
    fn a11y_node_id_encoding_is_stable_and_offset() {
980
1
        assert_eq!(A11yManager::encode_a11y_node_id(0, 0), A11yNodeId(1));
981
1
        assert_eq!(A11yManager::encode_a11y_node_id(0, 5), A11yNodeId(6));
982
1
        assert_eq!(
983
1
            A11yManager::encode_a11y_node_id(2, 3),
984
            A11yNodeId((2u64 << 32) | 4)
985
        );
986
        // Never collides with the root window node (id 0).
987
1
        assert_ne!(A11yManager::encode_a11y_node_id(0, 0), A11yNodeId(0));
988
1
    }
989
}
990

            
991
#[cfg(all(test, feature = "a11y"))]
992
#[allow(clippy::float_cmp, clippy::too_many_lines)]
993
mod autotest_generated {
994
    use std::collections::BTreeMap;
995

            
996
    use accesskit::{ActionData, Live, Point, TextPosition, TextSelection, Toggled, TreeId};
997
    use azul_core::{
998
        dom::{
999
            AttributeNameValue, AttributeType, FormattingContext, OptionDomNodeId,
        },
        styled_dom::NodeHierarchyItemId,
        window::OptionVirtualKeyCodeCombo,
    };
    use azul_css::{css::BoxOrStatic, props::basic::FloatValue, OptionString};
    use super::*;
    use crate::{managers::scroll_state::ScrollManager, solver3::geometry::PackedBoxProps};
    // ---------------------------------------------------------------------
    // fixtures
    // ---------------------------------------------------------------------
    /// A `LayoutNodeHot` with the given used size and packed box props.
    /// `PackedBoxProps` edges are `[top, right, bottom, left]` in tenths of a pixel.
    fn hot(used_size: Option<LogicalSize>, padding: [i16; 4], border: [i16; 4]) -> LayoutNodeHot {
        LayoutNodeHot {
            box_props: PackedBoxProps {
                padding,
                border,
                ..PackedBoxProps::default()
            },
            dom_node_id: Some(NodeId::new(0)),
            used_size,
            formatting_context: FormattingContext::Block {
                establishes_new_context: false,
            },
            parent: None,
        }
    }
    fn plain_hot() -> LayoutNodeHot {
        hot(Some(LogicalSize::new(100.0, 50.0)), [0; 4], [0; 4])
    }
    fn info(role: AccessibilityRole) -> AccessibilityInfo {
        AccessibilityInfo {
            accessibility_name: OptionString::None,
            accessibility_value: OptionString::None,
            description: OptionString::None,
            accelerator: OptionVirtualKeyCodeCombo::None,
            default_action: OptionString::None,
            states: Vec::<AccessibilityState>::new().into(),
            supported_actions: Vec::<AccessibilityAction>::new().into(),
            labelled_by: OptionDomNodeId::None,
            described_by: OptionDomNodeId::None,
            role,
            is_live_region: false,
        }
    }
    fn dom_node(dom: usize, idx: usize) -> DomNodeId {
        DomNodeId {
            dom: DomId { inner: dom },
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
        }
    }
    fn text_node(s: &str) -> NodeData {
        NodeData::create_node(NodeType::Text(BoxOrStatic::heap(AzString::from(s))))
    }
    fn request(action: Action, data: Option<ActionData>) -> ActionRequest {
        ActionRequest {
            action,
            target_tree: TreeId::ROOT,
            target_node: A11yNodeId(1),
            data,
        }
    }
    /// `update_tree` with no DOMs at all — the smallest legal input.
    fn empty_update(
        window_size: LogicalSize,
        focused_node: Option<DomNodeId>,
        hidpi_factor: f32,
        title: &str,
    ) -> TreeUpdate {
        let layout_results = BTreeMap::new();
        let scroll_manager = ScrollManager::new();
        let overrides = BTreeMap::new();
        A11yManager::update_tree(
            A11yNodeId(0),
            &layout_results,
            &scroll_manager,
            &AzString::from(title),
            window_size,
            focused_node,
            hidpi_factor,
            &overrides,
            None,
        )
    }
    // ---------------------------------------------------------------------
    // A11yManager::new / Default (constructor)
    // ---------------------------------------------------------------------
    #[test]
    fn new_starts_with_root_zero_and_uninitialized_tree() {
        let manager = A11yManager::new();
        assert_eq!(manager.root_id, A11yNodeId(0));
        assert!(manager.tree.is_none());
        assert!(manager.last_tree_update.is_none());
        assert!(
            !manager.tree_initialized,
            "a fresh manager must force a full first TreeUpdate"
        );
    }
    #[test]
    fn default_matches_new() {
        let a = A11yManager::new();
        let b = A11yManager::default();
        assert_eq!(a.root_id, b.root_id);
        assert_eq!(a.tree_initialized, b.tree_initialized);
        assert!(b.tree.is_none() && b.last_tree_update.is_none());
    }
    // ---------------------------------------------------------------------
    // encode_a11y_node_id / decode_a11y_node_id (numeric + round-trip)
    // ---------------------------------------------------------------------
    #[test]
    fn encode_decode_round_trips_over_the_representable_domain() {
        // node_idx must stay < u32::MAX so `idx + 1` cannot carry out of the
        // low 32 bits; dom_inner must stay <= u32::MAX so it cannot shift out.
        let cases: [(usize, usize); 7] = [
            (0, 0),
            (0, 1),
            (1, 0),
            (2, 3),
            (0, u32::MAX as usize - 1),
            (u32::MAX as usize, 0),
            (u32::MAX as usize, u32::MAX as usize - 1),
        ];
        for (dom, idx) in cases {
            let encoded = A11yManager::encode_a11y_node_id(dom, idx);
            let (decoded_dom, decoded_node) = decode_a11y_node_id(encoded);
            assert_eq!(decoded_dom.inner, dom, "dom round-trip for ({dom}, {idx})");
            assert_eq!(decoded_node.index(), idx, "idx round-trip for ({dom}, {idx})");
        }
    }
    #[test]
    fn encoded_ids_never_collide_with_the_root_window_id() {
        // root_id is 0; the +1 offset is the only thing keeping node 0 of dom 0
        // from being mistaken for the window itself.
        for (dom, idx) in [(0usize, 0usize), (0, 1), (5, 0), (u32::MAX as usize, 0)] {
            assert_ne!(
                A11yManager::encode_a11y_node_id(dom, idx),
                A11yNodeId(0),
                "({dom}, {idx}) must not encode to the root id"
            );
        }
    }
    #[test]
    fn encode_is_injective_for_neighbouring_ids() {
        let a = A11yManager::encode_a11y_node_id(0, 1);
        let b = A11yManager::encode_a11y_node_id(1, 0);
        let c = A11yManager::encode_a11y_node_id(0, 2);
        assert_ne!(a, b);
        assert_ne!(a, c);
        assert_ne!(b, c);
    }
    /// Characterisation of a known-lossy boundary: `idx + 1` carries out of the
    /// low 32 bits at `idx == u32::MAX`, silently incrementing the DomId field.
    /// Not reachable with real DOMs (a 4-billion-node tree), but pinned so the
    /// encoding contract can't drift unnoticed. Does not panic.
    #[test]
    fn encode_node_idx_at_u32_max_carries_into_the_dom_field() {
        let encoded = A11yManager::encode_a11y_node_id(0, u32::MAX as usize);
        assert_eq!(encoded, A11yNodeId(1u64 << 32));
        let (dom, node) = decode_a11y_node_id(encoded);
        assert_eq!(dom.inner, 1, "dom 0 aliases onto dom 1 at the carry");
        assert_eq!(node.index(), usize::MAX);
        // Still never collides with the root window node.
        assert_ne!(encoded, A11yNodeId(0));
    }
    /// Decoding the root id itself is nonsense (root is not a DOM node), but it
    /// must not panic: the `wrapping_sub(1)` yields the usize::MAX sentinel.
    #[test]
    fn decode_of_root_id_wraps_instead_of_panicking() {
        let (dom, node) = decode_a11y_node_id(A11yNodeId(0));
        assert_eq!(dom.inner, 0);
        assert_eq!(node.index(), usize::MAX);
    }
    #[test]
    fn decode_of_u64_max_does_not_panic() {
        let (dom, node) = decode_a11y_node_id(A11yNodeId(u64::MAX));
        assert_eq!(dom.inner, u32::MAX as usize);
        assert_eq!(node.index(), u32::MAX as usize - 1);
    }
    // ---------------------------------------------------------------------
    // a11y_node_id_for
    // ---------------------------------------------------------------------
    #[test]
    fn a11y_node_id_for_resolves_to_the_same_id_the_tree_walk_emits() {
        let target = dom_node(2, 3);
        assert_eq!(
            A11yManager::a11y_node_id_for(&target),
            Some(A11yManager::encode_a11y_node_id(2, 3)),
            "a relation must resolve to the node the walk emitted"
        );
    }
    #[test]
    fn a11y_node_id_for_returns_none_for_the_none_sentinel() {
        let target = DomNodeId {
            dom: DomId { inner: 0 },
            node: NodeHierarchyItemId::NONE,
        };
        assert_eq!(A11yManager::a11y_node_id_for(&target), None);
    }
    #[test]
    fn a11y_node_id_for_handles_extreme_dom_ids_without_panicking() {
        let target = dom_node(u32::MAX as usize, 0);
        assert_eq!(
            A11yManager::a11y_node_id_for(&target),
            Some(A11yNodeId((u32::MAX as u64) << 32 | 1))
        );
    }
    // ---------------------------------------------------------------------
    // node_type_to_role
    // ---------------------------------------------------------------------
    #[test]
    fn node_type_to_role_maps_the_documented_roles() {
        let cases: [(NodeType, Role); 18] = [
            (NodeType::Text(BoxOrStatic::heap(AzString::from("x"))), Role::Label),
            (NodeType::P, Role::Paragraph),
            (NodeType::Div, Role::Group),
            (NodeType::Body, Role::Group),
            (NodeType::A, Role::Link),
            (NodeType::Button, Role::Button),
            (NodeType::H1, Role::Heading),
            (NodeType::H6, Role::Heading),
            (NodeType::Input, Role::TextInput),
            (NodeType::TextArea, Role::MultilineTextInput),
            (NodeType::Table, Role::Table),
            (NodeType::Tr, Role::Row),
            (NodeType::Td, Role::Cell),
            (NodeType::Th, Role::ColumnHeader),
            (NodeType::Ul, Role::List),
            (NodeType::Li, Role::ListItem),
            (NodeType::Br, Role::LineBreak),
            (NodeType::Hr, Role::Splitter),
        ];
        for (node_type, expected) in cases {
            assert_eq!(
                A11yManager::node_type_to_role(&node_type),
                expected,
                "{node_type:?}"
            );
        }
    }
    #[test]
    fn node_type_to_role_falls_back_to_group_for_unmapped_types() {
        // The `_ => Role::Group` arm: metadata types that never reach the tree
        // must still produce a VoiceOver-visible role rather than a filtered one.
        for node_type in [NodeType::Script, NodeType::Style, NodeType::Meta, NodeType::Head] {
            assert_eq!(A11yManager::node_type_to_role(&node_type), Role::Group);
        }
    }
    /// The doc contract: no role emitted here may be filtered out by accesskit's
    /// `common_filter` (`GenericContainer` / `TextRun`), or VoiceOver skips the
    /// node. `Col`/`ColGroup` are the sole exceptions — see the test below.
    #[test]
    fn node_type_to_role_never_emits_a_voiceover_filtered_role() {
        let node_types = [
            NodeType::Text(BoxOrStatic::heap(AzString::from("x"))),
            NodeType::P,
            NodeType::Pre,
            NodeType::BlockQuote,
            NodeType::Code,
            NodeType::Em,
            NodeType::Strong,
            NodeType::Mark,
            NodeType::Del,
            NodeType::Ins,
            NodeType::Abbr,
            NodeType::Q,
            NodeType::Time,
            NodeType::Ruby,
            NodeType::Rt,
            NodeType::Br,
            NodeType::Hr,
            NodeType::Body,
            NodeType::Div,
            NodeType::Span,
            NodeType::Html,
            NodeType::Article,
            NodeType::Section,
            NodeType::Nav,
            NodeType::Main,
            NodeType::Header,
            NodeType::Footer,
            NodeType::Aside,
            NodeType::Figure,
            NodeType::FigCaption,
            NodeType::Details,
            NodeType::Summary,
            NodeType::Dialog,
            NodeType::H1,
            NodeType::H2,
            NodeType::H3,
            NodeType::H4,
            NodeType::H5,
            NodeType::H6,
            NodeType::Ul,
            NodeType::Ol,
            NodeType::Li,
            NodeType::Dl,
            NodeType::Dt,
            NodeType::Dd,
            NodeType::Menu,
            NodeType::MenuItem,
            NodeType::Table,
            NodeType::Caption,
            NodeType::THead,
            NodeType::TBody,
            NodeType::TFoot,
            NodeType::Tr,
            NodeType::Th,
            NodeType::Td,
            NodeType::Form,
            NodeType::FieldSet,
            NodeType::Legend,
            NodeType::Label,
            NodeType::Input,
            NodeType::Button,
            NodeType::Select,
            NodeType::SelectOption,
            NodeType::TextArea,
            NodeType::Output,
            NodeType::Progress,
            NodeType::Meter,
            NodeType::DataList,
            NodeType::A,
            NodeType::Canvas,
            NodeType::Audio,
            NodeType::Video,
            NodeType::Svg,
            NodeType::Object,
            NodeType::Embed,
            NodeType::Script,
        ];
        for node_type in node_types {
            let role = A11yManager::node_type_to_role(&node_type);
            assert!(
                role != Role::GenericContainer && role != Role::TextRun,
                "{node_type:?} -> {role:?} would be skipped by VoiceOver's common_filter"
            );
        }
    }
    /// `Col`/`ColGroup` do map to the filtered `GenericContainer` role, against the
    /// function's own doc comment. Harmless today only because `update_tree` drops
    /// both node types before they can reach the tree — pinned so that stays true.
    #[test]
    fn col_and_colgroup_map_to_the_filtered_generic_container_role() {
        assert_eq!(
            A11yManager::node_type_to_role(&NodeType::Col),
            Role::GenericContainer
        );
        assert_eq!(
            A11yManager::node_type_to_role(&NodeType::ColGroup),
            Role::GenericContainer
        );
    }
    // ---------------------------------------------------------------------
    // map_role
    // ---------------------------------------------------------------------
    #[test]
    fn map_role_is_total_and_matches_the_documented_table() {
        let cases: [(AccessibilityRole, Role); 65] = [
            (AccessibilityRole::TitleBar, Role::TitleBar),
            (AccessibilityRole::MenuBar, Role::MenuBar),
            (AccessibilityRole::ScrollBar, Role::ScrollBar),
            (AccessibilityRole::Grip, Role::Splitter),
            (AccessibilityRole::Sound, Role::Audio),
            (AccessibilityRole::Cursor, Role::Caret),
            (AccessibilityRole::Caret, Role::Caret),
            (AccessibilityRole::Alert, Role::Alert),
            (AccessibilityRole::Window, Role::Window),
            (AccessibilityRole::Client, Role::GenericContainer),
            (AccessibilityRole::MenuPopup, Role::Menu),
            (AccessibilityRole::MenuItem, Role::MenuItem),
            (AccessibilityRole::Tooltip, Role::Tooltip),
            (AccessibilityRole::Application, Role::Application),
            (AccessibilityRole::Document, Role::Document),
            (AccessibilityRole::Pane, Role::Pane),
            (AccessibilityRole::Chart, Role::Figure),
            (AccessibilityRole::Dialog, Role::Dialog),
            (AccessibilityRole::Border, Role::GenericContainer),
            (AccessibilityRole::Grouping, Role::Group),
            (AccessibilityRole::Separator, Role::GenericContainer),
            (AccessibilityRole::Toolbar, Role::Toolbar),
            (AccessibilityRole::StatusBar, Role::Status),
            (AccessibilityRole::Table, Role::Table),
            (AccessibilityRole::ColumnHeader, Role::ColumnHeader),
            (AccessibilityRole::RowHeader, Role::RowHeader),
            (AccessibilityRole::Column, Role::GenericContainer),
            (AccessibilityRole::Row, Role::Row),
            (AccessibilityRole::Cell, Role::Cell),
            (AccessibilityRole::Link, Role::Link),
            (AccessibilityRole::HelpBalloon, Role::Tooltip),
            (AccessibilityRole::Character, Role::GenericContainer),
            (AccessibilityRole::List, Role::List),
            (AccessibilityRole::ListItem, Role::ListItem),
            (AccessibilityRole::Outline, Role::Tree),
            (AccessibilityRole::OutlineItem, Role::TreeItem),
            (AccessibilityRole::PageTab, Role::Tab),
            (AccessibilityRole::PropertyPage, Role::TabPanel),
            (AccessibilityRole::Indicator, Role::Meter),
            (AccessibilityRole::Graphic, Role::Image),
            (AccessibilityRole::StaticText, Role::Label),
            (AccessibilityRole::Text, Role::TextInput),
            (AccessibilityRole::PushButton, Role::Button),
            (AccessibilityRole::CheckButton, Role::CheckBox),
            (AccessibilityRole::RadioButton, Role::RadioButton),
            (AccessibilityRole::ComboBox, Role::ComboBox),
            (AccessibilityRole::DropList, Role::ListBox),
            (AccessibilityRole::ProgressBar, Role::ProgressIndicator),
            (AccessibilityRole::Dial, Role::Meter),
            (AccessibilityRole::HotkeyField, Role::TextInput),
            (AccessibilityRole::Slider, Role::Slider),
            (AccessibilityRole::SpinButton, Role::SpinButton),
            (AccessibilityRole::Diagram, Role::Figure),
            (AccessibilityRole::Animation, Role::GenericContainer),
            (AccessibilityRole::Equation, Role::Math),
            (AccessibilityRole::ButtonDropdown, Role::Button),
            (AccessibilityRole::ButtonMenu, Role::Button),
            (AccessibilityRole::ButtonDropdownGrid, Role::Button),
            (AccessibilityRole::Whitespace, Role::GenericContainer),
            (AccessibilityRole::PageTabList, Role::TabList),
            (AccessibilityRole::Clock, Role::Timer),
            (AccessibilityRole::SplitButton, Role::Button),
            (AccessibilityRole::IpAddress, Role::TextInput),
            (AccessibilityRole::Unknown, Role::Unknown),
            (AccessibilityRole::Nothing, Role::GenericContainer),
        ];
        for (role, expected) in cases {
            assert_eq!(A11yManager::map_role(&role), expected, "{role:?}");
        }
    }
    // ---------------------------------------------------------------------
    // map_action_to_accesskit / map_accesskit_action (round-trip)
    // ---------------------------------------------------------------------
    /// Every payload-free action must survive outbound -> inbound unchanged:
    /// the tree declares `map_action_to_accesskit(a)`, the screen reader sends
    /// that action back, and `map_accesskit_action` must hand back exactly `a`.
    #[test]
    fn payload_free_actions_round_trip_through_accesskit() {
        let actions = [
            AccessibilityAction::Default,
            AccessibilityAction::Focus,
            AccessibilityAction::Blur,
            AccessibilityAction::Collapse,
            AccessibilityAction::Expand,
            AccessibilityAction::ScrollIntoView,
            AccessibilityAction::Increment,
            AccessibilityAction::Decrement,
            AccessibilityAction::ShowContextMenu,
            AccessibilityAction::HideTooltip,
            AccessibilityAction::ShowTooltip,
            AccessibilityAction::ScrollUp,
            AccessibilityAction::ScrollDown,
            AccessibilityAction::ScrollLeft,
            AccessibilityAction::ScrollRight,
            AccessibilityAction::SetSequentialFocusNavigationStartingPoint,
        ];
        for action in actions {
            let outbound = A11yManager::map_action_to_accesskit(&action);
            let inbound = map_accesskit_action(request(outbound, None));
            assert_eq!(
                inbound,
                Some(action.clone()),
                "{action:?} did not survive the outbound/inbound round-trip"
            );
        }
    }
    #[test]
    fn payload_carrying_actions_map_to_their_action_kind() {
        let cases = [
            (
                AccessibilityAction::ReplaceSelectedText(AzString::from("x")),
                Action::ReplaceSelectedText,
            ),
            (
                AccessibilityAction::ScrollToPoint(LogicalPosition::new(f32::NAN, 0.0)),
                Action::ScrollToPoint,
            ),
            (
                AccessibilityAction::SetScrollOffset(LogicalPosition::new(-1.0, f32::INFINITY)),
                Action::SetScrollOffset,
            ),
            (
                AccessibilityAction::SetTextSelection(TextSelectionStartEnd {
                    selection_start: usize::MAX,
                    selection_end: 0,
                }),
                Action::SetTextSelection,
            ),
            (
                AccessibilityAction::SetValue(AzString::from("")),
                Action::SetValue,
            ),
            (
                AccessibilityAction::SetNumericValue(FloatValue::new(f32::NAN)),
                Action::SetValue,
            ),
            (AccessibilityAction::CustomAction(i32::MIN), Action::CustomAction),
        ];
        for (action, expected) in cases {
            assert_eq!(
                A11yManager::map_action_to_accesskit(&action),
                expected,
                "{action:?}"
            );
        }
    }
    #[test]
    fn data_requiring_actions_return_none_when_data_is_missing() {
        for action in [
            Action::ReplaceSelectedText,
            Action::ScrollToPoint,
            Action::SetScrollOffset,
            Action::SetTextSelection,
            Action::SetValue,
            Action::CustomAction,
        ] {
            assert_eq!(
                map_accesskit_action(request(action, None)),
                None,
                "{action:?} must reject a request with no payload"
            );
        }
    }
    #[test]
    fn data_requiring_actions_return_none_on_mismatched_payloads() {
        let mismatches = [
            (Action::ReplaceSelectedText, ActionData::NumericValue(1.0)),
            (Action::ScrollToPoint, ActionData::NumericValue(1.0)),
            (
                Action::SetScrollOffset,
                ActionData::Value(Box::from("not a point")),
            ),
            (Action::SetTextSelection, ActionData::CustomAction(3)),
            (Action::SetValue, ActionData::CustomAction(3)),
            (Action::CustomAction, ActionData::NumericValue(1.0)),
        ];
        for (action, data) in mismatches {
            assert_eq!(
                map_accesskit_action(request(action, Some(data.clone()))),
                None,
                "{action:?} must reject payload {data:?}"
            );
        }
    }
    #[test]
    fn payload_free_actions_ignore_an_unexpected_payload() {
        // ScrollIntoView takes an *optional* hint; a stray payload must not
        // turn the request into a no-op.
        assert_eq!(
            map_accesskit_action(request(
                Action::ScrollIntoView,
                Some(ActionData::NumericValue(1.0))
            )),
            Some(AccessibilityAction::ScrollIntoView)
        );
    }
    #[test]
    fn set_value_accepts_unicode_and_empty_strings() {
        for s in ["", "héllo 🎉", "a\0b", "\u{202e}rtl"] {
            let got = map_accesskit_action(request(
                Action::SetValue,
                Some(ActionData::Value(Box::from(s))),
            ));
            assert_eq!(got, Some(AccessibilityAction::SetValue(AzString::from(s))));
        }
    }
    #[test]
    fn set_numeric_value_saturates_instead_of_panicking() {
        for v in [
            0.0_f64,
            -0.0,
            f64::NAN,
            f64::INFINITY,
            f64::NEG_INFINITY,
            1e300,
            -1e300,
            f64::from(f32::MAX),
        ] {
            let got = map_accesskit_action(request(
                Action::SetValue,
                Some(ActionData::NumericValue(v)),
            ));
            #[allow(clippy::cast_possible_truncation)]
            let expected = AccessibilityAction::SetNumericValue(FloatValue::new(v as f32));
            assert_eq!(got, Some(expected), "NumericValue({v}) must not panic");
        }
    }
    #[test]
    fn scroll_to_point_preserves_nan_and_saturates_out_of_range_f64() {
        let got = map_accesskit_action(request(
            Action::ScrollToPoint,
            Some(ActionData::ScrollToPoint(Point {
                x: f64::NAN,
                y: 1e308,
            })),
        ));
        let Some(AccessibilityAction::ScrollToPoint(p)) = got else {
            panic!("expected ScrollToPoint, got {got:?}");
        };
        assert!(p.x.is_nan(), "NaN must pass through, not trap");
        assert!(
            p.y.is_infinite() && p.y.is_sign_positive(),
            "1e308 must saturate to +inf, got {}",
            p.y
        );
    }
    #[test]
    fn set_scroll_offset_saturates_negative_out_of_range_f64() {
        let got = map_accesskit_action(request(
            Action::SetScrollOffset,
            Some(ActionData::SetScrollOffset(Point {
                x: -1e308,
                y: f64::NEG_INFINITY,
            })),
        ));
        let Some(AccessibilityAction::SetScrollOffset(p)) = got else {
            panic!("expected SetScrollOffset, got {got:?}");
        };
        assert!(p.x.is_infinite() && p.x.is_sign_negative());
        assert!(p.y.is_infinite() && p.y.is_sign_negative());
    }
    #[test]
    fn set_text_selection_passes_through_extreme_character_indices() {
        let got = map_accesskit_action(request(
            Action::SetTextSelection,
            Some(ActionData::SetTextSelection(TextSelection {
                anchor: TextPosition {
                    node: A11yNodeId(1),
                    character_index: usize::MAX,
                },
                focus: TextPosition {
                    node: A11yNodeId(2),
                    character_index: 0,
                },
            })),
        ));
        assert_eq!(
            got,
            Some(AccessibilityAction::SetTextSelection(TextSelectionStartEnd {
                selection_start: usize::MAX,
                selection_end: 0,
            })),
            "an inverted, out-of-range selection must be forwarded verbatim, not clamped"
        );
    }
    #[test]
    fn custom_action_forwards_extreme_ids() {
        for id in [i32::MIN, -1, 0, i32::MAX] {
            assert_eq!(
                map_accesskit_action(request(
                    Action::CustomAction,
                    Some(ActionData::CustomAction(id))
                )),
                Some(AccessibilityAction::CustomAction(id))
            );
        }
    }
    // ---------------------------------------------------------------------
    // build_node — bounds arithmetic (numeric)
    // ---------------------------------------------------------------------
    #[test]
    fn build_node_bounds_are_padding_inset_and_hidpi_scaled() {
        let node_data = NodeData::create_node(NodeType::Div);
        // padding 5px top/bottom, 2px left/right (packed as tenths of a px).
        let layout_node = hot(Some(LogicalSize::new(100.0, 50.0)), [50, 20, 50, 20], [0; 4]);
        let node = A11yManager::build_node(
            &node_data,
            &layout_node,
            Some(LogicalPosition::new(10.0, 20.0)),
            None,
            2.0,
            LogicalSize::new(1000.0, 1000.0),
        );
        let bounds = node.bounds().expect("in-viewport node must have bounds");
        assert_eq!(bounds.x0, 24.0); // (10 + 2) * 2
        assert_eq!(bounds.y0, 50.0); // (20 + 5) * 2
        assert_eq!(bounds.x1, 216.0); // (10 + 100 - 2) * 2
        assert_eq!(bounds.y1, 130.0); // (20 + 50 - 5) * 2
    }
    #[test]
    fn build_node_clips_bounds_to_the_window_viewport() {
        let node_data = NodeData::create_node(NodeType::Div);
        let layout_node = hot(Some(LogicalSize::new(10_000.0, 10_000.0)), [0; 4], [0; 4]);
        let node = A11yManager::build_node(
            &node_data,
            &layout_node,
            Some(LogicalPosition::new(-500.0, -500.0)),
            None,
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        let bounds = node.bounds().expect("clipped node still has bounds");
        assert_eq!((bounds.x0, bounds.y0), (0.0, 0.0), "off-screen origin clamps to 0");
        assert_eq!(
            (bounds.x1, bounds.y1),
            (800.0, 600.0),
            "overflowing extent clamps to the viewport"
        );
    }
    #[test]
    fn build_node_omits_bounds_when_padding_exceeds_the_used_size() {
        // Degenerate box: x1 <= x0, so accesskit must not be handed an inverted rect.
        let node_data = NodeData::create_node(NodeType::Div);
        let layout_node = hot(Some(LogicalSize::new(10.0, 10.0)), [500, 500, 500, 500], [0; 4]);
        let node = A11yManager::build_node(
            &node_data,
            &layout_node,
            Some(LogicalPosition::new(0.0, 0.0)),
            None,
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(node.bounds(), None);
    }
    #[test]
    fn build_node_omits_bounds_for_zero_and_nan_hidpi() {
        let node_data = NodeData::create_node(NodeType::Div);
        let layout_node = plain_hot();
        for hidpi in [0.0_f32, f32::NAN, -2.0] {
            let node = A11yManager::build_node(
                &node_data,
                &layout_node,
                Some(LogicalPosition::new(10.0, 20.0)),
                None,
                hidpi,
                LogicalSize::new(800.0, 600.0),
            );
            assert_eq!(
                node.bounds(),
                None,
                "hidpi {hidpi} collapses the rect; no bounds must be set"
            );
        }
    }
    #[test]
    fn build_node_never_emits_an_inverted_rect_for_hostile_geometry() {
        // The `x1 > x0 && y1 > y0` guard is the only thing between accesskit and
        // an inverted/degenerate rect. Sweep the nastiest float inputs at it.
        let node_data = NodeData::create_node(NodeType::Div);
        let sizes = [
            LogicalSize::new(0.0, 0.0),
            LogicalSize::new(f32::MAX, f32::MAX),
            LogicalSize::new(-100.0, -100.0),
            LogicalSize::new(f32::NAN, f32::NAN),
            LogicalSize::new(f32::INFINITY, f32::INFINITY),
        ];
        let positions = [
            LogicalPosition::new(0.0, 0.0),
            LogicalPosition::new(-f32::MAX, -f32::MAX),
            LogicalPosition::new(f32::NAN, 0.0),
            LogicalPosition::new(f32::INFINITY, f32::NEG_INFINITY),
        ];
        let windows = [
            LogicalSize::new(0.0, 0.0),
            LogicalSize::new(800.0, 600.0),
            LogicalSize::new(f32::MAX, f32::MAX),
            LogicalSize::new(f32::NAN, f32::NAN),
        ];
        for size in sizes {
            for pos in positions {
                for window in windows {
                    for hidpi in [1.0_f32, 0.5, 3.0, f32::INFINITY] {
                        let layout_node = hot(Some(size), [i16::MAX; 4], [i16::MIN; 4]);
                        let node = A11yManager::build_node(
                            &node_data,
                            &layout_node,
                            Some(pos),
                            None,
                            hidpi,
                            window,
                        );
                        if let Some(b) = node.bounds() {
                            assert!(
                                b.x1 > b.x0 && b.y1 > b.y0,
                                "inverted rect {b:?} for size={size:?} pos={pos:?} \
                                 window={window:?} hidpi={hidpi}"
                            );
                        }
                    }
                }
            }
        }
    }
    #[test]
    fn build_node_omits_bounds_when_layout_info_is_missing() {
        let node_data = NodeData::create_node(NodeType::Div);
        // No absolute position.
        let node = A11yManager::build_node(
            &node_data,
            &plain_hot(),
            None,
            None,
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(node.bounds(), None);
        // No used size.
        let node = A11yManager::build_node(
            &node_data,
            &hot(None, [0; 4], [0; 4]),
            Some(LogicalPosition::new(0.0, 0.0)),
            None,
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(node.bounds(), None);
    }
    // ---------------------------------------------------------------------
    // build_node — roles, labels, states, actions (invariants)
    // ---------------------------------------------------------------------
    #[test]
    fn build_node_always_declares_scroll_into_view() {
        let node = A11yManager::build_node(
            &NodeData::create_node(NodeType::Div),
            &plain_hot(),
            None,
            None,
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert!(node.supports_action(Action::ScrollIntoView));
    }
    #[test]
    fn build_node_sets_the_html_tag() {
        let node = A11yManager::build_node(
            &NodeData::create_node(NodeType::Div),
            &plain_hot(),
            None,
            None,
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        let expected = NodeType::Div.get_path().to_string();
        assert_eq!(node.html_tag(), Some(expected.as_str()));
    }
    #[test]
    fn build_node_contenteditable_wins_over_a11y_role_and_gains_focus() {
        let mut node_data = NodeData::create_node(NodeType::Div);
        node_data.set_contenteditable(true);
        // Even an explicit (conflicting) role must not override editability.
        node_data.set_accessibility_info(info(AccessibilityRole::PushButton));
        let node = A11yManager::build_node(
            &node_data,
            &plain_hot(),
            None,
            node_data.get_accessibility_info(),
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(node.role(), Role::MultilineTextInput);
        assert!(node.supports_action(Action::Focus));
    }
    #[test]
    fn build_node_labels_text_nodes_including_unicode_and_empty() {
        for s in ["", "héllo 🎉", "a\u{202e}b"] {
            let node_data = text_node(s);
            let node = A11yManager::build_node(
                &node_data,
                &plain_hot(),
                None,
                None,
                1.0,
                LogicalSize::new(800.0, 600.0),
            );
            assert_eq!(node.role(), Role::Label);
            assert_eq!(node.label(), Some(s));
        }
    }
    #[test]
    fn build_node_sets_heading_levels_one_through_six() {
        let expected = [
            (NodeType::H1, 1),
            (NodeType::H2, 2),
            (NodeType::H3, 3),
            (NodeType::H4, 4),
            (NodeType::H5, 5),
            (NodeType::H6, 6),
        ];
        for (node_type, level) in expected {
            let node_data = NodeData::create_node(node_type);
            let node = A11yManager::build_node(
                &node_data,
                &plain_hot(),
                None,
                None,
                1.0,
                LogicalSize::new(800.0, 600.0),
            );
            assert_eq!(node.role(), Role::Heading);
            assert_eq!(node.level(), Some(level));
        }
    }
    #[test]
    fn build_node_maps_every_handled_accessibility_state() {
        let states = [
            AccessibilityState::Unavailable,
            AccessibilityState::Readonly,
            AccessibilityState::CheckedTrue,
            AccessibilityState::Expanded,
            AccessibilityState::Selected,
            AccessibilityState::Busy,
            AccessibilityState::Offscreen,
            AccessibilityState::Focusable,
        ];
        let mut a11y = info(AccessibilityRole::CheckButton);
        a11y.states = states.to_vec().into();
        let node_data = NodeData::create_node(NodeType::Div);
        let node = A11yManager::build_node(
            &node_data,
            &plain_hot(),
            None,
            Some(&a11y),
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(node.role(), Role::CheckBox);
        assert!(node.is_disabled());
        assert!(node.is_read_only());
        assert_eq!(node.toggled(), Some(Toggled::True));
        assert_eq!(node.is_expanded(), Some(true));
        assert_eq!(node.is_selected(), Some(true));
        assert!(node.is_busy());
        assert!(node.is_hidden());
        assert!(node.supports_action(Action::Focus));
    }
    #[test]
    fn build_node_collapsed_and_checked_false_are_distinct_from_absent() {
        let mut a11y = info(AccessibilityRole::CheckButton);
        a11y.states = vec![
            AccessibilityState::Collapsed,
            AccessibilityState::CheckedFalse,
        ]
        .into();
        let node = A11yManager::build_node(
            &NodeData::create_node(NodeType::Div),
            &plain_hot(),
            None,
            Some(&a11y),
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(node.is_expanded(), Some(false));
        assert_eq!(node.toggled(), Some(Toggled::False));
    }
    #[test]
    fn build_node_declares_api_supplied_supported_actions() {
        let mut a11y = info(AccessibilityRole::Slider);
        a11y.supported_actions = vec![
            AccessibilityAction::Increment,
            AccessibilityAction::Decrement,
            AccessibilityAction::SetValue(AzString::from("x")),
            AccessibilityAction::CustomAction(1),
        ]
        .into();
        let node = A11yManager::build_node(
            &NodeData::create_node(NodeType::Div),
            &plain_hot(),
            None,
            Some(&a11y),
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert!(node.supports_action(Action::Increment));
        assert!(node.supports_action(Action::Decrement));
        assert!(node.supports_action(Action::SetValue));
        assert!(node.supports_action(Action::CustomAction));
    }
    #[test]
    fn build_node_relations_resolve_to_walk_emitted_ids() {
        let mut a11y = info(AccessibilityRole::Text);
        a11y.labelled_by = OptionDomNodeId::Some(dom_node(2, 3));
        a11y.described_by = OptionDomNodeId::Some(dom_node(0, 0));
        a11y.is_live_region = true;
        let node = A11yManager::build_node(
            &NodeData::create_node(NodeType::Div),
            &plain_hot(),
            None,
            Some(&a11y),
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(
            node.labelled_by(),
            &[A11yManager::encode_a11y_node_id(2, 3)]
        );
        assert_eq!(
            node.described_by(),
            &[A11yManager::encode_a11y_node_id(0, 0)]
        );
        assert_eq!(node.live(), Some(Live::Polite));
    }
    #[test]
    fn build_node_drops_relations_pointing_at_the_none_sentinel() {
        let mut a11y = info(AccessibilityRole::Text);
        let unresolvable = DomNodeId {
            dom: DomId { inner: 0 },
            node: NodeHierarchyItemId::NONE,
        };
        a11y.labelled_by = OptionDomNodeId::Some(unresolvable);
        a11y.described_by = OptionDomNodeId::Some(unresolvable);
        let node = A11yManager::build_node(
            &NodeData::create_node(NodeType::Div),
            &plain_hot(),
            None,
            Some(&a11y),
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert!(
            node.labelled_by().is_empty() && node.described_by().is_empty(),
            "an unresolvable relation must be dropped, not encoded as a bogus id"
        );
    }
    // ---------------------------------------------------------------------
    // build_node — HTML attributes
    // ---------------------------------------------------------------------
    #[test]
    fn build_node_parses_aria_live_case_insensitively() {
        for (value, expected) in [
            ("polite", Live::Polite),
            ("POLITE", Live::Polite),
            ("assertive", Live::Assertive),
            ("AsSeRtIvE", Live::Assertive),
            ("off", Live::Off),
            ("", Live::Off),
            ("banana", Live::Off),
            ("🎉", Live::Off),
        ] {
            let mut node_data = NodeData::create_node(NodeType::Div);
            node_data.set_attributes(
                vec![AttributeType::AriaProperty(AttributeNameValue {
                    attr_name: AzString::from("ARIA-LIVE"),
                    value: AzString::from(value),
                })]
                .into(),
            );
            let node = A11yManager::build_node(
                &node_data,
                &plain_hot(),
                None,
                None,
                1.0,
                LogicalSize::new(800.0, 600.0),
            );
            assert_eq!(node.live(), Some(expected), "aria-live={value:?}");
        }
    }
    #[test]
    fn build_node_wires_up_html_attributes() {
        let mut node_data = NodeData::create_node(NodeType::Input);
        node_data.set_attributes(
            vec![
                AttributeType::AriaLabel(AzString::from("label")),
                AttributeType::Title(AzString::from("desc")),
                AttributeType::Placeholder(AzString::from("hint")),
                AttributeType::Value(AzString::from("val")),
                AttributeType::Disabled,
                AttributeType::Readonly,
                AttributeType::Required,
                AttributeType::Hidden,
                AttributeType::CheckedTrue,
                AttributeType::Lang(AzString::from("de")),
                AttributeType::ColSpan(2),
                AttributeType::RowSpan(3),
            ]
            .into(),
        );
        let node = A11yManager::build_node(
            &node_data,
            &plain_hot(),
            None,
            None,
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(node.label(), Some("label"));
        assert_eq!(node.description(), Some("desc"));
        assert_eq!(node.placeholder(), Some("hint"));
        assert_eq!(node.value(), Some("val"));
        assert!(node.is_disabled());
        assert!(node.is_read_only());
        assert!(node.is_required());
        assert!(node.is_hidden());
        assert_eq!(node.toggled(), Some(Toggled::True));
        assert_eq!(node.language(), Some("de"));
        assert_eq!(node.column_span(), Some(2));
        assert_eq!(node.row_span(), Some(3));
    }
    /// `colspan`/`rowspan` are `i32` in the DOM but `usize` in accesskit, and the
    /// conversion is an unchecked `as` cast. A negative span (HTML lets you write
    /// `colspan="-1"`) sign-extends into an astronomically large span instead of
    /// being rejected or clamped. Pinned here: no panic, but the value is garbage.
    #[test]
    fn build_node_negative_col_and_row_span_sign_extend_to_usize_max() {
        let mut node_data = NodeData::create_node(NodeType::Td);
        node_data.set_attributes(
            vec![AttributeType::ColSpan(-1), AttributeType::RowSpan(-1)].into(),
        );
        let node = A11yManager::build_node(
            &node_data,
            &plain_hot(),
            None,
            None,
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(node.column_span(), Some(usize::MAX));
        assert_eq!(node.row_span(), Some(usize::MAX));
    }
    #[test]
    fn build_node_zero_span_is_forwarded_unchanged() {
        let mut node_data = NodeData::create_node(NodeType::Td);
        node_data.set_attributes(vec![AttributeType::ColSpan(0)].into());
        let node = A11yManager::build_node(
            &node_data,
            &plain_hot(),
            None,
            None,
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(node.column_span(), Some(0));
    }
    #[test]
    fn build_node_dom_attributes_override_accessibility_info() {
        // Documented priority: explicit a11y info < DOM attributes.
        let mut a11y = info(AccessibilityRole::PushButton);
        a11y.accessibility_name = OptionString::Some(AzString::from("from-info"));
        a11y.accessibility_value = OptionString::Some(AzString::from("info-value"));
        let mut node_data = NodeData::create_node(NodeType::Button);
        node_data.set_attributes(
            vec![
                AttributeType::AriaLabel(AzString::from("from-attr")),
                AttributeType::Value(AzString::from("attr-value")),
            ]
            .into(),
        );
        let node = A11yManager::build_node(
            &node_data,
            &plain_hot(),
            None,
            Some(&a11y),
            1.0,
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(node.label(), Some("from-attr"));
        assert_eq!(node.value(), Some("attr-value"));
    }
    // ---------------------------------------------------------------------
    // update_tree (numeric / no-panic)
    // ---------------------------------------------------------------------
    #[test]
    fn update_tree_with_no_doms_emits_only_the_root_window_node() {
        let update = empty_update(LogicalSize::new(800.0, 600.0), None, 1.0, "title");
        assert_eq!(update.nodes.len(), 1);
        assert_eq!(update.nodes[0].0, A11yNodeId(0));
        assert_eq!(update.nodes[0].1.role(), Role::Window);
        assert_eq!(update.nodes[0].1.label(), Some("title"));
        assert!(update.nodes[0].1.children().is_empty());
        assert!(update.tree.is_some(), "the first update must carry the tree");
        assert_eq!(update.tree_id, TreeId::ROOT);
        assert_eq!(
            update.focus,
            A11yNodeId(0),
            "with no content nodes, focus must fall back to the root"
        );
    }
    #[test]
    fn update_tree_survives_degenerate_window_sizes_and_hidpi() {
        let sizes = [
            LogicalSize::new(0.0, 0.0),
            LogicalSize::new(-1.0, -1.0),
            LogicalSize::new(f32::MAX, f32::MAX),
            LogicalSize::new(f32::NAN, f32::NAN),
            LogicalSize::new(f32::INFINITY, f32::NEG_INFINITY),
        ];
        for size in sizes {
            for hidpi in [0.0_f32, 1.0, -1.0, f32::NAN, f32::INFINITY, f32::MIN] {
                let update = empty_update(size, None, hidpi, "t");
                assert_eq!(update.nodes.len(), 1, "size={size:?} hidpi={hidpi}");
                assert_eq!(update.focus, A11yNodeId(0));
            }
        }
    }
    #[test]
    fn update_tree_focus_falls_back_to_root_for_an_unresolvable_focused_node() {
        // A focused node in a DOM that isn't in layout_results at all.
        let update = empty_update(
            LogicalSize::new(800.0, 600.0),
            Some(dom_node(9, 42)),
            1.0,
            "t",
        );
        assert_eq!(update.focus, A11yNodeId(0));
        // A focused node whose NodeHierarchyItemId is the None sentinel.
        let update = empty_update(
            LogicalSize::new(800.0, 600.0),
            Some(DomNodeId {
                dom: DomId { inner: 0 },
                node: NodeHierarchyItemId::NONE,
            }),
            1.0,
            "t",
        );
        assert_eq!(update.focus, A11yNodeId(0));
    }
    #[test]
    fn update_tree_preserves_unicode_and_empty_window_titles() {
        for title in ["", "Ünïcødé 🪟", "a\u{202e}b"] {
            let update = empty_update(LogicalSize::new(800.0, 600.0), None, 1.0, title);
            assert_eq!(update.nodes[0].1.label(), Some(title));
        }
    }
    #[test]
    fn update_tree_honours_a_non_zero_root_id() {
        let layout_results = BTreeMap::new();
        let scroll_manager = ScrollManager::new();
        let overrides = BTreeMap::new();
        let root = A11yNodeId(999);
        let update = A11yManager::update_tree(
            root,
            &layout_results,
            &scroll_manager,
            &AzString::from("t"),
            LogicalSize::new(800.0, 600.0),
            None,
            1.0,
            &overrides,
            None,
        );
        assert_eq!(update.nodes[0].0, root);
        assert_eq!(update.focus, root);
        assert_eq!(
            update.tree.map(|t| t.root),
            Some(root),
            "the declared tree root must match the emitted root node"
        );
    }
}