Skip to main content

incular_accessibility/
lib.rs

1//! Native accessibility projection for Incular.
2//!
3//! [`incular_semantics::SemanticsTree`] remains the retained, platform-neutral
4//! source of truth. This crate projects it to AccessKit at the desktop-native
5//! boundary; it does not introduce a second semantic model. AccessKit node IDs
6//! are private to one live native window and never enter widget/facade APIs.
7
8use accesskit::{
9    Action, ActionData, ActionRequest, Invalid, Node, NodeId, Rect as AccessKitRect,
10    Role as AccessKitRole, TextPosition, TextSelection as AccessKitSelection, Toggled, Tree,
11    TreeId, TreeUpdate,
12};
13use incular_core::Rect;
14use std::collections::HashMap;
15
16pub use incular_semantics::*;
17
18const HOST_NODE_ID: NodeId = NodeId(0);
19
20/// An owned semantic tree snapshot suitable for a platform bridge.
21#[derive(Clone, Debug, Default, PartialEq)]
22pub struct SemanticsSnapshot {
23    pub root: Option<SemanticNodeId>,
24    pub nodes: Vec<SemanticNode>,
25}
26
27impl From<&SemanticsTree> for SemanticsSnapshot {
28    fn from(tree: &SemanticsTree) -> Self {
29        Self {
30            root: tree.root(),
31            nodes: tree.iter().map(|(_, node)| node.clone()).collect(),
32        }
33    }
34}
35
36/// An action emitted by a native accessibility bridge for a semantic node.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct SemanticActionRequest {
39    pub node: SemanticNodeId,
40    pub action: SemanticAction,
41}
42
43/// Small platform-neutral testing seam retained from the previous adapter API.
44pub trait SemanticsAdapter {
45    fn publish(&mut self, snapshot: SemanticsSnapshot);
46    fn poll_action(&mut self) -> Option<SemanticActionRequest>;
47}
48
49/// Change categories emitted by the mobile accessibility projection. Native
50/// Android and iOS bridges translate these into their platform notifications.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum MobileAccessibilityEventKind {
53    ContentChanged,
54    BoundsChanged,
55    FocusChanged,
56    ValueChanged,
57    StateChanged,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct MobileAccessibilityEvent {
62    pub native_id: u64,
63    pub kind: MobileAccessibilityEventKind,
64}
65
66/// A node snapshot using stable native-facing IDs while retaining the
67/// renderer-independent semantic node for platform conversion.
68#[derive(Clone, Debug, PartialEq)]
69pub struct MobileAccessibilityNode {
70    pub native_id: u64,
71    pub semantic: SemanticNode,
72    pub children: Vec<u64>,
73}
74
75#[derive(Clone, Debug, Default, PartialEq)]
76pub struct MobileAccessibilityUpdate {
77    pub revision: u64,
78    pub root: Option<u64>,
79    pub nodes: Vec<MobileAccessibilityNode>,
80    pub removed: Vec<u64>,
81    pub events: Vec<MobileAccessibilityEvent>,
82}
83
84/// Incremental, generation-safe semantic projection for mobile screen-reader
85/// bridges. It deliberately has no JNI, Objective-C, or window references;
86/// each mobile crate can consume the same update contract on its UI thread.
87pub struct MobileAccessibilityProjection {
88    semantic_to_native: HashMap<SemanticNodeId, u64>,
89    native_to_semantic: HashMap<u64, SemanticNodeId>,
90    previous: HashMap<SemanticNodeId, SemanticNode>,
91    previous_root: Option<SemanticNodeId>,
92    synced_revision: Option<u64>,
93    next_native_id: u64,
94    active: bool,
95    force_full_update: bool,
96    diagnostics: AccessibilityDiagnostics,
97}
98
99impl Default for MobileAccessibilityProjection {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105impl MobileAccessibilityProjection {
106    #[must_use]
107    pub fn new() -> Self {
108        Self {
109            semantic_to_native: HashMap::new(),
110            native_to_semantic: HashMap::new(),
111            previous: HashMap::new(),
112            previous_root: None,
113            synced_revision: None,
114            next_native_id: 1,
115            active: true,
116            force_full_update: true,
117            diagnostics: AccessibilityDiagnostics::default(),
118        }
119    }
120
121    #[must_use]
122    pub fn diagnostics(&self) -> AccessibilityDiagnostics {
123        self.diagnostics
124    }
125
126    pub fn activate(&mut self) {
127        self.active = true;
128        self.force_full_update = true;
129    }
130
131    pub fn deactivate(&mut self) {
132        self.active = false;
133        self.force_full_update = true;
134    }
135
136    #[must_use]
137    pub fn native_node_id(&self, node: SemanticNodeId) -> Option<u64> {
138        self.semantic_to_native.get(&node).copied()
139    }
140
141    #[must_use]
142    pub fn semantic_node_id(&self, native_id: u64) -> Option<SemanticNodeId> {
143        self.native_to_semantic.get(&native_id).copied()
144    }
145
146    /// Produces a full update on activation and only changed nodes afterwards.
147    pub fn sync(&mut self, tree: &SemanticsTree) -> Option<MobileAccessibilityUpdate> {
148        if !self.active {
149            return None;
150        }
151        let revision = tree.revision();
152        if !self.force_full_update && self.synced_revision == Some(revision) {
153            return None;
154        }
155        let current: HashMap<_, _> = tree.iter().map(|(id, node)| (id, node.clone())).collect();
156        for id in current.keys().copied() {
157            if !self.ensure_native_id(id) {
158                self.active = false;
159                self.force_full_update = true;
160                return None;
161            }
162        }
163        let full = self.force_full_update;
164        let mut changed = if full {
165            current.keys().copied().collect::<Vec<_>>()
166        } else {
167            current
168                .iter()
169                .filter_map(|(id, node)| (self.previous.get(id) != Some(node)).then_some(*id))
170                .collect::<Vec<_>>()
171        };
172        changed.sort_by_key(|id| self.semantic_to_native[id]);
173        let removed = self
174            .previous
175            .keys()
176            .filter(|id| !current.contains_key(id))
177            .copied()
178            .collect::<Vec<_>>();
179        let root = tree
180            .root()
181            .and_then(|id| self.semantic_to_native.get(&id).copied());
182        if !full && changed.is_empty() && removed.is_empty() && self.previous_root == tree.root() {
183            self.synced_revision = Some(revision);
184            return None;
185        }
186
187        let mut nodes = Vec::with_capacity(changed.len());
188        let mut events = Vec::new();
189        for id in changed {
190            let Some(semantic) = current.get(&id) else {
191                continue;
192            };
193            let native_id = self.semantic_to_native[&id];
194            let children = semantic
195                .children
196                .iter()
197                .filter_map(|child| self.semantic_to_native.get(child).copied())
198                .collect();
199            nodes.push(MobileAccessibilityNode {
200                native_id,
201                semantic: mobile_semantic_node(semantic),
202                children,
203            });
204            if full || !self.previous.contains_key(&id) {
205                events.push(MobileAccessibilityEvent {
206                    native_id,
207                    kind: MobileAccessibilityEventKind::ContentChanged,
208                });
209            } else if let Some(old) = self.previous.get(&id) {
210                if old.state.focused != semantic.state.focused {
211                    events.push(MobileAccessibilityEvent {
212                        native_id,
213                        kind: MobileAccessibilityEventKind::FocusChanged,
214                    });
215                }
216                if old.value != semantic.value {
217                    events.push(MobileAccessibilityEvent {
218                        native_id,
219                        kind: MobileAccessibilityEventKind::ValueChanged,
220                    });
221                }
222                if old.state != semantic.state {
223                    events.push(MobileAccessibilityEvent {
224                        native_id,
225                        kind: MobileAccessibilityEventKind::StateChanged,
226                    });
227                }
228                if old.bounds != semantic.bounds {
229                    events.push(MobileAccessibilityEvent {
230                        native_id,
231                        kind: MobileAccessibilityEventKind::BoundsChanged,
232                    });
233                }
234                if old.role != semantic.role
235                    || old.label != semantic.label
236                    || old.description != semantic.description
237                    || old.actions != semantic.actions
238                    || old.children != semantic.children
239                {
240                    events.push(MobileAccessibilityEvent {
241                        native_id,
242                        kind: MobileAccessibilityEventKind::ContentChanged,
243                    });
244                }
245            }
246        }
247        let mut removed_native = removed
248            .iter()
249            .filter_map(|id| self.semantic_to_native.get(id).copied())
250            .collect::<Vec<_>>();
251        removed_native.sort_unstable();
252        for id in removed {
253            if let Some(native_id) = self.semantic_to_native.remove(&id) {
254                self.native_to_semantic.remove(&native_id);
255            }
256        }
257        self.previous = current;
258        self.previous_root = tree.root();
259        self.synced_revision = Some(revision);
260        self.force_full_update = false;
261        Some(MobileAccessibilityUpdate {
262            revision,
263            root,
264            nodes,
265            removed: removed_native,
266            events,
267        })
268    }
269
270    /// Validates that a native action is still supported by the live semantic
271    /// node before handing it to the runtime.
272    pub fn translate_action(
273        &mut self,
274        native_id: u64,
275        action: SemanticAction,
276    ) -> Option<SemanticActionRequest> {
277        self.diagnostics.actions_received = self.diagnostics.actions_received.wrapping_add(1);
278        if !self.active {
279            self.diagnostics.unsupported_actions =
280                self.diagnostics.unsupported_actions.wrapping_add(1);
281            return None;
282        }
283        let Some(node) = self.native_to_semantic.get(&native_id).copied() else {
284            self.diagnostics.stale_actions_rejected =
285                self.diagnostics.stale_actions_rejected.wrapping_add(1);
286            return None;
287        };
288        let Some(semantic) = self.previous.get(&node) else {
289            self.diagnostics.stale_actions_rejected =
290                self.diagnostics.stale_actions_rejected.wrapping_add(1);
291            return None;
292        };
293        if !semantic.actions.contains(&action.kind()) {
294            self.diagnostics.unsupported_actions =
295                self.diagnostics.unsupported_actions.wrapping_add(1);
296            return None;
297        }
298        self.diagnostics.actions_dispatched = self.diagnostics.actions_dispatched.wrapping_add(1);
299        Some(SemanticActionRequest { node, action })
300    }
301
302    fn ensure_native_id(&mut self, node: SemanticNodeId) -> bool {
303        if self.semantic_to_native.contains_key(&node) {
304            return true;
305        }
306        let native_id = self.next_native_id;
307        let Some(next_native_id) = self.next_native_id.checked_add(1) else {
308            return false;
309        };
310        self.next_native_id = next_native_id;
311        self.semantic_to_native.insert(node, native_id);
312        self.native_to_semantic.insert(native_id, node);
313        true
314    }
315}
316
317/// Keeps clear-text password values inside the retained runtime while making
318/// the mobile projection safe to hand to a native accessibility tree.
319fn mobile_semantic_node(node: &SemanticNode) -> SemanticNode {
320    let mut node = node.clone();
321    if node.state.obscured {
322        node.value = None;
323    }
324    node
325}
326
327/// Per-native-window health counters. They intentionally contain no private
328/// labels, descriptions, or text-field values.
329#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
330pub struct AccessibilityDiagnostics {
331    pub adapters_created: u64,
332    pub adapters_destroyed: u64,
333    pub adapter_activations: u64,
334    pub adapter_deactivations: u64,
335    pub full_tree_updates: u64,
336    pub incremental_tree_updates: u64,
337    pub nodes_published: u64,
338    pub nodes_updated: u64,
339    pub nodes_removed: u64,
340    pub actions_received: u64,
341    pub actions_dispatched: u64,
342    pub stale_actions_rejected: u64,
343    pub unsupported_actions: u64,
344    pub focus_updates: u64,
345    pub bounds_updates: u64,
346    pub semantic_updates_skipped_unchanged: u64,
347    pub accesskit_conversion_failures: u64,
348}
349
350#[derive(Clone, Copy, Debug, PartialEq, Eq)]
351pub enum AccessKitUpdateKind {
352    Full,
353    Incremental,
354}
355
356/// Generated native update consumed immediately by a Winit adapter.
357#[derive(Debug)]
358pub struct NativeAccessibilityUpdate {
359    kind: AccessKitUpdateKind,
360    update: TreeUpdate,
361}
362
363impl NativeAccessibilityUpdate {
364    #[must_use]
365    pub const fn kind(&self) -> AccessKitUpdateKind {
366        self.kind
367    }
368
369    /// Native-runner boundary only; this is deliberately not re-exported by
370    /// Incular's facade or prelude.
371    #[doc(hidden)]
372    #[must_use]
373    pub fn into_accesskit(self) -> TreeUpdate {
374        self.update
375    }
376}
377
378#[derive(Clone, Debug, PartialEq)]
379struct ProjectedNode {
380    semantic: SemanticNode,
381    scale_factor: f64,
382}
383
384#[derive(Clone, Copy, Debug, PartialEq)]
385struct HostProjection {
386    root: Option<SemanticNodeId>,
387    bounds: Option<Rect>,
388    scale_factor: f64,
389}
390
391/// Stateful, generation-safe projection from one Incular tree to one native
392/// AccessKit tree. One instance belongs to one native window.
393pub struct AccessKitProjection {
394    semantic_to_native: HashMap<SemanticNodeId, NodeId>,
395    native_to_semantic: HashMap<NodeId, SemanticNodeId>,
396    previous: HashMap<SemanticNodeId, ProjectedNode>,
397    host: Option<HostProjection>,
398    synced_revision: Option<u64>,
399    next_native_id: u64,
400    force_full_update: bool,
401    diagnostics: AccessibilityDiagnostics,
402}
403
404impl Default for AccessKitProjection {
405    fn default() -> Self {
406        Self::new()
407    }
408}
409
410impl AccessKitProjection {
411    #[must_use]
412    pub fn new() -> Self {
413        Self {
414            semantic_to_native: HashMap::new(),
415            native_to_semantic: HashMap::new(),
416            previous: HashMap::new(),
417            host: None,
418            synced_revision: None,
419            next_native_id: 1,
420            force_full_update: true,
421            diagnostics: AccessibilityDiagnostics::default(),
422        }
423    }
424
425    #[must_use]
426    pub const fn diagnostics(&self) -> AccessibilityDiagnostics {
427        self.diagnostics
428    }
429
430    /// Native bridge/test plumbing only, not an application identity.
431    #[doc(hidden)]
432    #[must_use]
433    pub fn native_node_id(&self, node: SemanticNodeId) -> Option<u64> {
434        self.semantic_to_native.get(&node).map(|id| id.0)
435    }
436
437    pub fn note_adapter_created(&mut self) {
438        self.diagnostics.adapters_created = self.diagnostics.adapters_created.wrapping_add(1);
439    }
440
441    pub fn note_adapter_destroyed(&mut self) {
442        self.diagnostics.adapters_destroyed = self.diagnostics.adapters_destroyed.wrapping_add(1);
443    }
444
445    /// Request a full retained-tree snapshot at native activation.
446    pub fn activate(&mut self) {
447        self.diagnostics.adapter_activations = self.diagnostics.adapter_activations.wrapping_add(1);
448        self.force_full_update = true;
449    }
450
451    /// Native deactivation doesn't discard Incular semantics. The next native
452    /// activation publishes a complete current tree using the same live-window
453    /// node map when possible.
454    pub fn deactivate(&mut self) {
455        self.diagnostics.adapter_deactivations =
456            self.diagnostics.adapter_deactivations.wrapping_add(1);
457        self.force_full_update = true;
458    }
459
460    /// Records a request that was valid when projected but was rejected by the
461    /// retained runtime before UI dispatch (for example, a node removed in the
462    /// small interval before the Winit user event was handled).
463    pub fn note_runtime_stale_action(&mut self) {
464        self.diagnostics.stale_actions_rejected =
465            self.diagnostics.stale_actions_rejected.wrapping_add(1);
466    }
467
468    /// Projects retained semantic changes. Incular bounds are logical; the
469    /// supplied native window scale is applied exactly once for AccessKit.
470    pub fn sync(
471        &mut self,
472        tree: &SemanticsTree,
473        scale_factor: f64,
474    ) -> Option<NativeAccessibilityUpdate> {
475        let scale_factor = if scale_factor.is_finite() && scale_factor > 0.0 {
476            scale_factor
477        } else {
478            1.0
479        };
480        let revision = tree.revision();
481        if !self.force_full_update && self.synced_revision == Some(revision) {
482            self.diagnostics.semantic_updates_skipped_unchanged = self
483                .diagnostics
484                .semantic_updates_skipped_unchanged
485                .wrapping_add(1);
486            return None;
487        }
488
489        let current: HashMap<_, _> = tree
490            .iter()
491            .map(|(id, semantic)| {
492                (
493                    id,
494                    ProjectedNode {
495                        semantic: semantic.clone(),
496                        scale_factor,
497                    },
498                )
499            })
500            .collect();
501        for id in current.keys().copied() {
502            if !self.ensure_native_id(id) {
503                self.force_full_update = true;
504                return None;
505            }
506        }
507        let next_host = HostProjection {
508            root: tree.root(),
509            bounds: tree
510                .root()
511                .and_then(|root| tree.node(root))
512                .map(|node| node.bounds),
513            scale_factor,
514        };
515        let full = self.force_full_update || self.synced_revision.is_none();
516        let removed: Vec<_> = self
517            .previous
518            .keys()
519            .copied()
520            .filter(|id| !current.contains_key(id))
521            .collect();
522        let mut changed: Vec<_> = if full {
523            current.keys().copied().collect()
524        } else {
525            current
526                .iter()
527                .filter_map(|(id, node)| (self.previous.get(id) != Some(node)).then_some(*id))
528                .collect()
529        };
530        let host_changed = full || self.host != Some(next_host);
531        if !full && changed.is_empty() && removed.is_empty() && !host_changed {
532            self.synced_revision = Some(revision);
533            self.diagnostics.semantic_updates_skipped_unchanged = self
534                .diagnostics
535                .semantic_updates_skipped_unchanged
536                .wrapping_add(1);
537            return None;
538        }
539
540        let old_focus = self.previous_focus();
541        let mut nodes = Vec::with_capacity(changed.len() + usize::from(host_changed));
542        if host_changed {
543            nodes.push((HOST_NODE_ID, self.host_node(next_host)));
544        }
545        changed.sort_by_key(|id| self.semantic_to_native[id].0);
546        for id in changed {
547            let projected = current.get(&id).expect("changed node is current");
548            nodes.push((
549                self.semantic_to_native[&id],
550                self.accesskit_node(&projected.semantic, projected.scale_factor),
551            ));
552        }
553        let focus = current
554            .iter()
555            .find_map(|(id, node)| node.semantic.state.focused.then_some(*id))
556            .and_then(|id| self.semantic_to_native.get(&id).copied())
557            .unwrap_or(HOST_NODE_ID);
558
559        self.diagnostics.focus_updates = self
560            .diagnostics
561            .focus_updates
562            .wrapping_add(u64::from(focus != old_focus));
563        self.diagnostics.bounds_updates = self.diagnostics.bounds_updates.wrapping_add(
564            current
565                .iter()
566                .filter(|(id, node)| {
567                    self.previous.get(id).is_some_and(|old| {
568                        old.semantic.bounds != node.semantic.bounds
569                            || old.scale_factor != node.scale_factor
570                    })
571                })
572                .count() as u64,
573        );
574        if full {
575            self.diagnostics.full_tree_updates = self.diagnostics.full_tree_updates.wrapping_add(1);
576            self.diagnostics.nodes_published = self
577                .diagnostics
578                .nodes_published
579                .wrapping_add(current.len() as u64 + 1);
580        } else {
581            self.diagnostics.incremental_tree_updates =
582                self.diagnostics.incremental_tree_updates.wrapping_add(1);
583            self.diagnostics.nodes_updated = self
584                .diagnostics
585                .nodes_updated
586                .wrapping_add(nodes.len().saturating_sub(usize::from(host_changed)) as u64);
587            self.diagnostics.nodes_removed = self
588                .diagnostics
589                .nodes_removed
590                .wrapping_add(removed.len() as u64);
591        }
592        for id in removed {
593            if let Some(native) = self.semantic_to_native.remove(&id) {
594                self.native_to_semantic.remove(&native);
595            }
596        }
597        self.previous = current;
598        self.host = Some(next_host);
599        self.synced_revision = Some(revision);
600        self.force_full_update = false;
601
602        Some(NativeAccessibilityUpdate {
603            kind: if full {
604                AccessKitUpdateKind::Full
605            } else {
606                AccessKitUpdateKind::Incremental
607            },
608            update: TreeUpdate {
609                nodes,
610                tree: full.then(|| {
611                    let mut tree = Tree::new(HOST_NODE_ID);
612                    tree.toolkit_name = Some("Incular".into());
613                    tree.toolkit_version = Some(env!("CARGO_PKG_VERSION").into());
614                    tree
615                }),
616                tree_id: TreeId::ROOT,
617                focus,
618            },
619        })
620    }
621
622    /// Converts a native request into an owned, validated Incular request. The
623    /// caller must dispatch it through the UI-thread runtime.
624    pub fn translate_action(&mut self, request: &ActionRequest) -> Option<SemanticActionRequest> {
625        self.diagnostics.actions_received = self.diagnostics.actions_received.wrapping_add(1);
626        if request.target_tree != TreeId::ROOT {
627            self.diagnostics.unsupported_actions =
628                self.diagnostics.unsupported_actions.wrapping_add(1);
629            return None;
630        }
631        let Some(node) = self.native_to_semantic.get(&request.target_node).copied() else {
632            self.diagnostics.stale_actions_rejected =
633                self.diagnostics.stale_actions_rejected.wrapping_add(1);
634            return None;
635        };
636        let Some(projected) = self.previous.get(&node) else {
637            self.diagnostics.stale_actions_rejected =
638                self.diagnostics.stale_actions_rejected.wrapping_add(1);
639            return None;
640        };
641        let action = match request.action {
642            Action::Click if supports(&projected.semantic, SemanticActionKind::Activate) => {
643                Some(SemanticAction::Activate)
644            }
645            Action::Focus if supports(&projected.semantic, SemanticActionKind::Focus) => {
646                Some(SemanticAction::Focus)
647            }
648            Action::ScrollDown | Action::ScrollRight
649                if supports(&projected.semantic, SemanticActionKind::ScrollForward) =>
650            {
651                Some(SemanticAction::ScrollForward)
652            }
653            Action::ScrollUp | Action::ScrollLeft
654                if supports(&projected.semantic, SemanticActionKind::ScrollBackward) =>
655            {
656                Some(SemanticAction::ScrollBackward)
657            }
658            Action::SetValue | Action::ReplaceSelectedText
659                if supports(&projected.semantic, SemanticActionKind::SetText) =>
660            {
661                match request.data.as_ref() {
662                    Some(ActionData::Value(value)) => {
663                        Some(SemanticAction::SetText(value.to_string()))
664                    }
665                    _ => None,
666                }
667            }
668            Action::SetTextSelection
669                if supports(&projected.semantic, SemanticActionKind::SetSelection) =>
670            {
671                match request.data.as_ref() {
672                    Some(ActionData::SetTextSelection(selection))
673                        if selection.anchor.node == request.target_node
674                            && selection.focus.node == request.target_node =>
675                    {
676                        let text = projected.semantic.value.as_deref().unwrap_or_default();
677                        Some(SemanticAction::SetSelection {
678                            base: utf8_offset(text, selection.anchor.character_index),
679                            extent: utf8_offset(text, selection.focus.character_index),
680                        })
681                    }
682                    _ => None,
683                }
684            }
685            Action::Increment if supports(&projected.semantic, SemanticActionKind::Increment) => {
686                Some(SemanticAction::Increment)
687            }
688            Action::Decrement if supports(&projected.semantic, SemanticActionKind::Decrement) => {
689                Some(SemanticAction::Decrement)
690            }
691            _ => None,
692        };
693        let Some(action) = action else {
694            self.diagnostics.unsupported_actions =
695                self.diagnostics.unsupported_actions.wrapping_add(1);
696            return None;
697        };
698        self.diagnostics.actions_dispatched = self.diagnostics.actions_dispatched.wrapping_add(1);
699        Some(SemanticActionRequest { node, action })
700    }
701
702    fn ensure_native_id(&mut self, node: SemanticNodeId) -> bool {
703        if self.semantic_to_native.contains_key(&node) {
704            return true;
705        }
706        let native = NodeId(self.next_native_id);
707        let Some(next_native_id) = self.next_native_id.checked_add(1) else {
708            return false;
709        };
710        self.next_native_id = next_native_id;
711        self.semantic_to_native.insert(node, native);
712        self.native_to_semantic.insert(native, node);
713        true
714    }
715
716    fn host_node(&self, host: HostProjection) -> Node {
717        let mut node = Node::new(AccessKitRole::Window);
718        node.set_label("Incular");
719        node.set_children(
720            host.root
721                .and_then(|id| self.semantic_to_native.get(&id).copied())
722                .into_iter()
723                .collect::<Vec<_>>(),
724        );
725        if let Some(bounds) = host.bounds {
726            node.set_bounds(to_accesskit_rect(bounds, host.scale_factor));
727        }
728        node
729    }
730
731    fn accesskit_node(&self, semantic: &SemanticNode, scale_factor: f64) -> Node {
732        let native_id = self.semantic_to_native[&semantic.id];
733        let mut node = Node::new(map_role(semantic.role, semantic.state.obscured));
734        node.set_bounds(to_accesskit_rect(semantic.bounds, scale_factor));
735        if semantic.role == Role::Text && !semantic.state.obscured {
736            if let Some(text) = semantic.value.as_ref().or(semantic.label.as_ref()) {
737                node.set_value(text.as_str());
738            }
739        } else if !semantic.state.obscured {
740            if let Some(label) = &semantic.label {
741                node.set_label(label.as_str());
742            }
743            if let Some(value) = &semantic.value {
744                node.set_value(value.as_str());
745            }
746        } else if let Some(label) = &semantic.label {
747            // Password values never cross the native accessibility boundary,
748            // but their accessible label still does.
749            node.set_label(label.as_str());
750        }
751        if let Some(description) = &semantic.description {
752            node.set_description(description.as_str());
753        }
754        if is_interactive_role(semantic.role) && !semantic.state.enabled {
755            node.set_disabled();
756        }
757        if semantic.state.selected {
758            node.set_selected(true);
759        }
760        if let Some(checked) = semantic.state.checked {
761            node.set_toggled(match checked {
762                incular_semantics::CheckedState::Checked => Toggled::True,
763                incular_semantics::CheckedState::Unchecked => Toggled::False,
764                incular_semantics::CheckedState::Indeterminate => Toggled::Mixed,
765            });
766        }
767        if let Some(expanded) = semantic.state.expanded {
768            node.set_expanded(expanded);
769        }
770        if semantic.state.busy {
771            node.set_busy();
772        }
773        if semantic.state.invalid {
774            node.set_invalid(Invalid::True);
775        }
776        if semantic.state.required {
777            node.set_required();
778        }
779        if let Some(level) = semantic.state.heading_level {
780            node.set_level(usize::from(level));
781        }
782        if let Some(value) = semantic
783            .state
784            .numeric_value
785            .filter(|value| value.is_finite())
786        {
787            node.set_numeric_value(value);
788        }
789        if let Some(value) = semantic.state.numeric_min.filter(|value| value.is_finite()) {
790            node.set_min_numeric_value(value);
791        }
792        if let Some(value) = semantic.state.numeric_max.filter(|value| value.is_finite()) {
793            node.set_max_numeric_value(value);
794        }
795        if let Some(value) = semantic
796            .state
797            .numeric_step
798            .filter(|value| value.is_finite())
799        {
800            node.set_numeric_value_step(value);
801        }
802        if semantic.state.read_only {
803            node.set_read_only();
804        }
805        if let Some(size) = semantic.state.set_size {
806            node.set_size_of_set(size);
807        }
808        if let Some(index) = semantic.state.item_index {
809            node.set_position_in_set(index.saturating_add(1));
810        }
811        if matches!(semantic.role, Role::ScrollView | Role::List) {
812            let (offset, maximum) = scroll_value(semantic.value.as_deref());
813            node.set_scroll_y(offset);
814            node.set_scroll_y_min(0.0);
815            node.set_scroll_y_max(maximum);
816        }
817        if semantic.state.editable
818            && !semantic.state.obscured
819            && let Some(value) = semantic.value.as_deref()
820        {
821            node.set_character_lengths(
822                value
823                    .chars()
824                    .map(|character| character.len_utf8() as u8)
825                    .collect::<Vec<_>>(),
826            );
827            if let Some(selection) = semantic.state.selection {
828                node.set_text_selection(AccessKitSelection {
829                    anchor: TextPosition {
830                        node: native_id,
831                        character_index: character_offset(value, selection.base),
832                    },
833                    focus: TextPosition {
834                        node: native_id,
835                        character_index: character_offset(value, selection.extent),
836                    },
837                });
838            }
839        }
840        for action in &semantic.actions {
841            if let Some(action) = map_action(*action) {
842                node.add_action(action);
843            }
844        }
845        node.set_children(
846            semantic
847                .children
848                .iter()
849                .filter_map(|child| self.semantic_to_native.get(child).copied())
850                .collect::<Vec<_>>(),
851        );
852        node
853    }
854
855    fn previous_focus(&self) -> NodeId {
856        self.previous
857            .iter()
858            .find_map(|(id, node)| node.semantic.state.focused.then_some(*id))
859            .and_then(|id| self.semantic_to_native.get(&id).copied())
860            .unwrap_or(HOST_NODE_ID)
861    }
862}
863
864fn supports(node: &SemanticNode, action: SemanticActionKind) -> bool {
865    node.actions.contains(&action)
866}
867
868fn map_role(role: Role, obscured: bool) -> AccessKitRole {
869    match role {
870        Role::Button => AccessKitRole::Button,
871        Role::Text => AccessKitRole::Label,
872        Role::TextField => {
873            if obscured {
874                AccessKitRole::PasswordInput
875            } else {
876                AccessKitRole::TextInput
877            }
878        }
879        Role::TextArea => AccessKitRole::MultilineTextInput,
880        Role::List => AccessKitRole::List,
881        Role::ListItem => AccessKitRole::ListItem,
882        Role::ScrollView => AccessKitRole::ScrollView,
883        Role::GenericContainer => AccessKitRole::GenericContainer,
884        Role::Checkbox => AccessKitRole::CheckBox,
885        Role::Radio => AccessKitRole::RadioButton,
886        Role::Switch => AccessKitRole::Switch,
887        Role::Slider => AccessKitRole::Slider,
888        Role::Menu => AccessKitRole::Menu,
889        Role::Dialog => AccessKitRole::Dialog,
890        Role::Image => AccessKitRole::Image,
891        Role::Heading => AccessKitRole::Heading,
892        Role::Link => AccessKitRole::Link,
893        Role::Group => AccessKitRole::Group,
894        Role::Tab => AccessKitRole::Tab,
895        Role::TabList => AccessKitRole::TabList,
896        Role::TabPanel => AccessKitRole::TabPanel,
897        Role::MenuItem => AccessKitRole::MenuItem,
898        Role::ProgressBar => AccessKitRole::ProgressIndicator,
899        Role::Meter => AccessKitRole::Meter,
900        Role::SearchField => AccessKitRole::SearchInput,
901    }
902}
903
904fn is_interactive_role(role: Role) -> bool {
905    matches!(
906        role,
907        Role::Button
908            | Role::TextField
909            | Role::TextArea
910            | Role::Checkbox
911            | Role::Radio
912            | Role::Switch
913            | Role::Slider
914            | Role::Link
915            | Role::Tab
916            | Role::MenuItem
917            | Role::SearchField
918    )
919}
920
921fn map_action(action: SemanticActionKind) -> Option<Action> {
922    match action {
923        SemanticActionKind::Focus => Some(Action::Focus),
924        SemanticActionKind::Activate => Some(Action::Click),
925        SemanticActionKind::SetText => Some(Action::SetValue),
926        SemanticActionKind::SetSelection => Some(Action::SetTextSelection),
927        SemanticActionKind::Increment => Some(Action::Increment),
928        SemanticActionKind::Decrement => Some(Action::Decrement),
929        SemanticActionKind::ScrollForward => Some(Action::ScrollDown),
930        SemanticActionKind::ScrollBackward => Some(Action::ScrollUp),
931    }
932}
933
934fn to_accesskit_rect(rect: Rect, scale_factor: f64) -> AccessKitRect {
935    AccessKitRect {
936        x0: f64::from(rect.origin.x) * scale_factor,
937        y0: f64::from(rect.origin.y) * scale_factor,
938        x1: f64::from(rect.origin.x + rect.size.width) * scale_factor,
939        y1: f64::from(rect.origin.y + rect.size.height) * scale_factor,
940    }
941}
942
943fn scroll_value(value: Option<&str>) -> (f64, f64) {
944    let Some((offset, maximum)) = value.and_then(|value| value.split_once('/')) else {
945        return (0.0, 0.0);
946    };
947    let parse = |value: &str| {
948        value
949            .parse::<f64>()
950            .ok()
951            .filter(|value| value.is_finite())
952            .unwrap_or(0.0)
953            .max(0.0)
954    };
955    (parse(offset), parse(maximum))
956}
957
958fn character_offset(text: &str, byte_offset: usize) -> usize {
959    let mut boundary = byte_offset.min(text.len());
960    while boundary > 0 && !text.is_char_boundary(boundary) {
961        boundary -= 1;
962    }
963    text[..boundary].chars().count()
964}
965
966fn utf8_offset(text: &str, character_offset: usize) -> usize {
967    text.char_indices()
968        .nth(character_offset)
969        .map_or(text.len(), |(offset, _)| offset)
970}