use std::cell::Cell;
use std::collections::{HashMap, HashSet};
thread_local! {
static NAMING_OVERRIDE: Cell<Option<bool>> = const { Cell::new(None) };
}
pub(super) fn names_cells_from_content() -> bool {
NAMING_OVERRIDE
.with(Cell::get)
.unwrap_or(cfg!(not(target_os = "macos")))
}
#[cfg(test)]
pub(crate) struct CellNamingOverride(Option<bool>);
#[cfg(test)]
impl CellNamingOverride {
pub(crate) fn new(name: bool) -> Self {
Self(NAMING_OVERRIDE.with(|o| o.replace(Some(name))))
}
}
#[cfg(test)]
impl Drop for CellNamingOverride {
fn drop(&mut self) {
NAMING_OVERRIDE.with(|o| o.set(self.0));
}
}
use accesskit::{Node, NodeId, Role};
fn named_from_content(role: Role) -> bool {
matches!(
role,
Role::Cell | Role::GridCell | Role::RowHeader | Role::ColumnHeader
)
}
fn text_surface(role: Role) -> bool {
matches!(
role,
Role::Document
| Role::MultilineTextInput
| Role::TextInput
| Role::SearchInput
| Role::Terminal
| Role::WebView
)
}
fn has_text(node: &Node) -> bool {
let text = match node.role() {
Role::Label => node.value(),
Role::Image => node.label(),
_ => None,
};
text.is_some_and(|t| !t.trim().is_empty())
}
pub(super) fn name_cells_from_content(
nodes: &mut [(NodeId, Node)],
index: &HashMap<NodeId, usize>,
) {
if !names_cells_from_content() || !nodes.iter().any(|(_, n)| named_from_content(n.role())) {
return;
}
let node = |id: NodeId| index.get(&id).map(|&i| &nodes[i].1);
let mut in_text_surface: HashSet<NodeId> = HashSet::new();
for (_, surface) in nodes.iter().filter(|(_, n)| text_surface(n.role())) {
let mut stack: Vec<NodeId> = surface.children().to_vec();
while let Some(at) = stack.pop() {
let Some(n) = node(at) else { continue };
if named_from_content(n.role()) {
in_text_surface.insert(at);
}
stack.extend_from_slice(n.children());
}
}
let mut names: Vec<(usize, Vec<NodeId>)> = Vec::new();
for (i, (id, cell)) in nodes.iter().enumerate() {
if !named_from_content(cell.role())
|| cell.label().is_some()
|| !cell.labelled_by().is_empty()
|| in_text_surface.contains(id)
{
continue;
}
let mut found = Vec::new();
let mut stack: Vec<NodeId> = cell.children().iter().rev().copied().collect();
while let Some(at) = stack.pop() {
let Some(n) = node(at) else { continue };
if n.is_hidden() {
continue;
}
match n.role() {
Role::Label | Role::Image => {
if has_text(n) {
found.push(at);
}
}
Role::GenericContainer => stack.extend(n.children().iter().rev().copied()),
_ => {}
}
}
if !found.is_empty() {
names.push((i, found));
}
}
for (i, targets) in names {
nodes[i].1.set_labelled_by(targets);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn label(text: &str) -> Node {
let mut n = Node::new(Role::Label);
n.set_value(text);
n
}
fn with_children(role: Role, children: &[u64]) -> Node {
let mut n = Node::new(role);
n.set_children(children.iter().map(|&c| NodeId(c)).collect::<Vec<_>>());
n
}
fn name(nodes: &mut [(NodeId, Node)]) {
let _on = CellNamingOverride::new(true);
let index: HashMap<NodeId, usize> = nodes
.iter()
.enumerate()
.map(|(i, (id, _))| (*id, i))
.collect();
name_cells_from_content(nodes, &index);
}
fn named(nodes: &[(NodeId, Node)], id: u64) -> Vec<NodeId> {
nodes
.iter()
.find(|(n, _)| *n == NodeId(id))
.map(|(_, n)| n.labelled_by().to_vec())
.unwrap_or_default()
}
#[test]
fn a_cell_is_labelled_by_the_text_it_holds_through_layout_nodes() {
let mut nodes = vec![
(NodeId(1), with_children(Role::Table, &[2])),
(NodeId(2), with_children(Role::Row, &[3, 7])),
(NodeId(3), with_children(Role::Cell, &[4])),
(NodeId(4), with_children(Role::GenericContainer, &[5, 6])),
(NodeId(5), label("README.md")),
(NodeId(6), label("Project overview.")),
(NodeId(7), with_children(Role::GridCell, &[8])),
(NodeId(8), label("markdown")),
];
name(&mut nodes);
assert_eq!(named(&nodes, 3), vec![NodeId(5), NodeId(6)]);
assert_eq!(named(&nodes, 7), vec![NodeId(8)]);
assert!(named(&nodes, 2).is_empty(), "a row is not named here");
}
#[test]
fn a_cell_keeps_its_own_name_and_is_not_named_after_a_control_or_hidden_text() {
let mut own = with_children(Role::Cell, &[3]);
own.set_label("Mine");
let mut hidden = label("secret");
hidden.set_hidden();
let mut nodes = vec![
(NodeId(1), with_children(Role::Row, &[2, 4, 7])),
(NodeId(2), own),
(NodeId(3), label("shown")),
(NodeId(4), with_children(Role::Cell, &[5, 6])),
(NodeId(5), with_children(Role::CheckBox, &[])),
(NodeId(6), hidden),
(NodeId(7), with_children(Role::Cell, &[8])),
(NodeId(8), with_children(Role::Button, &[9])),
(NodeId(9), label("inside a control")),
];
name(&mut nodes);
assert!(named(&nodes, 2).is_empty(), "its own name stands");
assert!(
named(&nodes, 4).is_empty(),
"a control and hidden text name nothing"
);
assert!(
named(&nodes, 7).is_empty(),
"text inside a control is the control's"
);
}
#[test]
fn a_cell_in_a_text_surface_is_left_to_its_text() {
let mut nodes = vec![
(NodeId(1), with_children(Role::MultilineTextInput, &[2])),
(NodeId(2), with_children(Role::Table, &[3])),
(NodeId(3), with_children(Role::Row, &[4])),
(NodeId(4), with_children(Role::Cell, &[5])),
(NodeId(5), label("cell text")),
];
name(&mut nodes);
assert!(named(&nodes, 4).is_empty());
}
#[test]
fn a_label_names_a_cell_by_its_value_only_as_the_consumer_reads_it() {
let mut by_label = Node::new(Role::Label);
by_label.set_label("not read");
let mut nodes = vec![
(NodeId(1), with_children(Role::Row, &[2, 4])),
(NodeId(2), with_children(Role::Cell, &[3])),
(NodeId(3), by_label),
(NodeId(4), with_children(Role::Cell, &[5])),
(NodeId(5), label("read")),
];
name(&mut nodes);
assert!(named(&nodes, 2).is_empty(), "a label() alone names nothing");
assert_eq!(named(&nodes, 4), vec![NodeId(5)]);
}
#[test]
fn a_cell_beside_a_text_surface_is_still_named() {
let mut nodes = vec![
(NodeId(1), with_children(Role::GenericContainer, &[2, 6])),
(NodeId(2), with_children(Role::Document, &[3])),
(NodeId(3), with_children(Role::Table, &[4])),
(NodeId(4), with_children(Role::Cell, &[5])),
(NodeId(5), label("in the document")),
(NodeId(6), with_children(Role::Grid, &[7])),
(NodeId(7), with_children(Role::GridCell, &[8])),
(NodeId(8), label("in the grid")),
];
name(&mut nodes);
assert!(named(&nodes, 4).is_empty());
assert_eq!(named(&nodes, 7), vec![NodeId(8)]);
}
fn table_tree() -> (crate::widget_tree::WidgetTree, crate::widget_id::WidgetId) {
use crate::test_widgets::{FillWidget, StackWidget};
use crate::widget_builder::WidgetBuilder;
let mut tree = crate::widget_tree::WidgetTree::new();
let name = tree.add(
FillWidget::new()
.access_role(Role::Label)
.access_value("assets"),
);
let count = tree.add(
FillWidget::new()
.access_role(Role::Label)
.access_value("24 entries"),
);
let cell = tree.add(
StackWidget::new()
.child(name)
.child(count)
.access_role(Role::Cell),
);
let row = tree.add(StackWidget::new().child(cell).access_role(Role::Row));
tree.add(StackWidget::new().child(row).access_role(Role::Table));
tree.layout(teksilo_canvas::SizeProposal::exact(200.0, 100.0));
(tree, cell)
}
fn cell_labelled_by(on: bool) -> Vec<NodeId> {
let _shape = CellNamingOverride::new(on);
let (mut tree, cell) = table_tree();
let id = crate::accessibility::widget_id_to_node_id(cell);
tree.sync_accessibility()
.nodes
.iter()
.find(|(n, _)| *n == id)
.map(|(_, n)| n.labelled_by().to_vec())
.expect("the cell is emitted")
}
#[test]
fn on_windows_and_linux_a_cell_is_named_by_its_labels() {
assert_eq!(cell_labelled_by(true).len(), 2);
}
#[test]
fn on_macos_a_cell_is_left_to_its_children() {
assert!(cell_labelled_by(false).is_empty());
}
#[test]
fn the_platform_shape_is_off_on_macos_only() {
assert_eq!(names_cells_from_content(), !cfg!(target_os = "macos"));
}
}