Skip to main content

smix_screen/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! smix-screen — A11yNode + Rect + Bounds + Role types + visibility
6//! primitives (stone). Ported from now-retired TS source: `src/core/screen.ts` +
7//! `src/core/resolve-selector.ts:62-114` (visibility/area logic, v1.5
8//! c5i-d semantics 1:1).
9//!
10//! # Scope
11//!
12//! - Pure types (`Rect`, `Bounds`, `Role`, `A11yNode`) with serde wire
13//!   compatibility (camelCase JSON, matching the existing Swift-side
14//!   SmixRunnerCore `/tree` route shape).
15//! - Pure functions (`is_visible_enough`, `visible_area`) that the
16//!   selector resolver consumes. No I/O, no `node:*` equivalent (跟 mailrs
17//!   `rfc5322` stone 同精神 — protocol parsing/types only).
18//!
19//! # Visibility semantics (v1.5 c5i-d, 1:1 with TS)
20//!
21//! - `b.w <= 0 || b.h <= 0` → invisible (zero-bounds early reject)
22//! - `root.w <= 0 || root.h <= 0` → conservative pass (unknown root)
23//! - Otherwise → any non-empty rectangle intersection with `tree.bounds`
24//!
25//! Matches swift `TreeRoute.isVisible` (any frame ∩ appFrame intersection)
26//! and maestro `ViewHierarchy.kt:40-50` `isVisible(node)`.
27
28#![doc(html_root_url = "https://docs.smix.dev/smix-screen")]
29
30use serde::{Deserialize, Serialize};
31
32/// Logical-points rectangle (origin top-left, +x right, +y down — matches
33/// UIKit / XCUITest coordinate space). All fields `f64` because runner
34/// `/tree` route emits floating-point points (sub-pixel scale factors).
35#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
36pub struct Rect {
37    /// Top-left x coordinate in logical points.
38    pub x: f64,
39    /// Top-left y coordinate in logical points.
40    pub y: f64,
41    /// Width in logical points (zero or negative = invisible).
42    pub w: f64,
43    /// Height in logical points (zero or negative = invisible).
44    pub h: f64,
45}
46
47/// Bounds alias — TS used `Bounds = Rect` interchangeably. Kept as a thin
48/// re-export for port-clarity; downstream code may prefer one or the other.
49pub type Bounds = Rect;
50
51/// Element summary — projected view of an [`A11yNode`] used in
52/// AI-readable failure prompts and `driver.describe()` output. Mirrors
53/// TS `elementSummarySchema` in `src/core/schemas.ts:45-54` 1:1.
54///
55/// `role` is `Some(Role)` for known XCUIElement types, `None` (TS
56/// `'unknown'` literal — but Rust serde serializes None as null + omits
57/// via skip_serializing_if; readers must accept both shapes).
58#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct ElementSummary {
61    /// Semantic role (None when the underlying XCUIElement type doesn't map).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub role: Option<Role>,
64    /// Primary display name (label → title → text → value → placeholder).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub name: Option<String>,
67    /// Accessibility identifier (`node.identifier`), if present.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub id: Option<String>,
70    /// Visible text, only when distinct from `name`.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub text: Option<String>,
73    /// Geometric bounds in logical points.
74    pub bounds: Rect,
75    /// Whether the element is currently enabled (interactable).
76    pub enabled: bool,
77}
78
79/// Aggregate screen description — Mirrors TS `ScreenDescription` in
80/// `src/core/schemas.ts:80-88`. `elements` is a DFS-collected ordered
81/// list of visible+enabled [`ElementSummary`] entries; `screenshot` is
82/// optional base64 PNG; `frontApp` / `summary` / `captured_at` are
83/// caller-populated metadata.
84#[derive(Clone, Debug, Default, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct ScreenDescription {
87    /// Optional base64-encoded PNG screenshot of the screen.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub screenshot: Option<String>,
90    /// Visible+enabled elements in DFS pre-order.
91    pub elements: Vec<ElementSummary>,
92    /// Bundle id of the frontmost app at capture time.
93    pub front_app: String,
94    /// Free-form one-line summary (caller-populated).
95    pub summary: String,
96    /// Wall-clock capture timestamp (Unix epoch milliseconds).
97    pub captured_at: f64,
98}
99
100/// Collect up to `limit` visible+enabled nodes (DFS pre-order), project
101/// each via [`summarize_node`]. Mirrors TS `collectVisibleSummaries` in
102/// `src/core/screen.ts:50-65`. Default limit = 1000 (v1.5 c5i-a S3).
103#[must_use]
104pub fn collect_visible_summaries(tree: &A11yNode, limit: usize) -> Vec<ElementSummary> {
105    let mut out: Vec<ElementSummary> = Vec::new();
106    fn walk(n: &A11yNode, limit: usize, out: &mut Vec<ElementSummary>) {
107        if out.len() >= limit {
108            return;
109        }
110        if n.enabled && n.visible {
111            out.push(summarize_node(n));
112        }
113        for c in &n.children {
114            if out.len() >= limit {
115                return;
116            }
117            walk(c, limit, out);
118        }
119    }
120    walk(tree, limit, &mut out);
121    out
122}
123
124/// Default visible-summary limit (跟 TS `DEFAULT_VISIBLE_LIMIT` line 26).
125pub const DEFAULT_VISIBLE_LIMIT: usize = 1000;
126
127/// Project an [`A11yNode`] to an [`ElementSummary`] (跟 TS
128/// `summarizeNode` in `src/core/screen.ts:5-17` 1:1).
129///
130/// `name` priority scan: label → title → text → value → placeholderValue
131/// (跟 TS line 11 v1.5 c5i-a S2 同源). `text` only set when distinct from
132/// `name`.
133#[must_use]
134pub fn summarize_node(node: &A11yNode) -> ElementSummary {
135    let name = node
136        .label
137        .clone()
138        .or_else(|| node.title.clone())
139        .or_else(|| node.text.clone())
140        .or_else(|| node.value.clone())
141        .or_else(|| node.placeholder_value.clone());
142    let text = match (&node.text, &name) {
143        (Some(t), Some(n)) if t != n => Some(t.clone()),
144        _ => None,
145    };
146    ElementSummary {
147        role: node.role,
148        name,
149        id: node.identifier.clone(),
150        text,
151        bounds: node.bounds,
152        enabled: node.enabled,
153    }
154}
155
156/// Accessibility role enum — mirrors TS `roleSchema` zod enum in
157/// `src/core/schemas.ts:14-44` (29 variants).
158///
159/// `serde(rename_all = "camelCase")` keeps the JSON wire identical to the
160/// Swift-side `/tree` route output ("staticText" not "static_text").
161#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub enum Role {
164    /// Tappable button (UIButton / SwiftUI Button).
165    Button,
166    /// Hyperlink (anchor-like target).
167    Link,
168    /// Plain editable text input (UITextField / TextInput).
169    TextField,
170    /// Password / sensitive text input (input masked).
171    SecureTextField,
172    /// Search-style text input (UISearchBar).
173    SearchField,
174    /// On/off toggle (UISwitch).
175    Switch,
176    /// Generic toggle button (two-state).
177    Toggle,
178    /// Multi-state checkbox.
179    CheckBox,
180    /// Radio button (one-of-many select).
181    Radio,
182    /// Image element (UIImageView).
183    Image,
184    /// Read-only display label (UILabel).
185    StaticText,
186    /// Tab element inside a tab bar.
187    Tab,
188    /// Tab bar container (UITabBar).
189    TabBar,
190    /// Top navigation bar (UINavigationBar).
191    NavigationBar,
192    /// List / collection cell (UITableViewCell / UICollectionViewCell).
193    Cell,
194    /// System alert popup (UIAlertController .alert style).
195    Alert,
196    /// Modal dialog (UIAlertController .dialog / custom modal).
197    Dialog,
198    /// Continuous slider input (UISlider).
199    Slider,
200    /// Progress indicator (UIProgressView).
201    ProgressBar,
202    /// Date / wheel-style picker (UIPickerView).
203    Picker,
204    /// Drop-down or action menu.
205    Menu,
206    /// Single menu item inside a Menu.
207    MenuItem,
208    /// Scrollable container (UIScrollView).
209    ScrollView,
210    /// Segmented control (UISegmentedControl).
211    SegmentedControl,
212    /// Table view (UITableView).
213    Table,
214    /// Collection view (UICollectionView).
215    CollectionView,
216    /// Embedded web view (WKWebView).
217    WebView,
218    /// On-screen software keyboard.
219    Keyboard,
220}
221
222impl Role {
223    /// camelCase string name matching the wire `roleSchema` enum variants
224    /// (跟 TS `src/core/schemas.ts:14-44` 1:1). Used by error / log /
225    /// describe_selector renderers that need the wire form without
226    /// pulling in serde_json.
227    #[must_use]
228    pub fn as_str(self) -> &'static str {
229        match self {
230            Role::Button => "button",
231            Role::Link => "link",
232            Role::TextField => "textField",
233            Role::SecureTextField => "secureTextField",
234            Role::SearchField => "searchField",
235            Role::Switch => "switch",
236            Role::Toggle => "toggle",
237            Role::CheckBox => "checkBox",
238            Role::Radio => "radio",
239            Role::Image => "image",
240            Role::StaticText => "staticText",
241            Role::Tab => "tab",
242            Role::TabBar => "tabBar",
243            Role::NavigationBar => "navigationBar",
244            Role::Cell => "cell",
245            Role::Alert => "alert",
246            Role::Dialog => "dialog",
247            Role::Slider => "slider",
248            Role::ProgressBar => "progressBar",
249            Role::Picker => "picker",
250            Role::Menu => "menu",
251            Role::MenuItem => "menuItem",
252            Role::ScrollView => "scrollView",
253            Role::SegmentedControl => "segmentedControl",
254            Role::Table => "table",
255            Role::CollectionView => "collectionView",
256            Role::WebView => "webView",
257            Role::Keyboard => "keyboard",
258        }
259    }
260}
261
262impl std::fmt::Display for Role {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        f.write_str(self.as_str())
265    }
266}
267
268/// Derive the curated [`Role`] from the raw XCUIElement type name (e.g.
269/// `"button"`, `"radioButton"`, `"progressIndicator"`).
270///
271/// The Swift `/tree` route only emits `rawType` on the wire — it never
272/// fills `role` — so the Rust-side `A11yNode.role` is `None` for every
273/// real-sim payload. This function gives the sense layer a single
274/// canonical place to lift `rawType` strings into semantic [`Role`]
275/// values, matching the reverse direction of the swift
276/// `TreeRoute.elementTypeName(_:)` table.
277///
278/// Returns `None` when the raw type has no curated semantic (`"any"`,
279/// `"other"`, `"window"`, `"group"`, …) — Selector::Role still won't
280/// match those, which is the intended behaviour.
281#[must_use]
282pub fn role_from_raw_type(raw_type: &str) -> Option<Role> {
283    Some(match raw_type {
284        "button" => Role::Button,
285        "link" => Role::Link,
286        "textField" => Role::TextField,
287        "secureTextField" => Role::SecureTextField,
288        "searchField" => Role::SearchField,
289        "switch" => Role::Switch,
290        "toggle" => Role::Toggle,
291        "checkBox" => Role::CheckBox,
292        // Swift wire uses "radioButton"; Rust enum uses Radio.
293        "radioButton" => Role::Radio,
294        "image" => Role::Image,
295        "staticText" => Role::StaticText,
296        "tabBar" => Role::TabBar,
297        "navigationBar" => Role::NavigationBar,
298        "cell" => Role::Cell,
299        "alert" => Role::Alert,
300        "dialog" => Role::Dialog,
301        "slider" => Role::Slider,
302        // Swift wire uses "progressIndicator"; Rust enum uses ProgressBar.
303        "progressIndicator" => Role::ProgressBar,
304        "picker" => Role::Picker,
305        "menu" => Role::Menu,
306        "menuItem" => Role::MenuItem,
307        "scrollView" => Role::ScrollView,
308        "segmentedControl" => Role::SegmentedControl,
309        "table" => Role::Table,
310        "collectionView" => Role::CollectionView,
311        "webView" => Role::WebView,
312        "keyboard" => Role::Keyboard,
313        // Role::Tab has no corresponding swift elementTypeName case —
314        // tabs come through as their containing element type. Leave None.
315        _ => return None,
316    })
317}
318
319/// Recursively fill `node.role` from `node.raw_type` whenever it is
320/// currently `None`. Host-set roles (test fixtures, recorder output) are
321/// left untouched.
322///
323/// Call once on the root after a wire deserialize (runner /tree response,
324/// recorder snapshot replay, ...) to make `Selector::Role` work against
325/// real-sim payloads where the wire only carries `rawType`.
326///
327/// G2 (v4.4 c1): iOS `UITabBar` items are `button`s nested inside a
328/// `tabBar` subtree — there is NO distinct tab `XCUIElement.ElementType`
329/// (the swift `elementTypeName` table has no "tab" case), so `rawType`
330/// alone can never yield [`Role::Tab`]. A button that lives anywhere inside
331/// a `tabBar` subtree is structurally a tab item, so it derives
332/// [`Role::Tab`] instead of [`Role::Button`]. The inference is ancestor-
333/// based (the real tree nests the tab buttons under wrapper `other` nodes),
334/// the only locale-invariant way to make `Selector::Role { Role::Tab }`
335/// match real tab-bar items.
336pub fn derive_roles_recursive(node: &mut A11yNode) {
337    derive_roles_inner(node, false);
338}
339
340fn derive_roles_inner(node: &mut A11yNode, inside_tab_bar: bool) {
341    if node.role.is_none() {
342        node.role = if inside_tab_bar && node.raw_type == "button" {
343            Some(Role::Tab)
344        } else {
345            role_from_raw_type(&node.raw_type)
346        };
347    }
348    let child_inside = inside_tab_bar || node.raw_type == "tabBar";
349    for child in &mut node.children {
350        derive_roles_inner(child, child_inside);
351    }
352}
353
354/// Accessibility tree node — mirrors TS `A11yNode` in
355/// `src/core/schemas.ts:124-139`.
356///
357/// `rawType` carries the underlying Apple `XCUIElement.ElementType` raw
358/// name (e.g. "any", "other", "staticText"); `role` is the curated semantic
359/// mapping (None when XCUIElement type doesn't map to a known [`Role`]).
360///
361/// Each optional string field maps to a single Apple a11y attribute, in
362/// the order maestro `IOSDriver.kt:192-210` scans them.
363///
364/// `#[serde(default)]` on the recursive `children: Vec<A11yNode>` allows
365/// terminal nodes in JSON to omit the field entirely (`/tree` route emits
366/// `"children":[]` consistently but we accept both for forward-compat).
367#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
368#[serde(rename_all = "camelCase")]
369pub struct A11yNode {
370    /// Raw Apple `XCUIElement.ElementType` name (e.g. `"any"`, `"other"`).
371    pub raw_type: String,
372    /// Curated semantic role; None when the raw type doesn't map.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub role: Option<Role>,
375    /// Accessibility identifier (Apple `accessibilityIdentifier`).
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub identifier: Option<String>,
378    /// Accessibility label (Apple `accessibilityLabel`).
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub label: Option<String>,
381    /// Element title (Apple `title`).
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub title: Option<String>,
384    /// Placeholder text shown when the field is empty.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub placeholder_value: Option<String>,
387    /// Element value (Apple `value`).
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub value: Option<String>,
390    /// Visible text content (Apple `text`).
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub text: Option<String>,
393    /// Geometric bounds in logical points.
394    pub bounds: Rect,
395    /// Whether the element is currently interactable.
396    pub enabled: bool,
397    /// Whether the element is currently selected.
398    pub selected: bool,
399    /// Whether the element currently has keyboard focus.
400    pub has_focus: bool,
401    /// Whether Apple's accessibility runtime reports this element as visible.
402    pub visible: bool,
403    /// Child nodes in stable DFS pre-order.
404    #[serde(default)]
405    pub children: Vec<A11yNode>,
406}
407
408// -------------------- visibility primitives ------------------------------
409
410/// Visibility check. v1.5 c5i-d semantics, 1:1 with TS
411/// `src/core/resolve-selector.ts:105-114`.
412///
413/// Returns `false` for nodes with zero-or-negative bounds (early reject).
414/// Returns `true` when tree.bounds is unknown (`w<=0||h<=0`) — conservative
415/// pass: a node with sensible bounds shouldn't be filtered just because we
416/// don't have a viewport to clip against. Otherwise checks for any
417/// non-empty rectangle intersection between `node.bounds` and `tree.bounds`.
418///
419/// Pure / branch-only / no allocations — LLVM should inline aggressively.
420#[inline]
421#[must_use]
422pub fn is_visible_enough(node: &A11yNode, tree: &A11yNode) -> bool {
423    let b = node.bounds;
424    if b.w <= 0.0 || b.h <= 0.0 {
425        return false;
426    }
427    let root = tree.bounds;
428    if root.w <= 0.0 || root.h <= 0.0 {
429        return true; // unknown root, conservative pass
430    }
431    let x1 = b.x.max(root.x);
432    let y1 = b.y.max(root.y);
433    let x2 = (b.x + b.w).min(root.x + root.w);
434    let y2 = (b.y + b.h).min(root.y + root.h);
435    x2 > x1 && y2 > y1
436}
437
438/// Intersection area in logical points². v1.5 c5i-f semantics, 1:1 with
439/// TS `src/core/resolve-selector.ts:64-78`.
440///
441/// Used by resolver multi-candidate sorting (favor truly visible elements
442/// over partial-offscreen residuals). Returns `0.0` for zero-bounds
443/// nodes; returns `b.w * b.h` when tree.bounds is unknown.
444#[inline]
445#[must_use]
446pub fn visible_area(node: &A11yNode, tree: &A11yNode) -> f64 {
447    let b = node.bounds;
448    if b.w <= 0.0 || b.h <= 0.0 {
449        return 0.0;
450    }
451    let root = tree.bounds;
452    if root.w <= 0.0 || root.h <= 0.0 {
453        return b.w * b.h;
454    }
455    let x1 = b.x.max(root.x);
456    let y1 = b.y.max(root.y);
457    let x2 = (b.x + b.w).min(root.x + root.w);
458    let y2 = (b.y + b.h).min(root.y + root.h);
459    if x2 <= x1 || y2 <= y1 {
460        return 0.0;
461    }
462    (x2 - x1) * (y2 - y1)
463}