Skip to main content

smix_host_coord_resolver/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! smix-host-coord-resolver — pure pipeline from `(A11yNode, Selector)`
6//! to normalized `(nx, ny)` tap coordinate, with no I/O.
7//!
8//! This is the host-side resolve step decoupled from any specific
9//! injector (HID / Apple native event chain / runner-side mode=resolve
10//! / etc.). Higher-level orchestrators (smix-driver, user code) fetch
11//! the tree, call this stone to compute the tap coord, then hand it to
12//! their preferred injector.
13//!
14//! # Stone scope
15//!
16//! Pure function, no async, no I/O, no awareness of any specific
17//! injector implementation. Any iOS-automation project that has an
18//! [`A11yNode`] (or equivalent that maps to one) and a structured
19//! selector can adopt this crate to get host-side resolve.
20//!
21//! # Pipeline (mirrors smix-driver's `tap` middle section)
22//!
23//! 1. `resolve_selector(tree, selector)` — DFS + spatial filter +
24//!    visibility check.
25//! 2. Sanity-check matched node frame (w*h > 0).
26//! 3. Compute centroid in app coords.
27//! 4. Normalize by `tree.bounds` (app frame).
28//! 5. Reject (nx, ny) outside (0, 1) as offscreen.
29//!
30//! # Example
31//!
32//! ```
33//! use smix_host_coord_resolver::{resolve_to_norm_coord, HostResolveError};
34//! use smix_screen::{A11yNode, Rect};
35//! use smix_selector::{Modifiers, Pattern, Selector};
36//!
37//! fn mk(label: &str, bounds: Rect) -> A11yNode {
38//!     A11yNode {
39//!         raw_type: "other".into(),
40//!         element_type_raw: 1,
41//!         role: None, identifier: None,
42//!         label: Some(label.into()),
43//!         title: None, placeholder_value: None, value: None, text: None,
44//!         bounds, enabled: true, selected: false, has_focus: false,
45//!         visible: true, children: vec![],
46//!     }
47//! }
48//!
49//! let mut root = mk("root", Rect { x: 0.0, y: 0.0, w: 390.0, h: 844.0 });
50//! root.children.push(mk("Login", Rect { x: 50.0, y: 100.0, w: 200.0, h: 40.0 }));
51//!
52//! let sel = Selector::Text {
53//!     text: Pattern::text("Login"),
54//!     modifiers: Modifiers::default(),
55//! };
56//! let (nx, ny) = resolve_to_norm_coord(&root, &sel).unwrap();
57//! assert!((nx - 150.0 / 390.0).abs() < 1e-9);
58//! assert!((ny - 120.0 / 844.0).abs() < 1e-9);
59//! ```
60
61#![doc(html_root_url = "https://docs.smix.dev/smix-host-coord-resolver")]
62
63use smix_screen::A11yNode;
64use smix_selector::Selector;
65use smix_selector_resolver::resolve_selector;
66use thiserror::Error;
67
68/// Errors returned by [`resolve_to_norm_coord`].
69#[derive(Debug, Error, PartialEq)]
70pub enum HostResolveError {
71    /// The selector matched no node in the tree (after visibility filter).
72    #[error("selector matched no node")]
73    NotFound,
74    /// Matched node has empty bounds (w*h == 0); element is offscreen
75    /// or hidden.
76    #[error("matched node has empty / offscreen frame")]
77    EmptyMatchedFrame,
78    /// Tree bounds (`tree.bounds`) have w/h ≤ 0 — unknown app frame,
79    /// cannot normalize.
80    #[error("tree bounds w/h ≤ 0 — unknown app frame, cannot normalize")]
81    UnknownAppFrame,
82    /// Computed `(nx, ny)` is outside the open interval `(0, 1)` —
83    /// matched node centroid is offscreen.
84    #[error("centroid (nx={nx:.3}, ny={ny:.3}) outside (0, 1) — element offscreen")]
85    CentroidOutOfFrame {
86        /// Normalized x coordinate (would-be tap target).
87        nx: f64,
88        /// Normalized y coordinate (would-be tap target).
89        ny: f64,
90    },
91}
92
93/// Resolve `selector` against `tree` and produce a normalized tap
94/// coordinate `(nx, ny)` in `(0, 1)`.
95///
96/// `tree.bounds` is treated as the app frame (origin top-left, +y down).
97/// The matched node's centroid in app coords is divided by app frame
98/// width/height to produce a value in `(0, 1)`.
99///
100/// Returns `Err(HostResolveError)` for any of: no match, matched node
101/// has empty frame, unknown app frame, computed centroid offscreen.
102#[must_use = "the coordinate should be passed to an injector"]
103pub fn resolve_to_norm_coord(
104    tree: &A11yNode,
105    selector: &Selector,
106) -> Result<(f64, f64), HostResolveError> {
107    let node = resolve_selector(tree, selector).ok_or(HostResolveError::NotFound)?;
108    let app_frame = tree.bounds;
109    if app_frame.w <= 0.0 || app_frame.h <= 0.0 {
110        return Err(HostResolveError::UnknownAppFrame);
111    }
112    if node.bounds.w <= 0.0 || node.bounds.h <= 0.0 {
113        return Err(HostResolveError::EmptyMatchedFrame);
114    }
115    let cx = node.bounds.x + node.bounds.w / 2.0;
116    let cy = node.bounds.y + node.bounds.h / 2.0;
117    let nx = cx / app_frame.w;
118    let ny = cy / app_frame.h;
119    if nx <= 0.0 || nx >= 1.0 || ny <= 0.0 || ny >= 1.0 {
120        return Err(HostResolveError::CentroidOutOfFrame { nx, ny });
121    }
122    Ok((nx, ny))
123}