Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 107 additions & 48 deletions crates/glass-core/src/accessibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<&AxNode>> {
fn walk(node: &AxNode, id: AxNodeId, path: &mut Vec<usize>) -> 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
Expand Down Expand Up @@ -1390,60 +1424,58 @@ 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<AxRole>,
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>,
role: Option<AxRole>,
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)]
Expand Down Expand Up @@ -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<_>>(),
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();
Expand Down
48 changes: 18 additions & 30 deletions crates/glass-core/src/session/a11y.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<Vec<&AxNode>> {
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
Expand All @@ -775,14 +760,14 @@ fn ancestor_path(root: &AxNode, target: AxNodeId) -> Option<Vec<&AxNode>> {
/// 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<crate::accessibility::AxRect> {
let path = ancestor_path(root, target)?;
let path = tree.path_to(target)?;
path.iter()
.filter_map(|node| {
let b = node.bounds?;
Expand Down Expand Up @@ -1106,35 +1091,35 @@ 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))
);

// Exactly at the tolerance on one axis is still in; one past it is out.
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
);

// The same on the other axis, so one tolerance tightened is not covered by the other.
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
);

Expand All @@ -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"
);
Expand All @@ -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))
);
}
Expand Down Expand Up @@ -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)
);
}
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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"
);
Expand Down Expand Up @@ -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"
Expand Down
1 change: 0 additions & 1 deletion crates/glass-core/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 2 additions & 4 deletions crates/glass-core/src/session/wait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down