xa11y_core/element.rs
1use std::collections::HashMap;
2use std::fmt;
3use std::ops::Deref;
4use std::sync::Arc;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::Error;
9use crate::provider::Provider;
10use crate::role::Role;
11
12/// Declare a reader/writer struct pair from one field list.
13///
14/// `#[non_exhaustive]` gives *readers* a stability guarantee: adding a field
15/// does not break a consumer that only reads one. *Writers* need the opposite
16/// — a new field should stop their build until they have decided what it
17/// means. Two types satisfy both, but only if their field sets cannot drift,
18/// which is what this macro enforces: there is one list, so a field cannot be
19/// added to one type and defaulted in the other.
20///
21/// The reader gets the doc comments, serde attributes, and `#[non_exhaustive]`.
22/// The writer gets the bare fields and stays exhaustive, so a struct literal
23/// in another crate fails to compile the moment the list grows.
24macro_rules! reader_writer_pair {
25 (
26 $(#[$reader_meta:meta])*
27 pub struct $reader:ident;
28
29 $(#[$writer_meta:meta])*
30 pub struct $writer:ident;
31
32 fields {
33 $(
34 $(#[$field_meta:meta])*
35 pub $field:ident : $ty:ty,
36 )*
37 }
38 ) => {
39 $(#[$reader_meta])*
40 #[non_exhaustive]
41 pub struct $reader {
42 $(
43 $(#[$field_meta])*
44 pub $field: $ty,
45 )*
46 }
47
48 $(#[$writer_meta])*
49 #[doc(hidden)]
50 pub struct $writer {
51 $(pub $field: $ty,)*
52 }
53
54 impl From<$writer> for $reader {
55 fn from(parts: $writer) -> Self {
56 // Destructured, not field-by-field: both halves come from the
57 // macro's single field list, so neither can gain a field the
58 // other silently defaults.
59 let $writer { $($field,)* } = parts;
60 Self { $($field,)* }
61 }
62 }
63 };
64}
65
66// Importable by path from sibling modules (`Event` uses it too).
67pub(crate) use reader_writer_pair;
68
69reader_writer_pair! {
70 /// The raw data for a single element in an accessibility tree.
71 ///
72 /// This is the underlying data struct. Most consumers should use
73 /// [`Element`], which wraps `ElementData` with a provider reference for
74 /// lazy navigation. `ElementData` is used directly by provider
75 /// implementors.
76 ///
77 /// `#[non_exhaustive]`: this is the type that grows every time the
78 /// normalized element model learns a new property, so adding a field must
79 /// not break the consumers that only ever *read* one. Providers, which
80 /// *write* one, get the opposite guarantee from [`ElementParts`].
81 ///
82 /// Build a partial element (an event target, a test fixture) with
83 /// [`ElementData::for_role`] and assign what you have:
84 ///
85 /// ```
86 /// # use xa11y_core::{ElementData, Role};
87 /// let mut data = ElementData::for_role(Role::Button);
88 /// data.name = Some("Submit".to_string());
89 /// ```
90 #[derive(Debug, Clone, Serialize, Deserialize)]
91 pub struct ElementData;
92
93 /// Every field a provider must decide on when it builds a *complete*
94 /// element from a platform node.
95 ///
96 /// Deliberately exhaustive: a struct literal in `xa11y-linux`,
97 /// `xa11y-macos`, or `xa11y-windows` stops compiling the moment a field
98 /// is added, which is the only thing that forces a per-platform decision
99 /// instead of a silent `None` on every backend.
100 ///
101 /// Paths that are partial by nature — an event target with no bounds, a
102 /// test fixture — should use [`ElementData::for_role`] instead and accept
103 /// that a new field arrives there as its default.
104 ///
105 /// Not public API (`#[doc(hidden)]`). Because a new field here is a
106 /// compile error in the sibling provider crates, they pin `xa11y-core`
107 /// with `=` rather than a caret requirement — see the workspace
108 /// `Cargo.toml`.
109 #[allow(
110 clippy::exhaustive_structs,
111 reason = "This type IS the completeness guard. Literal construction \
112 from the provider crates is exactly what makes a new \
113 ElementData field fail their build until each platform maps \
114 it; #[non_exhaustive] here would delete the property it \
115 exists for."
116 )]
117 #[derive(Debug, Clone)]
118 pub struct ElementParts;
119
120 fields {
121 /// Element role
122 pub role: Role,
123
124 /// Human-readable name (title, label).
125 ///
126 /// Stripped of Unicode bidi format controls (LRM, RLM, embeddings,
127 /// overrides, isolates) so equality assertions match the logical text.
128 /// The unstripped platform string is preserved in [`Self::raw`] under the
129 /// platform-native key (e.g. `AXTitle` on macOS, `atspi_name` on Linux,
130 /// `uia_name` on Windows). See [`crate::text::strip_bidi`].
131 pub name: Option<String>,
132
133 /// Current value (text content, slider position, etc.).
134 ///
135 /// Stripped of Unicode bidi format controls. The unstripped platform
136 /// string is preserved in [`Self::raw`] (`AXValue` on macOS, `atspi_value`
137 /// on Linux, `uia_value` on Windows). See [`crate::text::strip_bidi`].
138 pub value: Option<String>,
139
140 /// Supplementary description (tooltip, help text).
141 ///
142 /// Stripped of Unicode bidi format controls. The unstripped platform
143 /// string is preserved in [`Self::raw`] (`AXDescription`/`AXHelp` on
144 /// macOS, `atspi_description` on Linux, `uia_help_text` on Windows).
145 /// See [`crate::text::strip_bidi`].
146 pub description: Option<String>,
147
148 /// Bounding rectangle in **logical** screen coordinates
149 /// (device-independent points), origin at the top-left of the primary
150 /// display. This is the same coordinate space accepted by
151 /// [`crate::ScreenshotProvider::capture_region`] and by the input layer's
152 /// [`crate::input::Point`], so bounds can be fed directly to
153 /// `screenshot_element` / `click` without conversion.
154 ///
155 /// To map to physical device pixels (e.g. to index into a captured image),
156 /// multiply by the [`crate::Screenshot::scale`] reported for that display:
157 /// `physical = logical × scale`. See [`Rect::to_physical`] /
158 /// [`Rect::to_logical`].
159 pub bounds: Option<Rect>,
160
161 /// Available actions reported by the platform.
162 ///
163 /// Names are `snake_case` strings — well-known actions use their standard
164 /// names (`"press"`, `"toggle"`, `"expand"`, etc.) and platform-specific
165 /// actions use their converted names (e.g. macOS `AXCustomThing` →
166 /// `"custom_thing"`).
167 pub actions: Vec<String>,
168
169 /// Current state flags
170 pub states: StateSet,
171
172 /// Numeric value for range controls (sliders, progress bars, spinners).
173 pub numeric_value: Option<f64>,
174
175 /// Minimum value for range controls.
176 pub min_value: Option<f64>,
177
178 /// Maximum value for range controls.
179 pub max_value: Option<f64>,
180
181 /// Platform-assigned stable identifier for cross-snapshot correlation.
182 /// - macOS: `AXIdentifier`
183 /// - Windows: `AutomationId`
184 /// - Linux: D-Bus `object_path`
185 ///
186 /// Not all elements have one.
187 pub stable_id: Option<String>,
188
189 /// Process ID of the application that owns this element.
190 pub pid: Option<u32>,
191
192 /// Platform-specific raw data
193 pub raw: RawPlatformData,
194
195 /// Opaque handle for the provider to look up the platform object.
196 /// Not serialized — only valid within the provider that created it.
197 #[serde(skip, default)]
198 pub handle: u64,
199 }
200}
201
202impl ElementData {
203 /// An element with the given role and every other field empty.
204 ///
205 /// `states` starts at [`StateSet::default`] (enabled and visible, nothing
206 /// else), and `handle` at `0` — providers assign their own.
207 ///
208 /// This is the *partial* construction path. A provider translating a real
209 /// platform node should use [`ElementParts`] instead, so that a new field
210 /// fails its build rather than arriving as a default.
211 ///
212 /// Named `for_role` rather than `new` because `ElementData` is flattened
213 /// onto `Element` for the bindings-parity check, where a member called
214 /// `new` would collide with the existing [`Element::new`].
215 pub fn for_role(role: Role) -> Self {
216 // Struct literal, not a builder: this lives in the defining crate, so
217 // the compiler still checks it for completeness when a field is added.
218 Self {
219 role,
220 name: None,
221 value: None,
222 description: None,
223 bounds: None,
224 actions: Vec::new(),
225 states: StateSet::default(),
226 numeric_value: None,
227 min_value: None,
228 max_value: None,
229 stable_id: None,
230 pid: None,
231 raw: RawPlatformData::new(),
232 handle: 0,
233 }
234 }
235}
236
237impl Default for ElementData {
238 /// A [`Role::Unknown`] element with no properties.
239 fn default() -> Self {
240 Self::for_role(Role::Unknown)
241 }
242}
243
244/// A live element with lazy navigation via a provider reference.
245///
246/// `Element` dereferences to [`ElementData`], so all properties (`role`, `name`,
247/// `value`, `states`, etc.) are accessible via field access. Navigation
248/// methods (`parent()`, `children()`) call the provider on demand.
249///
250/// Elements are cheap to clone (they share the provider via `Arc`).
251#[derive(Clone)]
252pub struct Element {
253 data: ElementData,
254 provider: Arc<dyn Provider>,
255}
256
257impl Deref for Element {
258 type Target = ElementData;
259
260 fn deref(&self) -> &ElementData {
261 &self.data
262 }
263}
264
265impl fmt::Debug for Element {
266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267 fmt::Debug::fmt(&self.data, f)
268 }
269}
270
271impl fmt::Display for Element {
272 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273 let name_part = self
274 .data
275 .name
276 .as_ref()
277 .map(|n| format!(" \"{}\"", n))
278 .unwrap_or_default();
279 let value_part = self
280 .data
281 .value
282 .as_ref()
283 .map(|v| format!(" value=\"{}\"", v))
284 .unwrap_or_default();
285 write!(
286 f,
287 "{}{}{}",
288 self.data.role.to_snake_case(),
289 name_part,
290 value_part,
291 )
292 }
293}
294
295impl Serialize for Element {
296 fn serialize<S: serde::Serializer>(
297 &self,
298 serializer: S,
299 ) -> std::result::Result<S::Ok, S::Error> {
300 self.data.serialize(serializer)
301 }
302}
303
304impl Element {
305 /// Create an Element from raw data and a provider reference.
306 pub fn new(data: ElementData, provider: Arc<dyn Provider>) -> Self {
307 Self { data, provider }
308 }
309
310 /// Get the underlying ElementData.
311 pub fn data(&self) -> &ElementData {
312 &self.data
313 }
314
315 /// Get the provider reference.
316 pub fn provider(&self) -> &Arc<dyn Provider> {
317 &self.provider
318 }
319
320 /// Get direct children of this element.
321 ///
322 /// Each call queries the provider — results are not cached.
323 pub fn children(&self) -> crate::error::Result<Vec<Element>> {
324 let children = self.provider.get_children(Some(&self.data))?;
325 Ok(children
326 .into_iter()
327 .map(|d| Element::new(d, Arc::clone(&self.provider)))
328 .collect())
329 }
330
331 /// Get the parent element, if any (root-level elements have no parent).
332 ///
333 /// Each call queries the provider — results are not cached.
334 pub fn parent(&self) -> crate::error::Result<Option<Element>> {
335 let parent = self.provider.get_parent(&self.data)?;
336 Ok(parent.map(|d| Element::new(d, Arc::clone(&self.provider))))
337 }
338
339 /// Get the process ID from the element data.
340 pub fn pid(&self) -> Option<u32> {
341 self.data.pid
342 }
343
344 /// Capture the subtree rooted at this element as a recursive snapshot.
345 ///
346 /// `max_depth` limits traversal depth: `0` = only this node (no children),
347 /// `1` = node + direct children, and so on. `None` traverses the full subtree.
348 pub fn tree(&self, max_depth: Option<usize>) -> crate::error::Result<TreeNode> {
349 build_tree_node(self, max_depth, 0)
350 }
351
352 /// Render the subtree rooted at this element as an indented string.
353 ///
354 /// Each line is `{indent}{role} "{name}" [value="{value}"]`. Returns the
355 /// string without printing it. Same depth semantics as [`Element::tree`].
356 pub fn dump(&self, max_depth: Option<usize>) -> crate::error::Result<String> {
357 let node = self.tree(max_depth)?;
358 let mut out = String::new();
359 write_tree_node(&node, 0, &mut out);
360 Ok(out)
361 }
362
363 // ── Actions ─────────────────────────────────────────────────────
364 //
365 // Element actions invoke the platform via the captured provider handle —
366 // they do **not** re-resolve the selector. If the underlying element has
367 // been destroyed since this snapshot was taken, the provider returns a
368 // platform-specific "gone" error. For resilient retry-on-change semantics,
369 // use the equivalent method on [`crate::Locator`] instead.
370
371 /// Click / invoke this element via the accessibility action layer.
372 pub fn press(&self) -> crate::error::Result<()> {
373 self.provider.press(&self.data)
374 }
375
376 /// Set keyboard focus to this element.
377 pub fn focus(&self) -> crate::error::Result<()> {
378 self.provider.focus(&self.data)
379 }
380
381 /// Remove keyboard focus from this element.
382 pub fn blur(&self) -> crate::error::Result<()> {
383 self.provider.blur(&self.data)
384 }
385
386 /// Toggle a two- or three-state control (checkbox, switch).
387 pub fn toggle(&self) -> crate::error::Result<()> {
388 self.provider.toggle(&self.data)
389 }
390
391 /// Select this element (list item, tab, row).
392 pub fn select(&self) -> crate::error::Result<()> {
393 self.provider.select(&self.data)
394 }
395
396 /// Expand a disclosure, menu, combo box, or tree item.
397 pub fn expand(&self) -> crate::error::Result<()> {
398 self.provider.expand(&self.data)
399 }
400
401 /// Collapse an expanded element.
402 pub fn collapse(&self) -> crate::error::Result<()> {
403 self.provider.collapse(&self.data)
404 }
405
406 /// Open this element's context menu or dropdown.
407 pub fn show_menu(&self) -> crate::error::Result<()> {
408 self.provider.show_menu(&self.data)
409 }
410
411 /// Increment a numeric control (slider, spinner) by its platform step.
412 pub fn increment(&self) -> crate::error::Result<()> {
413 self.provider.increment(&self.data)
414 }
415
416 /// Decrement a numeric control (slider, spinner) by its platform step.
417 pub fn decrement(&self) -> crate::error::Result<()> {
418 self.provider.decrement(&self.data)
419 }
420
421 /// Scroll this element into the visible area.
422 ///
423 /// No-op on macOS — the macOS accessibility API has no equivalent.
424 pub fn scroll_into_view(&self) -> crate::error::Result<()> {
425 self.provider.scroll_into_view(&self.data)
426 }
427
428 /// Set the text value of this element. Replaces the entire value rather
429 /// than inserting at the caret — use [`Element::type_text`] for insertion.
430 pub fn set_value(&self, value: &str) -> crate::error::Result<()> {
431 self.provider.set_value(&self.data, value)
432 }
433
434 /// Set the numeric value of this element (slider, spinner).
435 ///
436 /// Returns [`Error::InvalidActionData`] if `value` is NaN or infinite.
437 pub fn set_numeric_value(&self, value: f64) -> crate::error::Result<()> {
438 if !value.is_finite() {
439 return Err(Error::InvalidActionData {
440 message: format!("set_numeric_value requires a finite value, got {}", value),
441 });
442 }
443 self.provider.set_numeric_value(&self.data, value)
444 }
445
446 /// Insert text at the current cursor position.
447 ///
448 /// Uses the platform accessibility API — never simulates keyboard events.
449 pub fn type_text(&self, text: &str) -> crate::error::Result<()> {
450 self.provider.type_text(&self.data, text)
451 }
452
453 /// Select the text range from `start` to `end` (0-based character offsets).
454 ///
455 /// Returns [`Error::InvalidActionData`] if `start > end`.
456 pub fn select_text(&self, start: u32, end: u32) -> crate::error::Result<()> {
457 if start > end {
458 return Err(Error::InvalidActionData {
459 message: format!("select_text start ({}) must be <= end ({})", start, end),
460 });
461 }
462 self.provider.set_text_selection(&self.data, start, end)
463 }
464
465 /// Perform an action by its `snake_case` name.
466 ///
467 /// Use this for actions the element advertises in its [`actions`](ElementData::actions)
468 /// list that don't have a dedicated method. Well-known names (`"press"`,
469 /// `"focus"`, etc.) also work — providers delegate to the named methods.
470 pub fn perform_action(&self, action: &str) -> crate::error::Result<()> {
471 self.provider.perform_action(&self.data, action)
472 }
473}
474
475fn build_tree_node(
476 element: &Element,
477 max_depth: Option<usize>,
478 depth: usize,
479) -> crate::error::Result<TreeNode> {
480 let children = if max_depth.is_none_or(|d| depth < d) {
481 element
482 .children()?
483 .into_iter()
484 .map(|child| build_tree_node(&child, max_depth, depth + 1))
485 .collect::<crate::error::Result<Vec<_>>>()?
486 } else {
487 vec![]
488 };
489 Ok(TreeNode {
490 role: element.data.role.to_snake_case().to_string(),
491 name: element.data.name.clone(),
492 value: element.data.value.clone(),
493 children,
494 })
495}
496
497fn write_tree_node(node: &TreeNode, depth: usize, out: &mut String) {
498 use fmt::Write as _;
499 let indent = " ".repeat(depth);
500 write!(out, "{}{}", indent, node.role).unwrap();
501 if let Some(ref n) = node.name {
502 write!(out, " \"{}\"", n).unwrap();
503 }
504 if let Some(ref v) = node.value {
505 write!(out, " value=\"{}\"", v).unwrap();
506 }
507 out.push('\n');
508 for child in &node.children {
509 write_tree_node(child, depth + 1, out);
510 }
511}
512
513reader_writer_pair! {
514 /// Boolean state flags for an element.
515 ///
516 /// **Semantics for non-applicable states:** When a state doesn't apply to
517 /// an element's role, the backend uses the platform's reported value or
518 /// defaults:
519 /// - `enabled`: `true` (elements are enabled unless explicitly disabled)
520 /// - `visible`: `true` (elements are visible unless explicitly hidden/offscreen)
521 /// - `focused`, `active`, `focusable`, `modal`, `selected`, `editable`, `required`, `busy`: `false`
522 ///
523 /// States that are inherently inapplicable use `Option`: `checked` is
524 /// `None` for non-checkable elements, `expanded` is `None` for
525 /// non-expandable elements.
526 ///
527 /// `#[non_exhaustive]`: more states arrive in compatible releases and must
528 /// not break readers. Providers building a complete state set use
529 /// [`StateParts`], which is exhaustive — the documented defaults above are
530 /// what a *partial* construction falls back to, not a licence for a
531 /// backend to skip deciding.
532 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
533 pub struct StateSet;
534
535 /// Every state a provider must decide on when it translates a platform
536 /// node's state bits.
537 ///
538 /// Deliberately exhaustive, for the same reason as [`ElementParts`]: a new
539 /// state must fail each backend's build rather than silently inherit
540 /// [`StateSet::default`]. That matters more here than the defaults
541 /// suggest — the parity check requires every state to surface as a binding
542 /// getter, so a silently-defaulted state ships as a documented API that no
543 /// platform populates.
544 ///
545 /// Not public API (`#[doc(hidden)]`).
546 #[allow(
547 clippy::exhaustive_structs,
548 reason = "This type IS the completeness guard for element state. See \
549 ElementParts; the same reasoning applies."
550 )]
551 #[derive(Debug, Clone)]
552 pub struct StateParts;
553
554 fields {
555 pub enabled: bool,
556 pub visible: bool,
557 pub focused: bool,
558 /// Whether this element is the active (foreground) window — the window that
559 /// currently receives the user's input. Only meaningful for window-like
560 /// elements (windows, dialogs); `false` elsewhere. Distinct from `focused`,
561 /// which is element-level keyboard focus. Platform mappings: the AT-SPI
562 /// `ACTIVE` state (Linux), `AXMain` (macOS), and the foreground `HWND`
563 /// (Windows).
564 #[serde(default)]
565 pub active: bool,
566 /// None = not checkable
567 pub checked: Option<Toggled>,
568 pub selected: bool,
569 /// None = not expandable
570 pub expanded: Option<bool>,
571 pub editable: bool,
572 /// Whether the element can receive keyboard focus
573 pub focusable: bool,
574 /// Whether the element is a modal dialog
575 pub modal: bool,
576 /// Form field required
577 pub required: bool,
578 /// Async operation in progress
579 pub busy: bool,
580 }
581}
582
583impl Default for StateSet {
584 fn default() -> Self {
585 Self {
586 enabled: true,
587 visible: true,
588 focused: false,
589 active: false,
590 checked: None,
591 selected: false,
592 expanded: None,
593 editable: false,
594 focusable: false,
595 modal: false,
596 required: false,
597 busy: false,
598 }
599 }
600}
601
602/// Tri-state toggle value.
603#[allow(
604 clippy::exhaustive_enums,
605 reason = "Closed domain: a toggle is off, on, or indeterminate. Every \
606 platform's tri-state checkbox is exactly these three values, \
607 and a fourth would not be a toggle."
608)]
609#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
610pub enum Toggled {
611 Off,
612 On,
613 /// Indeterminate / tri-state
614 Mixed,
615}
616
617/// Screen-pixel bounding rectangle (origin + size).
618/// `x`/`y` are signed to support negative multi-monitor coordinates.
619/// `width`/`height` are unsigned (always non-negative).
620#[allow(
621 clippy::exhaustive_structs,
622 reason = "Closed domain: an axis-aligned rectangle is fully described by \
623 an origin and a size. Literal construction is the point of the \
624 type, and it will not gain a fifth field."
625)]
626#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
627pub struct Rect {
628 pub x: i32,
629 pub y: i32,
630 pub width: u32,
631 pub height: u32,
632}
633
634impl Rect {
635 /// Convert a **logical** rectangle to **physical** device pixels by
636 /// multiplying every component by `scale` (the physical-to-logical ratio,
637 /// e.g. `1.5` at 150% or `2.0` on a typical Retina display).
638 ///
639 /// This is the inverse of [`Rect::to_logical`]. Each field is rounded to
640 /// the nearest integer independently; for a single rectangle the position
641 /// and size therefore round separately, which can differ by 1px from
642 /// scaling the far edge — acceptable for capture/hit-test use where a 1px
643 /// slack is expected on fractional scales.
644 ///
645 /// A non-finite or non-positive `scale` is treated as `1.0` (identity):
646 /// callers on platforms without a known scale factor pass `1.0`, and a
647 /// bogus value must never produce garbage coordinates.
648 #[must_use]
649 pub fn to_physical(self, scale: f64) -> Rect {
650 let s = sane_scale(scale);
651 Rect {
652 x: scale_i32(self.x, s),
653 y: scale_i32(self.y, s),
654 width: scale_u32(self.width, s),
655 height: scale_u32(self.height, s),
656 }
657 }
658
659 /// Convert a **physical** rectangle (device pixels) to **logical**
660 /// coordinates by dividing every component by `scale`. Inverse of
661 /// [`Rect::to_physical`]. See that method for rounding and `scale`
662 /// validity semantics.
663 #[must_use]
664 pub fn to_logical(self, scale: f64) -> Rect {
665 self.to_physical(1.0 / sane_scale(scale))
666 }
667}
668
669/// Clamp a scale factor to a usable positive, finite value. Non-finite or
670/// non-positive inputs collapse to `1.0` so a bad platform reading degrades
671/// to identity rather than producing nonsense coordinates.
672pub(crate) fn sane_scale(scale: f64) -> f64 {
673 if scale.is_finite() && scale > 0.0 {
674 scale
675 } else {
676 1.0
677 }
678}
679
680fn scale_i32(v: i32, scale: f64) -> i32 {
681 (f64::from(v) * scale).round() as i32
682}
683
684fn scale_u32(v: u32, scale: f64) -> u32 {
685 let scaled = (f64::from(v) * scale).round();
686 if scaled < 0.0 {
687 0
688 } else {
689 scaled as u32
690 }
691}
692
693#[cfg(test)]
694mod rect_scale_tests {
695 use super::Rect;
696
697 const R: Rect = Rect {
698 x: 100,
699 y: 200,
700 width: 300,
701 height: 40,
702 };
703
704 #[test]
705 fn scale_one_is_identity() {
706 assert_eq!(R.to_physical(1.0), R);
707 assert_eq!(R.to_logical(1.0), R);
708 }
709
710 #[test]
711 fn to_physical_multiplies_all_fields() {
712 assert_eq!(
713 R.to_physical(2.0),
714 Rect {
715 x: 200,
716 y: 400,
717 width: 600,
718 height: 80
719 }
720 );
721 }
722
723 #[test]
724 fn to_logical_divides_all_fields() {
725 // Physical bounds on a 150% display -> logical points.
726 let physical = Rect {
727 x: 150,
728 y: 300,
729 width: 450,
730 height: 60,
731 };
732 assert_eq!(
733 physical.to_logical(1.5),
734 Rect {
735 x: 100,
736 y: 200,
737 width: 300,
738 height: 40
739 }
740 );
741 }
742
743 #[test]
744 fn round_trip_preserves_within_one_px() {
745 for &scale in &[1.25_f64, 1.5, 1.75, 2.0] {
746 let back = R.to_physical(scale).to_logical(scale);
747 assert!((back.x - R.x).abs() <= 1, "x drift at {scale}");
748 assert!((back.y - R.y).abs() <= 1, "y drift at {scale}");
749 assert!(
750 (back.width as i64 - R.width as i64).abs() <= 1,
751 "w drift at {scale}"
752 );
753 assert!(
754 (back.height as i64 - R.height as i64).abs() <= 1,
755 "h drift at {scale}"
756 );
757 }
758 }
759
760 #[test]
761 fn negative_origin_scales_correctly() {
762 // Multi-monitor: a window on a display left of the primary.
763 let r = Rect {
764 x: -1920,
765 y: -100,
766 width: 200,
767 height: 100,
768 };
769 assert_eq!(
770 r.to_physical(2.0),
771 Rect {
772 x: -3840,
773 y: -200,
774 width: 400,
775 height: 200
776 }
777 );
778 }
779
780 #[test]
781 fn fractional_scale_rounds_to_nearest() {
782 let r = Rect {
783 x: 3,
784 y: 3,
785 width: 5,
786 height: 5,
787 };
788 // 3 * 1.5 = 4.5 -> 5 (round half away from zero via f64::round);
789 // 5 * 1.5 = 7.5 -> 8.
790 assert_eq!(
791 r.to_physical(1.5),
792 Rect {
793 x: 5,
794 y: 5,
795 width: 8,
796 height: 8
797 }
798 );
799 }
800
801 #[test]
802 fn bad_scale_degrades_to_identity() {
803 assert_eq!(R.to_physical(0.0), R);
804 assert_eq!(R.to_physical(-2.0), R);
805 assert_eq!(R.to_physical(f64::NAN), R);
806 assert_eq!(R.to_physical(f64::INFINITY), R);
807 assert_eq!(R.to_logical(0.0), R);
808 }
809}
810
811/// Platform-specific raw data attached to every element.
812///
813/// An untyped key-value map containing the original platform-specific data
814/// exactly as the platform reported it. Keys use `snake_case` naming. This is
815/// the escape hatch for consumers who need full platform fidelity.
816pub type RawPlatformData = HashMap<String, serde_json::Value>;
817
818/// A node in a recursive snapshot of the accessibility subtree.
819///
820/// Returned by [`Element::tree`] and [`Locator::tree`]. Each node carries the
821/// role, display name, and value of one element, plus its children recursively.
822/// `children` is empty when `max_depth` was reached or the element is a leaf.
823///
824/// `#[non_exhaustive]`: a dump node grows alongside [`ElementData`] — bounds
825/// and stable ids are both plausible additions. Build one with
826/// [`TreeNode::new`].
827#[derive(Debug, Clone, Serialize, Deserialize)]
828#[non_exhaustive]
829pub struct TreeNode {
830 pub role: String,
831 pub name: Option<String>,
832 pub value: Option<String>,
833 pub children: Vec<TreeNode>,
834}
835
836impl TreeNode {
837 /// A leaf node with the given role and no name, value, or children.
838 pub fn new(role: impl Into<String>) -> Self {
839 Self {
840 role: role.into(),
841 name: None,
842 value: None,
843 children: Vec::new(),
844 }
845 }
846}
847
848#[cfg(test)]
849mod tests {
850 //! Unit tests for `Element` action methods. Verifies each action records the
851 //! expected entry in the mock provider's action log and that validation
852 //! errors fire before the provider is ever called.
853
854 use super::*;
855 use crate::mock::{build_provider, MockProvider};
856 use crate::selector::Selector;
857
858 /// Resolve `selector` against the mock tree and return the first match
859 /// wrapped in an `Element`. Panics on no match — these are unit tests, not
860 /// production paths.
861 fn find_element(provider: &Arc<MockProvider>, selector: &str) -> Element {
862 let parsed = Selector::parse(selector).expect("selector must parse");
863 let provider_dyn: Arc<dyn Provider> = provider.clone();
864 let root = provider_dyn
865 .list_apps()
866 .expect("list_apps must succeed")
867 .into_iter()
868 .next()
869 .expect("mock provider must expose an application root");
870 let mut matches = provider_dyn
871 .find_elements(&root, &parsed, Some(1), None)
872 .expect("find_elements must succeed");
873 let data = matches.pop().expect("selector matched no elements");
874 Element::new(data, provider_dyn)
875 }
876
877 fn last_action(provider: &Arc<MockProvider>) -> (u64, String, Option<String>) {
878 provider
879 .actions()
880 .last()
881 .cloned()
882 .expect("expected at least one recorded action")
883 }
884
885 #[test]
886 fn nullary_actions_record_correct_name() {
887 let provider = build_provider();
888 let cases = [
889 (r#"button[name="Back"]"#, "press" as &str),
890 (r#"button[name="Back"]"#, "focus"),
891 (r#"button[name="Back"]"#, "blur"),
892 (r#"check_box[name="Agree"]"#, "toggle"),
893 (r#"list_item[name="Item 1"]"#, "select"),
894 (r#"list[name="Items"]"#, "expand"),
895 (r#"list[name="Items"]"#, "collapse"),
896 (r#"button[name="Back"]"#, "show_menu"),
897 (r#"slider[name="Volume"]"#, "increment"),
898 (r#"slider[name="Volume"]"#, "decrement"),
899 (r#"button[name="Back"]"#, "scroll_into_view"),
900 ];
901 for (selector, action) in cases {
902 provider.clear_actions();
903 let el = find_element(&provider, selector);
904 match action {
905 "press" => el.press().unwrap(),
906 "focus" => el.focus().unwrap(),
907 "blur" => el.blur().unwrap(),
908 "toggle" => el.toggle().unwrap(),
909 "select" => el.select().unwrap(),
910 "expand" => el.expand().unwrap(),
911 "collapse" => el.collapse().unwrap(),
912 "show_menu" => el.show_menu().unwrap(),
913 "increment" => el.increment().unwrap(),
914 "decrement" => el.decrement().unwrap(),
915 "scroll_into_view" => el.scroll_into_view().unwrap(),
916 _ => unreachable!(),
917 }
918 let (handle, name, data) = last_action(&provider);
919 assert_eq!(
920 name, action,
921 "wrong action recorded for selector {selector}"
922 );
923 assert_eq!(data, None, "nullary action should not carry data");
924 assert_eq!(handle, el.data.handle);
925 }
926 }
927
928 #[test]
929 fn set_value_records_text_payload() {
930 let provider = build_provider();
931 let el = find_element(&provider, r#"text_field[name="Search"]"#);
932 el.set_value("world").unwrap();
933 let (handle, name, data) = last_action(&provider);
934 assert_eq!(handle, el.data.handle);
935 assert_eq!(name, "set_value");
936 assert_eq!(data.as_deref(), Some("world"));
937 }
938
939 #[test]
940 fn set_numeric_value_records_payload() {
941 let provider = build_provider();
942 let el = find_element(&provider, r#"slider[name="Volume"]"#);
943 el.set_numeric_value(42.0).unwrap();
944 let (_, name, data) = last_action(&provider);
945 assert_eq!(name, "set_numeric_value");
946 assert_eq!(data.as_deref(), Some("42"));
947 }
948
949 #[test]
950 fn set_numeric_value_rejects_non_finite() {
951 let provider = build_provider();
952 let el = find_element(&provider, r#"slider[name="Volume"]"#);
953 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
954 assert!(matches!(
955 el.set_numeric_value(bad),
956 Err(Error::InvalidActionData { .. })
957 ));
958 }
959 // None of the validation failures should have reached the provider.
960 assert!(provider.actions().is_empty());
961 }
962
963 #[test]
964 fn type_text_records_payload() {
965 let provider = build_provider();
966 let el = find_element(&provider, r#"text_field[name="Search"]"#);
967 el.type_text("abc").unwrap();
968 let (_, name, data) = last_action(&provider);
969 assert_eq!(name, "type_text");
970 assert_eq!(data.as_deref(), Some("abc"));
971 }
972
973 #[test]
974 fn select_text_records_range() {
975 let provider = build_provider();
976 let el = find_element(&provider, r#"text_field[name="Search"]"#);
977 el.select_text(1, 4).unwrap();
978 let (_, name, data) = last_action(&provider);
979 assert_eq!(name, "set_text_selection");
980 assert_eq!(data.as_deref(), Some("1..4"));
981 }
982
983 #[test]
984 fn select_text_rejects_inverted_range() {
985 let provider = build_provider();
986 let el = find_element(&provider, r#"text_field[name="Search"]"#);
987 assert!(matches!(
988 el.select_text(5, 2),
989 Err(Error::InvalidActionData { .. })
990 ));
991 assert!(provider.actions().is_empty());
992 }
993
994 #[test]
995 fn perform_action_records_arbitrary_name() {
996 let provider = build_provider();
997 let el = find_element(&provider, r#"button[name="Back"]"#);
998 el.perform_action("raise").unwrap();
999 let (_, name, _) = last_action(&provider);
1000 assert_eq!(name, "raise");
1001 }
1002
1003 #[test]
1004 fn locator_actions_desugar_to_element_actions() {
1005 // Locator's auto-wait wraps the resolved data in an Element and calls
1006 // its action — no duplication at the provider call site. This test
1007 // pins that behavior: pressing via the Locator should record exactly
1008 // the same entry as pressing via the Element it resolves to.
1009 let provider = build_provider();
1010 let provider_dyn: Arc<dyn Provider> = provider.clone();
1011 let locator = crate::locator::Locator::new(provider_dyn, None, r#"button[name="Back"]"#);
1012 locator.press().unwrap();
1013 let (_, name, data) = last_action(&provider);
1014 assert_eq!(name, "press");
1015 assert_eq!(data, None);
1016 }
1017
1018 #[test]
1019 fn locator_validation_runs_before_auto_wait() {
1020 // Locator validates payloads before entering its 5s auto-wait poll.
1021 // We verify by passing invalid input against a never-matching selector:
1022 // if validation fired first we get InvalidActionData immediately, not
1023 // a Timeout 5 seconds later.
1024 let provider = build_provider();
1025 let provider_dyn: Arc<dyn Provider> = provider.clone();
1026 let locator =
1027 crate::locator::Locator::new(provider_dyn, None, r#"button[name="never-matches"]"#);
1028 let started = std::time::Instant::now();
1029 let err = locator.set_numeric_value(f64::NAN).unwrap_err();
1030 assert!(matches!(err, Error::InvalidActionData { .. }));
1031 assert!(
1032 started.elapsed() < std::time::Duration::from_secs(1),
1033 "validation must short-circuit auto-wait",
1034 );
1035 }
1036}