smix_selector_resolver/lib.rs
1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! smix-selector-resolver — Selector resolution against an [`A11yNode`]
6//! tree (stone, hot path).
7//!
8//! Resolution pipeline:
9//!
10//! 1. **Collect**: DFS pre-order over the tree, collect every node whose
11//! base form matches (text / id / label / role+name / focused / anchor
12//! accept-all).
13//! 2. **Visibility filter**: drop nodes whose bounds are
14//! zero or completely outside the tree viewport — unless no candidate
15//! is visible, then drop nothing, which preserves miss reports.
16//! 3. **Spatial filter**: for each
17//! `near/below/above/leftOf/rightOf/inside` key (top-level for
18//! base 1-4, or inside `anchor.*` for base 6), recursively resolve
19//! the anchor sub-selector; filter candidates by centroid-axis or
20//! geometric containment. AND semantics. Anchor null → overall null.
21//! 4. **Index pick**: apply `first/last/nth` in declaration
22//! order; later overrides earlier — `nth` wins if both `first` and
23//! `nth` are set.
24//!
25//! # Pattern compile cache
26//!
27//! Selector trees carry [`Pattern`] (wire form). Every text/regex match
28//! site re-compiles the regex unless cached. The resolver builds a
29//! per-call `ResolverContext` that walks the selector tree once, calls
30//! [`Pattern::compile`] on every [`Pattern`] node, and stores the
31//! resulting [`CompiledPattern`] keyed by the raw pointer to the wire
32//! `Pattern` (stable for the lifetime of the borrowed selector).
33//!
34//! This cache is the pipeline's key perf gain: for a 100-node tree with
35//! a single text base, 1× compile + 100 × match_compiled costs far less
36//! than 100 × compile-and-match.
37
38#![doc(html_root_url = "https://docs.smix.dev/smix-selector-resolver")]
39
40use smix_screen::{A11yNode, Rect, Role, is_visible_enough};
41use smix_selector::{
42 AnchorBox, CompiledPattern, Modifiers, Pattern, Selector, match_text_compiled,
43};
44use std::collections::HashMap;
45
46const NEAR_THRESHOLD_PT: f64 = 100.0; // logical points @1x
47
48// -------------------- public API -----------------------------------------
49
50/// Resolve a [`Selector`] against an [`A11yNode`] tree. Returns the first
51/// surviving candidate after collect → visibility → spatial → index.
52///
53/// Returns `None` when the selector has no matching node OR any
54/// transitive regex compilation fails — a compile error yields a silent
55/// `None` rather than an error, so callers who need to surface compile
56/// failures explicitly should `Pattern::compile()` first.
57#[must_use]
58pub fn resolve_selector<'tree>(
59 tree: &'tree A11yNode,
60 selector: &Selector,
61) -> Option<&'tree A11yNode> {
62 let ctx = ResolverContext::new(selector)?;
63 resolve_selector_compiled(tree, selector, &ctx)
64}
65
66/// Resolve all matching candidates.
67///
68/// Same pipeline as [`resolve_selector`] but skips the final index pick —
69/// `first/last/nth` are silently ignored when present (Playwright
70/// `locator(...).all()` semantics: "find all" is incompatible with "pick
71/// one by position").
72#[must_use]
73pub fn resolve_selector_all<'tree>(
74 tree: &'tree A11yNode,
75 selector: &Selector,
76) -> Vec<&'tree A11yNode> {
77 let Some(ctx) = ResolverContext::new(selector) else {
78 return vec![];
79 };
80 resolve_selector_all_compiled(tree, selector, &ctx)
81}
82
83/// Resolve a [`Selector`] against an [`A11yNode`] tree using a caller-
84/// provided [`ResolverContext`]. Same pipeline as [`resolve_selector`]
85/// but skips the per-call cache build — pass a reusable context built
86/// once via [`ResolverContext::new`] to amortize regex compile cost
87/// across many calls (typical retry loop in `smix-driver::wait_for` /
88/// `scroll`). See [`ResolverContext`] doc for the lifetime contract.
89#[must_use]
90pub fn resolve_selector_compiled<'tree>(
91 tree: &'tree A11yNode,
92 selector: &Selector,
93 ctx: &ResolverContext,
94) -> Option<&'tree A11yNode> {
95 resolve_inner(tree, selector, ctx).into_iter().next()
96}
97
98/// Resolve all matching candidates with a caller-provided
99/// [`ResolverContext`]. Same as [`resolve_selector_all`] minus the per-
100/// call cache build. See [`resolve_selector_compiled`] for the reuse
101/// pattern.
102#[must_use]
103pub fn resolve_selector_all_compiled<'tree>(
104 tree: &'tree A11yNode,
105 selector: &Selector,
106 ctx: &ResolverContext,
107) -> Vec<&'tree A11yNode> {
108 resolve_inner_no_index(tree, selector, ctx)
109}
110
111// -------------------- ResolverContext (compile cache) --------------------
112
113/// Per-call cache mapping raw pointer of each [`Pattern`] in the selector
114/// tree to its [`CompiledPattern`]. Built once at entry; lookup is O(1)
115/// hash.
116///
117/// # Cache reuse across calls
118///
119/// Build a single `ResolverContext` outside a retry loop and pass it to
120/// [`resolve_selector_compiled`] / [`resolve_selector_all_compiled`] on
121/// every iteration to skip the per-call regex compile prepass. The
122/// regex hit case drops from ~9.5 µs/iter to ~260 ns/iter on a 15-node
123/// tree (see `BUDGETS.md`). The convenience
124/// wrappers [`resolve_selector`] / [`resolve_selector_all`] keep the
125/// per-call construction for one-shot callers.
126///
127/// # Safety
128///
129/// `*const Pattern` is used only as a HashMap key — never dereferenced.
130/// The borrowed [`Selector`] passed to [`ResolverContext::new`] must
131/// outlive every subsequent `resolve_selector_compiled` call that uses
132/// this context. Pattern addresses stay stable for the lifetime of the
133/// outer `&Selector` borrow. Passing a context built from a different
134/// `Selector` than the one being resolved is a runtime contract
135/// violation (cache misses degrade to silent `None` from `ctx.pattern`).
136pub struct ResolverContext {
137 compiled: HashMap<*const Pattern, CompiledPattern>,
138}
139
140// SAFETY: `*const Pattern` is used only as a HashMap key — never
141// dereferenced. The borrowed Selector (whose Pattern addresses are
142// captured at `new`) outlives the context per the documented lifetime
143// contract. Pointer values are inert bits across threads, so moving a
144// ResolverContext between threads (e.g. a tokio worker carrying the
145// future built by `smix-driver::wait_for` / `scroll`) is safe.
146// CompiledPattern itself is already Send + Sync (regex::Regex +
147// String).
148unsafe impl Send for ResolverContext {}
149unsafe impl Sync for ResolverContext {}
150
151impl ResolverContext {
152 /// Build a context by walking the selector tree once and pre-compiling
153 /// every embedded [`Pattern`]. Returns `None` if any regex pattern
154 /// fails to compile (matches the silent-`None` semantic of
155 /// [`resolve_selector`]).
156 pub fn new(selector: &Selector) -> Option<Self> {
157 let mut compiled = HashMap::new();
158 if !Self::compile_selector(selector, &mut compiled) {
159 return None;
160 }
161 Some(ResolverContext { compiled })
162 }
163
164 /// Walks the selector tree once, compiles every [`Pattern`]. Returns
165 /// false on any compile error.
166 fn compile_selector(
167 selector: &Selector,
168 out: &mut HashMap<*const Pattern, CompiledPattern>,
169 ) -> bool {
170 match selector {
171 Selector::Text { text, modifiers } => {
172 if !Self::cache_pattern(text, out) {
173 return false;
174 }
175 Self::compile_modifiers(modifiers, out)
176 }
177 Selector::Id { modifiers, .. } | Selector::Label { modifiers, .. } => {
178 Self::compile_modifiers(modifiers, out)
179 }
180 Selector::Role {
181 name, modifiers, ..
182 } => {
183 if let Some(name_pat) = name
184 && !Self::cache_pattern(name_pat, out)
185 {
186 return false;
187 }
188 Self::compile_modifiers(modifiers, out)
189 }
190 Selector::Focused { .. } => true,
191 Selector::Anchor { anchor, .. } => Self::compile_anchor(anchor, out),
192 Selector::LocalizedText { modifiers, .. } => {
193 // The adapter is expected to desugar LocalizedText →
194 // Selector::Text before invoking the resolver. The variant
195 // reaches compile only when the caller forgot to desugar
196 // (test path / direct SDK use without adapter). Compile the
197 // modifiers so the call doesn't crash; the actual match
198 // (matches_base) will return false for any node, and the
199 // caller will see "no match" — debuggable via describe_selector.
200 Self::compile_modifiers(modifiers, out)
201 }
202 Selector::OcrText { modifiers, .. } => {
203 // The adapter handles OcrText dispatch directly via
204 // App::find_by_text_ocr + tap_at_norm_coord, bypassing the
205 // resolver pipeline. Variant reaches resolver only when
206 // adapter forgot to dispatch; treat same as LocalizedText
207 // (compile modifiers; matches_base returns false).
208 Self::compile_modifiers(modifiers, out)
209 }
210 Selector::AnchorRelative { anchor, .. } => {
211 // The adapter dispatches AnchorRelative directly via
212 // App::find_norm_coord(anchor) + tap_at_norm_coord, never
213 // calls resolver on the AnchorRelative itself. But the
214 // ANCHOR sub-selector reaches the resolver through SDK
215 // App::find — must compile its patterns recursively.
216 Self::compile_selector(anchor, out)
217 }
218 Selector::Point { .. } => true,
219 Selector::Fallback { fallback } => {
220 // Compile every chain element's patterns; the adapter
221 // iterates chain in dispatch but each sub-selector may need
222 // its own pattern cache. Returns false on first compile
223 // failure (matches LocalizedText / OcrText semantics).
224 fallback.iter().all(|s| Self::compile_selector(s, out))
225 }
226 }
227 }
228
229 fn cache_pattern(p: &Pattern, out: &mut HashMap<*const Pattern, CompiledPattern>) -> bool {
230 let key = p as *const Pattern;
231 if out.contains_key(&key) {
232 return true;
233 }
234 match p.compile() {
235 Ok(cp) => {
236 out.insert(key, cp);
237 true
238 }
239 Err(_) => false,
240 }
241 }
242
243 fn compile_modifiers(
244 m: &Modifiers,
245 out: &mut HashMap<*const Pattern, CompiledPattern>,
246 ) -> bool {
247 let slots = [
248 m.near.as_deref(),
249 m.below.as_deref(),
250 m.above.as_deref(),
251 m.left_of.as_deref(),
252 m.right_of.as_deref(),
253 m.inside.as_deref(),
254 m.ancestor.as_deref(),
255 ];
256 for child in slots.iter().flatten() {
257 if !Self::compile_selector(child, out) {
258 return false;
259 }
260 }
261 true
262 }
263
264 fn compile_anchor(a: &AnchorBox, out: &mut HashMap<*const Pattern, CompiledPattern>) -> bool {
265 let slots = [
266 a.near.as_deref(),
267 a.below.as_deref(),
268 a.above.as_deref(),
269 a.left_of.as_deref(),
270 a.right_of.as_deref(),
271 a.inside.as_deref(),
272 ];
273 for child in slots.iter().flatten() {
274 if !Self::compile_selector(child, out) {
275 return false;
276 }
277 }
278 true
279 }
280
281 /// O(1) cache lookup. Returns `None` if `p` was not seen during the
282 /// build prepass — typically means `p` belongs to a different
283 /// selector tree than the one passed to [`ResolverContext::new`].
284 pub fn pattern(&self, p: &Pattern) -> Option<&CompiledPattern> {
285 self.compiled.get(&(p as *const Pattern))
286 }
287}
288
289// -------------------- resolve pipeline -----------------------------------
290
291fn resolve_inner<'tree>(
292 tree: &'tree A11yNode,
293 selector: &Selector,
294 ctx: &ResolverContext,
295) -> Vec<&'tree A11yNode> {
296 let raw = dfs_collect(tree, |n| matches_base(n, selector, ctx));
297 let visible: Vec<&A11yNode> = raw
298 .into_iter()
299 .filter(|n| is_visible_enough(n, tree))
300 .collect();
301 let topmost = topmost_modal_filter(tree, visible);
302 // Explicit structural intent (ancestor / spatial modifier)
303 // overrides implicit interactive preference
304 // (tappable filter). If a non-tappable candidate is the one that
305 // satisfies the user-provided structural / spatial constraint and a
306 // tappable sibling sits in a position that fails it, tappable-first
307 // would drop the non-tappable, leaving only the failing tappable →
308 // empty result. Pipeline: ancestor → spatial → tappable → index.
309 let Some(after_ancestor) = apply_ancestor_filter(tree, topmost, selector) else {
310 return vec![];
311 };
312 let Some(after_spatial) = apply_spatial_filters(tree, after_ancestor, selector, ctx) else {
313 return vec![];
314 };
315 let tappable = tappable_subset_filter(after_spatial);
316 apply_index(tappable, selector)
317}
318
319fn resolve_inner_no_index<'tree>(
320 tree: &'tree A11yNode,
321 selector: &Selector,
322 ctx: &ResolverContext,
323) -> Vec<&'tree A11yNode> {
324 let raw = dfs_collect(tree, |n| matches_base(n, selector, ctx));
325 let visible: Vec<&A11yNode> = raw
326 .into_iter()
327 .filter(|n| is_visible_enough(n, tree))
328 .collect();
329 let topmost = topmost_modal_filter(tree, visible);
330 let Some(after_ancestor) = apply_ancestor_filter(tree, topmost, selector) else {
331 return vec![];
332 };
333 let Some(after_spatial) = apply_spatial_filters(tree, after_ancestor, selector, ctx) else {
334 return vec![];
335 };
336 tappable_subset_filter(after_spatial)
337}
338
339// Tappable preference. When the candidate set mixes tappable
340// (button / link / cell / tab / menuItem) and non-tappable (alert /
341// staticText / window / group / ...), drop the non-tappable so a single
342// label selector picks the actual interactive element, not the alert
343// container or its title. Same semantic as `XCUIElementQuery.firstMatch`
344// which favours hit-testable leaves over their surrounding chrome.
345// Single-kind candidate lists fall through untouched.
346fn tappable_subset_filter(candidates: Vec<&A11yNode>) -> Vec<&A11yNode> {
347 if candidates.len() <= 1 {
348 return candidates;
349 }
350 let is_tappable = |n: &A11yNode| -> bool {
351 matches!(
352 n.raw_type.as_str(),
353 "button" | "link" | "cell" | "tab" | "menuItem"
354 )
355 };
356 let has_tappable = candidates.iter().any(|n| is_tappable(n));
357 let has_non_tappable = candidates.iter().any(|n| !is_tappable(n));
358 if has_tappable && has_non_tappable {
359 candidates.into_iter().filter(|n| is_tappable(n)).collect()
360 } else {
361 candidates
362 }
363}
364
365// Topmost hit-test (Apple XCUIElementQuery.firstMatch + maestro
366// findElement semantic): when ≥1 candidate is inside a
367// Role::Alert / Role::Dialog subtree (modal overlay in play), drop
368// the candidates that live under the underlying drawer/page so a
369// single selector picks the modal button. When no modal overlay is
370// present, the input list passes through unchanged — plain DFS
371// pre-order behaviour stays intact.
372// Modal container detection. The Swift `/tree` route does not emit a `role`
373// field (only `rawType`, see TreeRoute.swift `nodeToDict`) — so Rust-side
374// `Role::Alert` / `Role::Dialog` are always None on real-sim payloads.
375// Match `raw_type` strings directly (lower-case wire shape, mirrors
376// `elementTypeName(7 / 8)`); accept the `Role` enum too so unit-test
377// fixtures that set `role` only stay valid.
378fn is_modal_node(n: &A11yNode) -> bool {
379 matches!(n.raw_type.as_str(), "alert" | "dialog")
380 || matches!(n.role, Some(Role::Alert) | Some(Role::Dialog))
381}
382
383fn tree_has_modal_role(node: &A11yNode) -> bool {
384 if is_modal_node(node) {
385 return true;
386 }
387 node.children.iter().any(tree_has_modal_role)
388}
389
390fn topmost_modal_filter<'tree>(
391 tree: &'tree A11yNode,
392 candidates: Vec<&'tree A11yNode>,
393) -> Vec<&'tree A11yNode> {
394 // Fast path: ≤1 candidate has nothing to disambiguate; trees with no
395 // Alert/Dialog node anywhere fall back to v1.x DFS pre-order with
396 // zero overhead (a single shallow tree walk via `any`).
397 if candidates.len() <= 1 || !tree_has_modal_role(tree) {
398 return candidates;
399 }
400 let mut parent: HashMap<*const A11yNode, &'tree A11yNode> = HashMap::new();
401 fn walk<'tree>(n: &'tree A11yNode, parent: &mut HashMap<*const A11yNode, &'tree A11yNode>) {
402 for c in &n.children {
403 parent.insert(c as *const A11yNode, n);
404 walk(c, parent);
405 }
406 }
407 walk(tree, &mut parent);
408
409 let in_modal_subtree = |start: &A11yNode| -> bool {
410 if is_modal_node(start) {
411 return true;
412 }
413 let mut cur: *const A11yNode = start;
414 while let Some(p) = parent.get(&cur) {
415 if is_modal_node(p) {
416 return true;
417 }
418 cur = *p as *const A11yNode;
419 }
420 false
421 };
422
423 let in_modal: Vec<&'tree A11yNode> = candidates
424 .iter()
425 .copied()
426 .filter(|n| in_modal_subtree(n))
427 .collect();
428 if in_modal.is_empty() {
429 candidates
430 } else {
431 in_modal
432 }
433}
434
435fn dfs_collect<'tree, F>(tree: &'tree A11yNode, pred: F) -> Vec<&'tree A11yNode>
436where
437 F: Fn(&A11yNode) -> bool,
438{
439 let mut out: Vec<&'tree A11yNode> = Vec::new();
440 fn walk<'tree, F: Fn(&A11yNode) -> bool>(
441 n: &'tree A11yNode,
442 pred: &F,
443 out: &mut Vec<&'tree A11yNode>,
444 ) {
445 if pred(n) {
446 out.push(n);
447 }
448 for c in &n.children {
449 walk(c, pred, out);
450 }
451 }
452 walk(tree, &pred, &mut out);
453 out
454}
455
456fn matches_base(node: &A11yNode, selector: &Selector, ctx: &ResolverContext) -> bool {
457 match selector {
458 // Anchor-only base: every node is a candidate.
459 Selector::Anchor { .. } => true,
460 Selector::Text { text, .. } => match ctx.pattern(text) {
461 Some(cp) => match_text_compiled(node, cp),
462 None => false,
463 },
464 Selector::Id { id, .. } => {
465 if id.is_empty() {
466 return false;
467 }
468 node.identifier.as_deref() == Some(id.as_str())
469 }
470 Selector::Label { label, .. } => {
471 if label.is_empty() {
472 return false;
473 }
474 node.label.as_deref() == Some(label.as_str())
475 }
476 Selector::Role { role, name, .. } => {
477 if node.role != Some(*role) {
478 return false;
479 }
480 match name {
481 None => true,
482 Some(name_pat) => match ctx.pattern(name_pat) {
483 Some(cp) => match_text_compiled(node, cp),
484 None => false,
485 },
486 }
487 }
488 Selector::Focused { .. } => node.has_focus,
489 // The adapter is expected to desugar LocalizedText → Text
490 // before resolving. If we somehow get here (unit-test path / direct
491 // SDK use), no node matches — describe_selector still renders this
492 // variant for AI-readable error output.
493 Selector::LocalizedText { .. } => false,
494 // The adapter dispatches OcrText directly via OCR + tap_at_coord
495 // and never invokes the resolver pipeline. The variant reaches here
496 // only when the adapter forgot to dispatch; no node matches.
497 Selector::OcrText { .. } => false,
498 // The adapter dispatches AnchorRelative directly (resolve anchor
499 // sub-selector → norm coord + dx/dy → tap_at_norm_coord). Variant
500 // never reaches here through the standard pipeline; if it somehow
501 // does, no node matches.
502 Selector::AnchorRelative { .. } => false,
503 // Point + Fallback are dispatched by the adapter without
504 // resolver involvement. Reaching matches_base means a caller
505 // forgot to dispatch; no node matches.
506 Selector::Point { .. } | Selector::Fallback { .. } => false,
507 }
508}
509
510// -------------------- ancestor modifier ----------------------------------
511
512// Ancestor-chain filter. Keep only candidates whose a11y tree parent chain
513// (recursive ancestors, excluding self) contains at least one node matching
514// the selector's `Modifiers::ancestor` sub-selector. Ancestor sub-selector
515// resolving to empty short-circuits the whole resolve to None — same
516// semantics as a null spatial anchor.
517//
518// Different from `inside` spatial modifier (geometric bounds-containment):
519// `ancestor` walks the a11y tree parent chain — structural filter, not spatial.
520// Same parent-map idiom as `topmost_modal_filter`.
521//
522// `Selector::Anchor` / `Selector::Focused` have no `Modifiers::ancestor`
523// field on their wire shape (anchor base is already a spatial intent,
524// focused is runtime-resolved without modifiers), so they passthrough.
525fn apply_ancestor_filter<'tree>(
526 tree: &'tree A11yNode,
527 candidates: Vec<&'tree A11yNode>,
528 selector: &Selector,
529) -> Option<Vec<&'tree A11yNode>> {
530 let ancestor_sel = match selector {
531 Selector::Text { modifiers, .. }
532 | Selector::Id { modifiers, .. }
533 | Selector::Label { modifiers, .. }
534 | Selector::Role { modifiers, .. }
535 | Selector::LocalizedText { modifiers, .. }
536 | Selector::OcrText { modifiers, .. } => modifiers.ancestor.as_deref(),
537 // The anchor form carries its own ancestor sub-selector.
538 Selector::Anchor { anchor, .. } => anchor.ancestor.as_deref(),
539 // AnchorRelative has no Modifiers (the anchor sub-selector carries
540 // its own); the adapter dispatches directly, so ancestor is n/a.
541 // Point + Fallback carry no Modifiers either.
542 Selector::Focused { .. }
543 | Selector::AnchorRelative { .. }
544 | Selector::Point { .. }
545 | Selector::Fallback { .. } => None,
546 };
547 let Some(ancestor_sel) = ancestor_sel else {
548 return Some(candidates);
549 };
550 let anchor = resolve_selector(tree, ancestor_sel)?;
551
552 let mut parent: HashMap<*const A11yNode, &'tree A11yNode> = HashMap::new();
553 fn walk<'tree>(n: &'tree A11yNode, parent: &mut HashMap<*const A11yNode, &'tree A11yNode>) {
554 for c in &n.children {
555 parent.insert(c as *const A11yNode, n);
556 walk(c, parent);
557 }
558 }
559 walk(tree, &mut parent);
560
561 let surviving: Vec<&'tree A11yNode> = candidates
562 .into_iter()
563 .filter(|c| {
564 if std::ptr::eq(*c, anchor) {
565 return false;
566 }
567 let mut cur: *const A11yNode = *c;
568 while let Some(p) = parent.get(&cur) {
569 if std::ptr::eq(*p, anchor) {
570 return true;
571 }
572 cur = *p as *const A11yNode;
573 }
574 false
575 })
576 .collect();
577 Some(surviving)
578}
579
580// -------------------- spatial filter -------------------------------------
581
582#[derive(Clone, Copy)]
583enum SpatialKey {
584 Near,
585 Below,
586 Above,
587 LeftOf,
588 RightOf,
589 Inside,
590}
591
592const SPATIAL_KEYS: [SpatialKey; 6] = [
593 SpatialKey::Near,
594 SpatialKey::Below,
595 SpatialKey::Above,
596 SpatialKey::LeftOf,
597 SpatialKey::RightOf,
598 SpatialKey::Inside,
599];
600
601fn get_spatial(selector: &Selector, key: SpatialKey) -> Option<&Selector> {
602 match selector {
603 Selector::Anchor { anchor, .. } => match key {
604 SpatialKey::Near => anchor.near.as_deref(),
605 SpatialKey::Below => anchor.below.as_deref(),
606 SpatialKey::Above => anchor.above.as_deref(),
607 SpatialKey::LeftOf => anchor.left_of.as_deref(),
608 SpatialKey::RightOf => anchor.right_of.as_deref(),
609 SpatialKey::Inside => anchor.inside.as_deref(),
610 },
611 Selector::Text { modifiers, .. }
612 | Selector::Id { modifiers, .. }
613 | Selector::Label { modifiers, .. }
614 | Selector::Role { modifiers, .. }
615 | Selector::LocalizedText { modifiers, .. }
616 | Selector::OcrText { modifiers, .. } => match key {
617 SpatialKey::Near => modifiers.near.as_deref(),
618 SpatialKey::Below => modifiers.below.as_deref(),
619 SpatialKey::Above => modifiers.above.as_deref(),
620 SpatialKey::LeftOf => modifiers.left_of.as_deref(),
621 SpatialKey::RightOf => modifiers.right_of.as_deref(),
622 SpatialKey::Inside => modifiers.inside.as_deref(),
623 },
624 Selector::Focused { .. }
625 | Selector::AnchorRelative { .. }
626 | Selector::Point { .. }
627 | Selector::Fallback { .. } => None,
628 }
629}
630
631fn apply_spatial_filters<'tree>(
632 tree: &'tree A11yNode,
633 candidates: Vec<&'tree A11yNode>,
634 selector: &Selector,
635 _ctx: &ResolverContext,
636) -> Option<Vec<&'tree A11yNode>> {
637 let mut surviving = candidates;
638 for key in &SPATIAL_KEYS {
639 let Some(anchor_sel) = get_spatial(selector, *key) else {
640 continue;
641 };
642 // Recursive anchor resolution. Each anchor sub-selector compiles
643 // its own ResolverContext (Pattern lifetimes are local — caller's
644 // Pattern pointers don't overlap recursively because Box<Selector>
645 // makes each sub-tree a fresh node tree).
646 let Some(anchor) = resolve_selector(tree, anchor_sel) else {
647 return None; // anchor null short-circuits whole resolve to None
648 };
649 surviving.retain(|c| satisfies(*key, c, anchor));
650 }
651 Some(surviving)
652}
653
654fn satisfies(key: SpatialKey, c: &A11yNode, a: &A11yNode) -> bool {
655 if std::ptr::eq(c, a) {
656 return false;
657 }
658 let cc = centroid(c.bounds);
659 let ac = centroid(a.bounds);
660 match key {
661 SpatialKey::Near => dist(cc, ac) <= NEAR_THRESHOLD_PT,
662 SpatialKey::Below => cc.1 > ac.1,
663 SpatialKey::Above => cc.1 < ac.1,
664 SpatialKey::LeftOf => cc.0 < ac.0,
665 SpatialKey::RightOf => cc.0 > ac.0,
666 SpatialKey::Inside => contains(a.bounds, c.bounds),
667 }
668}
669
670#[inline]
671fn centroid(r: Rect) -> (f64, f64) {
672 (r.x + r.w / 2.0, r.y + r.h / 2.0)
673}
674
675#[inline]
676fn dist(p: (f64, f64), q: (f64, f64)) -> f64 {
677 let dx = p.0 - q.0;
678 let dy = p.1 - q.1;
679 (dx * dx + dy * dy).sqrt()
680}
681
682#[inline]
683fn contains(outer: Rect, inner: Rect) -> bool {
684 inner.x >= outer.x
685 && inner.y >= outer.y
686 && inner.x + inner.w <= outer.x + outer.w
687 && inner.y + inner.h <= outer.y + outer.h
688}
689
690// -------------------- index pick -----------------------------------------
691
692fn apply_index<'tree>(list: Vec<&'tree A11yNode>, selector: &Selector) -> Vec<&'tree A11yNode> {
693 let (first, last, nth) = match selector {
694 Selector::Anchor { index, .. } => (index.first, index.last, index.nth),
695 Selector::Text { modifiers, .. }
696 | Selector::Id { modifiers, .. }
697 | Selector::Label { modifiers, .. }
698 | Selector::Role { modifiers, .. }
699 | Selector::LocalizedText { modifiers, .. }
700 | Selector::OcrText { modifiers, .. } => (modifiers.first, modifiers.last, modifiers.nth),
701 Selector::Focused { .. }
702 | Selector::AnchorRelative { .. }
703 | Selector::Point { .. }
704 | Selector::Fallback { .. } => return list,
705 };
706 let has_first = first == Some(true);
707 let has_last = last == Some(true);
708 let has_nth = nth.is_some();
709 if !has_first && !has_last && !has_nth {
710 return list;
711 }
712 // Precedence: first → list[0], last overrides → list[len-1],
713 // nth overrides both → list[nth]. Single-shot return [picked] or [].
714 let picked: Option<&'tree A11yNode> = if has_nth {
715 nth.and_then(|i| list.get(i).copied())
716 } else if has_last {
717 list.last().copied()
718 } else {
719 // has_first
720 list.first().copied()
721 };
722 match picked {
723 Some(n) => vec![n],
724 None => vec![],
725 }
726}