perf: trim actionability preflight and resolve-search allocations

- action_list: gate the AXValue and AXExpanded `is_settable` probes on whether
  the role could plausibly carry that capability (unknown roles always probe),
  skipping up to two AX round trips per preflight on common click-only targets.
  No capability is lost — value/expandable roles still probe.
- resolve_search: reuse one scratch FxHashSet across nodes instead of allocating
  a fresh dedup set per node during path and recursive search.
This commit is contained in:
Lahfir 2026-06-10 04:32:08 -07:00
parent 16351b05a2
commit 4784424fad
2 changed files with 50 additions and 5 deletions

View file

@ -34,13 +34,16 @@ pub(crate) fn platform_available_actions(
if has_scroll_mechanism(role, &has, has_scrollbars) {
push_unique(&mut actions, capability::SCROLL);
}
if has("AXIncrement") || has("AXDecrement") || is_attr_settable(el, kAXValueAttribute) {
if has("AXIncrement")
|| has("AXDecrement")
|| (role_may_bear_value(role) && is_attr_settable(el, kAXValueAttribute))
{
push_unique(&mut actions, capability::SET_VALUE);
}
if is_attr_settable(el, kAXFocusedAttribute) {
push_unique(&mut actions, capability::SET_FOCUS);
}
if is_attr_settable(el, "AXExpanded")
if (role_may_expand(role) && is_attr_settable(el, "AXExpanded"))
|| (has("AXPress") && agent_desktop_core::roles::is_expandable_role(role))
{
push_unique(&mut actions, capability::EXPAND);
@ -50,6 +53,38 @@ pub(crate) fn platform_available_actions(
actions
}
/// Whether a role could carry a settable `AXValue`, so the `is_settable` probe
/// is worth an IPC. Click/navigation-only roles never do; `unknown` always
/// probes so an unmapped role never loses a capability.
fn role_may_bear_value(role: &str) -> bool {
matches!(
role,
"textfield"
| "combobox"
| "slider"
| "incrementor"
| "stepper"
| "spinbutton"
| "checkbox"
| "radiobutton"
| "switch"
| "colorwell"
| "scrollbar"
| "valueindicator"
| "unknown"
)
}
/// Whether a role could expose a settable `AXExpanded`. Leaf/interactive roles
/// never expand; `unknown` always probes.
fn role_may_expand(role: &str) -> bool {
agent_desktop_core::roles::is_expandable_role(role)
|| matches!(
role,
"group" | "outline" | "row" | "browser" | "table" | "list" | "cell" | "unknown"
)
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn platform_available_actions(
_el: &AXElement,

View file

@ -54,11 +54,12 @@ fn element_at_path(
deadline: Instant,
) -> Result<Option<AXElement>, AdapterError> {
let mut current = root.clone();
let mut seen = FxHashSet::default();
for idx in path {
ensure_before_deadline(deadline)?;
set_messaging_timeout(&current, remaining_before_deadline(deadline)?);
let ax_role = copy_string_attr(&current, accessibility_sys::kAXRoleAttribute);
let children = resolve_children(&current, ax_role.as_deref(), deadline)?;
let children = resolve_children(&current, ax_role.as_deref(), deadline, &mut seen)?;
let Some(child) = children.get(*idx) else {
return Ok(None);
};
@ -77,6 +78,7 @@ pub(super) fn find_entry_in_roots(
) -> Result<NativeHandle, AdapterError> {
let mut matches = Vec::new();
let mut seen_matches = ElementDedupe;
let mut child_scratch = FxHashSet::default();
for root in roots {
if should_stop_collecting(matches.len(), entry) {
break;
@ -88,6 +90,7 @@ pub(super) fn find_entry_in_roots(
ancestors: &mut visited,
seen_matches: &mut seen_matches,
matches: &mut matches,
child_scratch: &mut child_scratch,
deadline,
};
collect_elements_recursive(root, 0, &mut context)?;
@ -102,6 +105,7 @@ struct CollectContext<'a> {
ancestors: &'a mut FxHashSet<usize>,
seen_matches: &'a mut ElementDedupe,
matches: &'a mut Vec<AXElement>,
child_scratch: &'a mut FxHashSet<usize>,
deadline: Instant,
}
@ -136,7 +140,12 @@ fn collect_elements_recursive(
}
if depth < context.max_depth && !should_prune_for_resolution(el, context.entry, depth) {
let children = resolve_children(el, ax_role.as_deref(), context.deadline)?;
let children = resolve_children(
el,
ax_role.as_deref(),
context.deadline,
context.child_scratch,
)?;
for child in &children {
collect_elements_recursive(child, depth + 1, context)?;
}
@ -187,8 +196,9 @@ fn resolve_children(
el: &AXElement,
ax_role: Option<&str>,
deadline: Instant,
seen: &mut FxHashSet<usize>,
) -> Result<Vec<AXElement>, AdapterError> {
let mut seen = FxHashSet::default();
seen.clear();
let mut result = Vec::new();
for attr in child_attributes(ax_role) {
ensure_before_deadline(deadline)?;