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