teksilo_widgets/table_view/a11y.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Accessibility wrappers for `TableView` / `TreeTableView`.
5//!
6//! AccessKit's table semantics work by labelling individual nodes with
7//! `Role::Table` / `Role::Row` / `Role::Cell` and stamping each cell with
8//! its row/column index. The cell delegate the user supplies typically
9//! produces a generic widget (Text, Button, …) that wouldn't carry table
10//! semantics by itself, so the body wraps each cell in a thin
11//! `CellA11y` node. `TableView`'s row containers themselves
12//! (`BodyRow`) carry `Role::Row` and
13//! row-index metadata directly. `TreeTableView` wraps each row in a
14//! `TreeRowA11y` instead, to declare `set_level` and `set_expanded`, and
15//! mirrors the level onto the tree column's cell as well — `CellA11y::
16//! with_level` says why the row alone is not heard.
17//!
18//! These wrappers do not paint or affect layout: they pass the proposed
19//! size straight through to their single child and forward all bounds.
20
21use teksilo_canvas::{Rect, SizeProposal};
22use teksilo_core::accessibility::AccessNodeBuilder;
23use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
24use teksilo_core::widget_id::WidgetId;
25
26use teksilo_data::SortDirection;
27
28/// Wrapper that announces a `Role::Cell` (or `Role::RowHeader`) with
29/// row/column indices and selection state.
30#[derive(Debug)]
31pub(crate) struct CellA11y {
32 child: WidgetId,
33 row_index_1based: usize,
34 col_index_1based: usize,
35 selected: bool,
36 /// When true, emit `Role::RowHeader` instead of `Role::Cell` — used
37 /// when the table promotes a column to row-header status.
38 is_row_header: bool,
39 /// Whether the cell is itself the selectable unit.
40 ///
41 /// Decides `Role::GridCell` over `Role::Cell`, which is not cosmetic on
42 /// Windows: `accesskit_windows` supports UIA's `SelectionItem` pattern for
43 /// `GridCell` unconditionally and for `Role::Cell` not at all, so a
44 /// selected cell announced as `Cell` never reports `IsSelected`. The two
45 /// map identically on macOS (`NSAccessibilityCellRole`) and AT-SPI
46 /// (`TableCell`), so this costs nothing there.
47 is_grid_cell: bool,
48 /// Optional name override (when the cell content isn't textual).
49 name: Option<String>,
50 /// 1-based hierarchy level, mirrored from the row this cell belongs to.
51 ///
52 /// `None` on a flat table, and on every column of a tree table but the
53 /// one that draws the indent. See `with_level`.
54 level_1based: Option<usize>,
55 /// Expand state of the row this cell belongs to: `Some(true|false)` on a
56 /// branch, `None` on a leaf and on every cell that is not the tree
57 /// column's. See `with_expanded`.
58 expanded: Option<bool>,
59 /// How many columns the cell covers, when it covers more than its own.
60 /// See `with_column_span`.
61 column_span: Option<usize>,
62}
63
64impl CellA11y {
65 pub(crate) fn new(
66 child: WidgetId,
67 row_index_1based: usize,
68 col_index_1based: usize,
69 selected: bool,
70 ) -> Self {
71 Self {
72 child,
73 row_index_1based,
74 col_index_1based,
75 selected,
76 is_row_header: false,
77 is_grid_cell: false,
78 name: None,
79 level_1based: None,
80 expanded: None,
81 column_span: None,
82 }
83 }
84
85 /// Announce `Role::GridCell` — the cell is the selectable unit. Named for
86 /// the sibling `with_role_row_header`, not `as_*`, which clippy reserves
87 /// for by-reference conversions.
88 pub(crate) fn with_grid_cell_role(mut self, is_grid_cell: bool) -> Self {
89 self.is_grid_cell = is_grid_cell;
90 self
91 }
92
93 /// Promote to `Role::RowHeader` (`row_header_column` support).
94 #[allow(dead_code)]
95 pub(crate) fn with_role_row_header(mut self, is_row_header: bool) -> Self {
96 self.is_row_header = is_row_header;
97 self
98 }
99
100 /// Override the cell's accessible name (`cell_label_fn`).
101 #[allow(dead_code)]
102 pub(crate) fn with_name(mut self, name: Option<String>) -> Self {
103 self.name = name;
104 self
105 }
106
107 /// Mirror the row's 1-based hierarchy level onto this cell.
108 ///
109 /// The depth belongs to the *row*, and `TreeRowA11y` publishes it there.
110 /// But `TreeTableView` navigates by cell: it nominates the focused *cell*
111 /// as the container's `active_descendant`, which leaves the row an
112 /// ancestor of the focused node — and NVDA drops `positionInfo_level`
113 /// from an ancestor it announces on the way in (`speech.py` clears
114 /// `allowProperties["positionInfo_level"]` for
115 /// `OutputReason.FOCUSENTERED`). So the depth was published correctly on
116 /// the row and spoken nowhere. `TreeView` does not have this problem: its
117 /// rows *are* the focused nodes.
118 ///
119 /// Only the tree column asks for this. It is the column that draws the
120 /// indent and the chevron, so it is the one whose announcement matches
121 /// what is on screen, and repeating "level 3" on all seven columns of one
122 /// row is noise. The price is that arrowing *down* a non-tree column
123 /// announces no depth change; the tree column is one arrow away, and
124 /// mirroring everywhere to close that gap would charge every horizontal
125 /// move for it.
126 ///
127 /// Windows-only in effect, and not because of this decision: `level` is
128 /// the one positional property `accesskit_macos-0.27.0` and
129 /// `accesskit_atspi_common-0.20.0` do not expose at all (the latter
130 /// publishes `posinset` / `setsize` and no `level`). VoiceOver and Orca
131 /// heard nothing from the row either — for them the indent is what
132 /// carries depth.
133 pub(crate) fn with_level(mut self, level_1based: Option<usize>) -> Self {
134 self.level_1based = level_1based;
135 self
136 }
137
138 /// Mirror the row's expand state onto this cell — same reason as
139 /// `with_level`, and one more.
140 ///
141 /// Announcing it is the first reason: "expanded" is a *state*, and a
142 /// screen reader reads the states of the node it is on, which here is the
143 /// cell. But a state on an ancestor fails a second way that a level does
144 /// not. Toggling a row with ArrowRight moves nothing, so no focus event
145 /// follows; all the user has to go on is a property change, and
146 /// `accesskit_windows` raises that on the node whose property changed —
147 /// the row, which is not the focused element, so NVDA drops it. Opening
148 /// and closing a branch was silent.
149 ///
150 /// The state owes an answer, and already has one. `accesskit_consumer`
151 /// derives UIA's ExpandCollapse pattern from the *property*, not from the
152 /// action list, so a cell that declares a state will be sent `Expand` and
153 /// `Collapse` whether or not anything handles them — the
154 /// advertised-and-inert bug the row's own call site fixed. Nothing new is
155 /// needed for the cell: an `AccessAction` bubbles, and the row carrying
156 /// that pair is on the cell's path to the root. Handlers on the cell as
157 /// well would only run `set_expanded_at` twice.
158 pub(crate) fn with_expanded(mut self, expanded: Option<bool>) -> Self {
159 self.expanded = expanded;
160 self
161 }
162
163 /// Announce that the cell covers `span` columns (`aria-colspan`), from its
164 /// own column index on. `TreeTableView`'s full-width rows are one cell
165 /// across every column and say so here.
166 ///
167 /// Published, not yet heard: none of the adapters this workspace pins
168 /// (`accesskit_windows` 0.35, `accesskit_macos` 0.27,
169 /// `accesskit_atspi_common` 0.21) reads `column_span`. It is the property
170 /// AccessKit defines for exactly this, on the node it belongs to, so an
171 /// adapter that learns it needs nothing more from here.
172 pub(crate) fn with_column_span(mut self, span: Option<usize>) -> Self {
173 self.column_span = span;
174 self
175 }
176}
177
178impl Widget for CellA11y {
179 fn layout_response(
180 &self,
181 proposal: SizeProposal,
182 ctx: &LayoutContext,
183 ) -> teksilo_core::widget::LayoutResponse {
184 ctx.child_size(self.child, proposal)
185 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
186 .into()
187 }
188
189 fn place_children(
190 &self,
191 bounds: Rect,
192 _proposal: SizeProposal,
193 children: &mut [WidgetPlacement],
194 _ctx: &LayoutContext,
195 ) {
196 for child in children.iter_mut() {
197 child.origin = bounds.origin();
198 child.size = bounds.size();
199 }
200 }
201
202 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
203 builder.set_role(if self.is_row_header {
204 teksilo_core::accesskit::Role::RowHeader
205 } else if self.is_grid_cell {
206 teksilo_core::accesskit::Role::GridCell
207 } else {
208 teksilo_core::accesskit::Role::Cell
209 });
210 if let Some(ref name) = self.name {
211 builder.set_name(name.clone());
212 }
213 builder.set_selected(self.selected);
214 builder.set_row_index(self.row_index_1based);
215 builder.set_column_index(self.col_index_1based);
216 // The floor matches `TreeRowA11y`: a caller that computed a depth of
217 // 0 lands on the root level, not one above it.
218 if let Some(level) = self.level_1based {
219 builder.set_level(level.max(1));
220 }
221 // A leaf declares nothing, which UIA reads as `LeafNode` — the state
222 // a cell with no subtree should report, and the reason this is not a
223 // plain `bool`.
224 if let Some(expanded) = self.expanded {
225 builder.set_expanded(expanded);
226 }
227 if let Some(span) = self.column_span {
228 builder.set_column_span(span);
229 }
230 }
231
232 fn children(&self) -> Vec<WidgetId> {
233 vec![self.child]
234 }
235}
236
237/// `TreeTableView`-flavoured row wrapper. Announces `Role::Row` and, in
238/// addition to the row index, declares `set_level` (1-based depth),
239/// `set_expanded` when the row has children, and `set_position_in_set`
240/// among the row's siblings, mirroring what `TreeView`'s `TreeItemWrapper`
241/// (`list_item_a11y.rs`) announces, so both widgets say which sibling a
242/// row is.
243///
244/// No `size_of_set` goes with it, here or on the container: AccessKit
245/// resolves a set size by walking up from the item, and a flattened tree has
246/// no per-branch node to hold one, so neither widget can say "of 5". The
247/// comment in `accessibility` below spells out what expressing it would cost.
248#[derive(Debug)]
249pub(crate) struct TreeRowA11y {
250 child: WidgetId,
251 row_index_1based: usize,
252 /// 1-based hierarchy level (root rows are 1).
253 level_1based: usize,
254 /// `Some(true|false)` for non-leaf rows; `None` for leaves.
255 expanded: Option<bool>,
256 selected: bool,
257 /// 1-based position among this row's siblings (`TreeSource::sibling_pos`).
258 position_in_set: usize,
259}
260
261impl TreeRowA11y {
262 pub(crate) fn new(
263 child: WidgetId,
264 row_index_1based: usize,
265 level_1based: usize,
266 expanded: Option<bool>,
267 selected: bool,
268 position_in_set: usize,
269 ) -> Self {
270 Self {
271 child,
272 row_index_1based,
273 level_1based,
274 expanded,
275 selected,
276 position_in_set,
277 }
278 }
279}
280
281impl Widget for TreeRowA11y {
282 fn layout_response(
283 &self,
284 proposal: SizeProposal,
285 ctx: &LayoutContext,
286 ) -> teksilo_core::widget::LayoutResponse {
287 ctx.child_size(self.child, proposal)
288 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
289 .into()
290 }
291
292 fn place_children(
293 &self,
294 bounds: Rect,
295 _proposal: SizeProposal,
296 children: &mut [WidgetPlacement],
297 _ctx: &LayoutContext,
298 ) {
299 for child in children.iter_mut() {
300 child.origin = bounds.origin();
301 child.size = bounds.size();
302 }
303 }
304
305 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
306 builder.set_role(teksilo_core::accesskit::Role::Row);
307 builder.set_selected(self.selected);
308 if let Some(exp) = self.expanded {
309 builder.set_expanded(exp);
310 }
311 builder.set_row_index(self.row_index_1based);
312 // The floor keeps a caller that computed a depth of 0 on the root
313 // level rather than one above it. `set_level` takes the ARIA 1-based
314 // number and converts to AccessKit's zero-based property itself.
315 builder.set_level(self.level_1based.max(1));
316 builder.set_position_in_set(self.position_in_set);
317 // No `size_of_set` here, and none on the container either.
318 //
319 // AccessKit resolves an item's set size by walking *up* from it
320 // (`size_of_set_from_container`), so the only value a flattened tree
321 // could publish is one shared by every row at every depth — which is
322 // not what "the 2nd of 5 siblings" means. Expressing it correctly needs
323 // a real `Role::Group` node per expanded branch, between the container
324 // and its rows, and that changes the AT tree shape for every tree
325 // widget. Writing the number on this node instead is dead: no adapter
326 // on any platform reads it, and leaving it there makes a missing
327 // feature look like a working one.
328 //
329 // The level and the expanded state still carry the hierarchy, and
330 // `position_in_set` still says which sibling this is.
331 }
332
333 fn children(&self) -> Vec<WidgetId> {
334 vec![self.child]
335 }
336}
337
338/// Header column wrapper — `Role::ColumnHeader` with sort direction
339/// when this is the active sort column.
340#[derive(Debug)]
341#[allow(dead_code)]
342pub(crate) struct ColumnHeaderA11y {
343 child: WidgetId,
344 col_index_1based: usize,
345 name: String,
346 sort: Option<SortDirection>,
347}
348
349#[allow(dead_code)] // not wired: `HeaderCell` publishes its own node
350impl ColumnHeaderA11y {
351 pub(crate) fn new(
352 child: WidgetId,
353 col_index_1based: usize,
354 name: impl Into<String>,
355 sort: Option<SortDirection>,
356 ) -> Self {
357 Self {
358 child,
359 col_index_1based,
360 name: name.into(),
361 sort,
362 }
363 }
364}
365
366impl Widget for ColumnHeaderA11y {
367 fn layout_response(
368 &self,
369 proposal: SizeProposal,
370 ctx: &LayoutContext,
371 ) -> teksilo_core::widget::LayoutResponse {
372 ctx.child_size(self.child, proposal)
373 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
374 .into()
375 }
376
377 fn place_children(
378 &self,
379 bounds: Rect,
380 _proposal: SizeProposal,
381 children: &mut [WidgetPlacement],
382 _ctx: &LayoutContext,
383 ) {
384 for child in children.iter_mut() {
385 child.origin = bounds.origin();
386 child.size = bounds.size();
387 }
388 }
389
390 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
391 builder.set_role(teksilo_core::accesskit::Role::ColumnHeader);
392 builder.set_name(self.name.clone());
393 builder.set_column_index(self.col_index_1based);
394 if let Some(dir) = self.sort {
395 let ak_dir = match dir {
396 SortDirection::Ascending => teksilo_core::accesskit::SortDirection::Ascending,
397 SortDirection::Descending => teksilo_core::accesskit::SortDirection::Descending,
398 };
399 builder.inner_mut().set_sort_direction(ak_dir);
400 }
401 }
402
403 fn children(&self) -> Vec<WidgetId> {
404 vec![self.child]
405 }
406}