Skip to main content

teksilo_scene/
a11y.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Accessibility policies for [`SceneView`](crate::SceneView).
5//!
6//! Two layers cooperate. The **visual-default** path emits AT nodes
7//! for every visible heavyweight widget and every visible lightweight
8//! item with a role and a scene-space rectangle, gated by an
9//! [`A11yOffScreenMode`] policy that decides which off-viewport items
10//! are still announced. (Scene-space, with the camera declared once as
11//! an AccessKit node transform above them — see
12//! `crates/teksilo-scene/src/view/a11y_impl.rs`.) The
13//! **logical-structural API** (groups,
14//! parents, relations, auto-graft, custom focus callbacks) layers
15//! over the top — see [`docs/teksilo-scene-a11y.md`](https://github.com/ferntech-eu/teksilo/blob/main/docs/teksilo-scene-a11y.md)
16//! for the full picture.
17//!
18//! Defaults are chosen so a quick prototype is accessible out of the
19//! box: heavyweight widgets emit normally, lightweight items get
20//! synthetic nodes, Tab cycles in reading order. Apps shape the
21//! reading experience by declaring [`A11yGroup`]s, reparenting nodes,
22//! and installing a focus-order callback.
23
24use std::sync::atomic::{AtomicU64, Ordering};
25
26use teksilo_canvas::Rect;
27use teksilo_core::widget_id::WidgetId;
28
29use crate::item::ItemId;
30use teksilo_i18n::LocalizedString;
31
32// ---------------------------------------------------------------------------
33// Logical AT structure
34// ---------------------------------------------------------------------------
35
36/// Opaque identifier for a logical AT group declared via
37/// [`Scene::add_a11y_group`](crate::Scene::add_a11y_group). Stable
38/// across the lifetime of the process; safe to hash, compare, store.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
40pub struct A11yGroupId(pub(crate) u64);
41
42impl A11yGroupId {
43    pub(crate) fn next() -> Self {
44        static COUNTER: AtomicU64 = AtomicU64::new(1);
45        A11yGroupId(COUNTER.fetch_add(1, Ordering::Relaxed))
46    }
47
48    /// Raw numeric value. Used by the AT walker to derive a synthetic
49    /// `NodeId` via `synthetic_node_id(scene_view_id, id.as_u64(),
50    /// SyntheticKind::SceneGroup)`.
51    pub fn as_u64(self) -> u64 {
52        self.0
53    }
54}
55
56/// Address of a node in the parallel logical AT tree. Lets apps
57/// uniformly target scene entries, virtual groups, and ad-hoc
58/// widgets when declaring relationships, parents, or rotor
59/// categories.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum A11yNode {
62    /// Any entry in the scene — lightweight `SceneItem` or
63    /// heavyweight `Widget` added via `Scene::add_widget`. The
64    /// walker discriminates by entry kind: lightweight items get
65    /// a synthetic `SyntheticKind::SceneItem` AT node; heavyweight
66    /// items get auto-grafted via the framework redirect hook,
67    /// landing the real widget's `NodeId` under the declared
68    /// parent.
69    Item(ItemId),
70    /// A virtual `A11yGroup` declared via
71    /// [`Scene::add_a11y_group`](crate::Scene::add_a11y_group).
72    Group(A11yGroupId),
73    /// A real interactive widget addressed by its arena
74    /// [`WidgetId`]. Use this to relocate widgets that aren't
75    /// `Scene::add_widget`-managed — typically a *descendant* of
76    /// a heavyweight scene item that should logically belong
77    /// elsewhere (a global `ComboBox` nested visually inside a
78    /// Scene card but logically under a top-level "Tools" group).
79    /// For widgets you added via `Scene::add_widget`, prefer
80    /// `A11yNode::Item(item_id)` — the walker handles the
81    /// heavyweight-item auto-graft for you.
82    Widget(WidgetId),
83}
84
85/// AT relationship kind, applied via
86/// [`Scene::add_a11y_relation`](crate::Scene::add_a11y_relation).
87/// Maps to AccessKit's relationship arrays.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub enum A11yRelation {
90    /// `from` controls `to` (e.g. a button that opens a menu).
91    Controls,
92    /// `from` is described by `to` (cross-item annotation).
93    DescribedBy,
94    /// `from` is labelled by `to` (cross-item label).
95    LabelledBy,
96    /// Logical flow direction — many node-graph editors use this so
97    /// VoiceOver / NVDA "next item" follows data-flow order rather
98    /// than reading order.
99    FlowTo,
100}
101
102/// App-defined category tag for AT rotor / quick-nav navigation.
103/// Surfaced to AT clients that support categorized navigation
104/// (VoiceOver rotor on macOS, NVDA quick-nav). Apps coin their own
105/// tag values like `"node"`, `"connector"`, `"comment"`.
106#[derive(Debug, Clone, PartialEq, Eq, Hash)]
107pub struct A11yCategory(pub std::borrow::Cow<'static, str>);
108
109impl A11yCategory {
110    /// Create a new category tag from a string or `&'static str`.
111    /// Accepts `"node"`, `"connector"`, `String`, or any `Cow<'static, str>`.
112    pub fn new(name: impl Into<std::borrow::Cow<'static, str>>) -> Self {
113        Self(name.into())
114    }
115}
116
117/// Builder for an [`A11yGroup`]. Returned by
118/// [`A11yGroup::builder`]; consumed by
119/// [`Scene::add_a11y_group`](crate::Scene::add_a11y_group).
120///
121/// ```ignore
122/// let act_one = scene.add_a11y_group(
123///     A11yGroup::builder()
124///         .label("Act 1")
125///         .role(accesskit::Role::Group)
126/// );
127/// scene.set_a11y_parent(A11yNode::Item(scene_card), Some(A11yNode::Group(act_one)));
128/// ```
129#[derive(Debug)]
130pub struct A11yGroupBuilder {
131    pub(crate) label: Option<LocalizedString>,
132    pub(crate) role: accesskit::Role,
133}
134
135impl A11yGroupBuilder {
136    /// Human-readable label for the group, announced when AT clients
137    /// land on the group node. Accepts anything convertible into
138    /// [`LocalizedString`].
139    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
140        let ls: LocalizedString = label.into();
141        self.label = Some(ls);
142        self
143    }
144
145    /// Override the AccessKit role. Default: `Role::Group`. Apps
146    /// commonly use `Role::Region` for landmark-style groups.
147    pub fn role(mut self, role: accesskit::Role) -> Self {
148        self.role = role;
149        self
150    }
151}
152
153/// A logical AT group. Pure structure — no visual counterpart, no
154/// hit-test, no paint. Declares AT-shape that
155/// diverges from visual scene layout (Acts containing Scene cards,
156/// Subgraphs containing Nodes, Layers containing Components).
157#[derive(Debug)]
158pub struct A11yGroup {
159    pub(crate) id: A11yGroupId,
160    pub(crate) label: Option<LocalizedString>,
161    pub(crate) role: accesskit::Role,
162}
163
164impl A11yGroup {
165    /// A fresh builder for a logical group. Default role is
166    /// `Role::Group`; override with [`A11yGroupBuilder::role`].
167    pub fn builder() -> A11yGroupBuilder {
168        A11yGroupBuilder {
169            label: None,
170            role: accesskit::Role::Group,
171        }
172    }
173
174    /// The group's id. Stable for the lifetime of the process.
175    pub fn id(&self) -> A11yGroupId {
176        self.id
177    }
178
179    /// The label set on the builder, if any.
180    pub fn label(&self) -> Option<String> {
181        self.label.as_ref().map(|l| l.resolve_now())
182    }
183
184    /// The role set on the builder. Default `Role::Group`.
185    pub fn role(&self) -> accesskit::Role {
186        self.role
187    }
188}
189
190/// AT-emission strategy for `SceneView`. Decides whether items /
191/// widgets that have *not* been placed in the app-declared logical
192/// tree appear in the AT tree by default, or are suppressed.
193///
194/// Pick `Cooperative` when the visual scene layout *is* a sensible
195/// AT structure for your app (charts, dashboards, simple maps).
196/// Pick `StrictlyParallel` when AT shape diverges meaningfully
197/// from visual layout — story corkboards (Acts → Scene cards),
198/// node-graph editors (Subgraphs → Nodes → Ports), CAD canvases
199/// (Layers → Components). Apps in this category typically declare
200/// every AT edge anyway, so the default visual-emission becomes
201/// noise.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
203pub enum A11yMode {
204    /// **Default.** Visual is the AT structure unless overridden.
205    /// Items inside the off-screen-mode policy emit as direct AT
206    /// children of `SceneView` (or their declared logical parent
207    /// if `set_a11y_parent` placed them). Heavyweight widgets
208    /// emit through the arena walker as natural descendants of
209    /// `SceneView`. The logical-tree machinery layers on
210    /// top.
211    #[default]
212    Cooperative,
213
214    /// AT structure is purely declared. Items are emitted **only**
215    /// if the app placed them in the logical tree via
216    /// `Scene::set_a11y_parent`. Heavyweight widgets still emit
217    /// (they own focus / interaction state the AT layer can't
218    /// suppress) but their parent in the AT tree is the declared
219    /// logical parent if any, else `SceneView` itself.
220    ///
221    /// Use this when your app's AT shape is fundamentally
222    /// different from its visual layout — declaring every node
223    /// once is cheaper than overriding the visual default for
224    /// every node.
225    StrictlyParallel,
226}
227
228/// Off-screen visibility policy for the AT walker. Decides which
229/// scene items get emitted as synthetic AT nodes per AT-rebuild.
230///
231/// `ViewportPlusN { n: 1 }` is the default: an item appears in the
232/// AT tree if its `bounds_in_scene` intersects `viewport ∪ (1×
233/// viewport-grown-rect)`. That keeps the tree close to "what the
234/// user can interact with right now" while letting screen-reader
235/// users discover items just outside the visible region by jumping
236/// to the next/prev — at which point `SceneView::ensure_visible`
237/// pans the view to bring the focused item into view.
238#[derive(Debug, Clone, Copy)]
239pub enum A11yOffScreenMode {
240    /// Emit *every* item in the scene as a synthetic AT node.
241    /// Heaviest mode — appropriate for small scenes (< ~500 items)
242    /// where AT users want a complete table of contents.
243    AllItems,
244
245    /// Emit items inside the viewport plus an `n × viewport`-grown
246    /// margin around it. `n = 0` collapses to "viewport only" with
247    /// the same allocation pattern as `ViewportOnly`. `n = 1` is
248    /// the default — gives screen-reader users a one-screen
249    /// "lookahead" to navigate without `ensure_visible` round-tripping
250    /// through pan animation.
251    ViewportPlusN {
252        /// How many viewports of margin to grow the window by.
253        n: u32,
254    },
255
256    /// Strict: only items intersecting the current viewport. Pairs
257    /// with apps that have very large scenes where listing
258    /// off-screen content would overwhelm AT clients.
259    ViewportOnly,
260}
261
262impl A11yOffScreenMode {
263    /// Compute the scene-coord rectangle a given mode considers
264    /// "AT-visible" given the current visible scene region. Used by
265    /// `SceneView::accessibility` as the spatial-index query rect.
266    /// `AllItems` returns `None` so the caller knows to bypass the
267    /// query and emit every item.
268    pub fn at_visible_region(&self, visible_scene_region: Rect) -> Option<Rect> {
269        match *self {
270            A11yOffScreenMode::AllItems => None,
271            A11yOffScreenMode::ViewportOnly => Some(visible_scene_region),
272            A11yOffScreenMode::ViewportPlusN { n } => {
273                if n == 0 {
274                    return Some(visible_scene_region);
275                }
276                let margin_x = visible_scene_region.width * n as f32;
277                let margin_y = visible_scene_region.height * n as f32;
278                Some(Rect::new(
279                    visible_scene_region.x - margin_x,
280                    visible_scene_region.y - margin_y,
281                    visible_scene_region.width + margin_x * 2.0,
282                    visible_scene_region.height + margin_y * 2.0,
283                ))
284            }
285        }
286    }
287}
288
289impl Default for A11yOffScreenMode {
290    fn default() -> Self {
291        A11yOffScreenMode::ViewportPlusN { n: 1 }
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn default_is_viewport_plus_one() {
301        assert!(matches!(
302            A11yOffScreenMode::default(),
303            A11yOffScreenMode::ViewportPlusN { n: 1 }
304        ));
305    }
306
307    #[test]
308    fn all_items_returns_none() {
309        assert_eq!(
310            A11yOffScreenMode::AllItems.at_visible_region(Rect::new(0.0, 0.0, 100.0, 100.0)),
311            None
312        );
313    }
314
315    #[test]
316    fn viewport_only_passes_through() {
317        let viewport = Rect::new(10.0, 20.0, 100.0, 50.0);
318        assert_eq!(
319            A11yOffScreenMode::ViewportOnly.at_visible_region(viewport),
320            Some(viewport)
321        );
322    }
323
324    #[test]
325    fn viewport_plus_n_grows_symmetrically() {
326        // Viewport at (0,0)-(100,80), n=1 → grow by ±100 in x, ±80
327        // in y → final rect (-100,-80)-(200,160) i.e. 300×240.
328        let viewport = Rect::new(0.0, 0.0, 100.0, 80.0);
329        let grown = A11yOffScreenMode::ViewportPlusN { n: 1 }
330            .at_visible_region(viewport)
331            .unwrap();
332        assert_eq!(grown, Rect::new(-100.0, -80.0, 300.0, 240.0));
333    }
334
335    #[test]
336    fn viewport_plus_zero_equals_viewport_only() {
337        let viewport = Rect::new(50.0, 50.0, 200.0, 100.0);
338        assert_eq!(
339            A11yOffScreenMode::ViewportPlusN { n: 0 }.at_visible_region(viewport),
340            A11yOffScreenMode::ViewportOnly.at_visible_region(viewport),
341        );
342    }
343
344    #[test]
345    fn viewport_plus_two_grows_by_two_viewports_each_side() {
346        let viewport = Rect::new(0.0, 0.0, 100.0, 100.0);
347        let grown = A11yOffScreenMode::ViewportPlusN { n: 2 }
348            .at_visible_region(viewport)
349            .unwrap();
350        // Margin is 200 on each side → final 500×500.
351        assert_eq!(grown, Rect::new(-200.0, -200.0, 500.0, 500.0));
352    }
353}