use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AXTree {
pub nodes: HashMap<String, AXNode>,
pub root_id: Option<String>,
}
impl AXTree {
pub fn new() -> Self {
Self {
nodes: HashMap::new(),
root_id: None,
}
}
pub fn from_nodes(nodes: Vec<AXNode>) -> Self {
let mut tree = Self::new();
for node in nodes {
if tree.root_id.is_none() {
tree.root_id = Some(node.node_id.clone());
}
tree.nodes.insert(node.node_id.clone(), node);
}
tree
}
pub fn get_node(&self, node_id: &str) -> Option<&AXNode> {
self.nodes.get(node_id)
}
pub fn root(&self) -> Option<&AXNode> {
self.root_id.as_ref().and_then(|id| self.nodes.get(id))
}
pub fn iter(&self) -> impl Iterator<Item = &AXNode> {
self.nodes.values().filter(|n| !n.is_browser_generated())
}
pub fn iter_all(&self) -> impl Iterator<Item = &AXNode> {
self.nodes.values()
}
pub fn nodes_with_role(&self, role: &str) -> Vec<&AXNode> {
self.iter()
.filter(|n| n.role.as_deref() == Some(role))
.collect()
}
pub fn images(&self) -> Vec<&AXNode> {
self.iter()
.filter(|n| matches!(n.role.as_deref(), Some("image") | Some("img")))
.collect()
}
pub fn headings(&self) -> Vec<&AXNode> {
self.iter()
.filter(|n| matches!(n.role.as_deref(), Some("heading")))
.collect()
}
pub fn form_controls(&self) -> Vec<&AXNode> {
self.iter()
.filter(|n| {
matches!(
n.role.as_deref(),
Some("textbox")
| Some("checkbox")
| Some("radio")
| Some("combobox")
| Some("listbox")
| Some("spinbutton")
| Some("slider")
| Some("searchbox")
)
})
.collect()
}
pub fn links(&self) -> Vec<&AXNode> {
self.nodes_with_role("link")
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
}
impl Default for AXTree {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AXNode {
pub node_id: String,
#[serde(default)]
pub ignored: bool,
#[serde(default)]
pub ignored_reasons: Vec<AXProperty>,
pub role: Option<String>,
pub name: Option<String>,
pub name_source: Option<NameSource>,
pub description: Option<String>,
pub value: Option<String>,
#[serde(default)]
pub properties: Vec<AXProperty>,
#[serde(default)]
pub child_ids: Vec<String>,
pub parent_id: Option<String>,
pub backend_dom_node_id: Option<i64>,
}
impl AXNode {
pub fn has_name(&self) -> bool {
self.name.as_ref().is_some_and(|n| !n.trim().is_empty())
}
pub fn is_focusable(&self) -> bool {
self.get_property_bool("focusable").unwrap_or(false)
}
pub fn is_interactive(&self) -> bool {
matches!(
self.role.as_deref(),
Some("button")
| Some("link")
| Some("textbox")
| Some("checkbox")
| Some("radio")
| Some("combobox")
| Some("menuitem")
| Some("tab")
) || self.is_focusable()
}
pub fn heading_level(&self) -> Option<u8> {
if self.role.as_deref() != Some("heading") {
return None;
}
self.get_property_int("level").map(|l| l.clamp(1, 6) as u8)
}
pub fn get_property_bool(&self, name: &str) -> Option<bool> {
self.properties
.iter()
.find(|p| p.name == name)
.and_then(|p| p.value.as_bool())
}
pub fn get_property_int(&self, name: &str) -> Option<i64> {
self.properties
.iter()
.find(|p| p.name == name)
.and_then(|p| p.value.as_int())
}
pub fn get_property_str(&self, name: &str) -> Option<&str> {
self.properties
.iter()
.find(|p| p.name == name)
.and_then(|p| p.value.as_str())
}
pub fn has_role(&self, role: &str) -> bool {
self.role.as_deref() == Some(role)
}
pub fn is_browser_generated(&self) -> bool {
matches!(
self.role.as_deref(),
Some("StaticText")
| Some("InlineTextBox")
| Some("LineBreak")
| Some("ListMarker")
| Some("LayoutTable")
| Some("LayoutTableRow")
| Some("LayoutTableCell")
)
}
pub fn is_required(&self) -> bool {
self.get_property_bool("required").unwrap_or(false)
}
pub fn is_invalid(&self) -> bool {
self.get_property_bool("invalid").unwrap_or(false)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NameSource {
Attribute,
RelatedElement,
Contents,
Placeholder,
Title,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AXProperty {
pub name: String,
pub value: AXValue,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AXValue {
Bool(bool),
Int(i64),
Float(f64),
String(String),
Node { related_nodes: Vec<RelatedNode> },
List(Vec<AXValue>),
}
impl AXValue {
pub fn as_bool(&self) -> Option<bool> {
match self {
AXValue::Bool(b) => Some(*b),
_ => None,
}
}
pub fn as_int(&self) -> Option<i64> {
match self {
AXValue::Int(i) => Some(*i),
AXValue::Float(f) => Some(*f as i64),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
AXValue::String(s) => Some(s),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelatedNode {
pub backend_dom_node_id: Option<i64>,
pub idref: Option<String>,
pub text: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_node(id: &str, role: &str, name: Option<&str>) -> AXNode {
AXNode {
node_id: id.to_string(),
ignored: false,
ignored_reasons: vec![],
role: Some(role.to_string()),
name: name.map(String::from),
name_source: None,
description: None,
value: None,
properties: vec![],
child_ids: vec![],
parent_id: None,
backend_dom_node_id: None,
}
}
#[test]
fn test_axtree_from_nodes() {
let nodes = vec![
create_test_node("1", "WebArea", Some("Page")),
create_test_node("2", "heading", Some("Title")),
create_test_node("3", "image", None),
];
let tree = AXTree::from_nodes(nodes);
assert_eq!(tree.len(), 3);
assert_eq!(tree.root_id, Some("1".to_string()));
}
#[test]
fn test_axtree_images() {
let nodes = vec![
create_test_node("1", "WebArea", Some("Page")),
create_test_node("2", "image", Some("Logo")),
create_test_node("3", "image", None),
create_test_node("4", "heading", Some("Title")),
];
let tree = AXTree::from_nodes(nodes);
let images = tree.images();
assert_eq!(images.len(), 2);
}
#[test]
fn test_axtree_headings() {
let nodes = vec![
create_test_node("1", "WebArea", Some("Page")),
create_test_node("2", "heading", Some("Title")),
create_test_node("3", "heading", Some("Subtitle")),
];
let tree = AXTree::from_nodes(nodes);
let headings = tree.headings();
assert_eq!(headings.len(), 2);
}
#[test]
fn test_axnode_has_name() {
let node_with_name = create_test_node("1", "image", Some("Logo"));
let node_without_name = create_test_node("2", "image", None);
let node_empty_name = create_test_node("3", "image", Some(" "));
assert!(node_with_name.has_name());
assert!(!node_without_name.has_name());
assert!(!node_empty_name.has_name());
}
#[test]
fn test_axnode_heading_level() {
let mut heading = create_test_node("1", "heading", Some("Title"));
heading.properties.push(AXProperty {
name: "level".to_string(),
value: AXValue::Int(2),
});
assert_eq!(heading.heading_level(), Some(2));
let non_heading = create_test_node("2", "paragraph", Some("Text"));
assert_eq!(non_heading.heading_level(), None);
}
}