Skip to main content

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}