1
//! Platform-neutral accessibility snapshot.
2
//!
3
//! `accesskit` covers Windows (UIA), macOS (`NSAccessibility`) and Unix
4
//! (AT-SPI). It ships NO UIKit backend and NO Android backend, so the iOS and
5
//! Android shells cannot reuse [`crate::managers::a11y::A11yManager::update_tree`]
6
//! — they have to hand UIKit a `UIAccessibilityElement` list and hand Android's
7
//! `AccessibilityNodeProvider` a virtual-view tree, both built from Azul's own
8
//! types.
9
//!
10
//! [`A11ySnapshot`] is that shared intermediate: a flat, index-addressed list of
11
//! everything a screen reader can see, with the label / value / role / bounds /
12
//! supported actions each platform needs, plus the parent-child links UIKit and
13
//! Android both require.
14
//!
15
//! # Why FLAT and index-addressed
16
//!
17
//! Android's `AccessibilityNodeProvider` addresses nodes by a plain `int`
18
//! virtual-view id, and UIKit's container protocol addresses them by
19
//! `NSInteger` index. A snapshot index is the natural id for both, and it round
20
//! trips back to `(DomId, NodeId)` through [`A11ySnapshot::element`] with no
21
//! bit-packing scheme to overflow. (`a11y::decode_a11y_node_id` packs both into
22
//! a `u64`, which is fine for accesskit's `NodeId` and does NOT fit an Android
23
//! `int`.)
24
//!
25
//! Indices are only valid for the snapshot that produced them. Rebuild the
26
//! snapshot on every layout and tell the platform the tree changed — which is
27
//! exactly what both platforms expect after a layout change anyway.
28
//!
29
//! # Node membership
30
//!
31
//! Exactly [`crate::managers::a11y::is_exposed_to_accessibility`], the same
32
//! predicate the accesskit tree uses. If these disagreed, a node would be
33
//! actionable on Linux and invisible on Android for no reason a user could
34
//! understand.
35

            
36
use alloc::{string::{String, ToString}, vec::Vec};
37
use std::collections::BTreeMap;
38

            
39
use azul_core::{
40
    dom::{
41
        AccessibilityAction, AccessibilityRole, AccessibilityState, AttributeType, DomId,
42
        DomNodeId, NodeData, NodeId, NodeType,
43
    },
44
    geom::{LogicalPosition, LogicalRect, LogicalSize},
45
};
46

            
47
use crate::{
48
    managers::{a11y::is_exposed_to_accessibility, scroll_state::ScrollManager},
49
    window::DomLayoutResult,
50
};
51

            
52
/// One node as assistive technology sees it.
53
///
54
/// `clippy::struct_excessive_bools` is allowed rather than satisfied. The four
55
/// flags are INDEPENDENT platform a11y states — a node can be focusable and
56
/// editable and disabled at the same time — and each maps 1:1 onto a flag the
57
/// bridges hand to `UIAccessibilityTraits` / `AccessibilityNodeInfo`. Folding
58
/// them into an enum would make illegal what the platforms consider normal, and
59
/// a bitflags newtype would only move the same four bits behind accessors this
60
/// struct exists to expose.
61
#[allow(clippy::struct_excessive_bools)]
62
#[derive(Debug, Clone, PartialEq, Eq)]
63
pub struct A11yElement {
64
    /// The DOM node this element stands for.
65
    pub dom_id: DomId,
66
    /// The DOM node this element stands for.
67
    pub node_id: NodeId,
68
    /// Index of the nearest exposed ancestor in the same snapshot.
69
    /// `None` means "child of the window root".
70
    pub parent: Option<usize>,
71
    /// Indices of the exposed descendants that attach directly to this element.
72
    pub children: Vec<usize>,
73
    /// What a screen reader announces. Empty when the node has no name.
74
    pub label: String,
75
    /// The editable / current value, for inputs and contenteditable nodes.
76
    pub value: Option<String>,
77
    /// Element purpose.
78
    pub role: AccessibilityRole,
79
    /// Absolute bounds in LOGICAL units, padding/border inset, clipped to the
80
    /// window. Logical because the two consumers disagree about pixels: `UIKit`
81
    /// works in points (== logical), Android in physical pixels. Each bridge
82
    /// scales; neither has to un-scale.
83
    pub bounds: LogicalRect,
84
    /// Actions this element accepts. Drives the `UIKit` traits / Android action
85
    /// list, and is what [`A11ySnapshot::supports`] checks before a platform
86
    /// action is forwarded to the engine.
87
    pub actions: Vec<AccessibilityAction>,
88
    /// Can take keyboard focus.
89
    pub focusable: bool,
90
    /// Currently has keyboard focus.
91
    pub focused: bool,
92
    /// Text can be edited in place.
93
    pub editable: bool,
94
    /// `Some(true)` / `Some(false)` for checkable elements, `None` otherwise.
95
    pub checked: Option<bool>,
96
    /// Element is disabled and must not be activated.
97
    pub disabled: bool,
98
}
99

            
100
impl A11yElement {
101
    /// Does this element accept `action`?
102
    #[must_use]
103
    pub fn supports(&self, action: &AccessibilityAction) -> bool {
104
        self.actions.contains(action)
105
    }
106
}
107

            
108
/// A whole window's worth of [`A11yElement`]s, index-addressed.
109
#[derive(Debug, Clone, Default, PartialEq, Eq)]
110
pub struct A11ySnapshot {
111
    /// Window title, i.e. the root container's label.
112
    pub title: String,
113
    /// Every exposed node, in document order.
114
    pub elements: Vec<A11yElement>,
115
    /// Indices of elements with no exposed ancestor (children of the root).
116
    pub roots: Vec<usize>,
117
    /// Window size in logical units, so a bridge can convert without also
118
    /// needing the window state.
119
    pub window_size: LogicalSize,
120
}
121

            
122
impl A11ySnapshot {
123
    /// Build a snapshot from the current layout.
124
    ///
125
    /// Mirrors the three passes of `A11yManager::update_tree` (create, link,
126
    /// attach) so the two surfaces expose the same nodes with the same parents.
127
    #[must_use]
128
    // `too_many_lines` and `cognitive_complexity` are allowed, not satisfied.
129
    // This is a single linear projection: walk every DOM node once and emit one
130
    // flat element per node. The length is the number of a11y attributes a node
131
    // has, not nested control flow, and the obvious split — a helper per
132
    // attribute group — would take eight parameters each and read worse than the
133
    // straight line it replaced.
134
    //
135
    // Worth revisiting when this file gains test coverage: it currently has
136
    // none, which is the real reason not to refactor it blind today. Tracked on
137
    // the deferred-items task.
138
    #[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
139
    pub fn build(
140
        layout_results: &BTreeMap<DomId, DomLayoutResult>,
141
        scroll_manager: &ScrollManager,
142
        gpu_state: &crate::managers::gpu_state::GpuStateManager,
143
        focused_node: Option<DomNodeId>,
144
        title: &str,
145
        window_size: LogicalSize,
146
    ) -> Self {
147
        let mut elements: Vec<A11yElement> = Vec::new();
148
        let mut roots: Vec<usize> = Vec::new();
149
        // (dom.inner, node index) -> snapshot index
150
        let mut index_of: BTreeMap<(usize, usize), usize> = BTreeMap::new();
151

            
152
        let focused = focused_node.and_then(|f| f.node.into_crate_internal().map(|n| (f.dom, n)));
153

            
154
        // ── Pass 1: create an element per exposed node ─────────────────
155
        for (dom_id, layout_result) in layout_results {
156
            let styled_dom = &layout_result.styled_dom;
157
            let node_data_slice = styled_dom.node_data.as_ref();
158
            let node_hierarchy = styled_dom.node_hierarchy.as_ref();
159

            
160
            for (dom_idx, node_data) in node_data_slice.iter().enumerate() {
161
                if !is_exposed_to_accessibility(node_data) {
162
                    continue;
163
                }
164
                let node_id = NodeId::new(dom_idx);
165

            
166
                let bounds = element_bounds(
167
                    layout_result,
168
                    *dom_id,
169
                    node_id,
170
                    window_size,
171
                    scroll_manager,
172
                    gpu_state,
173
                );
174

            
175
                // Child text is the element's name when the node has no
176
                // interactive children — same rule the accesskit builder uses,
177
                // so a group is announced by its text instead of swallowing its
178
                // children.
179
                let (child_text, has_non_text_children) =
180
                    collect_child_text(node_data_slice, node_hierarchy, dom_idx);
181

            
182
                let editable = node_data.is_contenteditable()
183
                    || matches!(node_data.node_type, NodeType::TextArea | NodeType::Input);
184

            
185
                let mut label = String::new();
186
                let mut value: Option<String> = None;
187

            
188
                if let Some(info) = node_data.get_accessibility_info() {
189
                    if let Some(name) = info.accessibility_name.as_option() {
190
                        label = name.as_str().to_string();
191
                    }
192
                    if let Some(v) = info.accessibility_value.as_option() {
193
                        value = Some(v.as_str().to_string());
194
                    }
195
                }
196
                if let Some(l) = node_data.get_accessible_label() {
197
                    label = l.to_string();
198
                }
199
                if let Some(v) = node_data.get_accessible_value() {
200
                    value = Some(v.to_string());
201
                }
202
                if let NodeType::Text(text) = &node_data.node_type {
203
                    label = text.as_str().to_string();
204
                }
205
                if !child_text.is_empty() {
206
                    if editable {
207
                        value = Some(child_text);
208
                    } else if !has_non_text_children && label.is_empty() {
209
                        label = child_text;
210
                    }
211
                }
212

            
213
                let role = node_data.get_accessibility_info().map_or_else(
214
                    || node_type_to_role(&node_data.node_type),
215
                    |info| info.role,
216
                );
217

            
218
                let mut checked = None;
219
                let mut disabled = false;
220
                if let Some(info) = node_data.get_accessibility_info() {
221
                    for state in info.states.as_ref() {
222
                        match state {
223
                            AccessibilityState::CheckedTrue => checked = Some(true),
224
                            AccessibilityState::CheckedFalse => checked = Some(false),
225
                            AccessibilityState::Unavailable => disabled = true,
226
                            _ => {}
227
                        }
228
                    }
229
                }
230
                for attr in node_data.attributes().as_ref() {
231
                    match attr {
232
                        AttributeType::CheckedTrue => checked = Some(true),
233
                        AttributeType::CheckedFalse => checked = Some(false),
234
                        AttributeType::Disabled => disabled = true,
235
                        _ => {}
236
                    }
237
                }
238

            
239
                let actions = supported_actions(node_data, scroll_manager, *dom_id, node_id);
240

            
241
                index_of.insert((dom_id.inner, dom_idx), elements.len());
242
                elements.push(A11yElement {
243
                    dom_id: *dom_id,
244
                    node_id,
245
                    parent: None,
246
                    children: Vec::new(),
247
                    label,
248
                    value,
249
                    role,
250
                    bounds,
251
                    actions,
252
                    focusable: node_data.is_focusable(),
253
                    focused: focused == Some((*dom_id, node_id)),
254
                    editable,
255
                    checked,
256
                    disabled,
257
                });
258
            }
259
        }
260

            
261
        // ── Pass 2: link each element to its nearest exposed ancestor ──
262
        //
263
        // The DOM parent is often NOT exposed (a wrapper div stripped by the
264
        // predicate), so walk up until an exposed ancestor is found — otherwise
265
        // whole subtrees would detach and a screen reader would never reach
266
        // them. Bounded, because a corrupt hierarchy must not hang the UI
267
        // thread that is asking for the tree.
268
        for (dom_id, layout_result) in layout_results {
269
            let styled_dom = &layout_result.styled_dom;
270
            let node_hierarchy = styled_dom.node_hierarchy.as_ref();
271

            
272
            for dom_idx in 0..styled_dom.node_data.as_ref().len() {
273
                let Some(&self_idx) = index_of.get(&(dom_id.inner, dom_idx)) else {
274
                    continue;
275
                };
276

            
277
                let mut current = node_hierarchy[dom_idx].parent_id();
278
                let mut parent_idx = None;
279
                let mut guard = 0usize;
280
                while let Some(parent_node_id) = current {
281
                    guard += 1;
282
                    if guard > 10_000 {
283
                        break;
284
                    }
285
                    let p = parent_node_id.index();
286
                    if let Some(&idx) = index_of.get(&(dom_id.inner, p)) {
287
                        parent_idx = Some(idx);
288
                        break;
289
                    }
290
                    if p >= node_hierarchy.len() {
291
                        break;
292
                    }
293
                    current = node_hierarchy[p].parent_id();
294
                }
295

            
296
                match parent_idx {
297
                    Some(p) => {
298
                        elements[self_idx].parent = Some(p);
299
                        elements[p].children.push(self_idx);
300
                    }
301
                    None => roots.push(self_idx),
302
                }
303
            }
304
        }
305

            
306
        Self {
307
            title: title.to_string(),
308
            elements,
309
            roots,
310
            window_size,
311
        }
312
    }
313

            
314
    /// Element at `index`, or `None` when the index is stale (the snapshot was
315
    /// rebuilt under the platform's feet). Returning `None` rather than
316
    /// panicking matters: `index` comes straight from `UIKit` / Android, i.e.
317
    /// from another process's idea of what the tree looks like.
318
    #[must_use]
319
    pub fn element(&self, index: usize) -> Option<&A11yElement> {
320
        self.elements.get(index)
321
    }
322

            
323
    /// Snapshot index for a DOM node, if it is exposed.
324
    #[must_use]
325
    pub fn index_of(&self, dom_id: DomId, node_id: NodeId) -> Option<usize> {
326
        self.elements
327
            .iter()
328
            .position(|e| e.dom_id == dom_id && e.node_id == node_id)
329
    }
330

            
331
    /// Index of the currently focused element, if any is exposed.
332
    #[must_use]
333
    pub fn focused(&self) -> Option<usize> {
334
        self.elements.iter().position(|e| e.focused)
335
    }
336

            
337
    #[must_use]
338
    pub const fn is_empty(&self) -> bool {
339
        self.elements.is_empty()
340
    }
341

            
342
    #[must_use]
343
    pub const fn len(&self) -> usize {
344
        self.elements.len()
345
    }
346
}
347

            
348
/// Absolute, padding/border-inset, viewport-clipped bounds in logical units.
349
///
350
/// Same geometry `A11yManager::build_node` computes for accesskit, minus the
351
/// `HiDPI` multiply — see [`A11yElement::bounds`] for why the scale stays with
352
/// the platform. A node with no layout (display:none, never laid out) gets a
353
/// zero rect, which every platform reads as "nothing to highlight".
354
fn element_bounds(
355
    layout_result: &DomLayoutResult,
356
    dom_id: DomId,
357
    node_id: NodeId,
358
    window_size: LogicalSize,
359
    scroll_manager: &ScrollManager,
360
    gpu_state: &crate::managers::gpu_state::GpuStateManager,
361
) -> LogicalRect {
362
    let zero = LogicalRect {
363
        origin: LogicalPosition { x: 0.0, y: 0.0 },
364
        size: LogicalSize {
365
            width: 0.0,
366
            height: 0.0,
367
        },
368
    };
369

            
370
    let Some(layout_idx) = layout_result
371
        .layout_tree
372
        .dom_to_layout
373
        .get(&node_id)
374
        .and_then(|indices| indices.first())
375
        .copied()
376
    else {
377
        return zero;
378
    };
379
    let Some(hot) = layout_result.layout_tree.get(layout_idx) else {
380
        return zero;
381
    };
382
    let (Some(pos), Some(size)) = (
383
        layout_result.calculated_positions.get(layout_idx.index()).copied(),
384
        hot.used_size,
385
    ) else {
386
        return zero;
387
    };
388

            
389
    let bp = hot.box_props.unpack();
390
    let pad_left = bp.padding.left + bp.border.left;
391
    let pad_top = bp.padding.top + bp.border.top;
392
    let pad_right = bp.padding.right + bp.border.right;
393
    let pad_bottom = bp.padding.bottom + bp.border.bottom;
394

            
395
    // Static padded rect in local space, THEN mapped to on-screen space
396
    // (ancestor scroll offsets + reference-frame transforms — what the
397
    // renderer actually painted), THEN clamped to the viewport. A screen
398
    // reader must be told where the element IS, not where it was laid out
399
    // before the user scrolled or an animation moved it.
400
    let local = LogicalRect {
401
        origin: LogicalPosition {
402
            x: pos.x + pad_left,
403
            y: pos.y + pad_top,
404
        },
405
        size: LogicalSize {
406
            width: (size.width - pad_left - pad_right).max(0.0),
407
            height: (size.height - pad_top - pad_bottom).max(0.0),
408
        },
409
    };
410
    let on_screen = crate::headless::node_rect_to_screen(
411
        layout_result,
412
        dom_id,
413
        layout_idx.index(),
414
        local,
415
        &|d, n| scroll_manager.get_current_offset(d, n),
416
        &|d, n| {
417
            gpu_state
418
                .caches
419
                .get(&d)
420
                .and_then(|c| c.css_current_transform_values.get(&n))
421
                .copied()
422
        },
423
    );
424

            
425
    let clamp = |v: f32, max: f32| v.max(0.0).min(max);
426
    let x0 = clamp(on_screen.origin.x, window_size.width);
427
    let y0 = clamp(on_screen.origin.y, window_size.height);
428
    let x1 = clamp(on_screen.origin.x + on_screen.size.width, window_size.width);
429
    let y1 = clamp(on_screen.origin.y + on_screen.size.height, window_size.height);
430

            
431
    if x1 <= x0 || y1 <= y0 {
432
        return zero;
433
    }
434
    LogicalRect {
435
        origin: LogicalPosition { x: x0, y: y0 },
436
        size: LogicalSize {
437
            width: x1 - x0,
438
            height: y1 - y0,
439
        },
440
    }
441
}
442

            
443
/// Concatenate direct text children, and report whether any non-text child
444
/// exists (in which case the text must NOT become a group label — the screen
445
/// reader has to be able to navigate into the interactive children).
446
fn collect_child_text(
447
    node_data: &[NodeData],
448
    node_hierarchy: &[azul_core::styled_dom::NodeHierarchyItem],
449
    dom_idx: usize,
450
) -> (String, bool) {
451
    let mut text = String::new();
452
    let mut has_non_text = false;
453

            
454
    let mut child = node_hierarchy[dom_idx].first_child_id(NodeId::new(dom_idx));
455
    let mut guard = 0usize;
456
    while let Some(child_id) = child {
457
        guard += 1;
458
        if guard > 10_000 {
459
            break;
460
        }
461
        if let Some(child_data) = node_data.get(child_id.index()) {
462
            if let NodeType::Text(t) = &child_data.node_type {
463
                if !text.is_empty() {
464
                    text.push(' ');
465
                }
466
                text.push_str(t.as_str());
467
            } else {
468
                has_non_text = true;
469
            }
470
        }
471
        if child_id.index() >= node_hierarchy.len() {
472
            break;
473
        }
474
        child = node_hierarchy[child_id.index()].next_sibling_id();
475
    }
476

            
477
    (text, has_non_text)
478
}
479

            
480
/// Which actions this node accepts.
481
///
482
/// Mirrors what the accesskit builder advertises: `ScrollIntoView` on
483
/// everything, `Focus` on anything focusable or editable, `Default` on anything
484
/// with activation behaviour, the scroll family on an actually-scrollable
485
/// container, the text family on an editable one, plus whatever the app
486
/// declared in `AccessibilityInfo::supported_actions`.
487
///
488
/// This list is not decoration: [`A11yElement::supports`] is what the iOS and
489
/// Android bridges check before forwarding a platform action, so an element
490
/// cannot be sent an action the engine would silently drop.
491
fn supported_actions(
492
    node_data: &NodeData,
493
    scroll_manager: &ScrollManager,
494
    dom_id: DomId,
495
    node_id: NodeId,
496
) -> Vec<AccessibilityAction> {
497
    let mut actions = Vec::new();
498

            
499
    actions.push(AccessibilityAction::ScrollIntoView);
500

            
501
    if node_data.is_focusable() || node_data.is_contenteditable() {
502
        actions.push(AccessibilityAction::Focus);
503
        actions.push(AccessibilityAction::Blur);
504
    }
505
    if node_data.has_activation_behavior() {
506
        actions.push(AccessibilityAction::Default);
507
    }
508
    if node_data.is_contenteditable()
509
        || matches!(node_data.node_type, NodeType::TextArea | NodeType::Input)
510
    {
511
        actions.push(AccessibilityAction::SetValue(azul_css::AzString::from("")));
512
        actions.push(AccessibilityAction::ReplaceSelectedText(
513
            azul_css::AzString::from(""),
514
        ));
515
    }
516

            
517
    if let Some((_offset, max_x, max_y)) = scroll_manager.a11y_scroll_info(dom_id, node_id) {
518
        if max_y > 0.0 {
519
            actions.push(AccessibilityAction::ScrollUp);
520
            actions.push(AccessibilityAction::ScrollDown);
521
        }
522
        if max_x > 0.0 {
523
            actions.push(AccessibilityAction::ScrollLeft);
524
            actions.push(AccessibilityAction::ScrollRight);
525
        }
526
        actions.push(AccessibilityAction::SetScrollOffset(LogicalPosition {
527
            x: 0.0,
528
            y: 0.0,
529
        }));
530
    }
531

            
532
    if let Some(info) = node_data.get_accessibility_info() {
533
        for declared in info.supported_actions.as_ref() {
534
            if !actions.contains(declared) {
535
                actions.push(declared.clone());
536
            }
537
        }
538
    }
539

            
540
    actions
541
}
542

            
543
/// Fallback role for a node with no explicit `AccessibilityInfo`.
544
///
545
/// Deliberately small: only the roles the mobile platforms announce
546
/// differently. Everything else is `Grouping`, which both `UIKit` and Android
547
/// read as "a plain container" — the honest answer for a bare `<div>`, and
548
/// better than claiming a role the node does not have.
549
const fn node_type_to_role(node_type: &NodeType) -> AccessibilityRole {
550
    match node_type {
551
        NodeType::Button => AccessibilityRole::PushButton,
552
        NodeType::A => AccessibilityRole::Link,
553
        NodeType::Text(_)
554
        | NodeType::P
555
        | NodeType::Span
556
        | NodeType::H1
557
        | NodeType::H2
558
        | NodeType::H3
559
        | NodeType::H4
560
        | NodeType::H5
561
        | NodeType::H6 => AccessibilityRole::StaticText,
562
        NodeType::Input | NodeType::TextArea => AccessibilityRole::Text,
563
        NodeType::Image(_) => AccessibilityRole::Graphic,
564
        NodeType::Ul | NodeType::Ol => AccessibilityRole::List,
565
        NodeType::Li => AccessibilityRole::ListItem,
566
        NodeType::Table => AccessibilityRole::Table,
567
        NodeType::Td | NodeType::Th => AccessibilityRole::Cell,
568
        _ => AccessibilityRole::Grouping,
569
    }
570
}