1use std::sync::atomic::{AtomicU32, Ordering};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct UiNode {
15 pub depth: u32,
16 pub role: String,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub name: Option<String>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub automation_id: Option<String>,
21 #[serde(skip)]
22 pub parent: Option<usize>,
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct ElementOut {
28 #[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
29 pub ref_id: Option<String>,
30 pub role: String,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub name: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub automation_id: Option<String>,
35 pub depth: u32,
36}
37
38pub const INTERACTIVE_ROLES: &[&str] = &[
41 "Button",
42 "CheckBox",
43 "RadioButton",
44 "ComboBox",
45 "Edit",
46 "ListItem",
47 "MenuItem",
48 "TabItem",
49 "Hyperlink",
50 "ToggleButton",
51 "DataGrid",
52 "Spinner",
53 "Slider",
54 "Document",
55 "Tree",
56 "TreeItem",
57 "Table",
58 "Calendar",
59 "Menu",
60];
61
62pub fn is_interactive_role(role: &str) -> bool {
63 INTERACTIVE_ROLES.contains(&role)
64}
65
66pub fn new_snapshot_id() -> String {
68 static COUNTER: AtomicU32 = AtomicU32::new(0);
69 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
70 let nanos = SystemTime::now()
71 .duration_since(UNIX_EPOCH)
72 .map(|d| d.as_nanos() as u64)
73 .unwrap_or(0);
74 format!("s{nanos:x}{n:x}")
75}
76
77pub struct SnapshotBuilder {
83 nodes: Vec<UiNode>,
84 max_elements: usize,
85 truncated: bool,
86}
87
88#[derive(Debug, Clone, PartialEq)]
89pub struct SnapshotOutput {
90 pub snapshot_id: String,
91 pub truncated: bool,
92 pub elements: Vec<ElementOut>,
93}
94
95impl SnapshotBuilder {
96 pub fn new(max_elements: usize) -> Self {
97 Self {
98 nodes: Vec::new(),
99 max_elements,
100 truncated: false,
101 }
102 }
103
104 pub fn push(&mut self, node: UiNode) -> bool {
106 if self.nodes.len() >= self.max_elements {
107 self.truncated = true;
108 return false;
109 }
110 self.nodes.push(node);
111 true
112 }
113
114 pub fn finish(self, skeleton: bool) -> SnapshotOutput {
115 let keep = if skeleton {
116 self.skeleton_keep_set()
117 } else {
118 self.all_keep()
119 };
120 let mut next_ref = 0;
121 let elements = self
122 .nodes
123 .iter()
124 .enumerate()
125 .filter(|(i, _)| keep[*i])
126 .map(|(i, n)| {
127 let ref_id = if is_interactive_role(&n.role) {
128 next_ref += 1;
129 Some(format!("@e{next_ref}"))
130 } else {
131 None
132 };
133 let _ = i;
134 ElementOut {
135 ref_id,
136 role: n.role.clone(),
137 name: n.name.clone(),
138 automation_id: n.automation_id.clone(),
139 depth: n.depth,
140 }
141 })
142 .collect();
143 SnapshotOutput {
144 snapshot_id: new_snapshot_id(),
145 truncated: self.truncated,
146 elements,
147 }
148 }
149
150 fn all_keep(&self) -> Vec<bool> {
151 vec![true; self.nodes.len()]
152 }
153
154 fn skeleton_keep_set(&self) -> Vec<bool> {
159 let mut keep = vec![false; self.nodes.len()];
160 for (i, n) in self.nodes.iter().enumerate() {
161 if is_interactive_role(&n.role) {
162 keep[i] = true;
163 let mut p = n.parent;
164 while let Some(pi) = p {
165 if keep[pi] {
166 break; }
168 keep[pi] = true;
169 p = self.nodes[pi].parent;
170 }
171 }
172 }
173 let mut kept_children = vec![0usize; self.nodes.len()];
176 for (i, n) in self.nodes.iter().enumerate() {
177 if keep[i] {
178 if let Some(pi) = n.parent {
179 kept_children[pi] += 1;
180 }
181 }
182 }
183 for i in (0..self.nodes.len()).rev() {
184 if i > 0
185 && keep[i]
186 && !is_interactive_role(&self.nodes[i].role)
187 && kept_children[i] == 1
188 {
189 keep[i] = false;
190 if let Some(pi) = self.nodes[i].parent {
191 kept_children[pi] -= 1;
192 }
193 }
194 }
195 keep
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn node(depth: u32, role: &str, parent: Option<usize>) -> UiNode {
204 UiNode {
205 depth,
206 role: role.into(),
207 name: None,
208 automation_id: None,
209 parent,
210 }
211 }
212
213 #[test]
214 fn refs_are_sequential_and_interactive_only() {
215 let mut b = SnapshotBuilder::new(100);
216 b.push(node(0, "Window", None));
217 b.push(node(1, "Pane", Some(0)));
218 b.push(node(2, "Button", Some(1)));
219 b.push(node(2, "Text", Some(1)));
220 b.push(node(2, "Edit", Some(1)));
221 let out = b.finish(false);
222 let refs: Vec<&str> = out
223 .elements
224 .iter()
225 .filter_map(|e| e.ref_id.as_deref())
226 .collect();
227 assert_eq!(refs, vec!["@e1", "@e2"]); }
229
230 #[test]
231 fn skeleton_keeps_interactives_and_collapses_single_chains() {
232 let mut b = SnapshotBuilder::new(100);
233 b.push(node(0, "Window", None));
234 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);
240 let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
241 assert_eq!(roles, vec!["Window", "Button"]);
243 }
244
245 #[test]
246 fn skeleton_keeps_shared_ancestors_with_multiple_kept_children() {
247 let mut b = SnapshotBuilder::new(100);
248 b.push(node(0, "Window", None));
249 b.push(node(1, "Pane", Some(0))); b.push(node(2, "Button", Some(1)));
251 b.push(node(2, "Edit", Some(1)));
252 let out = b.finish(true);
253 let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
254 assert_eq!(roles, vec!["Window", "Pane", "Button", "Edit"]);
255 }
256
257 #[test]
258 fn truncation_is_flagged() {
259 let mut b = SnapshotBuilder::new(2);
260 assert!(b.push(node(0, "Window", None)));
261 assert!(b.push(node(1, "Button", Some(0))));
262 assert!(!b.push(node(1, "Button", Some(0))));
263 let out = b.finish(false);
264 assert!(out.truncated);
265 assert_eq!(out.elements.len(), 2);
266 }
267
268 #[test]
269 fn snapshot_ids_are_unique_within_process() {
270 let a = new_snapshot_id();
271 let b = new_snapshot_id();
272 assert_ne!(a, b);
273 assert!(a.starts_with('s'));
274 }
275
276 #[test]
277 fn chinese_names_survive_round_trip() {
278 let n = UiNode {
279 depth: 2,
280 role: "Button".into(),
281 name: Some("确定".into()),
282 automation_id: Some("OK".into()),
283 parent: None,
284 };
285 let mut b = SnapshotBuilder::new(10);
286 b.push(n);
287 let out = b.finish(false);
288 let json = serde_json::to_value(&out.elements[0]).unwrap();
289 assert_eq!(json["name"], "确定");
290 assert_eq!(json["ref"], "@e1");
291 }
292}