diff --git a/consumer/src/node.rs b/consumer/src/node.rs index 10f71da4..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() @@ -2089,4 +2093,338 @@ 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::FilterResult; + use crate::tests::nid; + + 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()); + } + } } diff --git a/consumer/src/text.rs b/consumer/src/text.rs index bd9ec4b1..ad4cda20 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. @@ -1359,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> { @@ -1375,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 } @@ -2833,4 +2927,357 @@ 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 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; + + // 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) + } + + let update = TreeUpdate { + nodes: vec![ + (NodeId(0), { + 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("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 + }), + ], + 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 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)] + 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()); + } + } } 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()); + } + } } 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) + ); + } +} 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() {