Skip to main content

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/// The raw data for a single element in an accessibility tree.
13///
14/// This is the underlying data struct. Most consumers should use [`Element`],
15/// which wraps `ElementData` with a provider reference for lazy navigation.
16/// `ElementData` is used directly by provider implementors.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ElementData {
19    /// Element role
20    pub role: Role,
21
22    /// Human-readable name (title, label).
23    ///
24    /// Stripped of Unicode bidi format controls (LRM, RLM, embeddings,
25    /// overrides, isolates) so equality assertions match the logical text.
26    /// The unstripped platform string is preserved in [`Self::raw`] under the
27    /// platform-native key (e.g. `AXTitle` on macOS, `atspi_name` on Linux,
28    /// `uia_name` on Windows). See [`crate::text::strip_bidi`].
29    pub name: Option<String>,
30
31    /// Current value (text content, slider position, etc.).
32    ///
33    /// Stripped of Unicode bidi format controls. The unstripped platform
34    /// string is preserved in [`Self::raw`] (`AXValue` on macOS, `atspi_value`
35    /// on Linux, `uia_value` on Windows). See [`crate::text::strip_bidi`].
36    pub value: Option<String>,
37
38    /// Supplementary description (tooltip, help text).
39    ///
40    /// Stripped of Unicode bidi format controls. The unstripped platform
41    /// string is preserved in [`Self::raw`] (`AXDescription`/`AXHelp` on
42    /// macOS, `atspi_description` on Linux, `uia_help_text` on Windows).
43    /// See [`crate::text::strip_bidi`].
44    pub description: Option<String>,
45
46    /// Bounding rectangle in screen pixels
47    pub bounds: Option<Rect>,
48
49    /// Available actions reported by the platform.
50    ///
51    /// Names are `snake_case` strings — well-known actions use their standard
52    /// names (`"press"`, `"toggle"`, `"expand"`, etc.) and platform-specific
53    /// actions use their converted names (e.g. macOS `AXCustomThing` →
54    /// `"custom_thing"`).
55    pub actions: Vec<String>,
56
57    /// Current state flags
58    pub states: StateSet,
59
60    /// Numeric value for range controls (sliders, progress bars, spinners).
61    pub numeric_value: Option<f64>,
62
63    /// Minimum value for range controls.
64    pub min_value: Option<f64>,
65
66    /// Maximum value for range controls.
67    pub max_value: Option<f64>,
68
69    /// Platform-assigned stable identifier for cross-snapshot correlation.
70    /// - macOS: `AXIdentifier`
71    /// - Windows: `AutomationId`
72    /// - Linux: D-Bus `object_path`
73    ///
74    /// Not all elements have one.
75    pub stable_id: Option<String>,
76
77    /// Process ID of the application that owns this element.
78    pub pid: Option<u32>,
79
80    /// Platform-specific raw data
81    pub raw: RawPlatformData,
82
83    /// Opaque handle for the provider to look up the platform object.
84    /// Not serialized — only valid within the provider that created it.
85    #[serde(skip, default)]
86    pub handle: u64,
87}
88
89/// A live element with lazy navigation via a provider reference.
90///
91/// `Element` dereferences to [`ElementData`], so all properties (`role`, `name`,
92/// `value`, `states`, etc.) are accessible via field access. Navigation
93/// methods (`parent()`, `children()`) call the provider on demand.
94///
95/// Elements are cheap to clone (they share the provider via `Arc`).
96#[derive(Clone)]
97pub struct Element {
98    data: ElementData,
99    provider: Arc<dyn Provider>,
100}
101
102impl Deref for Element {
103    type Target = ElementData;
104
105    fn deref(&self) -> &ElementData {
106        &self.data
107    }
108}
109
110impl fmt::Debug for Element {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        fmt::Debug::fmt(&self.data, f)
113    }
114}
115
116impl fmt::Display for Element {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        let name_part = self
119            .data
120            .name
121            .as_ref()
122            .map(|n| format!(" \"{}\"", n))
123            .unwrap_or_default();
124        let value_part = self
125            .data
126            .value
127            .as_ref()
128            .map(|v| format!(" value=\"{}\"", v))
129            .unwrap_or_default();
130        write!(
131            f,
132            "{}{}{}",
133            self.data.role.to_snake_case(),
134            name_part,
135            value_part,
136        )
137    }
138}
139
140impl Serialize for Element {
141    fn serialize<S: serde::Serializer>(
142        &self,
143        serializer: S,
144    ) -> std::result::Result<S::Ok, S::Error> {
145        self.data.serialize(serializer)
146    }
147}
148
149impl Element {
150    /// Create an Element from raw data and a provider reference.
151    pub fn new(data: ElementData, provider: Arc<dyn Provider>) -> Self {
152        Self { data, provider }
153    }
154
155    /// Get the underlying ElementData.
156    pub fn data(&self) -> &ElementData {
157        &self.data
158    }
159
160    /// Get the provider reference.
161    pub fn provider(&self) -> &Arc<dyn Provider> {
162        &self.provider
163    }
164
165    /// Get direct children of this element.
166    ///
167    /// Each call queries the provider — results are not cached.
168    pub fn children(&self) -> crate::error::Result<Vec<Element>> {
169        let children = self.provider.get_children(Some(&self.data))?;
170        Ok(children
171            .into_iter()
172            .map(|d| Element::new(d, Arc::clone(&self.provider)))
173            .collect())
174    }
175
176    /// Get the parent element, if any (root-level elements have no parent).
177    ///
178    /// Each call queries the provider — results are not cached.
179    pub fn parent(&self) -> crate::error::Result<Option<Element>> {
180        let parent = self.provider.get_parent(&self.data)?;
181        Ok(parent.map(|d| Element::new(d, Arc::clone(&self.provider))))
182    }
183
184    /// Get the process ID from the element data.
185    pub fn pid(&self) -> Option<u32> {
186        self.data.pid
187    }
188
189    /// Capture the subtree rooted at this element as a recursive snapshot.
190    ///
191    /// `max_depth` limits traversal depth: `0` = only this node (no children),
192    /// `1` = node + direct children, and so on. `None` traverses the full subtree.
193    pub fn tree(&self, max_depth: Option<usize>) -> crate::error::Result<TreeNode> {
194        build_tree_node(self, max_depth, 0)
195    }
196
197    /// Render the subtree rooted at this element as an indented string.
198    ///
199    /// Each line is `{indent}{role} "{name}" [value="{value}"]`. Returns the
200    /// string without printing it. Same depth semantics as [`Element::tree`].
201    pub fn dump(&self, max_depth: Option<usize>) -> crate::error::Result<String> {
202        let node = self.tree(max_depth)?;
203        let mut out = String::new();
204        write_tree_node(&node, 0, &mut out);
205        Ok(out)
206    }
207
208    // ── Actions ─────────────────────────────────────────────────────
209    //
210    // Element actions invoke the platform via the captured provider handle —
211    // they do **not** re-resolve the selector. If the underlying element has
212    // been destroyed since this snapshot was taken, the provider returns a
213    // platform-specific "gone" error. For resilient retry-on-change semantics,
214    // use the equivalent method on [`crate::Locator`] instead.
215
216    /// Click / invoke this element via the accessibility action layer.
217    pub fn press(&self) -> crate::error::Result<()> {
218        self.provider.press(&self.data)
219    }
220
221    /// Set keyboard focus to this element.
222    pub fn focus(&self) -> crate::error::Result<()> {
223        self.provider.focus(&self.data)
224    }
225
226    /// Remove keyboard focus from this element.
227    pub fn blur(&self) -> crate::error::Result<()> {
228        self.provider.blur(&self.data)
229    }
230
231    /// Toggle a two- or three-state control (checkbox, switch).
232    pub fn toggle(&self) -> crate::error::Result<()> {
233        self.provider.toggle(&self.data)
234    }
235
236    /// Select this element (list item, tab, row).
237    pub fn select(&self) -> crate::error::Result<()> {
238        self.provider.select(&self.data)
239    }
240
241    /// Expand a disclosure, menu, combo box, or tree item.
242    pub fn expand(&self) -> crate::error::Result<()> {
243        self.provider.expand(&self.data)
244    }
245
246    /// Collapse an expanded element.
247    pub fn collapse(&self) -> crate::error::Result<()> {
248        self.provider.collapse(&self.data)
249    }
250
251    /// Open this element's context menu or dropdown.
252    pub fn show_menu(&self) -> crate::error::Result<()> {
253        self.provider.show_menu(&self.data)
254    }
255
256    /// Increment a numeric control (slider, spinner) by its platform step.
257    pub fn increment(&self) -> crate::error::Result<()> {
258        self.provider.increment(&self.data)
259    }
260
261    /// Decrement a numeric control (slider, spinner) by its platform step.
262    pub fn decrement(&self) -> crate::error::Result<()> {
263        self.provider.decrement(&self.data)
264    }
265
266    /// Scroll this element into the visible area.
267    ///
268    /// No-op on macOS — the macOS accessibility API has no equivalent.
269    pub fn scroll_into_view(&self) -> crate::error::Result<()> {
270        self.provider.scroll_into_view(&self.data)
271    }
272
273    /// Set the text value of this element. Replaces the entire value rather
274    /// than inserting at the caret — use [`Element::type_text`] for insertion.
275    pub fn set_value(&self, value: &str) -> crate::error::Result<()> {
276        self.provider.set_value(&self.data, value)
277    }
278
279    /// Set the numeric value of this element (slider, spinner).
280    ///
281    /// Returns [`Error::InvalidActionData`] if `value` is NaN or infinite.
282    pub fn set_numeric_value(&self, value: f64) -> crate::error::Result<()> {
283        if !value.is_finite() {
284            return Err(Error::InvalidActionData {
285                message: format!("set_numeric_value requires a finite value, got {}", value),
286            });
287        }
288        self.provider.set_numeric_value(&self.data, value)
289    }
290
291    /// Insert text at the current cursor position.
292    ///
293    /// Uses the platform accessibility API — never simulates keyboard events.
294    pub fn type_text(&self, text: &str) -> crate::error::Result<()> {
295        self.provider.type_text(&self.data, text)
296    }
297
298    /// Select the text range from `start` to `end` (0-based character offsets).
299    ///
300    /// Returns [`Error::InvalidActionData`] if `start > end`.
301    pub fn select_text(&self, start: u32, end: u32) -> crate::error::Result<()> {
302        if start > end {
303            return Err(Error::InvalidActionData {
304                message: format!("select_text start ({}) must be <= end ({})", start, end),
305            });
306        }
307        self.provider.set_text_selection(&self.data, start, end)
308    }
309
310    /// Perform an action by its `snake_case` name.
311    ///
312    /// Use this for actions the element advertises in its [`actions`](ElementData::actions)
313    /// list that don't have a dedicated method. Well-known names (`"press"`,
314    /// `"focus"`, etc.) also work — providers delegate to the named methods.
315    pub fn perform_action(&self, action: &str) -> crate::error::Result<()> {
316        self.provider.perform_action(&self.data, action)
317    }
318}
319
320fn build_tree_node(
321    element: &Element,
322    max_depth: Option<usize>,
323    depth: usize,
324) -> crate::error::Result<TreeNode> {
325    let children = if max_depth.is_none_or(|d| depth < d) {
326        element
327            .children()?
328            .into_iter()
329            .map(|child| build_tree_node(&child, max_depth, depth + 1))
330            .collect::<crate::error::Result<Vec<_>>>()?
331    } else {
332        vec![]
333    };
334    Ok(TreeNode {
335        role: element.data.role.to_snake_case().to_string(),
336        name: element.data.name.clone(),
337        value: element.data.value.clone(),
338        children,
339    })
340}
341
342fn write_tree_node(node: &TreeNode, depth: usize, out: &mut String) {
343    use fmt::Write as _;
344    let indent = "  ".repeat(depth);
345    write!(out, "{}{}", indent, node.role).unwrap();
346    if let Some(ref n) = node.name {
347        write!(out, " \"{}\"", n).unwrap();
348    }
349    if let Some(ref v) = node.value {
350        write!(out, " value=\"{}\"", v).unwrap();
351    }
352    out.push('\n');
353    for child in &node.children {
354        write_tree_node(child, depth + 1, out);
355    }
356}
357
358/// Boolean state flags for an element.
359///
360/// **Semantics for non-applicable states:** When a state doesn't apply to an
361/// element's role, the backend uses the platform's reported value or defaults:
362/// - `enabled`: `true` (elements are enabled unless explicitly disabled)
363/// - `visible`: `true` (elements are visible unless explicitly hidden/offscreen)
364/// - `focused`, `focusable`, `modal`, `selected`, `editable`, `required`, `busy`: `false`
365///
366/// States that are inherently inapplicable use `Option`: `checked` is `None`
367/// for non-checkable elements, `expanded` is `None` for non-expandable elements.
368#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
369pub struct StateSet {
370    pub enabled: bool,
371    pub visible: bool,
372    pub focused: bool,
373    /// None = not checkable
374    pub checked: Option<Toggled>,
375    pub selected: bool,
376    /// None = not expandable
377    pub expanded: Option<bool>,
378    pub editable: bool,
379    /// Whether the element can receive keyboard focus
380    pub focusable: bool,
381    /// Whether the element is a modal dialog
382    pub modal: bool,
383    /// Form field required
384    pub required: bool,
385    /// Async operation in progress
386    pub busy: bool,
387}
388
389impl Default for StateSet {
390    fn default() -> Self {
391        Self {
392            enabled: true,
393            visible: true,
394            focused: false,
395            checked: None,
396            selected: false,
397            expanded: None,
398            editable: false,
399            focusable: false,
400            modal: false,
401            required: false,
402            busy: false,
403        }
404    }
405}
406
407/// Tri-state toggle value.
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
409pub enum Toggled {
410    Off,
411    On,
412    /// Indeterminate / tri-state
413    Mixed,
414}
415
416/// Screen-pixel bounding rectangle (origin + size).
417/// `x`/`y` are signed to support negative multi-monitor coordinates.
418/// `width`/`height` are unsigned (always non-negative).
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
420pub struct Rect {
421    pub x: i32,
422    pub y: i32,
423    pub width: u32,
424    pub height: u32,
425}
426
427/// Platform-specific raw data attached to every element.
428///
429/// An untyped key-value map containing the original platform-specific data
430/// exactly as the platform reported it. Keys use `snake_case` naming. This is
431/// the escape hatch for consumers who need full platform fidelity.
432pub type RawPlatformData = HashMap<String, serde_json::Value>;
433
434/// A node in a recursive snapshot of the accessibility subtree.
435///
436/// Returned by [`Element::tree`] and [`Locator::tree`]. Each node carries the
437/// role, display name, and value of one element, plus its children recursively.
438/// `children` is empty when `max_depth` was reached or the element is a leaf.
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct TreeNode {
441    pub role: String,
442    pub name: Option<String>,
443    pub value: Option<String>,
444    pub children: Vec<TreeNode>,
445}
446
447#[cfg(test)]
448mod tests {
449    //! Unit tests for `Element` action methods. Verifies each action records the
450    //! expected entry in the mock provider's action log and that validation
451    //! errors fire before the provider is ever called.
452
453    use super::*;
454    use crate::mock::{build_provider, MockProvider};
455    use crate::selector::Selector;
456
457    /// Resolve `selector` against the mock tree and return the first match
458    /// wrapped in an `Element`. Panics on no match — these are unit tests, not
459    /// production paths.
460    fn find_element(provider: &Arc<MockProvider>, selector: &str) -> Element {
461        let parsed = Selector::parse(selector).expect("selector must parse");
462        let provider_dyn: Arc<dyn Provider> = provider.clone();
463        let root = provider_dyn
464            .list_apps()
465            .expect("list_apps must succeed")
466            .into_iter()
467            .next()
468            .expect("mock provider must expose an application root");
469        let mut matches = provider_dyn
470            .find_elements(&root, &parsed, Some(1), None)
471            .expect("find_elements must succeed");
472        let data = matches.pop().expect("selector matched no elements");
473        Element::new(data, provider_dyn)
474    }
475
476    fn last_action(provider: &Arc<MockProvider>) -> (u64, String, Option<String>) {
477        provider
478            .actions()
479            .last()
480            .cloned()
481            .expect("expected at least one recorded action")
482    }
483
484    #[test]
485    fn nullary_actions_record_correct_name() {
486        let provider = build_provider();
487        let cases = [
488            (r#"button[name="Back"]"#, "press" as &str),
489            (r#"button[name="Back"]"#, "focus"),
490            (r#"button[name="Back"]"#, "blur"),
491            (r#"check_box[name="Agree"]"#, "toggle"),
492            (r#"list_item[name="Item 1"]"#, "select"),
493            (r#"list[name="Items"]"#, "expand"),
494            (r#"list[name="Items"]"#, "collapse"),
495            (r#"button[name="Back"]"#, "show_menu"),
496            (r#"slider[name="Volume"]"#, "increment"),
497            (r#"slider[name="Volume"]"#, "decrement"),
498            (r#"button[name="Back"]"#, "scroll_into_view"),
499        ];
500        for (selector, action) in cases {
501            provider.clear_actions();
502            let el = find_element(&provider, selector);
503            match action {
504                "press" => el.press().unwrap(),
505                "focus" => el.focus().unwrap(),
506                "blur" => el.blur().unwrap(),
507                "toggle" => el.toggle().unwrap(),
508                "select" => el.select().unwrap(),
509                "expand" => el.expand().unwrap(),
510                "collapse" => el.collapse().unwrap(),
511                "show_menu" => el.show_menu().unwrap(),
512                "increment" => el.increment().unwrap(),
513                "decrement" => el.decrement().unwrap(),
514                "scroll_into_view" => el.scroll_into_view().unwrap(),
515                _ => unreachable!(),
516            }
517            let (handle, name, data) = last_action(&provider);
518            assert_eq!(
519                name, action,
520                "wrong action recorded for selector {selector}"
521            );
522            assert_eq!(data, None, "nullary action should not carry data");
523            assert_eq!(handle, el.data.handle);
524        }
525    }
526
527    #[test]
528    fn set_value_records_text_payload() {
529        let provider = build_provider();
530        let el = find_element(&provider, r#"text_field[name="Search"]"#);
531        el.set_value("world").unwrap();
532        let (handle, name, data) = last_action(&provider);
533        assert_eq!(handle, el.data.handle);
534        assert_eq!(name, "set_value");
535        assert_eq!(data.as_deref(), Some("world"));
536    }
537
538    #[test]
539    fn set_numeric_value_records_payload() {
540        let provider = build_provider();
541        let el = find_element(&provider, r#"slider[name="Volume"]"#);
542        el.set_numeric_value(42.0).unwrap();
543        let (_, name, data) = last_action(&provider);
544        assert_eq!(name, "set_numeric_value");
545        assert_eq!(data.as_deref(), Some("42"));
546    }
547
548    #[test]
549    fn set_numeric_value_rejects_non_finite() {
550        let provider = build_provider();
551        let el = find_element(&provider, r#"slider[name="Volume"]"#);
552        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
553            assert!(matches!(
554                el.set_numeric_value(bad),
555                Err(Error::InvalidActionData { .. })
556            ));
557        }
558        // None of the validation failures should have reached the provider.
559        assert!(provider.actions().is_empty());
560    }
561
562    #[test]
563    fn type_text_records_payload() {
564        let provider = build_provider();
565        let el = find_element(&provider, r#"text_field[name="Search"]"#);
566        el.type_text("abc").unwrap();
567        let (_, name, data) = last_action(&provider);
568        assert_eq!(name, "type_text");
569        assert_eq!(data.as_deref(), Some("abc"));
570    }
571
572    #[test]
573    fn select_text_records_range() {
574        let provider = build_provider();
575        let el = find_element(&provider, r#"text_field[name="Search"]"#);
576        el.select_text(1, 4).unwrap();
577        let (_, name, data) = last_action(&provider);
578        assert_eq!(name, "set_text_selection");
579        assert_eq!(data.as_deref(), Some("1..4"));
580    }
581
582    #[test]
583    fn select_text_rejects_inverted_range() {
584        let provider = build_provider();
585        let el = find_element(&provider, r#"text_field[name="Search"]"#);
586        assert!(matches!(
587            el.select_text(5, 2),
588            Err(Error::InvalidActionData { .. })
589        ));
590        assert!(provider.actions().is_empty());
591    }
592
593    #[test]
594    fn perform_action_records_arbitrary_name() {
595        let provider = build_provider();
596        let el = find_element(&provider, r#"button[name="Back"]"#);
597        el.perform_action("raise").unwrap();
598        let (_, name, _) = last_action(&provider);
599        assert_eq!(name, "raise");
600    }
601
602    #[test]
603    fn locator_actions_desugar_to_element_actions() {
604        // Locator's auto-wait wraps the resolved data in an Element and calls
605        // its action — no duplication at the provider call site. This test
606        // pins that behavior: pressing via the Locator should record exactly
607        // the same entry as pressing via the Element it resolves to.
608        let provider = build_provider();
609        let provider_dyn: Arc<dyn Provider> = provider.clone();
610        let locator = crate::locator::Locator::new(provider_dyn, None, r#"button[name="Back"]"#);
611        locator.press().unwrap();
612        let (_, name, data) = last_action(&provider);
613        assert_eq!(name, "press");
614        assert_eq!(data, None);
615    }
616
617    #[test]
618    fn locator_validation_runs_before_auto_wait() {
619        // Locator validates payloads before entering its 5s auto-wait poll.
620        // We verify by passing invalid input against a never-matching selector:
621        // if validation fired first we get InvalidActionData immediately, not
622        // a Timeout 5 seconds later.
623        let provider = build_provider();
624        let provider_dyn: Arc<dyn Provider> = provider.clone();
625        let locator =
626            crate::locator::Locator::new(provider_dyn, None, r#"button[name="never-matches"]"#);
627        let started = std::time::Instant::now();
628        let err = locator.set_numeric_value(f64::NAN).unwrap_err();
629        assert!(matches!(err, Error::InvalidActionData { .. }));
630        assert!(
631            started.elapsed() < std::time::Duration::from_secs(1),
632            "validation must short-circuit auto-wait",
633        );
634    }
635}