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 the visible+enabled [`ElementSummary`] entries a reader could
78/// name (anonymous layout containers are skipped); `screenshot` is an
79/// optional base64 PNG.
80///
81/// `frontApp` and `capturedAt` are filled at capture. Only `summary` is
82/// caller-populated. This comment used to call all three of them that,
83/// which is how two fields stayed unconditionally empty while a CLI
84/// help string promised a title and a status bar.
85#[derive(Clone, Debug, Default, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct ScreenDescription {
88 /// Optional base64-encoded PNG screenshot of the screen.
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub screenshot: Option<String>,
91 /// Nameable visible+enabled elements in DFS pre-order.
92 pub elements: Vec<ElementSummary>,
93 /// Bundle id of the app this description was taken from, read from
94 /// the a11y tree root's identifier.
95 ///
96 /// Deliberately not called "frontmost": the runner reports the app
97 /// it resolved for the request, which is what a caller can act on.
98 /// `None` when the root carried no identifier — distinct from an
99 /// empty string, which would claim knowledge of an empty answer.
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub front_app: Option<String>,
102 /// Free-form one-line summary. The caller writes this; nothing in
103 /// smix produces it.
104 pub summary: String,
105 /// Wall-clock capture timestamp (Unix epoch milliseconds).
106 pub captured_at: f64,
107}
108
109/// Collect up to `limit` visible+enabled nodes a reader could name (DFS
110/// pre-order), projecting each via [`summarize_node`]. Default limit = 1000.
111///
112/// Anonymous layout scaffolding is skipped — see [`has_identity`].
113#[must_use]
114pub fn collect_visible_summaries(tree: &A11yNode, limit: usize) -> Vec<ElementSummary> {
115 let mut out: Vec<ElementSummary> = Vec::new();
116 fn walk(n: &A11yNode, limit: usize, out: &mut Vec<ElementSummary>) {
117 if out.len() >= limit {
118 return;
119 }
120 if n.enabled && n.visible {
121 let s = summarize_node(n);
122 if has_identity(&s) {
123 out.push(s);
124 }
125 }
126 for c in &n.children {
127 if out.len() >= limit {
128 return;
129 }
130 walk(c, limit, out);
131 }
132 }
133 walk(tree, limit, &mut out);
134 out
135}
136
137/// Whether a summary tells a reader anything.
138///
139/// A node with no role, name, id, or text renders as the bare word "unknown"
140/// — it cannot be recognized or acted on. Real app trees are mostly these:
141/// on a stock Settings screen, 120 of 166 visible nodes are anonymous
142/// layout scaffolding, and they sit at the top in DFS order. A failure
143/// showing its ten "visible elements" would spend nine of them on
144/// `Application → Window → other → other …` and tell the reader nothing,
145/// which defeats the point of putting the screen in the failure at all.
146fn has_identity(s: &ElementSummary) -> bool {
147 s.role.is_some() || s.name.is_some() || s.id.is_some() || s.text.is_some()
148}
149
150/// Default visible-summary limit.
151pub const DEFAULT_VISIBLE_LIMIT: usize = 1000;
152
153/// Project an [`A11yNode`] to an [`ElementSummary`].
154///
155/// `name` priority scan: label → title → text → value → placeholderValue.
156/// `text` is only set when distinct from `name`.
157#[must_use]
158pub fn summarize_node(node: &A11yNode) -> ElementSummary {
159 let name = node
160 .label
161 .clone()
162 .or_else(|| node.title.clone())
163 .or_else(|| node.text.clone())
164 .or_else(|| node.value.clone())
165 .or_else(|| node.placeholder_value.clone());
166 let text = match (&node.text, &name) {
167 (Some(t), Some(n)) if t != n => Some(t.clone()),
168 _ => None,
169 };
170 ElementSummary {
171 role: node.role,
172 name,
173 id: node.identifier.clone(),
174 text,
175 bounds: node.bounds,
176 enabled: node.enabled,
177 }
178}
179
180/// Accessibility role enum (29 variants).
181///
182/// `serde(rename_all = "camelCase")` keeps the JSON wire identical to the
183/// Swift-side `/tree` route output ("staticText" not "static_text").
184#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub enum Role {
187 /// Tappable button (UIButton / SwiftUI Button).
188 Button,
189 /// Hyperlink (anchor-like target).
190 Link,
191 /// Plain editable text input (UITextField / TextInput).
192 TextField,
193 /// Password / sensitive text input (input masked).
194 SecureTextField,
195 /// Search-style text input (UISearchBar).
196 SearchField,
197 /// On/off toggle (UISwitch).
198 Switch,
199 /// Generic toggle button (two-state).
200 Toggle,
201 /// Multi-state checkbox.
202 CheckBox,
203 /// Radio button (one-of-many select).
204 Radio,
205 /// Image element (UIImageView).
206 Image,
207 /// Read-only display label (UILabel).
208 StaticText,
209 /// Tab element inside a tab bar.
210 Tab,
211 /// Tab bar container (UITabBar).
212 TabBar,
213 /// Top navigation bar (UINavigationBar).
214 NavigationBar,
215 /// List / collection cell (UITableViewCell / UICollectionViewCell).
216 Cell,
217 /// System alert popup (UIAlertController .alert style).
218 Alert,
219 /// Modal dialog (UIAlertController .dialog / custom modal).
220 Dialog,
221 /// Continuous slider input (UISlider).
222 Slider,
223 /// Progress indicator (UIProgressView).
224 ProgressBar,
225 /// Date / wheel-style picker (UIPickerView).
226 Picker,
227 /// Drop-down or action menu.
228 Menu,
229 /// Single menu item inside a Menu.
230 MenuItem,
231 /// Scrollable container (UIScrollView).
232 ScrollView,
233 /// Segmented control (UISegmentedControl).
234 SegmentedControl,
235 /// Table view (UITableView).
236 Table,
237 /// Collection view (UICollectionView).
238 CollectionView,
239 /// Embedded web view (WKWebView).
240 WebView,
241 /// On-screen software keyboard.
242 Keyboard,
243}
244
245impl Role {
246 /// camelCase string name matching the wire `roleSchema` enum variants.
247 /// Used by error / log / describe_selector renderers that need the
248 /// wire form without pulling in serde_json.
249 #[must_use]
250 pub fn as_str(self) -> &'static str {
251 match self {
252 Role::Button => "button",
253 Role::Link => "link",
254 Role::TextField => "textField",
255 Role::SecureTextField => "secureTextField",
256 Role::SearchField => "searchField",
257 Role::Switch => "switch",
258 Role::Toggle => "toggle",
259 Role::CheckBox => "checkBox",
260 Role::Radio => "radio",
261 Role::Image => "image",
262 Role::StaticText => "staticText",
263 Role::Tab => "tab",
264 Role::TabBar => "tabBar",
265 Role::NavigationBar => "navigationBar",
266 Role::Cell => "cell",
267 Role::Alert => "alert",
268 Role::Dialog => "dialog",
269 Role::Slider => "slider",
270 Role::ProgressBar => "progressBar",
271 Role::Picker => "picker",
272 Role::Menu => "menu",
273 Role::MenuItem => "menuItem",
274 Role::ScrollView => "scrollView",
275 Role::SegmentedControl => "segmentedControl",
276 Role::Table => "table",
277 Role::CollectionView => "collectionView",
278 Role::WebView => "webView",
279 Role::Keyboard => "keyboard",
280 }
281 }
282}
283
284impl std::fmt::Display for Role {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 f.write_str(self.as_str())
287 }
288}
289
290/// Derive the curated [`Role`] from the raw XCUIElement type name (e.g.
291/// `"button"`, `"radioButton"`, `"progressIndicator"`).
292///
293/// The Swift `/tree` route only emits `rawType` on the wire — it never
294/// fills `role` — so the Rust-side `A11yNode.role` is `None` for every
295/// real-sim payload. This function gives the sense layer a single
296/// canonical place to lift `rawType` strings into semantic [`Role`]
297/// values, matching the reverse direction of the swift
298/// `TreeRoute.elementTypeName(_:)` table.
299///
300/// Returns `None` when the raw type has no curated semantic (`"any"`,
301/// `"other"`, `"window"`, `"group"`, …) — Selector::Role still won't
302/// match those, which is the intended behaviour.
303#[must_use]
304pub fn role_from_raw_type(raw_type: &str) -> Option<Role> {
305 Some(match raw_type {
306 "button" => Role::Button,
307 "link" => Role::Link,
308 "textField" => Role::TextField,
309 "secureTextField" => Role::SecureTextField,
310 "searchField" => Role::SearchField,
311 "switch" => Role::Switch,
312 "toggle" => Role::Toggle,
313 "checkBox" => Role::CheckBox,
314 // Swift wire uses "radioButton"; Rust enum uses Radio.
315 "radioButton" => Role::Radio,
316 "image" => Role::Image,
317 "staticText" => Role::StaticText,
318 "tabBar" => Role::TabBar,
319 "navigationBar" => Role::NavigationBar,
320 "cell" => Role::Cell,
321 "alert" => Role::Alert,
322 "dialog" => Role::Dialog,
323 "slider" => Role::Slider,
324 // Swift wire uses "progressIndicator"; Rust enum uses ProgressBar.
325 "progressIndicator" => Role::ProgressBar,
326 "picker" => Role::Picker,
327 "menu" => Role::Menu,
328 "menuItem" => Role::MenuItem,
329 "scrollView" => Role::ScrollView,
330 "segmentedControl" => Role::SegmentedControl,
331 "table" => Role::Table,
332 "collectionView" => Role::CollectionView,
333 "webView" => Role::WebView,
334 "keyboard" => Role::Keyboard,
335 // Role::Tab has no corresponding swift elementTypeName case —
336 // tabs come through as their containing element type. Leave None.
337 _ => return None,
338 })
339}
340
341/// Recursively fill `node.role` from `node.raw_type` whenever it is
342/// currently `None`. Host-set roles (test fixtures, recorder output) are
343/// left untouched.
344///
345/// Call once on the root after a wire deserialize (runner /tree response,
346/// recorder snapshot replay, ...) to make `Selector::Role` work against
347/// real-sim payloads where the wire only carries `rawType`.
348///
349/// iOS `UITabBar` items are `button`s nested inside a `tabBar` subtree
350/// — there is NO distinct tab `XCUIElement.ElementType` (the swift
351/// `elementTypeName` table has no "tab" case), so `rawType` alone can
352/// never yield [`Role::Tab`]. A button that lives anywhere inside a
353/// `tabBar` subtree is structurally a tab item, so it derives
354/// [`Role::Tab`] instead of [`Role::Button`]. The inference is ancestor-
355/// based (the real tree nests the tab buttons under wrapper `other`
356/// nodes), the only locale-invariant way to make
357/// `Selector::Role { Role::Tab }` match real tab-bar items.
358pub fn derive_roles_recursive(node: &mut A11yNode) {
359 derive_roles_inner(node, false);
360}
361
362fn derive_roles_inner(node: &mut A11yNode, inside_tab_bar: bool) {
363 if node.role.is_none() {
364 node.role = if inside_tab_bar && node.raw_type == "button" {
365 Some(Role::Tab)
366 } else {
367 role_from_raw_type(&node.raw_type)
368 };
369 }
370 let child_inside = inside_tab_bar || node.raw_type == "tabBar";
371 for child in &mut node.children {
372 derive_roles_inner(child, child_inside);
373 }
374}
375
376/// Older wire payloads predate the `elementTypeRaw` field; default to
377/// 1 (`.other`), which is the safest fallback and matches how Swift
378/// `elementTypeName` treats unknown raw values.
379fn default_element_type_raw() -> u64 {
380 1
381}
382
383/// Accessibility tree node — a single-node snapshot as returned by `/tree`,
384/// mirroring the Swift-side `TreeRoute.nodeToDict` shape.
385///
386/// `rawType` carries the underlying Apple `XCUIElement.ElementType` raw
387/// name (e.g. "any", "other", "staticText"); `role` is the curated semantic
388/// mapping (None when XCUIElement type doesn't map to a known [`Role`]).
389///
390/// Each optional string field maps to a single Apple a11y attribute, in
391/// the order the standard iOS a11y drivers scan them.
392///
393/// `#[serde(default)]` on the recursive `children: Vec<A11yNode>` allows
394/// terminal nodes in JSON to omit the field entirely (`/tree` route emits
395/// `"children":[]` consistently but we accept both for forward-compat).
396#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
397#[serde(rename_all = "camelCase")]
398pub struct A11yNode {
399 /// Raw Apple `XCUIElement.ElementType` name (e.g. `"any"`, `"other"`).
400 pub raw_type: String,
401 /// Raw numeric `XCUIElement.ElementType.rawValue`.
402 /// `raw_type` above is a string form derived by `elementTypeName`,
403 /// but consumers debugging a degraded a11y tree (RN Fabric on iOS
404 /// 26.5 is the motivating case) need the numeric form to spot
405 /// "iOS types this as .button (9) but identifier / label empty"
406 /// — the signature of an app-side accessibility-bridge drop. The
407 /// numeric form also disambiguates the alert/dialog button
408 /// promotion (rawType is lifted to "button" for consumer
409 /// selectors, but the original ElementType number stays here).
410 /// Defaults to 1 (`.other`) for wire payloads that omit it.
411 #[serde(default = "default_element_type_raw")]
412 pub element_type_raw: u64,
413 /// Curated semantic role; None when the raw type doesn't map.
414 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub role: Option<Role>,
416 /// Accessibility identifier (Apple `accessibilityIdentifier`).
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub identifier: Option<String>,
419 /// Accessibility label (Apple `accessibilityLabel`).
420 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub label: Option<String>,
422 /// Element title (Apple `title`).
423 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub title: Option<String>,
425 /// Placeholder text shown when the field is empty.
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub placeholder_value: Option<String>,
428 /// Element value (Apple `value`).
429 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub value: Option<String>,
431 /// Visible text content (Apple `text`).
432 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub text: Option<String>,
434 /// Geometric bounds in logical points.
435 pub bounds: Rect,
436 /// Whether the element is currently interactable.
437 pub enabled: bool,
438 /// Whether the element is currently selected.
439 pub selected: bool,
440 /// Whether the element currently has keyboard focus.
441 pub has_focus: bool,
442 /// Whether Apple's accessibility runtime reports this element as visible.
443 pub visible: bool,
444 /// Child nodes in stable DFS pre-order.
445 #[serde(default)]
446 pub children: Vec<A11yNode>,
447}
448
449// -------------------- visibility primitives ------------------------------
450
451/// Visibility check.
452///
453/// Returns `false` for nodes with zero-or-negative bounds (early reject).
454/// Returns `true` when tree.bounds is unknown (`w<=0||h<=0`) — conservative
455/// pass: a node with sensible bounds shouldn't be filtered just because we
456/// don't have a viewport to clip against. Otherwise checks for any
457/// non-empty rectangle intersection between `node.bounds` and `tree.bounds`.
458///
459/// Pure / branch-only / no allocations — LLVM should inline aggressively.
460#[inline]
461#[must_use]
462pub fn is_visible_enough(node: &A11yNode, tree: &A11yNode) -> bool {
463 let b = node.bounds;
464 if b.w <= 0.0 || b.h <= 0.0 {
465 return false;
466 }
467 let root = tree.bounds;
468 if root.w <= 0.0 || root.h <= 0.0 {
469 return true; // unknown root, conservative pass
470 }
471 let x1 = b.x.max(root.x);
472 let y1 = b.y.max(root.y);
473 let x2 = (b.x + b.w).min(root.x + root.w);
474 let y2 = (b.y + b.h).min(root.y + root.h);
475 x2 > x1 && y2 > y1
476}
477
478/// Intersection area in logical points².
479///
480/// Used by resolver multi-candidate sorting (favor truly visible elements
481/// over partial-offscreen residuals). Returns `0.0` for zero-bounds
482/// nodes; returns `b.w * b.h` when tree.bounds is unknown.
483#[inline]
484#[must_use]
485pub fn visible_area(node: &A11yNode, tree: &A11yNode) -> f64 {
486 let b = node.bounds;
487 if b.w <= 0.0 || b.h <= 0.0 {
488 return 0.0;
489 }
490 let root = tree.bounds;
491 if root.w <= 0.0 || root.h <= 0.0 {
492 return b.w * b.h;
493 }
494 let x1 = b.x.max(root.x);
495 let y1 = b.y.max(root.y);
496 let x2 = (b.x + b.w).min(root.x + root.w);
497 let y2 = (b.y + b.h).min(root.y + root.h);
498 if x2 <= x1 || y2 <= y1 {
499 return 0.0;
500 }
501 (x2 - x1) * (y2 - y1)
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 fn node(
509 raw_type: &str,
510 id: Option<&str>,
511 label: Option<&str>,
512 children: Vec<A11yNode>,
513 ) -> A11yNode {
514 A11yNode {
515 raw_type: raw_type.into(),
516 element_type_raw: 1,
517 role: role_from_raw_type(raw_type),
518 identifier: id.map(str::to_string),
519 label: label.map(str::to_string),
520 title: None,
521 placeholder_value: None,
522 value: None,
523 text: None,
524 bounds: Rect {
525 x: 0.0,
526 y: 0.0,
527 w: 10.0,
528 h: 10.0,
529 },
530 enabled: true,
531 selected: false,
532 has_focus: false,
533 visible: true,
534 children,
535 }
536 }
537
538 /// A real app tree, in miniature: the button is buried under layers of
539 /// anonymous layout. Measured on a stock Settings screen, 120 of 166
540 /// visible nodes look like these wrappers.
541 fn scaffolded_tree() -> A11yNode {
542 node(
543 "application",
544 Some("com.apple.Preferences"),
545 Some("Settings"),
546 vec![node(
547 "window",
548 None,
549 None,
550 vec![node(
551 "other",
552 None,
553 None,
554 vec![node(
555 "other",
556 None,
557 None,
558 vec![node(
559 "other",
560 None,
561 None,
562 vec![node(
563 "button",
564 Some("com.apple.settings.general"),
565 Some("General"),
566 vec![],
567 )],
568 )],
569 )],
570 )],
571 )],
572 )
573 }
574
575 #[test]
576 fn anonymous_scaffolding_does_not_take_up_the_reader_s_slots() {
577 // The failure surface asks for ten. Spending nine on `other → other →
578 // other` tells the reader nothing about the screen.
579 let got = collect_visible_summaries(&scaffolded_tree(), 10);
580 assert!(
581 got.iter().all(|s| s.role.is_some()
582 || s.name.is_some()
583 || s.id.is_some()
584 || s.text.is_some()),
585 "every entry must be something a reader can recognize; got {got:?}"
586 );
587 }
588
589 #[test]
590 fn the_element_a_reader_wants_survives_a_small_limit() {
591 // Before the filter, a limit of 3 returned application + window +
592 // other, and the button — the thing you would tap — never appeared.
593 let got = collect_visible_summaries(&scaffolded_tree(), 3);
594 assert!(
595 got.iter()
596 .any(|s| s.id.as_deref() == Some("com.apple.settings.general")),
597 "the named button must reach a short list; got {got:?}"
598 );
599 }
600
601 #[test]
602 fn a_node_with_an_id_but_no_role_still_counts() {
603 // Role is None for uncurated types. An id alone is plenty to act on.
604 let tree = node("other", Some("qa-bubble"), None, vec![]);
605 let got = collect_visible_summaries(&tree, 10);
606 assert_eq!(got.len(), 1, "an id is identity enough; got {got:?}");
607 }
608
609 #[test]
610 fn invisible_and_disabled_nodes_stay_out() {
611 let mut hidden = node("button", Some("hidden-btn"), None, vec![]);
612 hidden.visible = false;
613 let mut off = node("button", Some("disabled-btn"), None, vec![]);
614 off.enabled = false;
615 let tree = node("application", Some("app"), None, vec![hidden, off]);
616 let got = collect_visible_summaries(&tree, 10);
617 assert_eq!(
618 got.len(),
619 1,
620 "only the app node is visible+enabled; got {got:?}"
621 );
622 }
623}