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