use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UiNode {
pub depth: u32,
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub automation_id: Option<String>,
#[serde(skip)]
pub parent: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ElementOut {
#[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
pub ref_id: Option<String>,
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub automation_id: Option<String>,
pub depth: u32,
}
pub const INTERACTIVE_ROLES: &[&str] = &[
"Button",
"CheckBox",
"RadioButton",
"ComboBox",
"Edit",
"ListItem",
"MenuItem",
"TabItem",
"Hyperlink",
"ToggleButton",
"DataGrid",
"Spinner",
"Slider",
"Document",
"Tree",
"TreeItem",
"Table",
"Calendar",
"Menu",
];
pub fn is_interactive_role(role: &str) -> bool {
INTERACTIVE_ROLES.contains(&role)
}
pub fn new_snapshot_id() -> String {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
format!("s{nanos:x}{n:x}")
}
pub struct SnapshotBuilder {
nodes: Vec<UiNode>,
max_elements: usize,
truncated: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SnapshotOutput {
pub snapshot_id: String,
pub truncated: bool,
pub elements: Vec<ElementOut>,
}
impl SnapshotBuilder {
pub fn new(max_elements: usize) -> Self {
Self {
nodes: Vec::new(),
max_elements,
truncated: false,
}
}
pub fn push(&mut self, node: UiNode) -> bool {
if self.nodes.len() >= self.max_elements {
self.truncated = true;
return false;
}
self.nodes.push(node);
true
}
pub fn finish(self, skeleton: bool) -> SnapshotOutput {
let keep = if skeleton {
self.skeleton_keep_set()
} else {
self.all_keep()
};
let mut next_ref = 0;
let elements = self
.nodes
.iter()
.enumerate()
.filter(|(i, _)| keep[*i])
.map(|(i, n)| {
let ref_id = if is_interactive_role(&n.role) {
next_ref += 1;
Some(format!("@e{next_ref}"))
} else {
None
};
let _ = i;
ElementOut {
ref_id,
role: n.role.clone(),
name: n.name.clone(),
automation_id: n.automation_id.clone(),
depth: n.depth,
}
})
.collect();
SnapshotOutput {
snapshot_id: new_snapshot_id(),
truncated: self.truncated,
elements,
}
}
fn all_keep(&self) -> Vec<bool> {
vec![true; self.nodes.len()]
}
fn skeleton_keep_set(&self) -> Vec<bool> {
let mut keep = vec![false; self.nodes.len()];
for (i, n) in self.nodes.iter().enumerate() {
if is_interactive_role(&n.role) {
keep[i] = true;
let mut p = n.parent;
while let Some(pi) = p {
if keep[pi] {
break; }
keep[pi] = true;
p = self.nodes[pi].parent;
}
}
}
keep
}
}
#[cfg(test)]
mod tests {
use super::*;
fn node(depth: u32, role: &str, parent: Option<usize>) -> UiNode {
UiNode {
depth,
role: role.into(),
name: None,
automation_id: None,
parent,
}
}
#[test]
fn refs_are_sequential_and_interactive_only() {
let mut b = SnapshotBuilder::new(100);
b.push(node(0, "Window", None));
b.push(node(1, "Pane", Some(0)));
b.push(node(2, "Button", Some(1)));
b.push(node(2, "Text", Some(1)));
b.push(node(2, "Edit", Some(1)));
let out = b.finish(false);
let refs: Vec<&str> = out
.elements
.iter()
.filter_map(|e| e.ref_id.as_deref())
.collect();
assert_eq!(refs, vec!["@e1", "@e2"]); }
#[test]
fn skeleton_keeps_interactives_and_ancestors_only() {
let mut b = SnapshotBuilder::new(100);
b.push(node(0, "Window", None));
b.push(node(1, "Pane", Some(0))); b.push(node(2, "Pane", Some(1))); b.push(node(3, "Text", Some(2))); b.push(node(2, "Button", Some(1)));
b.push(node(1, "Text", Some(0))); let out = b.finish(true);
let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
assert_eq!(roles, vec!["Window", "Pane", "Button"]);
}
#[test]
fn truncation_is_flagged() {
let mut b = SnapshotBuilder::new(2);
assert!(b.push(node(0, "Window", None)));
assert!(b.push(node(1, "Button", Some(0))));
assert!(!b.push(node(1, "Button", Some(0))));
let out = b.finish(false);
assert!(out.truncated);
assert_eq!(out.elements.len(), 2);
}
#[test]
fn snapshot_ids_are_unique_within_process() {
let a = new_snapshot_id();
let b = new_snapshot_id();
assert_ne!(a, b);
assert!(a.starts_with('s'));
}
#[test]
fn chinese_names_survive_round_trip() {
let n = UiNode {
depth: 2,
role: "Button".into(),
name: Some("确定".into()),
automation_id: Some("OK".into()),
parent: None,
};
let mut b = SnapshotBuilder::new(10);
b.push(n);
let out = b.finish(false);
let json = serde_json::to_value(&out.elements[0]).unwrap();
assert_eq!(json["name"], "确定");
assert_eq!(json["ref"], "@e1");
}
}