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