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}