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` adds an extra
14//! `TreeRowA11y` wrapper around the tree column to declare `set_level`
15//! and `set_expanded` for the row.
16//!
17//! These wrappers do not paint or affect layout: they pass the proposed
18//! size straight through to their single child and forward all bounds.
19
20use teksilo_canvas::{Rect, SizeProposal};
21use teksilo_core::accessibility::AccessNodeBuilder;
22use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
23use teksilo_core::widget_id::WidgetId;
24
25use teksilo_data::SortDirection;
26
27/// Wrapper that announces a `Role::Cell` (or `Role::RowHeader`) with
28/// row/column indices and selection state.
29#[derive(Debug)]
30pub(crate) struct CellA11y {
31    child: WidgetId,
32    row_index_1based: usize,
33    col_index_1based: usize,
34    selected: bool,
35    /// When true, emit `Role::RowHeader` instead of `Role::Cell` — used
36    /// when the table promotes a column to row-header status.
37    is_row_header: bool,
38    /// Optional name override (when the cell content isn't textual).
39    name: Option<String>,
40}
41
42impl CellA11y {
43    pub(crate) fn new(
44        child: WidgetId,
45        row_index_1based: usize,
46        col_index_1based: usize,
47        selected: bool,
48    ) -> Self {
49        Self {
50            child,
51            row_index_1based,
52            col_index_1based,
53            selected,
54            is_row_header: false,
55            name: None,
56        }
57    }
58
59    /// Promote to `Role::RowHeader` (`row_header_column` support).
60    #[allow(dead_code)]
61    pub(crate) fn with_role_row_header(mut self, is_row_header: bool) -> Self {
62        self.is_row_header = is_row_header;
63        self
64    }
65
66    /// Override the cell's accessible name (`cell_label_fn`).
67    #[allow(dead_code)]
68    pub(crate) fn with_name(mut self, name: Option<String>) -> Self {
69        self.name = name;
70        self
71    }
72}
73
74impl Widget for CellA11y {
75    fn layout_response(
76        &self,
77        proposal: SizeProposal,
78        ctx: &LayoutContext,
79    ) -> teksilo_core::widget::LayoutResponse {
80        ctx.child_size(self.child, proposal)
81            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
82            .into()
83    }
84
85    fn place_children(
86        &self,
87        bounds: Rect,
88        _proposal: SizeProposal,
89        children: &mut [WidgetPlacement],
90        _ctx: &LayoutContext,
91    ) {
92        for child in children.iter_mut() {
93            child.origin = bounds.origin();
94            child.size = bounds.size();
95        }
96    }
97
98    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
99        builder.set_role(if self.is_row_header {
100            teksilo_core::accesskit::Role::RowHeader
101        } else {
102            teksilo_core::accesskit::Role::Cell
103        });
104        if let Some(ref name) = self.name {
105            builder.set_name(name.clone());
106        }
107        builder.set_selected(self.selected);
108        builder.set_row_index(self.row_index_1based);
109        builder.set_column_index(self.col_index_1based);
110    }
111
112    fn children(&self) -> Vec<WidgetId> {
113        vec![self.child]
114    }
115}
116
117/// `TreeTableView`-flavoured row wrapper. Announces `Role::Row` and, in
118/// addition to the row index, declares `set_level` (1-based depth) and
119/// `set_expanded` when the row has children, plus `set_position_in_set` /
120/// `set_size_of_set` among the row's siblings — mirroring what
121/// `TreeView`'s `TreeItemWrapper` (`list_item_a11y.rs`) already announces,
122/// so a screen reader reads "item 2 of 5" the same way in both widgets.
123#[derive(Debug)]
124pub(crate) struct TreeRowA11y {
125    child: WidgetId,
126    row_index_1based: usize,
127    /// 1-based hierarchy level (root rows are 1).
128    level_1based: usize,
129    /// `Some(true|false)` for non-leaf rows; `None` for leaves.
130    expanded: Option<bool>,
131    selected: bool,
132    /// 1-based position among this row's siblings (`TreeSource::sibling_pos`).
133    position_in_set: usize,
134    /// Total sibling count at this row's level.
135    size_of_set: usize,
136}
137
138impl TreeRowA11y {
139    #[allow(clippy::too_many_arguments)]
140    pub(crate) fn new(
141        child: WidgetId,
142        row_index_1based: usize,
143        level_1based: usize,
144        expanded: Option<bool>,
145        selected: bool,
146        position_in_set: usize,
147        size_of_set: usize,
148    ) -> Self {
149        Self {
150            child,
151            row_index_1based,
152            level_1based,
153            expanded,
154            selected,
155            position_in_set,
156            size_of_set,
157        }
158    }
159}
160
161impl Widget for TreeRowA11y {
162    fn layout_response(
163        &self,
164        proposal: SizeProposal,
165        ctx: &LayoutContext,
166    ) -> teksilo_core::widget::LayoutResponse {
167        ctx.child_size(self.child, proposal)
168            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
169            .into()
170    }
171
172    fn place_children(
173        &self,
174        bounds: Rect,
175        _proposal: SizeProposal,
176        children: &mut [WidgetPlacement],
177        _ctx: &LayoutContext,
178    ) {
179        for child in children.iter_mut() {
180            child.origin = bounds.origin();
181            child.size = bounds.size();
182        }
183    }
184
185    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
186        builder.set_role(teksilo_core::accesskit::Role::Row);
187        builder.set_selected(self.selected);
188        if let Some(exp) = self.expanded {
189            builder.set_expanded(exp);
190        }
191        builder.set_row_index(self.row_index_1based);
192        // Clamp to 1.. — AccessKit's `set_level` is `usize` but ARIA
193        // levels start at 1.
194        builder.inner_mut().set_level(self.level_1based.max(1));
195        builder
196            .inner_mut()
197            .set_position_in_set(self.position_in_set);
198        builder.inner_mut().set_size_of_set(self.size_of_set);
199    }
200
201    fn children(&self) -> Vec<WidgetId> {
202        vec![self.child]
203    }
204}
205
206/// Header column wrapper — `Role::ColumnHeader` with sort direction
207/// when this is the active sort column.
208#[derive(Debug)]
209#[allow(dead_code)]
210pub(crate) struct ColumnHeaderA11y {
211    child: WidgetId,
212    col_index_1based: usize,
213    name: String,
214    sort: Option<SortDirection>,
215}
216
217#[allow(dead_code)] // wired up by HeaderRow
218impl ColumnHeaderA11y {
219    pub(crate) fn new(
220        child: WidgetId,
221        col_index_1based: usize,
222        name: impl Into<String>,
223        sort: Option<SortDirection>,
224    ) -> Self {
225        Self {
226            child,
227            col_index_1based,
228            name: name.into(),
229            sort,
230        }
231    }
232}
233
234impl Widget for ColumnHeaderA11y {
235    fn layout_response(
236        &self,
237        proposal: SizeProposal,
238        ctx: &LayoutContext,
239    ) -> teksilo_core::widget::LayoutResponse {
240        ctx.child_size(self.child, proposal)
241            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
242            .into()
243    }
244
245    fn place_children(
246        &self,
247        bounds: Rect,
248        _proposal: SizeProposal,
249        children: &mut [WidgetPlacement],
250        _ctx: &LayoutContext,
251    ) {
252        for child in children.iter_mut() {
253            child.origin = bounds.origin();
254            child.size = bounds.size();
255        }
256    }
257
258    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
259        builder.set_role(teksilo_core::accesskit::Role::ColumnHeader);
260        builder.set_name(self.name.clone());
261        builder.set_column_index(self.col_index_1based);
262        if let Some(dir) = self.sort {
263            let ak_dir = match dir {
264                SortDirection::Ascending => teksilo_core::accesskit::SortDirection::Ascending,
265                SortDirection::Descending => teksilo_core::accesskit::SortDirection::Descending,
266            };
267            builder.inner_mut().set_sort_direction(ak_dir);
268        }
269    }
270
271    fn children(&self) -> Vec<WidgetId> {
272        vec![self.child]
273    }
274}