From 76b992695e98b1738f608feffde23965985975cb Mon Sep 17 00:00:00 2001 From: Ritesh Date: Fri, 4 Sep 2026 15:50:37 +0000 Subject: [PATCH] feat(unix): implement AT-SPI Table and TableCell interfaces --- adapters/atspi-common/src/error.rs | 2 +- adapters/atspi-common/src/node.rs | 657 ++++++++++++++++++ adapters/atspi-common/src/util.rs | 151 +++- adapters/unix/src/atspi/bus.rs | 11 + adapters/unix/src/atspi/interfaces/mod.rs | 4 + adapters/unix/src/atspi/interfaces/table.rs | 328 +++++++++ .../unix/src/atspi/interfaces/table_cell.rs | 58 ++ 7 files changed, 1207 insertions(+), 4 deletions(-) create mode 100644 adapters/unix/src/atspi/interfaces/table.rs create mode 100644 adapters/unix/src/atspi/interfaces/table_cell.rs diff --git a/adapters/atspi-common/src/error.rs b/adapters/atspi-common/src/error.rs index b61290324..81aed5c6f 100644 --- a/adapters/atspi-common/src/error.rs +++ b/adapters/atspi-common/src/error.rs @@ -5,7 +5,7 @@ use std::fmt; -#[derive(Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Error { Defunct, UnsupportedInterface, diff --git a/adapters/atspi-common/src/node.rs b/adapters/atspi-common/src/node.rs index a05062649..02b35c1dd 100644 --- a/adapters/atspi-common/src/node.rs +++ b/adapters/atspi-common/src/node.rs @@ -485,6 +485,14 @@ impl NodeWrapper<'_> { self.0.is_container_with_selectable_children() } + fn supports_table(&self) -> bool { + matches!(self.0.role(), Role::Table | Role::Grid | Role::ListGrid) + } + + fn supports_table_cell(&self) -> bool { + matches!(self.0.role(), Role::Cell | Role::GridCell) + } + fn supports_text(&self) -> bool { self.0.supports_text_ranges() } @@ -513,6 +521,12 @@ impl NodeWrapper<'_> { if self.supports_selection() { interfaces.insert(Interface::Selection); } + if self.supports_table() { + interfaces.insert(Interface::Table); + } + if self.supports_table_cell() { + interfaces.insert(Interface::TableCell); + } if self.supports_text() { interfaces.insert(Interface::Text); } @@ -783,6 +797,41 @@ impl PlatformNode { }) } + fn resolve_for_table_with_context(&self, f: F) -> Result + where + for<'a> F: FnOnce(NodeRef<'a>, &'a Tree, &Context) -> Result, + { + self.resolve_with_context(|node, tree, context| { + let wrapper = NodeWrapper(&node); + if wrapper.supports_table() { + f(node, tree, context) + } else { + Err(Error::UnsupportedInterface) + } + }) + } + + fn resolve_for_table(&self, f: F) -> Result + where + for<'a> F: FnOnce(NodeRef<'a>) -> Result, + { + self.resolve_for_table_with_context(|node, _, _| f(node)) + } + + fn resolve_for_table_cell(&self, f: F) -> Result + where + for<'a> F: FnOnce(NodeRef<'a>) -> Result, + { + self.resolve(|node| { + let wrapper = NodeWrapper(&node); + if wrapper.supports_table_cell() { + f(node) + } else { + Err(Error::UnsupportedInterface) + } + }) + } + fn resolve(&self, f: F) -> Result where for<'a> F: FnOnce(NodeRef<'a>) -> Result, @@ -1380,6 +1429,425 @@ impl PlatformNode { }) } + pub fn table_row_count(&self) -> Result { + self.resolve_for_table(|node| { + i32::try_from(table_grid(&node).len()).map_err(|_| Error::TooManyChildren) + }) + } + + pub fn table_column_count(&self) -> Result { + self.resolve_for_table(|node| { + i32::try_from(table_column_count(&table_grid(&node))) + .map_err(|_| Error::TooManyChildren) + }) + } + + pub fn table_caption(&self) -> Result> { + self.resolve_for_table(|node| { + Ok(node + .filtered_children(filter) + .find(|child| child.role() == Role::Caption) + .map(|child| child.id())) + }) + } + + pub fn table_summary(&self) -> Result { + self.resolve_for_table(|node| { + let wrapper = NodeWrapper(&node); + Ok(wrapper.description().unwrap_or_default()) + }) + } + + pub fn table_accessible_at(&self, row: i32, column: i32) -> Result> { + self.resolve_for_table(|node| { + let (Ok(row), Ok(column)) = (usize::try_from(row), usize::try_from(column)) else { + return Ok(None); + }; + Ok(table_grid_cell(&table_grid(&node), row, column).map(|cell| cell.id())) + }) + } + + pub fn table_index_at(&self, row: i32, column: i32) -> Result { + self.resolve_for_table(|node| { + let (Ok(row), Ok(column)) = (usize::try_from(row), usize::try_from(column)) else { + return Ok(-1); + }; + let grid = table_grid(&node); + let columns = table_column_count(&grid); + if columns == 0 || table_grid_cell(&grid, row, column).is_none() { + return Ok(-1); + } + i32::try_from(row * columns + column).map_err(|_| Error::TooManyChildren) + }) + } + + pub fn table_row_at_index(&self, index: i32) -> Result { + self.resolve_for_table(|node| { + let Ok(index) = usize::try_from(index) else { + return Ok(-1); + }; + let grid = table_grid(&node); + let columns = table_column_count(&grid); + if columns == 0 { + return Ok(-1); + } + let row = index / columns; + if row >= grid.len() { + return Ok(-1); + } + i32::try_from(row).map_err(|_| Error::TooManyChildren) + }) + } + + pub fn table_column_at_index(&self, index: i32) -> Result { + self.resolve_for_table(|node| { + let Ok(index) = usize::try_from(index) else { + return Ok(-1); + }; + let grid = table_grid(&node); + let columns = table_column_count(&grid); + if columns == 0 { + return Ok(-1); + } + let row = index / columns; + if row >= grid.len() { + return Ok(-1); + } + i32::try_from(index % columns).map_err(|_| Error::TooManyChildren) + }) + } + + pub fn table_row_description(&self, row: i32) -> Result { + self.resolve_for_table(|node| { + let Ok(row) = usize::try_from(row) else { + return Ok(String::new()); + }; + let grid = table_grid(&node); + Ok(table_row_cells(&grid, row) + .find(|cell| cell.role() == Role::RowHeader) + .and_then(|cell| cell.label()) + .unwrap_or_default()) + }) + } + + pub fn table_column_description(&self, column: i32) -> Result { + self.resolve_for_table(|node| { + let Ok(column) = usize::try_from(column) else { + return Ok(String::new()); + }; + let grid = table_grid(&node); + Ok(table_column_cells(&grid, column) + .find(|cell| cell.role() == Role::ColumnHeader) + .and_then(|cell| cell.label()) + .unwrap_or_default()) + }) + } + + pub fn table_row_extent_at(&self, row: i32, column: i32) -> Result { + self.resolve_for_table(|node| { + let (Ok(row), Ok(column)) = (usize::try_from(row), usize::try_from(column)) else { + return Ok(1); + }; + let span = table_grid_cell(&table_grid(&node), row, column) + .and_then(|cell| cell.data().row_span()) + .unwrap_or(1); + i32::try_from(span).map_err(|_| Error::TooManyChildren) + }) + } + + pub fn table_column_extent_at(&self, row: i32, column: i32) -> Result { + self.resolve_for_table(|node| { + let (Ok(row), Ok(column)) = (usize::try_from(row), usize::try_from(column)) else { + return Ok(1); + }; + let span = table_grid_cell(&table_grid(&node), row, column) + .and_then(|cell| cell.data().column_span()) + .unwrap_or(1); + i32::try_from(span).map_err(|_| Error::TooManyChildren) + }) + } + + pub fn table_row_header(&self, row: i32) -> Result> { + self.resolve_for_table(|node| { + let Ok(row) = usize::try_from(row) else { + return Ok(None); + }; + let grid = table_grid(&node); + Ok(table_row_cells(&grid, row) + .find(|cell| cell.role() == Role::RowHeader) + .map(|cell| cell.id())) + }) + } + + pub fn table_column_header(&self, column: i32) -> Result> { + self.resolve_for_table(|node| { + let Ok(column) = usize::try_from(column) else { + return Ok(None); + }; + let grid = table_grid(&node); + Ok(table_column_cells(&grid, column) + .find(|cell| cell.role() == Role::ColumnHeader) + .map(|cell| cell.id())) + }) + } + + pub fn table_is_selected(&self, row: i32, column: i32) -> Result { + self.resolve_for_table(|node| { + let (Ok(row), Ok(column)) = (usize::try_from(row), usize::try_from(column)) else { + return Ok(false); + }; + Ok(table_grid_cell(&table_grid(&node), row, column) + .and_then(|cell| cell.is_selected()) + .unwrap_or(false)) + }) + } + + pub fn table_is_row_selected(&self, row: i32) -> Result { + self.resolve_for_table(|node| { + let Ok(row) = usize::try_from(row) else { + return Ok(false); + }; + Ok(table_row_is_selected(&table_grid(&node), row)) + }) + } + + pub fn table_is_column_selected(&self, column: i32) -> Result { + self.resolve_for_table(|node| { + let Ok(column) = usize::try_from(column) else { + return Ok(false); + }; + Ok(table_column_is_selected(&table_grid(&node), column)) + }) + } + + pub fn table_selected_rows(&self) -> Result> { + self.resolve_for_table(|node| { + let grid = table_grid(&node); + (0..grid.len()) + .filter(|&row| table_row_is_selected(&grid, row)) + .map(|row| i32::try_from(row).map_err(|_| Error::TooManyChildren)) + .collect() + }) + } + + pub fn table_selected_columns(&self) -> Result> { + self.resolve_for_table(|node| { + let grid = table_grid(&node); + let columns = table_column_count(&grid); + (0..columns) + .filter(|&column| table_column_is_selected(&grid, column)) + .map(|column| i32::try_from(column).map_err(|_| Error::TooManyChildren)) + .collect() + }) + } + + pub fn table_n_selected_rows(&self) -> Result { + i32::try_from(self.table_selected_rows()?.len()).map_err(|_| Error::TooManyChildren) + } + + pub fn table_n_selected_columns(&self) -> Result { + i32::try_from(self.table_selected_columns()?.len()).map_err(|_| Error::TooManyChildren) + } + + pub fn table_row_column_extents_at_index( + &self, + index: i32, + ) -> Result<(bool, i32, i32, i32, i32, bool)> { + self.resolve_for_table(|node| { + let Ok(index) = usize::try_from(index) else { + return Ok((false, -1, -1, 0, 0, false)); + }; + let grid = table_grid(&node); + let columns = table_column_count(&grid); + if columns == 0 { + return Ok((false, -1, -1, 0, 0, false)); + } + let row = index / columns; + let column = index % columns; + let Some(cell) = table_grid_cell(&grid, row, column) else { + return Ok((false, -1, -1, 0, 0, false)); + }; + let row_span = cell.data().row_span().unwrap_or(1); + let column_span = cell.data().column_span().unwrap_or(1); + Ok(( + true, + i32::try_from(row).map_err(|_| Error::TooManyChildren)?, + i32::try_from(column).map_err(|_| Error::TooManyChildren)?, + i32::try_from(row_span).map_err(|_| Error::TooManyChildren)?, + i32::try_from(column_span).map_err(|_| Error::TooManyChildren)?, + cell.is_selected().unwrap_or(false), + )) + }) + } + + /// Clicks every unselected, selectable cell in `row`. + pub fn table_add_row_selection(&self, row: i32) -> Result { + self.resolve_for_table_with_context(|node, tree, context| { + let Ok(row) = usize::try_from(row) else { + return Ok(false); + }; + let grid = table_grid(&node); + let Some(cells) = grid.get(row) else { + return Ok(false); + }; + let mut changed = false; + for cell in cells + .iter() + .flatten() + .filter(|cell| matches!(cell.role(), Role::Cell | Role::GridCell)) + { + match cell.is_selected() { + Some(true) => changed = true, + _ if cell.is_selectable() && cell.is_clickable(&filter) => { + let (target_node, target_tree) = + tree.state().locate_node(cell.id()).ok_or(Error::Defunct)?; + context.do_action(ActionRequest { + action: Action::Click, + target_tree, + target_node, + data: None, + }); + changed = true; + } + _ => {} + } + } + Ok(changed) + }) + } + + pub fn table_remove_row_selection(&self, row: i32) -> Result { + self.resolve_for_table_with_context(|node, tree, context| { + let Ok(row) = usize::try_from(row) else { + return Ok(false); + }; + let grid = table_grid(&node); + let Some(cells) = grid.get(row) else { + return Ok(false); + }; + let mut changed = false; + for cell in cells + .iter() + .flatten() + .filter(|cell| matches!(cell.role(), Role::Cell | Role::GridCell)) + { + if cell.is_selected() == Some(true) && cell.is_clickable(&filter) { + let (target_node, target_tree) = + tree.state().locate_node(cell.id()).ok_or(Error::Defunct)?; + context.do_action(ActionRequest { + action: Action::Click, + target_tree, + target_node, + data: None, + }); + changed = true; + } + } + Ok(changed) + }) + } + + pub fn table_add_column_selection(&self, column: i32) -> Result { + self.resolve_for_table_with_context(|node, tree, context| { + let Ok(column) = usize::try_from(column) else { + return Ok(false); + }; + let grid = table_grid(&node); + if column >= table_column_count(&grid) { + return Ok(false); + } + let mut changed = false; + for cell in table_column_cells(&grid, column) + .filter(|cell| matches!(cell.role(), Role::Cell | Role::GridCell)) + { + match cell.is_selected() { + Some(true) => changed = true, + _ if cell.is_selectable() && cell.is_clickable(&filter) => { + let (target_node, target_tree) = + tree.state().locate_node(cell.id()).ok_or(Error::Defunct)?; + context.do_action(ActionRequest { + action: Action::Click, + target_tree, + target_node, + data: None, + }); + changed = true; + } + _ => {} + } + } + Ok(changed) + }) + } + + pub fn table_remove_column_selection(&self, column: i32) -> Result { + self.resolve_for_table_with_context(|node, tree, context| { + let Ok(column) = usize::try_from(column) else { + return Ok(false); + }; + let grid = table_grid(&node); + if column >= table_column_count(&grid) { + return Ok(false); + } + let mut changed = false; + for cell in table_column_cells(&grid, column) + .filter(|cell| matches!(cell.role(), Role::Cell | Role::GridCell)) + { + if cell.is_selected() == Some(true) && cell.is_clickable(&filter) { + let (target_node, target_tree) = + tree.state().locate_node(cell.id()).ok_or(Error::Defunct)?; + context.do_action(ActionRequest { + action: Action::Click, + target_tree, + target_node, + data: None, + }); + changed = true; + } + } + Ok(changed) + }) + } + + pub fn table_cell_position(&self) -> Result<(i32, i32)> { + self.resolve_for_table_cell(|node| { + let Some(table) = find_table_ancestor(&node) else { + return Ok((-1, -1)); + }; + let Some((row, column)) = find_cell_position(&table_grid(&table), node.id()) else { + return Ok((-1, -1)); + }; + Ok(( + i32::try_from(row).map_err(|_| Error::TooManyChildren)?, + i32::try_from(column).map_err(|_| Error::TooManyChildren)?, + )) + }) + } + + pub fn table_cell_row_column_span(&self) -> Result<(bool, i32, i32, i32, i32)> { + self.resolve_for_table_cell(|node| { + let Some(table) = find_table_ancestor(&node) else { + return Ok((false, -1, -1, 0, 0)); + }; + let Some((row, column)) = find_cell_position(&table_grid(&table), node.id()) else { + return Ok((false, -1, -1, 0, 0)); + }; + let row_span = node.data().row_span().unwrap_or(1); + let column_span = node.data().column_span().unwrap_or(1); + Ok(( + true, + i32::try_from(row).map_err(|_| Error::TooManyChildren)?, + i32::try_from(column).map_err(|_| Error::TooManyChildren)?, + i32::try_from(row_span).map_err(|_| Error::TooManyChildren)?, + i32::try_from(column_span).map_err(|_| Error::TooManyChildren)?, + )) + }) + } + + pub fn table_cell_table(&self) -> Result> { + self.resolve_for_table_cell(|node| Ok(find_table_ancestor(&node).map(|table| table.id()))) + } + pub fn character_count(&self) -> Result { self.resolve_for_text(|node| { node.document_range() @@ -2031,3 +2499,192 @@ pub struct CacheNode { pub role: AtspiRole, pub states: StateSet, } + +#[cfg(test)] +mod table_tests { + use super::*; + use crate::{AdapterCallback, Event, adapter::Adapter, context::AppContext}; + use accesskit::{ActionHandler, Node, TreeInfo, TreeUpdate}; + use std::sync::{Arc, Mutex}; + + const WINDOW: NodeId = NodeId(0); + const TABLE: NodeId = NodeId(1); + const HEADER_ROW: NodeId = NodeId(2); + const NAME_HEADER: NodeId = NodeId(3); + const STATUS_HEADER: NodeId = NodeId(4); + const DATA_ROW: NodeId = NodeId(5); + const CELL_A: NodeId = NodeId(6); + const CELL_DONE: NodeId = NodeId(7); + + struct NoOpCallback; + + impl AdapterCallback for NoOpCallback { + fn register_interfaces(&self, _: &Adapter, _: FullNodeId, _: InterfaceSet) {} + fn unregister_interfaces(&self, _: &Adapter, _: FullNodeId, _: InterfaceSet) {} + fn emit_event(&self, _: &Adapter, _: Event) {} + } + + struct CapturingActionHandler { + requests: Arc>>, + } + + impl ActionHandler for CapturingActionHandler { + fn do_action(&mut self, request: ActionRequest) { + self.requests.lock().unwrap().push(request); + } + } + + fn with_children(role: Role, children: &[NodeId]) -> Node { + let mut node = Node::new(role); + node.set_children(children.to_vec()); + node + } + + fn build_table_tree() -> (Adapter, Arc>>) { + let requests = Arc::new(Mutex::new(Vec::new())); + let app_context = AppContext::new(None); + + let mut name_header = Node::new(Role::ColumnHeader); + name_header.set_label("Name"); + let mut status_header = Node::new(Role::ColumnHeader); + status_header.set_label("Status"); + + let mut cell_a = Node::new(Role::Cell); + cell_a.set_label("a"); + cell_a.set_selected(false); + cell_a.add_action(Action::Click); + let mut cell_done = Node::new(Role::Cell); + cell_done.set_label("done"); + cell_done.set_selected(true); + cell_done.add_action(Action::Click); + + let initial = TreeUpdate { + nodes: vec![ + (WINDOW, with_children(Role::Window, &[TABLE])), + (TABLE, with_children(Role::Table, &[HEADER_ROW, DATA_ROW])), + ( + HEADER_ROW, + with_children(Role::Row, &[NAME_HEADER, STATUS_HEADER]), + ), + (NAME_HEADER, name_header), + (STATUS_HEADER, status_header), + (DATA_ROW, with_children(Role::Row, &[CELL_A, CELL_DONE])), + (CELL_A, cell_a), + (CELL_DONE, cell_done), + ], + tree: Some(TreeInfo::new(WINDOW)), + tree_id: TreeId::ROOT, + focus: WINDOW, + }; + let adapter = Adapter::new( + &app_context, + NoOpCallback, + initial, + false, + WindowBounds::default(), + CapturingActionHandler { + requests: requests.clone(), + }, + ); + (adapter, requests) + } + + /// Walks from the tree root through `filtered_children` indices, e.g. + /// `&[0, 1]` is the table's second header/data row's... first child. + /// `child_at_index` (public on `PlatformNode`) is the only way to get a + /// `FullNodeId` for a test-built tree from outside `accesskit_consumer`. + fn node_at(adapter: &Adapter, path: &[usize]) -> PlatformNode { + let mut id = adapter.root_id(); + for &index in path { + id = adapter + .platform_node(id) + .child_at_index(index) + .unwrap() + .unwrap(); + } + adapter.platform_node(id) + } + + #[test] + fn table_row_and_column_counts_match_live_children() { + let (adapter, _) = build_table_tree(); + let table = node_at(&adapter, &[0]); + assert_eq!(table.table_row_count(), Ok(2)); + assert_eq!(table.table_column_count(), Ok(2)); + } + + #[test] + fn only_table_like_roles_support_the_table_interface() { + let (adapter, _) = build_table_tree(); + let table = node_at(&adapter, &[0]); + let window = node_at(&adapter, &[]); + assert!(table.table_row_count().is_ok()); + assert_eq!(window.table_row_count(), Err(Error::UnsupportedInterface)); + } + + #[test] + fn only_cell_like_roles_support_the_table_cell_interface() { + let (adapter, _) = build_table_tree(); + let cell = node_at(&adapter, &[0, 1, 0]); + let row = node_at(&adapter, &[0, 1]); + assert!(cell.table_cell_position().is_ok()); + assert_eq!(row.table_cell_position(), Err(Error::UnsupportedInterface)); + } + + #[test] + fn accessible_at_and_index_at_round_trip() { + let (adapter, _) = build_table_tree(); + let table = node_at(&adapter, &[0]); + for row in 0..2 { + for column in 0..2 { + let index = table.table_index_at(row, column).unwrap(); + assert_ne!(index, -1); + assert_eq!(table.table_row_at_index(index), Ok(row)); + assert_eq!(table.table_column_at_index(index), Ok(column)); + } + } + let cell = table.table_accessible_at(1, 1).unwrap(); + assert_eq!(cell, Some(node_at(&adapter, &[0, 1, 1]).id())); + } + + #[test] + fn table_cell_get_table_returns_the_real_ancestor() { + let (adapter, _) = build_table_tree(); + let cell = node_at(&adapter, &[0, 1, 1]); + let table = node_at(&adapter, &[0]); + assert_eq!(cell.table_cell_table(), Ok(Some(table.id()))); + assert_eq!(cell.table_cell_position(), Ok((1, 1))); + } + + #[test] + fn row_and_column_headers_and_descriptions_use_header_cells() { + let (adapter, _) = build_table_tree(); + let table = node_at(&adapter, &[0]); + let name_header = node_at(&adapter, &[0, 0, 0]); + assert_eq!(table.table_column_header(0), Ok(Some(name_header.id()))); + assert_eq!(table.table_column_description(1), Ok("Status".into())); + assert_eq!(table.table_row_header(1), Ok(None)); + assert_eq!(table.table_row_description(1), Ok(String::new())); + } + + #[test] + fn selection_queries_reflect_live_cell_state() { + let (adapter, _) = build_table_tree(); + let table = node_at(&adapter, &[0]); + assert_eq!(table.table_is_selected(1, 1), Ok(true)); + assert_eq!(table.table_is_selected(1, 0), Ok(false)); + assert_eq!(table.table_selected_rows(), Ok(vec![])); + assert_eq!(table.table_selected_columns(), Ok(vec![1])); + assert_eq!(table.table_n_selected_columns(), Ok(1)); + } + + #[test] + fn add_row_selection_clicks_unselected_clickable_cells() { + let (adapter, requests) = build_table_tree(); + let table = node_at(&adapter, &[0]); + assert_eq!(table.table_add_row_selection(1), Ok(true)); + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].action, Action::Click); + } +} diff --git a/adapters/atspi-common/src/util.rs b/adapters/atspi-common/src/util.rs index 740f50e2b..2e596f775 100644 --- a/adapters/atspi-common/src/util.rs +++ b/adapters/atspi-common/src/util.rs @@ -3,11 +3,11 @@ // the LICENSE-APACHE file) or the MIT license (found in // the LICENSE-MIT file), at your option. -use accesskit::{Point, Rect, ScrollHint}; -use accesskit_consumer::{NodeRef, TextPosition, TextRange}; +use accesskit::{Point, Rect, Role, ScrollHint}; +use accesskit_consumer::{FullNodeId, NodeRef, TextPosition, TextRange}; use atspi_common::{CoordType, Granularity, ScrollType}; -use crate::Error; +use crate::{Error, filters::filter}; #[derive(Clone, Copy, Default, Debug)] pub struct WindowBounds { @@ -132,3 +132,148 @@ pub(crate) fn atspi_scroll_type_to_scroll_hint(scroll_type: ScrollType) -> Optio ScrollType::Anywhere => None, } } + +fn is_table_cell_role(role: Role) -> bool { + matches!( + role, + Role::Cell | Role::GridCell | Role::RowHeader | Role::ColumnHeader + ) +} + +fn collect_table_rows<'a>(node: &NodeRef<'a>, rows: &mut Vec>) { + for child in node.filtered_children(filter) { + match child.role() { + Role::Row => rows.push(child), + Role::RowGroup => collect_table_rows(&child, rows), + _ => {} + } + } +} + +fn collect_table_cells<'a>(node: &NodeRef<'a>, cells: &mut Vec>) { + for child in node.filtered_children(filter) { + if is_table_cell_role(child.role()) { + cells.push(child); + } else { + collect_table_cells(&child, cells); + } + } +} + +/// `grid[row][col]` for a `Table`/`Grid`/`ListGrid` node. Built from +/// `Role::Row` children, the shape every known accesskit consumer produces. +/// Falls back to `row_index`/`column_index` on descendant cells for +/// ARIA-grid-style tables that have no `Row` children at all. +pub(crate) fn table_grid<'a>(table: &NodeRef<'a>) -> Vec>>> { + let mut rows = Vec::new(); + collect_table_rows(table, &mut rows); + if !rows.is_empty() { + return rows + .into_iter() + .map(|row| { + row.filtered_children(filter) + .filter(|cell| is_table_cell_role(cell.role())) + .map(Some) + .collect() + }) + .collect(); + } + + let mut cells = Vec::new(); + collect_table_cells(table, &mut cells); + let mut grid: Vec>>> = Vec::new(); + for cell in cells { + let (Some(row), Some(column)) = (cell.data().row_index(), cell.data().column_index()) + else { + continue; + }; + if grid.len() <= row { + grid.resize_with(row + 1, Vec::new); + } + if grid[row].len() <= column { + grid[row].resize(column + 1, None); + } + grid[row][column] = Some(cell); + } + grid +} + +pub(crate) fn table_column_count(grid: &[Vec>]) -> usize { + grid.iter().map(Vec::len).max().unwrap_or(0) +} + +pub(crate) fn table_grid_cell<'a, 'g>( + grid: &'g [Vec>>], + row: usize, + column: usize, +) -> Option<&'g NodeRef<'a>> { + grid.get(row)?.get(column)?.as_ref() +} + +pub(crate) fn table_row_cells<'a, 'g>( + grid: &'g [Vec>>], + row: usize, +) -> impl Iterator> { + grid.get(row) + .into_iter() + .flat_map(|row| row.iter().filter_map(Option::as_ref)) +} + +pub(crate) fn table_column_cells<'a, 'g>( + grid: &'g [Vec>>], + column: usize, +) -> impl Iterator> { + grid.iter() + .filter_map(move |row| row.get(column).and_then(Option::as_ref)) +} + +fn is_data_cell_role(role: Role) -> bool { + matches!(role, Role::Cell | Role::GridCell) +} + +pub(crate) fn table_row_is_selected(grid: &[Vec>], row: usize) -> bool { + let mut any = false; + for cell in table_row_cells(grid, row).filter(|cell| is_data_cell_role(cell.role())) { + any = true; + if cell.is_selected() != Some(true) { + return false; + } + } + any +} + +pub(crate) fn table_column_is_selected(grid: &[Vec>], column: usize) -> bool { + let mut any = false; + for cell in table_column_cells(grid, column).filter(|cell| is_data_cell_role(cell.role())) { + any = true; + if cell.is_selected() != Some(true) { + return false; + } + } + any +} + +pub(crate) fn find_table_ancestor<'a>(node: &NodeRef<'a>) -> Option> { + let mut current = node.filtered_parent(&filter); + while let Some(candidate) = current { + if matches!(candidate.role(), Role::Table | Role::Grid | Role::ListGrid) { + return Some(candidate); + } + current = candidate.filtered_parent(&filter); + } + None +} + +pub(crate) fn find_cell_position( + grid: &[Vec>], + id: FullNodeId, +) -> Option<(usize, usize)> { + for (row, cells) in grid.iter().enumerate() { + for (column, cell) in cells.iter().enumerate() { + if cell.as_ref().is_some_and(|cell| cell.id() == id) { + return Some((row, column)); + } + } + } + None +} diff --git a/adapters/unix/src/atspi/bus.rs b/adapters/unix/src/atspi/bus.rs index 9375ff5eb..9e1d3eabc 100644 --- a/adapters/unix/src/atspi/bus.rs +++ b/adapters/unix/src/atspi/bus.rs @@ -164,6 +164,17 @@ impl Bus { ) .await?; } + if new_interfaces.contains(Interface::Table) { + self.register_interface(&path, TableInterface::new(bus_name.clone(), node.clone())) + .await?; + } + if new_interfaces.contains(Interface::TableCell) { + self.register_interface( + &path, + TableCellInterface::new(bus_name.clone(), node.clone()), + ) + .await?; + } if new_interfaces.contains(Interface::Text) { self.register_interface(&path, TextInterface::new(node.clone())) .await?; diff --git a/adapters/unix/src/atspi/interfaces/mod.rs b/adapters/unix/src/atspi/interfaces/mod.rs index 99bdbd294..b13e50b9f 100644 --- a/adapters/unix/src/atspi/interfaces/mod.rs +++ b/adapters/unix/src/atspi/interfaces/mod.rs @@ -12,6 +12,8 @@ mod document; mod editable_text; mod hyperlink; mod selection; +mod table; +mod table_cell; mod text; mod value; @@ -41,5 +43,7 @@ pub(crate) use document::*; pub(crate) use editable_text::*; pub(crate) use hyperlink::*; pub(crate) use selection::*; +pub(crate) use table::*; +pub(crate) use table_cell::*; pub(crate) use text::*; pub(crate) use value::*; diff --git a/adapters/unix/src/atspi/interfaces/table.rs b/adapters/unix/src/atspi/interfaces/table.rs new file mode 100644 index 000000000..58f586796 --- /dev/null +++ b/adapters/unix/src/atspi/interfaces/table.rs @@ -0,0 +1,328 @@ +// Copyright 2026 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +use accesskit_atspi_common::PlatformNode; +use zbus::{fdo, interface, names::OwnedUniqueName}; + +use crate::atspi::{ObjectId, OwnedObjectAddress}; + +pub(crate) struct TableInterface { + bus_name: OwnedUniqueName, + node: PlatformNode, +} + +impl TableInterface { + pub fn new(bus_name: OwnedUniqueName, node: PlatformNode) -> Self { + Self { bus_name, node } + } + + fn map_error(&self) -> impl '_ + FnOnce(accesskit_atspi_common::Error) -> fdo::Error { + |error| crate::util::map_error_from_node(&self.node, error) + } + + fn object_address(&self, id: Option) -> OwnedObjectAddress { + let id = id.map(|node| ObjectId::Node { + adapter: self.node.adapter_id(), + node, + }); + super::optional_object_address(&self.bus_name, id).0 + } +} + +#[interface(name = "org.a11y.atspi.Table")] +impl TableInterface { + #[zbus(property)] + fn n_rows(&self) -> fdo::Result { + self.node.table_row_count().map_err(self.map_error()) + } + + #[zbus(property)] + fn n_columns(&self) -> fdo::Result { + self.node.table_column_count().map_err(self.map_error()) + } + + #[zbus(property)] + fn caption(&self) -> fdo::Result<(OwnedObjectAddress,)> { + let caption = self.node.table_caption().map_err(self.map_error())?; + Ok((self.object_address(caption),)) + } + + #[zbus(property)] + fn summary(&self) -> fdo::Result { + self.node.table_summary().map_err(self.map_error()) + } + + #[zbus(property)] + fn n_selected_rows(&self) -> fdo::Result { + self.node.table_n_selected_rows().map_err(self.map_error()) + } + + #[zbus(property)] + fn n_selected_columns(&self) -> fdo::Result { + self.node + .table_n_selected_columns() + .map_err(self.map_error()) + } + + fn get_accessible_at(&self, row: i32, column: i32) -> fdo::Result<(OwnedObjectAddress,)> { + let cell = self + .node + .table_accessible_at(row, column) + .map_err(self.map_error())?; + Ok((self.object_address(cell),)) + } + + fn get_index_at(&self, row: i32, column: i32) -> fdo::Result { + self.node + .table_index_at(row, column) + .map_err(self.map_error()) + } + + fn get_row_at_index(&self, index: i32) -> fdo::Result { + self.node + .table_row_at_index(index) + .map_err(self.map_error()) + } + + fn get_column_at_index(&self, index: i32) -> fdo::Result { + self.node + .table_column_at_index(index) + .map_err(self.map_error()) + } + + fn get_row_description(&self, row: i32) -> fdo::Result { + self.node + .table_row_description(row) + .map_err(self.map_error()) + } + + fn get_column_description(&self, column: i32) -> fdo::Result { + self.node + .table_column_description(column) + .map_err(self.map_error()) + } + + fn get_row_extent_at(&self, row: i32, column: i32) -> fdo::Result { + self.node + .table_row_extent_at(row, column) + .map_err(self.map_error()) + } + + fn get_column_extent_at(&self, row: i32, column: i32) -> fdo::Result { + self.node + .table_column_extent_at(row, column) + .map_err(self.map_error()) + } + + fn get_row_header(&self, row: i32) -> fdo::Result<(OwnedObjectAddress,)> { + let header = self.node.table_row_header(row).map_err(self.map_error())?; + Ok((self.object_address(header),)) + } + + fn get_column_header(&self, column: i32) -> fdo::Result<(OwnedObjectAddress,)> { + let header = self + .node + .table_column_header(column) + .map_err(self.map_error())?; + Ok((self.object_address(header),)) + } + + fn get_selected_rows(&self) -> fdo::Result> { + self.node.table_selected_rows().map_err(self.map_error()) + } + + fn get_selected_columns(&self) -> fdo::Result> { + self.node.table_selected_columns().map_err(self.map_error()) + } + + fn is_row_selected(&self, row: i32) -> fdo::Result { + self.node + .table_is_row_selected(row) + .map_err(self.map_error()) + } + + fn is_column_selected(&self, column: i32) -> fdo::Result { + self.node + .table_is_column_selected(column) + .map_err(self.map_error()) + } + + fn is_selected(&self, row: i32, column: i32) -> fdo::Result { + self.node + .table_is_selected(row, column) + .map_err(self.map_error()) + } + + fn add_row_selection(&self, row: i32) -> fdo::Result { + self.node + .table_add_row_selection(row) + .map_err(self.map_error()) + } + + fn add_column_selection(&self, column: i32) -> fdo::Result { + self.node + .table_add_column_selection(column) + .map_err(self.map_error()) + } + + fn remove_row_selection(&self, row: i32) -> fdo::Result { + self.node + .table_remove_row_selection(row) + .map_err(self.map_error()) + } + + fn remove_column_selection(&self, column: i32) -> fdo::Result { + self.node + .table_remove_column_selection(column) + .map_err(self.map_error()) + } + + fn get_row_column_extents_at_index( + &self, + index: i32, + ) -> fdo::Result<(bool, i32, i32, i32, i32, bool)> { + self.node + .table_row_column_extents_at_index(index) + .map_err(self.map_error()) + } +} + +#[cfg(test)] +mod tests { + use super::TableInterface; + use crate::atspi::ObjectId; + use accesskit::{ + ActionHandler, ActionRequest, Node, NodeId, Role, TreeId, TreeInfo, TreeUpdate, + }; + use accesskit_atspi_common::{ + Adapter, AdapterCallback, AppContext, Event, FullNodeId, PlatformNode, WindowBounds, + }; + use zbus::names::{OwnedUniqueName, UniqueName}; + + struct NoOpActionHandler; + impl ActionHandler for NoOpActionHandler { + fn do_action(&mut self, _request: ActionRequest) {} + } + + struct NoOpCallback; + impl AdapterCallback for NoOpCallback { + fn register_interfaces(&self, _: &Adapter, _: FullNodeId, _: atspi::InterfaceSet) {} + fn unregister_interfaces(&self, _: &Adapter, _: FullNodeId, _: atspi::InterfaceSet) {} + fn emit_event(&self, _: &Adapter, _: Event) {} + } + + fn with_children(role: Role, children: &[NodeId]) -> Node { + let mut node = Node::new(role); + node.set_children(children.to_vec()); + node + } + + const BUS_NAME: &str = ":1.0"; + + fn bus_name() -> OwnedUniqueName { + OwnedUniqueName::try_from(BUS_NAME).unwrap() + } + + fn table_iface() -> (Adapter, TableInterface) { + const WINDOW: NodeId = NodeId(0); + const TABLE: NodeId = NodeId(1); + const HEADER_ROW: NodeId = NodeId(2); + const NAME_HEADER: NodeId = NodeId(3); + const STATUS_HEADER: NodeId = NodeId(4); + const DATA_ROW: NodeId = NodeId(5); + const CELL_A: NodeId = NodeId(6); + const CELL_DONE: NodeId = NodeId(7); + + let mut name_header = Node::new(Role::ColumnHeader); + name_header.set_label("Name"); + let mut status_header = Node::new(Role::ColumnHeader); + status_header.set_label("Status"); + let mut cell_a = Node::new(Role::Cell); + cell_a.set_label("a"); + cell_a.set_selected(false); + let mut cell_done = Node::new(Role::Cell); + cell_done.set_label("done"); + cell_done.set_selected(true); + + let update = TreeUpdate { + nodes: vec![ + (WINDOW, with_children(Role::Window, &[TABLE])), + (TABLE, with_children(Role::Table, &[HEADER_ROW, DATA_ROW])), + ( + HEADER_ROW, + with_children(Role::Row, &[NAME_HEADER, STATUS_HEADER]), + ), + (NAME_HEADER, name_header), + (STATUS_HEADER, status_header), + (DATA_ROW, with_children(Role::Row, &[CELL_A, CELL_DONE])), + (CELL_A, cell_a), + (CELL_DONE, cell_done), + ], + tree: Some(TreeInfo::new(WINDOW)), + tree_id: TreeId::ROOT, + focus: WINDOW, + }; + let app_context = AppContext::new(None); + let adapter = Adapter::new( + &app_context, + NoOpCallback, + update, + false, + WindowBounds::default(), + NoOpActionHandler, + ); + let table_id = adapter + .platform_node(adapter.root_id()) + .child_at_index(0) + .unwrap() + .unwrap(); + let table = TableInterface::new(bus_name(), adapter.platform_node(table_id)); + (adapter, table) + } + + fn cell_address(node: &PlatformNode, id: FullNodeId) -> crate::atspi::OwnedObjectAddress { + ObjectId::Node { + adapter: node.adapter_id(), + node: id, + } + .to_address(&UniqueName::from_static_str_unchecked(BUS_NAME)) + } + + #[test] + fn n_rows_and_n_columns_match_the_tree() { + let (_adapter, table) = table_iface(); + assert_eq!(table.n_rows(), Ok(2)); + assert_eq!(table.n_columns(), Ok(2)); + } + + #[test] + fn get_accessible_at_returns_the_real_cell_address() { + let (adapter, table) = table_iface(); + let data_row = adapter + .platform_node(adapter.root_id()) + .child_at_index(0) + .unwrap() + .unwrap(); + let data_row = adapter + .platform_node(data_row) + .child_at_index(1) + .unwrap() + .unwrap(); + let cell_done = adapter + .platform_node(data_row) + .child_at_index(1) + .unwrap() + .unwrap(); + let (address,) = table.get_accessible_at(1, 1).unwrap(); + assert_eq!(address, cell_address(&table.node, cell_done)); + } + + #[test] + fn is_selected_reflects_live_cell_state() { + let (_adapter, table) = table_iface(); + assert_eq!(table.is_selected(1, 1), Ok(true)); + assert_eq!(table.is_selected(1, 0), Ok(false)); + } +} diff --git a/adapters/unix/src/atspi/interfaces/table_cell.rs b/adapters/unix/src/atspi/interfaces/table_cell.rs new file mode 100644 index 000000000..f875dd539 --- /dev/null +++ b/adapters/unix/src/atspi/interfaces/table_cell.rs @@ -0,0 +1,58 @@ +// Copyright 2026 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +use accesskit_atspi_common::PlatformNode; +use zbus::{fdo, interface, names::OwnedUniqueName}; + +use crate::atspi::{ObjectId, OwnedObjectAddress}; + +pub(crate) struct TableCellInterface { + bus_name: OwnedUniqueName, + node: PlatformNode, +} + +impl TableCellInterface { + pub fn new(bus_name: OwnedUniqueName, node: PlatformNode) -> Self { + Self { bus_name, node } + } + + fn map_error(&self) -> impl '_ + FnOnce(accesskit_atspi_common::Error) -> fdo::Error { + |error| crate::util::map_error_from_node(&self.node, error) + } +} + +#[interface(name = "org.a11y.atspi.TableCell")] +impl TableCellInterface { + #[zbus(property)] + fn row_index(&self) -> fdo::Result { + let (row, _) = self.node.table_cell_position().map_err(self.map_error())?; + Ok(row) + } + + #[zbus(property)] + fn column_index(&self) -> fdo::Result { + let (_, column) = self.node.table_cell_position().map_err(self.map_error())?; + Ok(column) + } + + fn get_position(&self) -> fdo::Result<(i32, i32)> { + self.node.table_cell_position().map_err(self.map_error()) + } + + fn get_row_column_span(&self) -> fdo::Result<(bool, i32, i32, i32, i32)> { + self.node + .table_cell_row_column_span() + .map_err(self.map_error()) + } + + fn get_table(&self) -> fdo::Result<(OwnedObjectAddress,)> { + let table = self.node.table_cell_table().map_err(self.map_error())?; + let table = table.map(|node| ObjectId::Node { + adapter: self.node.adapter_id(), + node, + }); + Ok(super::optional_object_address(&self.bus_name, table)) + } +}