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