Skip to main content

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}