From ab7795fd7718392c72a23e8c8ca1d49bbcbe5015 Mon Sep 17 00:00:00 2001 From: William Selna Date: Mon, 22 Jun 2026 23:47:14 +0200 Subject: [PATCH 1/7] test(consumer): cover node property, state, and accessor surface Adds direct tests for the numeric/scroll/state accessors plus a broad accessor sweep (supported actions, labels, descriptions, set membership, role-derived predicates, values, read-only/disabled composition, and hidden-flag inheritance). Lifts node.rs from 57% to ~97% function coverage. --- consumer/src/node.rs | 331 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) diff --git a/consumer/src/node.rs b/consumer/src/node.rs index 10f71da4..296ed6a8 100644 --- a/consumer/src/node.rs +++ b/consumer/src/node.rs @@ -2089,4 +2089,335 @@ mod tests { .is_focused() ); } + + // The numeric, scroll, and semantic-state accessors are thin reads of the + // underlying node data, but none of them were exercised by the existing + // suite; these pin the setter-to-getter round trip the platform adapters + // depend on. + #[test] + fn numeric_and_scroll_accessors() { + let update = TreeUpdate { + nodes: vec![ + (NodeId(0), { + let mut node = Node::new(Role::RootWebArea); + node.set_children(vec![NodeId(1)]); + node + }), + (NodeId(1), { + let mut node = Node::new(Role::Slider); + node.set_numeric_value(3.0); + node.set_min_numeric_value(0.0); + node.set_max_numeric_value(10.0); + node.set_numeric_value_step(1.0); + node.set_numeric_value_jump(2.0); + node.set_scroll_x(5.0); + node.set_scroll_x_min(0.0); + node.set_scroll_x_max(50.0); + node.set_scroll_y(6.0); + node.set_scroll_y_min(0.0); + node.set_scroll_y_max(60.0); + node + }), + ], + tree: Some(Tree::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }; + let tree = crate::Tree::new(update, false); + let state = tree.state(); + let node = state.node_by_id(nid(NodeId(1))).unwrap(); + assert_eq!(Some(3.0), node.numeric_value()); + assert_eq!(Some(0.0), node.min_numeric_value()); + assert_eq!(Some(10.0), node.max_numeric_value()); + assert_eq!(Some(1.0), node.numeric_value_step()); + assert_eq!(Some(2.0), node.numeric_value_jump()); + assert_eq!(Some(5.0), node.scroll_x()); + assert_eq!(Some(0.0), node.scroll_x_min()); + assert_eq!(Some(50.0), node.scroll_x_max()); + assert_eq!(Some(6.0), node.scroll_y()); + assert_eq!(Some(0.0), node.scroll_y_min()); + assert_eq!(Some(60.0), node.scroll_y_max()); + } + + #[test] + fn state_and_semantic_accessors() { + let update = TreeUpdate { + nodes: vec![ + (NodeId(0), { + let mut node = Node::new(Role::RootWebArea); + node.set_children(vec![NodeId(1)]); + node + }), + (NodeId(1), { + let mut node = Node::new(Role::CheckBox); + node.set_toggled(accesskit::Toggled::Mixed); + node.set_orientation(accesskit::Orientation::Vertical); + node.set_has_popup(accesskit::HasPopup::Menu); + node.set_aria_current(accesskit::AriaCurrent::Page); + node.set_level(3); + node.set_role_description("custom"); + node.set_multiselectable(); + node + }), + ], + tree: Some(Tree::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }; + let tree = crate::Tree::new(update, false); + let state = tree.state(); + let node = state.node_by_id(nid(NodeId(1))).unwrap(); + assert_eq!(Some(accesskit::Toggled::Mixed), node.toggled()); + assert_eq!(Some(accesskit::Orientation::Vertical), node.orientation()); + assert_eq!(Some(accesskit::HasPopup::Menu), node.has_popup()); + assert_eq!(Some(accesskit::AriaCurrent::Page), node.aria_current()); + assert_eq!(Some(3), node.level()); + assert_eq!(Some("custom"), node.role_description()); + assert!(node.has_role_description()); + assert!(node.is_multiselectable()); + } + + #[test] + fn read_only_and_disabled_states() { + let update = TreeUpdate { + nodes: vec![ + (NodeId(0), { + let mut node = Node::new(Role::RootWebArea); + node.set_children(vec![NodeId(1), NodeId(2), NodeId(3)]); + node + }), + // An editable field that is neither read-only nor disabled. + (NodeId(1), Node::new(Role::TextInput)), + // The same kind of field, explicitly marked read-only. + (NodeId(2), { + let mut node = Node::new(Role::TextInput); + node.set_read_only(); + node + }), + // A disabled field; `is_read_only_or_disabled` must fold it in. + (NodeId(3), { + let mut node = Node::new(Role::TextInput); + node.set_disabled(); + node + }), + ], + tree: Some(Tree::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }; + let tree = crate::Tree::new(update, false); + let state = tree.state(); + let editable = state.node_by_id(nid(NodeId(1))).unwrap(); + assert!(!editable.is_read_only()); + assert!(!editable.is_disabled()); + assert!(!editable.is_read_only_or_disabled()); + let read_only = state.node_by_id(nid(NodeId(2))).unwrap(); + assert!(read_only.is_read_only()); + assert!(read_only.is_read_only_or_disabled()); + let disabled = state.node_by_id(nid(NodeId(3))).unwrap(); + assert!(disabled.is_disabled()); + assert!(disabled.is_read_only_or_disabled()); + } + + #[test] + fn is_hidden_is_inherited_from_an_ancestor() { + // Only the parent sets the hidden flag; the child must inherit it. + let update = TreeUpdate { + nodes: vec![ + (NodeId(0), { + let mut node = Node::new(Role::RootWebArea); + node.set_children(vec![NodeId(1)]); + node + }), + (NodeId(1), { + let mut node = Node::new(Role::GenericContainer); + node.set_hidden(); + node.set_children(vec![NodeId(2)]); + node + }), + (NodeId(2), Node::new(Role::Button)), + ], + tree: Some(Tree::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }; + let tree = crate::Tree::new(update, false); + let state = tree.state(); + assert!(state.node_by_id(nid(NodeId(1))).unwrap().is_hidden()); + assert!(state.node_by_id(nid(NodeId(2))).unwrap().is_hidden()); + } + + // A node carrying a broad set of explicitly-provided properties and + // supported actions, used to exercise the accessor surface in one place. + #[cfg(test)] + mod accessors { + use accesskit::{Action, Node, NodeId, Rect, Role, Tree, TreeId, TreeUpdate}; + use alloc::string::ToString; + use alloc::vec; + use alloc::vec::Vec; + + use crate::tests::nid; + use crate::FilterResult; + + fn accessor_tree() -> crate::Tree { + let mut root = Node::new(Role::RootWebArea); + root.set_children(vec![ + NodeId(1), + NodeId(2), + NodeId(4), + NodeId(5), + NodeId(6), + NodeId(7), + ]); + + let mut listbox = Node::new(Role::ListBox); + listbox.set_multiselectable(); + listbox.set_children(vec![NodeId(3)]); + + let mut option = Node::new(Role::ListBoxOption); + option.set_selected(true); + option.set_size_of_set(5); + option.set_position_in_set(2); + + let mut button = Node::new(Role::Button); + button.add_action(Action::Click); + button.add_action(Action::Focus); + button.add_action(Action::Increment); + button.add_action(Action::Decrement); + button.set_label("ok"); + button.set_description("a button"); + button.set_busy(); + button.set_live_atomic(); + button.set_modal(); + button.set_required(); + button.set_touch_transparent(); + button.set_live(accesskit::Live::Polite); + button.set_author_id("auth-1"); + button.set_class_name("btn"); + button.set_sort_direction(accesskit::SortDirection::Ascending); + button.set_braille_label("braille"); + button.set_braille_role_description("braille role"); + button.set_column_index_text("C"); + button.set_row_index_text("R"); + button.set_bounds(Rect { + x0: 0.0, + y0: 0.0, + x1: 10.0, + y1: 10.0, + }); + button.set_controls(vec![NodeId(4)]); + + let mut link = Node::new(Role::Link); + link.set_url("https://example.com"); + + let update = TreeUpdate { + nodes: vec![ + (NodeId(0), root), + (NodeId(1), listbox), + (NodeId(3), option), + (NodeId(2), button), + (NodeId(4), link), + (NodeId(5), Node::new(Role::Dialog)), + (NodeId(6), Node::new(Role::MultilineTextInput)), + (NodeId(7), Node::new(Role::TabList)), + ], + tree: Some(Tree::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(2), + }; + crate::Tree::new(update, false) + } + + #[test] + fn rich_node_accessors() { + let tree = accessor_tree(); + let state = tree.state(); + let filter = |_: &crate::Node| FilterResult::Include; + let button = state.node_by_id(nid(NodeId(2))).unwrap(); + assert!(button.is_clickable(&filter)); + assert!(button.is_focusable(&filter)); + assert!(button.is_focused_in_tree()); + assert!(button.is_invocable(&filter)); + assert!(button.supports_increment(&filter)); + assert!(button.supports_decrement(&filter)); + assert_eq!(Some("ok".to_string()), button.label()); + assert!(button.has_label()); + assert_eq!(Some("a button".to_string()), button.description()); + assert!(button.has_description()); + assert!(button.is_busy()); + assert!(button.is_live_atomic()); + assert!(button.is_modal()); + assert!(button.is_required()); + assert!(button.is_touch_transparent()); + assert_eq!(accesskit::Live::Polite, button.live()); + assert_eq!(Some("auth-1"), button.author_id()); + assert_eq!(Some("btn"), button.class_name()); + assert_eq!( + Some(accesskit::SortDirection::Ascending), + button.sort_direction() + ); + assert_eq!(Some("braille"), button.braille_label()); + assert!(button.has_braille_label()); + assert_eq!(Some("braille role"), button.braille_role_description()); + assert!(button.has_braille_role_description()); + assert_eq!(Some("C"), button.column_index_text()); + assert_eq!(Some("R"), button.row_index_text()); + assert!(button.has_bounds()); + assert!(button.bounding_box().is_some()); + let controlled = button.controls().map(|n| n.id()).collect::>(); + assert!(controlled == vec![nid(NodeId(4))]); + assert!(!button.index_path().is_empty()); + } + + #[test] + fn role_and_membership_accessors() { + let tree = accessor_tree(); + let state = tree.state(); + let filter = |_: &crate::Node| FilterResult::Include; + let listbox = state.node_by_id(nid(NodeId(1))).unwrap(); + assert!(listbox.is_container_with_selectable_children()); + assert_eq!(Some(accesskit::Orientation::Vertical), listbox.orientation()); + let items = listbox.items(filter).map(|n| n.id()).collect::>(); + assert!(items == vec![nid(NodeId(3))]); + + let option = state.node_by_id(nid(NodeId(3))).unwrap(); + assert!(option.is_selectable()); + assert!(option.is_item_like()); + assert_eq!(Some(5), option.size_of_set()); + assert_eq!(Some(2), option.position_in_set()); + assert!(option.selection_container(&filter).map(|n| n.id()) == Some(nid(NodeId(1)))); + + let link = state.node_by_id(nid(NodeId(4))).unwrap(); + assert_eq!(Some("https://example.com"), link.url()); + assert!(link.supports_url()); + assert!(!link.is_focused_in_tree()); + + assert!(state.node_by_id(nid(NodeId(5))).unwrap().is_dialog()); + assert!(state.node_by_id(nid(NodeId(6))).unwrap().is_multiline()); + assert_eq!( + Some(accesskit::Orientation::Horizontal), + state.node_by_id(nid(NodeId(7))).unwrap().orientation() + ); + } + + #[test] + fn value_accessors() { + let mut node = Node::new(Role::SpinButton); + node.set_value("42"); + let mut root = Node::new(Role::RootWebArea); + root.set_children(vec![NodeId(1)]); + let update = TreeUpdate { + nodes: vec![(NodeId(0), root), (NodeId(1), node)], + tree: Some(Tree::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }; + let tree = crate::Tree::new(update, false); + let state = tree.state(); + let node = state.node_by_id(nid(NodeId(1))).unwrap(); + assert_eq!(Some("42".to_string()), node.value()); + assert!(node.has_value()); + assert_eq!(Some("42"), node.raw_value()); + } + } } From 6498584373bb029bebc57c89f477216afc2b5a3f Mon Sep 17 00:00:00 2001 From: William Selna Date: Mon, 22 Jun 2026 23:47:14 +0200 Subject: [PATCH 2/7] test(consumer): cover text positions, ranges, and selection accessors Adds the Range/WeakRange weak-reference round trip (including the degenerate range and refuse-to-upgrade-against-a-non-text-node cases), the Position page-navigation/document-bound helpers, and the node-level text-selection accessors. Lifts text.rs to ~97% line / ~99% function coverage. --- consumer/src/text.rs | 211 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/consumer/src/text.rs b/consumer/src/text.rs index bd9ec4b1..bd2aa40c 100644 --- a/consumer/src/text.rs +++ b/consumer/src/text.rs @@ -2833,4 +2833,215 @@ mod tests { RangePropertyValue::Mixed ); } + + // Coverage for the weak-reference round trip on text ranges, which the + // platform adapters rely on to persist a selection across tree updates. + #[cfg(test)] + mod weak_range_round_trip { + use accesskit::{Node, NodeId, Role, Tree, TreeId, TreeUpdate}; + use alloc::vec; + + use crate::tests::nid; + + fn single_run_text_tree() -> crate::Tree { + let update = TreeUpdate { + nodes: vec![ + (NodeId(0), { + let mut node = Node::new(Role::TextInput); + node.set_children(vec![NodeId(1)]); + node + }), + (NodeId(1), { + let mut node = Node::new(Role::TextRun); + node.set_value("text"); + node.set_character_lengths([1, 1, 1, 1]); + node + }), + ], + tree: Some(Tree::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }; + crate::Tree::new(update, false) + } + + #[test] + fn document_range_survives_downgrade_and_upgrade() { + let tree = single_run_text_tree(); + let state = tree.state(); + let node = state.node_by_id(nid(NodeId(0))).unwrap(); + let range = node.document_range(); + let upgraded = range.downgrade().upgrade(state).unwrap(); + assert!(range == upgraded); + } + + #[test] + fn degenerate_range_survives_downgrade_and_upgrade() { + let tree = single_run_text_tree(); + let state = tree.state(); + let node = state.node_by_id(nid(NodeId(0))).unwrap(); + let range = node.document_start().to_degenerate_range(); + let upgraded = range.downgrade().upgrade(state).unwrap(); + assert!(range == upgraded); + } + + #[test] + fn weak_range_does_not_upgrade_against_a_non_text_node() { + // Capture a weak range, then upgrade it against a tree whose + // matching node is not a text container: it must refuse rather than + // produce a bogus range. + let weak = { + let tree = single_run_text_tree(); + let state = tree.state(); + let node = state.node_by_id(nid(NodeId(0))).unwrap(); + node.document_range().downgrade() + }; + let other = { + let update = TreeUpdate { + nodes: vec![(NodeId(0), Node::new(Role::Button))], + tree: Some(Tree::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }; + crate::Tree::new(update, false) + }; + assert!(weak.upgrade(other.state()).is_none()); + } + } + + // `Position` exposes page-granularity navigation, but AccessKit models a + // document as a single page, so every page motion resolves to a document + // boundary (and `forward_to_page_start`/`forward_to_page_end` deliberately + // coincide). This was previously untested; pin the mapping so it can't + // silently drift. + #[cfg(test)] + mod page_navigation { + use accesskit::{Node, NodeId, Role, Tree, TreeId, TreeUpdate}; + use alloc::vec; + + use crate::tests::nid; + + #[test] + fn page_navigation_resolves_to_document_bounds() { + let update = TreeUpdate { + nodes: vec![ + (NodeId(0), { + let mut node = Node::new(Role::TextInput); + node.set_children(vec![NodeId(1)]); + node + }), + (NodeId(1), { + let mut node = Node::new(Role::TextRun); + node.set_value("text"); + node.set_character_lengths([1, 1, 1, 1]); + node + }), + ], + tree: Some(Tree::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }; + let tree = crate::Tree::new(update, false); + let state = tree.state(); + let node = state.node_by_id(nid(NodeId(0))).unwrap(); + let start = node.document_start(); + assert!(start.document_end() == start.forward_to_page_start()); + assert!(start.document_end() == start.forward_to_page_end()); + assert!(start.document_start() == start.backward_to_page_start()); + } + } + + + // Coverage for the remaining Position/Range/WeakRange helpers and the + // node-level text-selection accessors. + #[cfg(test)] + mod text_helpers { + use accesskit::{Node, NodeId, Role, TextPosition, TextSelection, Tree, TreeId, TreeUpdate}; + use alloc::string::ToString; + use alloc::vec; + + use crate::tests::nid; + + fn text_tree_with_selection() -> crate::Tree { + let selection = TextSelection { + anchor: TextPosition { + node: NodeId(1), + character_index: 0, + }, + focus: TextPosition { + node: NodeId(1), + character_index: 2, + }, + }; + let update = TreeUpdate { + nodes: vec![ + (NodeId(0), { + let mut node = Node::new(Role::TextInput); + node.set_children(vec![NodeId(1)]); + node.set_text_selection(selection); + node + }), + (NodeId(1), { + let mut node = Node::new(Role::TextRun); + node.set_value("text"); + node.set_character_lengths([1, 1, 1, 1]); + node + }), + ], + tree: Some(Tree::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }; + crate::Tree::new(update, false) + } + + #[test] + fn position_helpers_at_document_start() { + let tree = text_tree_with_selection(); + let state = tree.state(); + let node = state.node_by_id(nid(NodeId(0))).unwrap(); + let start = node.document_start(); + assert!(start.is_page_start()); + assert!(!start.is_paragraph_separator()); + assert!(start.inner_node().id() == nid(NodeId(1))); + // Biasing the document start to the start is a no-op on the raw form. + assert!(start.to_raw() == start.biased_to_start().to_raw()); + } + + #[test] + fn range_and_weak_range_getters() { + let tree = text_tree_with_selection(); + let state = tree.state(); + let node = state.node_by_id(nid(NodeId(0))).unwrap(); + let range = node.document_range(); + assert!(range.node().id() == nid(NodeId(0))); + let _ = range.to_text_selection(); + let weak = range.downgrade(); + assert!(weak.node_id() == nid(NodeId(0))); + // The document range is non-empty, so its endpoints differ. + assert_ne!(weak.start_comparable(), weak.end_comparable()); + } + + #[test] + fn node_text_selection_accessors() { + let tree = text_tree_with_selection(); + let state = tree.state(); + let node = state.node_by_id(nid(NodeId(0))).unwrap(); + assert!(node.has_text_selection()); + assert!(node.text_selection().is_some()); + assert!(node.text_selection_anchor().is_some()); + assert!(node.text_selection_focus().is_some()); + } + + #[test] + fn single_line_value_serializes_text_runs() { + // A single-line text input with no explicit value serializes its + // text runs, exercising the write_text/traverse_text path. + let tree = text_tree_with_selection(); + let state = tree.state(); + let node = state.node_by_id(nid(NodeId(0))).unwrap(); + assert!(node.has_value()); + assert_eq!(Some("text".to_string()), node.value()); + } + } } From 624e42e506d7e9d76f4c151f73b91ed2b4878ac1 Mon Sep 17 00:00:00 2001 From: William Selna Date: Mon, 22 Jun 2026 23:47:14 +0200 Subject: [PATCH 3/7] test(consumer): cover host-focus state and toolkit metadata Exercises is_host_focused, focus_in_tree, active_dialog, toolkit_name, toolkit_version, and the update_host_focus_state_and_process_changes path. --- consumer/src/tree.rs | 57 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/consumer/src/tree.rs b/consumer/src/tree.rs index 0b99e2d9..ea260d30 100644 --- a/consumer/src/tree.rs +++ b/consumer/src/tree.rs @@ -3135,4 +3135,61 @@ mod tests { assert_eq!(handler.old_focus, Some(node_id(2))); assert_eq!(handler.new_focus, Some(node_id(3))); } + + // The host-focus toggle and toolkit metadata accessors had no coverage. + #[cfg(test)] + mod host_focus { + use accesskit::{Node, NodeId, Role, Tree, TreeId, TreeUpdate}; + use alloc::vec; + + use crate::tests::nid; + + struct NoOpHandler; + impl crate::TreeChangeHandler for NoOpHandler { + fn node_added(&mut self, _node: &crate::Node) {} + fn node_updated(&mut self, _old_node: &crate::Node, _new_node: &crate::Node) {} + fn focus_moved( + &mut self, + _old_node: Option<&crate::Node>, + _new_node: Option<&crate::Node>, + ) { + } + fn node_removed(&mut self, _node: &crate::Node) {} + } + + #[test] + fn host_focus_and_toolkit_metadata() { + let mut data_tree = Tree::new(NodeId(0)); + data_tree.toolkit_name = Some("test-kit".into()); + data_tree.toolkit_version = Some("1.2.3".into()); + let update = TreeUpdate { + nodes: vec![ + (NodeId(0), { + let mut node = Node::new(Role::RootWebArea); + node.set_children(vec![NodeId(1)]); + node + }), + (NodeId(1), Node::new(Role::Dialog)), + ], + tree: Some(data_tree), + tree_id: TreeId::ROOT, + focus: NodeId(1), + }; + let mut tree = crate::Tree::new(update, true); + { + let state = tree.state(); + assert!(state.is_host_focused()); + assert!(state.focus_id_in_tree() == nid(NodeId(1))); + assert!(state.focus_in_tree().id() == nid(NodeId(1))); + assert_eq!(Some("test-kit"), state.toolkit_name()); + assert_eq!(Some("1.2.3"), state.toolkit_version()); + // Focus rests on a dialog, so it is the active dialog. + assert!(state.active_dialog().map(|n| n.id()) == Some(nid(NodeId(1)))); + } + // Dropping host focus is reflected by `is_host_focused`. + let mut handler = NoOpHandler; + tree.update_host_focus_state_and_process_changes(false, &mut handler); + assert!(!tree.state().is_host_focused()); + } + } } From 8434a4ef5ec34bda9bc32cbcbe230034f9b8842f Mon Sep 17 00:00:00 2001 From: William Selna Date: Tue, 23 Jun 2026 14:55:11 +0200 Subject: [PATCH 4/7] feat(consumer): implement page-granularity text navigation Replaces the placeholder page_navigation_resolves_to_document_bounds test, which pinned the degenerate stub behavior, with a real implementation and behavioral tests. Position's page-navigation methods were stubs that resolved every page motion to a document boundary, ignoring the is_page_breaking_object node flag the schema already defines. This wires them to honor real page breaks. Page boundaries are detected from block structure (a Position's text run has an ancestor flagged is_page_breaking_object whose subtree excludes the preceding run), unlike line boundaries (run links) or paragraph boundaries (a run value ending in '\n'). The break is treated as occurring before the flagged node. - is_page_start and the new is_page_end consult the page-break flag. - forward_to_page_start, forward_to_page_end, and backward_to_page_start walk page boundaries, mirroring the paragraph-navigation methods. - A document with no page-break flags is a single page, so behavior is unchanged for every existing provider; only trees that set the flag exercise the new path. The Windows UIA TextUnit_Page mapping inherits this for free; macOS and AT-SPI have no page text granularity and are unaffected. --- consumer/src/text.rs | 268 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 249 insertions(+), 19 deletions(-) diff --git a/consumer/src/text.rs b/consumer/src/text.rs index bd2aa40c..1ec96b2b 100644 --- a/consumer/src/text.rs +++ b/consumer/src/text.rs @@ -80,6 +80,50 @@ impl<'a> InnerPosition<'a> { self.is_run_end() && self.node.following_text_runs(root_node).next().is_none() } + /// Whether the text run at this position begins a new page. + /// + /// Unlike line boundaries (run links via `previous_on_line`/`next_on_line`) + /// or paragraph boundaries (a run value ending in `'\n'`), page boundaries + /// come from block structure: AccessKit models a page break with the + /// [`is_page_breaking_object`] node flag. This returns `true` when the run + /// has an ancestor below `root_node` carrying that flag whose subtree does + /// not also contain the immediately preceding run — i.e. document order has + /// just crossed into a page-breaking object. The break is treated as + /// occurring *before* the flagged node. + /// + /// [`is_page_breaking_object`]: accesskit::Node::is_page_breaking_object + fn starts_new_page(&self, root_node: &Node) -> bool { + let preceding_run = match self.node.preceding_text_runs(root_node).next() { + // No preceding run: this is the document's first run, which always + // starts the first page. + None => return true, + Some(run) => run, + }; + let mut ancestor = self.node.parent(); + while let Some(node) = ancestor { + if node.id() == root_node.id() { + break; + } + if node.data().is_page_breaking_object() && !node_contains(&node, &preceding_run) { + return true; + } + ancestor = node.parent(); + } + false + } + + /// Whether the run immediately following this position begins a new page. + fn next_run_starts_new_page(&self, root_node: &Node) -> bool { + match self.node.following_text_runs(root_node).next() { + Some(next) => InnerPosition { + node: next, + character_index: 0, + } + .starts_new_page(root_node), + None => false, + } + } + fn biased_to_start(&self, root_node: &Node) -> Self { if self.is_run_end() { if let Some(node) = self.node.following_text_runs(root_node).next() { @@ -224,6 +268,19 @@ impl<'a> Position<'a> { pub fn is_page_start(&self) -> bool { self.is_document_start() + || (self.is_line_start() + && self + .inner + .biased_to_start(&self.root_node) + .starts_new_page(&self.root_node)) + } + + pub fn is_page_end(&self) -> bool { + // Detected from the run-end side, mirroring `is_paragraph_end`: a page + // ends where the following run starts a new page. A position expressed + // as the start of the next page's run is a page *start*, not a page end. + self.is_document_end() + || (self.inner.is_line_end() && self.inner.next_run_starts_new_page(&self.root_node)) } pub fn is_document_start(&self) -> bool { @@ -552,15 +609,36 @@ impl<'a> Position<'a> { } pub fn forward_to_page_start(&self) -> Self { - self.document_end() + let mut current = *self; + loop { + current = current.forward_to_line_start(); + if current.is_document_end() || current.is_page_start() { + break; + } + } + current } pub fn forward_to_page_end(&self) -> Self { - self.document_end() + let mut current = *self; + loop { + current = current.forward_to_line_end(); + if current.is_document_end() || current.is_page_end() { + break; + } + } + current } pub fn backward_to_page_start(&self) -> Self { - self.document_start() + let mut current = *self; + loop { + current = current.backward_to_line_start(); + if current.is_page_start() { + break; + } + } + current } pub fn document_end(&self) -> Self { @@ -937,6 +1015,18 @@ fn text_node_filter(root_id: NodeId, node: &Node) -> FilterResult { } } +/// Whether `descendant` is `ancestor` itself or appears in its subtree. +fn node_contains(ancestor: &Node, descendant: &Node) -> bool { + let mut current = Some(*descendant); + while let Some(node) = current { + if node.id() == ancestor.id() { + return true; + } + current = node.parent(); + } + false +} + fn character_index_at_point(node: &Node, point: Point) -> usize { // We know the node has a bounding rectangle because it was returned // by a hit test. @@ -2909,30 +2999,163 @@ mod tests { } } - // `Position` exposes page-granularity navigation, but AccessKit models a - // document as a single page, so every page motion resolves to a document - // boundary (and `forward_to_page_start`/`forward_to_page_end` deliberately - // coincide). This was previously untested; pin the mapping so it can't - // silently drift. + // `Position` exposes page-granularity navigation driven by the + // `is_page_breaking_object` node flag: a page break occurs before a flagged + // block, so navigation lands on real page boundaries while skipping + // intra-page line and paragraph boundaries. A document with no page-break + // flags is a single page, so page motions resolve to document boundaries — + // the backward-compatible path every existing provider exercises. #[cfg(test)] mod page_navigation { + use crate::tests::nid; use accesskit::{Node, NodeId, Role, Tree, TreeId, TreeUpdate}; use alloc::vec; - use crate::tests::nid; + // A three-page document. Page boundaries come from `is_page_breaking_object` + // on the block-level "page" containers — NOT from line or paragraph + // structure. Page B deliberately holds two paragraphs ("Bravo\n" and + // "Charlie\n") so the tests can prove that page navigation lands on real + // page breaks while skipping the intra-page paragraph boundary at USV + // offset 12. + // + // Document (1) + // page A container (2, break) -> run (3) "Alpha\n" usv 0..6 + // page B container (4, break) -> run (5) "Bravo\n" usv 6..12 + // run (7) "Charlie\n" usv 12..20 + // page C container (8, break) -> run (9) "Delta" usv 20..25 + fn three_page_tree() -> crate::Tree { + fn run(id: u64, value: &'static str) -> (NodeId, Node) { + let mut node = Node::new(Role::TextRun); + node.set_value(value); + node.set_character_lengths(vec![1u8; value.chars().count()]); + (NodeId(id), node) + } + fn page(id: u64, children: vec::Vec) -> (NodeId, Node) { + let mut node = Node::new(Role::GenericContainer); + node.set_is_page_breaking_object(); + node.set_children(children); + (NodeId(id), node) + } - #[test] - fn page_navigation_resolves_to_document_bounds() { let update = TreeUpdate { nodes: vec![ (NodeId(0), { - let mut node = Node::new(Role::TextInput); + let mut node = Node::new(Role::Window); node.set_children(vec![NodeId(1)]); node }), + (NodeId(1), { + let mut node = Node::new(Role::Document); + node.set_children(vec![NodeId(2), NodeId(4), NodeId(8)]); + node + }), + page(2, vec![NodeId(3)]), + run(3, "Alpha\n"), + page(4, vec![NodeId(5), NodeId(7)]), + run(5, "Bravo\n"), + run(7, "Charlie\n"), + page(8, vec![NodeId(9)]), + run(9, "Delta"), + ], + tree: Some(Tree::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }; + crate::Tree::new(update, false) + } + + #[test] + fn is_page_start_tracks_breaks_not_paragraphs() { + let tree = three_page_tree(); + let state = tree.state(); + let doc = state.node_by_id(nid(NodeId(1))).unwrap(); + + let start = doc.document_start(); + assert!(start.is_document_start()); + assert!(start.is_page_start()); + + // Page B starts at the second page-breaking container (usv 6). + let page_b = start.forward_to_page_start(); + assert_eq!(page_b.to_global_usv_index(), 6); + assert!(page_b.is_page_start()); + + // The paragraph boundary *inside* page B (usv 12) is a paragraph + // start but NOT a page start — this is what distinguishes page + // navigation from paragraph navigation. + let paragraph = page_b.forward_to_paragraph_start(); + assert_eq!(paragraph.to_global_usv_index(), 12); + assert!(!paragraph.is_page_start()); + assert!(!paragraph.is_page_end()); + } + + #[test] + fn forward_to_page_start_skips_intra_page_paragraphs() { + let tree = three_page_tree(); + let state = tree.state(); + let doc = state.node_by_id(nid(NodeId(1))).unwrap(); + + let p = doc.document_start().forward_to_page_start(); + assert_eq!(p.to_global_usv_index(), 6); // page B + let p = p.forward_to_page_start(); + assert_eq!(p.to_global_usv_index(), 20); // page C — skipped the paragraph at 12 + let p = p.forward_to_page_start(); + assert_eq!(p.to_global_usv_index(), 25); // no further page: document end + assert!(p.is_document_end()); + } + + #[test] + fn forward_to_page_end_lands_on_page_boundaries() { + let tree = three_page_tree(); + let state = tree.state(); + let doc = state.node_by_id(nid(NodeId(1))).unwrap(); + + let e = doc.document_start().forward_to_page_end(); + assert_eq!(e.to_global_usv_index(), 6); // end of page A + assert!(e.is_page_end()); + let e = e.forward_to_page_end(); + assert_eq!(e.to_global_usv_index(), 20); // end of page B — skipped the paragraph at 12 + let e = e.forward_to_page_end(); + assert_eq!(e.to_global_usv_index(), 25); // end of page C = document end + assert!(e.is_document_end()); + } + + #[test] + fn backward_to_page_start_walks_pages_in_reverse() { + let tree = three_page_tree(); + let state = tree.state(); + let doc = state.node_by_id(nid(NodeId(1))).unwrap(); + + let b = doc.document_end().backward_to_page_start(); + assert_eq!(b.to_global_usv_index(), 20); // page C start + let b = b.backward_to_page_start(); + assert_eq!(b.to_global_usv_index(), 6); // page B start — skipped the paragraph at 12 + let b = b.backward_to_page_start(); + assert_eq!(b.to_global_usv_index(), 0); // page A start = document start + assert!(b.is_document_start()); + } + + #[test] + fn without_page_breaks_navigation_resolves_to_document_bounds() { + // A plain document with no `is_page_breaking_object` flags is a single + // page: every page motion resolves to a document boundary, exactly as + // it did before this feature. This pins the backward-compatible path + // that every existing provider and fixture exercises. + let update = TreeUpdate { + nodes: vec![ + (NodeId(0), { + let mut node = Node::new(Role::Document); + node.set_children(vec![NodeId(1), NodeId(2)]); + node + }), (NodeId(1), { let mut node = Node::new(Role::TextRun); - node.set_value("text"); + node.set_value("One.\n"); + node.set_character_lengths([1, 1, 1, 1, 1]); + node + }), + (NodeId(2), { + let mut node = Node::new(Role::TextRun); + node.set_value("Two."); node.set_character_lengths([1, 1, 1, 1]); node }), @@ -2943,15 +3166,22 @@ mod tests { }; let tree = crate::Tree::new(update, false); let state = tree.state(); - let node = state.node_by_id(nid(NodeId(0))).unwrap(); - let start = node.document_start(); - assert!(start.document_end() == start.forward_to_page_start()); - assert!(start.document_end() == start.forward_to_page_end()); - assert!(start.document_start() == start.backward_to_page_start()); + let doc = state.node_by_id(nid(NodeId(0))).unwrap(); + + let start = doc.document_start(); + let end = doc.document_end(); + assert!(start.is_page_start()); + assert!(end.is_page_end()); + assert!(start.forward_to_page_start().is_document_end()); + assert!(start.forward_to_page_end().is_document_end()); + assert!(end.backward_to_page_start().is_document_start()); + + // The lone interior paragraph boundary is not a page boundary. + let paragraph = start.forward_to_paragraph_start(); + assert!(!paragraph.is_page_start()); } } - // Coverage for the remaining Position/Range/WeakRange helpers and the // node-level text-selection accessors. #[cfg(test)] From f748a772e8f3803e8730cd3b14deafe98293f2ce Mon Sep 17 00:00:00 2001 From: William Selna Date: Tue, 23 Jun 2026 15:07:53 +0200 Subject: [PATCH 5/7] style: run cargo fmt on consumer test additions The CI fmt job (cargo fmt --all -- --check) rejected three spots in the consumer test code: unsorted imports in node.rs, plus an assert_eq! and a use list that rustfmt wraps across lines. Normalize them so the check passes. No behavior change. --- consumer/src/node.rs | 7 +++++-- consumer/src/text.rs | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/consumer/src/node.rs b/consumer/src/node.rs index 296ed6a8..fb77a0ad 100644 --- a/consumer/src/node.rs +++ b/consumer/src/node.rs @@ -2256,8 +2256,8 @@ mod tests { use alloc::vec; use alloc::vec::Vec; - use crate::tests::nid; use crate::FilterResult; + use crate::tests::nid; fn accessor_tree() -> crate::Tree { let mut root = Node::new(Role::RootWebArea); @@ -2376,7 +2376,10 @@ mod tests { let filter = |_: &crate::Node| FilterResult::Include; let listbox = state.node_by_id(nid(NodeId(1))).unwrap(); assert!(listbox.is_container_with_selectable_children()); - assert_eq!(Some(accesskit::Orientation::Vertical), listbox.orientation()); + assert_eq!( + Some(accesskit::Orientation::Vertical), + listbox.orientation() + ); let items = listbox.items(filter).map(|n| n.id()).collect::>(); assert!(items == vec![nid(NodeId(3))]); diff --git a/consumer/src/text.rs b/consumer/src/text.rs index 1ec96b2b..ca04b55c 100644 --- a/consumer/src/text.rs +++ b/consumer/src/text.rs @@ -3186,7 +3186,9 @@ mod tests { // node-level text-selection accessors. #[cfg(test)] mod text_helpers { - use accesskit::{Node, NodeId, Role, TextPosition, TextSelection, Tree, TreeId, TreeUpdate}; + use accesskit::{ + Node, NodeId, Role, TextPosition, TextSelection, Tree, TreeId, TreeUpdate, + }; use alloc::string::ToString; use alloc::vec; From 79c5f96181b362084c4b24f188f10ea52f2f0584 Mon Sep 17 00:00:00 2001 From: William Selna Date: Tue, 23 Jun 2026 20:24:48 +0200 Subject: [PATCH 6/7] feat(atspi): map is_visited to the AT-SPI Visited state A node flagged is_visited (e.g. a hyperlink the user has already followed) now maps to the AT-SPI Visited state. The flag was previously write-only: providers could set it but no adapter read it, so on Unix a visited link was indistinguishable from an unvisited one. - consumer: add Node::is_visited(), matching the sibling flag getters. - atspi-common: insert State::Visited in NodeWrapper::state() alongside the other flag-driven states, with a test covering both directions. State::Visited is the natural target (atspi-common documents it as a hyperlink that has already been activated). Windows has no per-node visited concept; macOS gains one only once objc2-app-kit reaches 0.3 (NSAccessibilityVisitedAttribute), which is gated on the pending objc2 upgrade, so both are left untouched here. --- consumer/src/node.rs | 4 +++ platforms/atspi-common/src/node.rs | 48 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/consumer/src/node.rs b/consumer/src/node.rs index fb77a0ad..396874d8 100644 --- a/consumer/src/node.rs +++ b/consumer/src/node.rs @@ -886,6 +886,10 @@ impl<'a> Node<'a> { self.data().is_required() } + pub fn is_visited(&self) -> bool { + self.data().is_visited() + } + pub fn live(&self) -> Live { self.data() .live() diff --git a/platforms/atspi-common/src/node.rs b/platforms/atspi-common/src/node.rs index c930ccc1..7cd889fd 100644 --- a/platforms/atspi-common/src/node.rs +++ b/platforms/atspi-common/src/node.rs @@ -326,6 +326,9 @@ impl NodeWrapper<'_> { if state.is_multiselectable() { atspi_state.insert(State::Multiselectable); } + if state.is_visited() { + atspi_state.insert(State::Visited); + } if let Some(orientation) = state.orientation() { atspi_state.insert(if orientation == Orientation::Horizontal { State::Horizontal @@ -1942,3 +1945,48 @@ pub struct CacheNode { pub role: AtspiRole, pub states: StateSet, } + +#[cfg(test)] +mod tests { + use super::NodeWrapper; + use accesskit::{Node, NodeId, Role, Tree as TreeData, TreeId, TreeUpdate}; + use accesskit_consumer::Tree; + use atspi_common::State; + + // A node flagged `is_visited` (e.g. an already-followed link) should expose + // the AT-SPI `Visited` state; an otherwise-identical node without the flag + // should not. + #[test] + fn visited_flag_maps_to_atspi_visited_state() { + let mut visited_link = Node::new(Role::Link); + visited_link.set_visited(); + let update = TreeUpdate { + nodes: vec![ + (NodeId(0), { + let mut root = Node::new(Role::Window); + root.set_children(vec![NodeId(1), NodeId(2)]); + root + }), + (NodeId(1), visited_link), + (NodeId(2), Node::new(Role::Link)), + ], + tree: Some(TreeData::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }; + let tree = Tree::new(update, false); + let state = tree.state(); + let children: Vec<_> = state.root().children().collect(); + + assert!( + NodeWrapper(&children[0]) + .state(true) + .contains(State::Visited) + ); + assert!( + !NodeWrapper(&children[1]) + .state(true) + .contains(State::Visited) + ); + } +} From 8602594a03dfdb14bd8f0cc4afaf9f46b62fc2b5 Mon Sep 17 00:00:00 2001 From: William Selna Date: Tue, 23 Jun 2026 20:49:29 +0200 Subject: [PATCH 7/7] feat: expose spelling and grammar errors as text attributes Nodes flagged is_spelling_error / is_grammar_error now surface through the text-attribute pipeline so assistive technology can announce misspelled and grammatically incorrect spans. Both flags were previously write-only: providers could set them but no adapter read them. - consumer: treat the flags as inherited text attributes (exposing them on Range/Position) and split text runs on them. - AT-SPI: report the "invalid" attribute ("spelling" / "grammar"). - Windows: report the UIA AnnotationTypes attribute (AnnotationType_ SpellingError / GrammarError) as an array, using the reserved "mixed" value when a flag varies across the range. The consumer and AT-SPI paths are covered by tests; the Windows arm is type-checked against the windows-msvc target but relies on CI for runtime verification, since this was authored without a Windows host. The macOS adapter is left untouched: its text-attribute path is about to be reworked by the pending objc2 upgrade (AccessKit/accesskit#616), so the marked-misspelled arm is better added on top of that. --- consumer/src/text.rs | 6 ++- platforms/atspi-common/src/text_attributes.rs | 53 +++++++++++++++++++ platforms/windows/src/text.rs | 5 ++ platforms/windows/src/util.rs | 46 ++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/consumer/src/text.rs b/consumer/src/text.rs index ca04b55c..ad4cda20 100644 --- a/consumer/src/text.rs +++ b/consumer/src/text.rs @@ -1449,7 +1449,9 @@ macro_rules! inherited_flags { } inherited_flags! { - (is_italic, set_italic) + (is_italic, set_italic), + (is_spelling_error, set_is_spelling_error), + (is_grammar_error, set_is_grammar_error) } impl<'a> Node<'a> { @@ -1465,6 +1467,8 @@ impl<'a> Node<'a> { || self.underline() != other.underline() || self.text_align() != other.text_align() || self.vertical_offset() != other.vertical_offset() + || self.is_spelling_error() != other.is_spelling_error() + || self.is_grammar_error() != other.is_grammar_error() // TODO: more attributes } diff --git a/platforms/atspi-common/src/text_attributes.rs b/platforms/atspi-common/src/text_attributes.rs index 0893f2f7..496b9494 100644 --- a/platforms/atspi-common/src/text_attributes.rs +++ b/platforms/atspi-common/src/text_attributes.rs @@ -65,6 +65,16 @@ fn justification(node: &Node) -> Option { }) } +fn invalid(node: &Node) -> Option { + if node.is_spelling_error() { + Some("spelling".into()) + } else if node.is_grammar_error() { + Some("grammar".into()) + } else { + None + } +} + pub(crate) const ATTRIBUTE_GETTERS: phf::Map<&'static str, fn(&Node) -> Option> = phf_map! { "family-name" => family_name, "size" => size, @@ -76,4 +86,47 @@ pub(crate) const ATTRIBUTE_GETTERS: phf::Map<&'static str, fn(&Node) -> Option fg_color, "language" => language, "justification" => justification, + "invalid" => invalid, }; + +#[cfg(test)] +mod tests { + use accesskit::{Node, NodeId, Role, Tree as TreeData, TreeId, TreeUpdate}; + use accesskit_consumer::Tree; + + // A text run flagged `is_spelling_error` / `is_grammar_error` should report + // the AT-SPI `invalid` text attribute with the matching value; an unflagged + // run reports nothing. + #[test] + fn invalid_attribute_reflects_error_flags() { + fn run(id: u64, mark: impl FnOnce(&mut Node)) -> (NodeId, Node) { + let mut node = Node::new(Role::TextRun); + node.set_value("word"); + node.set_character_lengths([1, 1, 1, 1]); + mark(&mut node); + (NodeId(id), node) + } + let update = TreeUpdate { + nodes: vec![ + (NodeId(0), { + let mut root = Node::new(Role::TextInput); + root.set_children(vec![NodeId(1), NodeId(2), NodeId(3)]); + root + }), + run(1, |n| n.set_is_spelling_error()), + run(2, |n| n.set_is_grammar_error()), + run(3, |_| {}), + ], + tree: Some(TreeData::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }; + let tree = Tree::new(update, false); + let state = tree.state(); + let runs: Vec<_> = state.root().children().collect(); + + assert_eq!(super::invalid(&runs[0]).as_deref(), Some("spelling")); + assert_eq!(super::invalid(&runs[1]).as_deref(), Some("grammar")); + assert_eq!(super::invalid(&runs[2]), None); + } +} diff --git a/platforms/windows/src/text.rs b/platforms/windows/src/text.rs index e3388cfb..876b67e7 100644 --- a/platforms/windows/src/text.rs +++ b/platforms/windows/src/text.rs @@ -486,6 +486,11 @@ impl ITextRangeProvider_Impl for PlatformRange_Impl { .map(|o| o == VerticalOffset::Superscript), ) .into()), + UIA_AnnotationTypesAttributeId => Ok(annotation_types_variant( + range.is_spelling_error(), + range.is_grammar_error(), + ) + .into()), // TODO: implement more attributes _ => { let value = unsafe { UiaGetReservedNotSupportedValue() }.unwrap(); diff --git a/platforms/windows/src/util.rs b/platforms/windows/src/util.rs index beecb40a..e77bdd59 100644 --- a/platforms/windows/src/util.rs +++ b/platforms/windows/src/util.rs @@ -229,6 +229,52 @@ impl From> for Variant { } } +impl From> for Variant { + fn from(value: Vec) -> Self { + if value.is_empty() { + Variant::empty() + } else { + let parray = safe_array_from_i32_slice(&value); + Self(VARIANT { + Anonymous: VARIANT_0 { + Anonymous: ManuallyDrop::new(VARIANT_0_0 { + vt: VT_ARRAY | VT_I4, + wReserved1: 0, + wReserved2: 0, + wReserved3: 0, + Anonymous: VARIANT_0_0_0 { parray }, + }), + }, + }) + } + } +} + +/// Builds the UIA `AnnotationTypes` text attribute from a range's spelling and +/// grammar error flags. Returns the reserved "mixed" value if either flag +/// varies across the range, otherwise an array of the applicable +/// `AnnotationType_*` ids (empty when the range has no errors). +pub(crate) fn annotation_types_variant( + spelling: TextRangePropertyValue, + grammar: TextRangePropertyValue, +) -> Variant { + if matches!(spelling, TextRangePropertyValue::Mixed) + || matches!(grammar, TextRangePropertyValue::Mixed) + { + return unsafe { UiaGetReservedMixedAttributeValue() } + .unwrap() + .into(); + } + let mut types = Vec::new(); + if matches!(spelling, TextRangePropertyValue::Single(true)) { + types.push(AnnotationType_SpellingError.0); + } + if matches!(grammar, TextRangePropertyValue::Single(true)) { + types.push(AnnotationType_GrammarError.0); + } + types.into() +} + fn safe_array_from_primitive_slice(vt: VARENUM, slice: &[T]) -> *mut SAFEARRAY { let sa = unsafe { SafeArrayCreateVector(VARENUM(vt.0), 0, slice.len().try_into().unwrap()) }; if sa.is_null() {