diff --git a/crates/glass-core/src/accessibility.rs b/crates/glass-core/src/accessibility.rs index fd7405eb..4cc0f259 100644 --- a/crates/glass-core/src/accessibility.rs +++ b/crates/glass-core/src/accessibility.rs @@ -692,13 +692,47 @@ impl AxTree { /// Find a node by id (pre-order). Call after [`AxTree::assign_ids`]. pub fn find(&self, id: AxNodeId) -> Option<&AxNode> { - fn walk(node: &AxNode, id: AxNodeId) -> Option<&AxNode> { - if node.id == id { + self.find_first(|node| node.id == id) + } + + /// Find the first node in pre-order DFS satisfying `pred`. + pub fn find_first(&self, pred: impl Fn(&AxNode) -> bool) -> Option<&AxNode> { + fn walk<'a>(node: &'a AxNode, pred: &impl Fn(&AxNode) -> bool) -> Option<&'a AxNode> { + if pred(node) { return Some(node); } - node.children.iter().find_map(|c| walk(c, id)) + node.children.iter().find_map(|child| walk(child, pred)) } - walk(&self.root, id) + walk(&self.root, &pred) + } + + /// Nodes from the root through `id`, inclusive, or `None` when `id` is not in this tree. + pub fn path_to(&self, id: AxNodeId) -> Option> { + fn walk(node: &AxNode, id: AxNodeId, path: &mut Vec) -> bool { + if node.id == id { + return true; + } + for (index, child) in node.children.iter().enumerate() { + path.push(index); + if walk(child, id, path) { + return true; + } + path.pop(); + } + false + } + + let mut indices = Vec::new(); + if !walk(&self.root, id, &mut indices) { + return None; + } + let mut nodes = vec![&self.root]; + let mut node = &self.root; + for index in indices { + node = &node.children[index]; + nodes.push(node); + } + Some(nodes) } /// [`Self::find`], mutably — for patching a field of a cached node in place rather than @@ -1390,23 +1424,50 @@ impl ElementInfo { } } -/// Find the first node (pre-order DFS) satisfying `pred`. -fn find_preorder<'a>(node: &'a AxNode, pred: &dyn Fn(&AxNode) -> bool) -> Option<&'a AxNode> { - if pred(node) { - return Some(node); +impl AxTree { + /// Evaluate `condition` against the first pre-order node matching the optional name, role, + /// and value filters. `Disappears` is satisfied only when no node matches the selector. + /// Name and value filters do not match missing fields. + pub fn element_match( + &self, + name: Option<&str>, + role: Option, + value_contains: Option<&str>, + condition: ElementCondition, + ) -> ElementMatch<'_> { + // Jetpack Compose can expose clickable controls as focusable `Group`/`Other` nodes, so a + // qualified interactable role may match them; requiring a qualifier prevents role-only + // queries from matching arbitrary focusable containers. + let has_disambiguator = name.is_some() || value_contains.is_some(); + let role_match = |n: &AxNode, r: AxRole| { + n.role == r + || (r.is_interactable() + && has_disambiguator + && n.states.focusable + && matches!(n.role, AxRole::Group | AxRole::Other)) + }; + let selector_match = |n: &AxNode| -> bool { + name.is_none_or(|q| n.name.as_deref().is_some_and(|nm| nm.contains(q))) + && role.is_none_or(|r| role_match(n, r)) + && value_contains + .is_none_or(|v| n.value.as_deref().is_some_and(|val| val.contains(v))) + }; + if condition == ElementCondition::Disappears { + return if self.find_first(selector_match).is_none() { + ElementMatch::Satisfied(None) + } else { + ElementMatch::Pending + }; + } + let pred = condition.state_pred(); + match self.find_first(|n| selector_match(n) && pred(&n.states)) { + Some(n) => ElementMatch::Satisfied(Some(n)), + None => ElementMatch::Pending, + } } - node.children.iter().find_map(|c| find_preorder(c, pred)) } -/// Evaluate a precise element condition against `tree`. The selector is the -/// conjunction of: `name` substring of the node's name, `role` equality, and -/// `value_contains` substring of the node's value (each optional). For positive -/// conditions, returns the first node matching selector + state; for -/// `Disappears`, satisfied iff no node matches the selector. -/// -/// Note: a `name` or `value_contains` filter only matches nodes whose `name`/`value` -/// field is `Some` — a node with `name: None` never matches a name query. Pass -/// `name: None` to skip the name filter entirely. +/// Free-function form of [`AxTree::element_match`]. pub fn element_match<'a>( tree: &'a AxTree, name: Option<&str>, @@ -1414,36 +1475,7 @@ pub fn element_match<'a>( value_contains: Option<&str>, condition: ElementCondition, ) -> ElementMatch<'a> { - // Jetpack Compose surfaces a real button as a clickable `Group`/`Other` with the role - // lost, so an exact filter misses it; name + actability finds it anyway. - // - // The disambiguator is required: without it a role-only query would match the first - // focusable container in the tree — a confident wrong match, not an honest miss. - let has_disambiguator = name.is_some() || value_contains.is_some(); - let role_match = |n: &AxNode, r: AxRole| { - n.role == r - || (r.is_interactable() - && has_disambiguator - && n.states.focusable - && matches!(n.role, AxRole::Group | AxRole::Other)) - }; - let selector_match = |n: &AxNode| -> bool { - name.is_none_or(|q| n.name.as_deref().is_some_and(|nm| nm.contains(q))) - && role.is_none_or(|r| role_match(n, r)) - && value_contains.is_none_or(|v| n.value.as_deref().is_some_and(|val| val.contains(v))) - }; - if condition == ElementCondition::Disappears { - return if find_preorder(&tree.root, &selector_match).is_none() { - ElementMatch::Satisfied(None) - } else { - ElementMatch::Pending - }; - } - let pred = condition.state_pred(); - match find_preorder(&tree.root, &|n| selector_match(n) && pred(&n.states)) { - Some(n) => ElementMatch::Satisfied(Some(n)), - None => ElementMatch::Pending, - } + tree.element_match(name, role, value_contains, condition) } #[cfg(test)] @@ -2800,6 +2832,33 @@ mod tests { assert!(t.find(AxNodeId(99)).is_none()); } + #[test] + fn find_first_uses_preorder() { + let mut t = sample_tree(); + t.assign_ids(); + assert_eq!( + t.find_first(|node| node.role != AxRole::Window) + .map(|node| node.id), + Some(AxNodeId(1)) + ); + assert!(t.find_first(|node| node.role == AxRole::Dialog).is_none()); + } + + #[test] + fn path_to_returns_root_through_target() { + let mut t = sample_tree(); + t.root.children[0] + .children + .push(leaf(AxRole::Label, "Nested")); + t.assign_ids(); + let path = t.path_to(AxNodeId(2)).unwrap(); + assert_eq!( + path.iter().map(|node| node.id).collect::>(), + vec![AxNodeId(0), AxNodeId(1), AxNodeId(2)] + ); + assert!(t.path_to(AxNodeId(99)).is_none()); + } + #[test] fn find_mut_patches_the_node_in_place_without_touching_the_rest() { let mut t = sample_tree(); diff --git a/crates/glass-core/src/session/a11y.rs b/crates/glass-core/src/session/a11y.rs index 0c1286d6..27ec2ff3 100644 --- a/crates/glass-core/src/session/a11y.rs +++ b/crates/glass-core/src/session/a11y.rs @@ -211,7 +211,7 @@ impl Glass { let container = { let s = self.require_active()?; let tree = s.last_ax.as_ref().ok_or(GlassError::NoAxSnapshot)?; - menu_container_bounds(&tree.root, id, &popover_geo) + menu_container_bounds(tree, id, &popover_geo) } .ok_or(GlassError::AxElementInUnmappedPopover(id.0))?; let prev = windows.iter().find(|w| w.active).map(|w| w.id); @@ -746,21 +746,6 @@ fn owning_popover( .map(|w| w.id) } -/// Path of nodes from `root` to `target` (inclusive of both ends), in that order — -/// `None` if `target` isn't in this tree. -fn ancestor_path(root: &AxNode, target: AxNodeId) -> Option> { - if root.id == target { - return Some(vec![root]); - } - for child in &root.children { - if let Some(mut path) = ancestor_path(child, target) { - path.insert(0, root); - return Some(path); - } - } - None -} - /// The bounds of the ancestor of `target` whose size most closely matches `popover`'s /// window size (within 16px tolerance on each dimension) — the element's realized /// menu/list container, e.g. a dropdown popup's `List`. Its origin recovers the @@ -775,14 +760,14 @@ fn ancestor_path(root: &AxNode, target: AxNodeId) -> Option> { /// the popover's exact size (not proximity to `target`) picks the real container: it /// tracks the popover's size most tightly, while wrappers trimmed by padding/scrollbars /// drift further from it. Ties (equal score) break toward the shallower ancestor — the -/// one closer to `root` — since `ancestor_path` walks root-to-target and `min_by_key` +/// one closer to `root` — since [`AxTree::path_to`] returns root-to-target and `min_by_key` /// keeps the first minimum. fn menu_container_bounds( - root: &AxNode, + tree: &AxTree, target: AxNodeId, popover: &WindowGeometry, ) -> Option { - let path = ancestor_path(root, target)?; + let path = tree.path_to(target)?; path.iter() .filter_map(|node| { let b = node.bounds?; @@ -1106,7 +1091,7 @@ mod tests { let looser = ax_node(1, AxRole::Group, Some(rect(0, 0, 110, 110)), vec![close]); let root = ax_node(0, AxRole::Window, Some(rect(0, 0, 400, 400)), vec![looser]); assert_eq!( - menu_container_bounds(&root, AxNodeId(2), &popover), + menu_container_bounds(&AxTree::new(root.clone()), AxNodeId(2), &popover), Some(rect(0, 0, 100, 100)) ); @@ -1114,13 +1099,13 @@ mod tests { let edge = ax_node(2, AxRole::List, Some(rect(0, 0, 116, 100)), vec![]); let root_edge = ax_node(0, AxRole::Window, Some(rect(0, 0, 400, 400)), vec![edge]); assert_eq!( - menu_container_bounds(&root_edge, AxNodeId(2), &popover), + menu_container_bounds(&AxTree::new(root_edge.clone()), AxNodeId(2), &popover), Some(rect(0, 0, 116, 100)) ); let past = ax_node(2, AxRole::List, Some(rect(0, 0, 117, 100)), vec![]); let root_past = ax_node(0, AxRole::Window, Some(rect(0, 0, 400, 400)), vec![past]); assert_eq!( - menu_container_bounds(&root_past, AxNodeId(2), &popover), + menu_container_bounds(&AxTree::new(root_past.clone()), AxNodeId(2), &popover), None ); @@ -1128,13 +1113,13 @@ mod tests { let edge_h = ax_node(2, AxRole::List, Some(rect(0, 0, 100, 116)), vec![]); let root_edge_h = ax_node(0, AxRole::Window, Some(rect(0, 0, 400, 400)), vec![edge_h]); assert_eq!( - menu_container_bounds(&root_edge_h, AxNodeId(2), &popover), + menu_container_bounds(&AxTree::new(root_edge_h.clone()), AxNodeId(2), &popover), Some(rect(0, 0, 100, 116)) ); let past_h = ax_node(2, AxRole::List, Some(rect(0, 0, 100, 117)), vec![]); let root_past_h = ax_node(0, AxRole::Window, Some(rect(0, 0, 400, 400)), vec![past_h]); assert_eq!( - menu_container_bounds(&root_past_h, AxNodeId(2), &popover), + menu_container_bounds(&AxTree::new(root_past_h.clone()), AxNodeId(2), &popover), None ); @@ -1145,7 +1130,7 @@ mod tests { let even = ax_node(1, AxRole::Group, Some(rect(0, 0, 105, 105)), vec![lopsided]); let root_score = ax_node(0, AxRole::Window, Some(rect(0, 0, 400, 400)), vec![even]); assert_eq!( - menu_container_bounds(&root_score, AxNodeId(2), &popover), + menu_container_bounds(&AxTree::new(root_score.clone()), AxNodeId(2), &popover), Some(rect(0, 0, 105, 105)), "the lower summed difference must win, not the lower product" ); @@ -1154,7 +1139,7 @@ mod tests { let smaller = ax_node(2, AxRole::List, Some(rect(0, 0, 90, 90)), vec![]); let root_small = ax_node(0, AxRole::Window, Some(rect(0, 0, 400, 400)), vec![smaller]); assert_eq!( - menu_container_bounds(&root_small, AxNodeId(2), &popover), + menu_container_bounds(&AxTree::new(root_small.clone()), AxNodeId(2), &popover), Some(rect(0, 0, 90, 90)) ); } @@ -1285,7 +1270,7 @@ mod tests { height: 135, }; assert_eq!( - menu_container_bounds(&root, AxNodeId(2), &popover), + menu_container_bounds(&AxTree::new(root.clone()), AxNodeId(2), &popover), Some(list_bounds) ); } @@ -1322,7 +1307,10 @@ mod tests { width: 326, height: 135, }; - assert_eq!(menu_container_bounds(&root, AxNodeId(1), &popover), None); + assert_eq!( + menu_container_bounds(&AxTree::new(root.clone()), AxNodeId(1), &popover), + None + ); } #[test] @@ -1411,7 +1399,7 @@ mod tests { vec![container], ); assert_eq!( - menu_container_bounds(&root, AxNodeId(6), &popover), + menu_container_bounds(&AxTree::new(root.clone()), AxNodeId(6), &popover), Some(container_bounds), "the real container (closest in size to the popover) must win over nearer wrapper groups" ); @@ -1458,7 +1446,7 @@ mod tests { vec![content], ); assert_eq!( - menu_container_bounds(&root, AxNodeId(2), &popover), + menu_container_bounds(&AxTree::new(root.clone()), AxNodeId(2), &popover), Some(content_bounds), "both root and content are within tolerance, but content is numerically \ closest to the popover's size and must win over the outer window root" diff --git a/crates/glass-core/src/session/mod.rs b/crates/glass-core/src/session/mod.rs index 8a80604f..13ec636d 100644 --- a/crates/glass-core/src/session/mod.rs +++ b/crates/glass-core/src/session/mod.rs @@ -4,7 +4,6 @@ use crate::accessibility::{ Accessibility, AxContext, AxNode, AxNodeId, AxRect, AxRole, AxTarget, AxTree, ChangeSignal, ChangeWait, ClickMethod, ElementCondition, ElementInfo, ElementMatch, WalkLimits, - element_match, }; use crate::baseline::BaselineStore; use crate::deadline::Deadline; diff --git a/crates/glass-core/src/session/wait.rs b/crates/glass-core/src/session/wait.rs index dffbb99b..748093ff 100644 --- a/crates/glass-core/src/session/wait.rs +++ b/crates/glass-core/src/session/wait.rs @@ -462,8 +462,7 @@ impl Glass { Err(e) => return Err(e), }; Ok( - match element_match( - &tree, + match tree.element_match( params.name.as_deref(), params.role, params.value_contains.as_deref(), @@ -621,8 +620,7 @@ impl Glass { // `?`, so a reader giving up at the deadline would turn the sweep's soft `{matched:false}` // into an error. let tree = self.a11y_resnapshot(Deadline::UNBOUNDED)?; - let found = match element_match( - &tree, + let found = match tree.element_match( params.name.as_deref(), params.role, params.value_contains.as_deref(),