1use std::sync::atomic::{AtomicU32, Ordering};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use serde::{Deserialize, Serialize};
11
12use crate::{CtlError, ErrorCode};
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct UiNode {
17 pub depth: u32,
18 pub role: String,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub name: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub automation_id: Option<String>,
23 #[serde(skip)]
24 pub parent: Option<usize>,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct ElementOut {
30 #[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
31 pub ref_id: Option<String>,
32 pub role: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub name: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub automation_id: Option<String>,
37 pub depth: u32,
38}
39
40pub const INTERACTIVE_ROLES: &[&str] = &[
43 "Button",
44 "CheckBox",
45 "RadioButton",
46 "ComboBox",
47 "Edit",
48 "ListItem",
49 "MenuItem",
50 "TabItem",
51 "Hyperlink",
52 "ToggleButton",
53 "DataGrid",
54 "Spinner",
55 "Slider",
56 "Document",
57 "Tree",
58 "TreeItem",
59 "Table",
60 "Calendar",
61 "Menu",
62];
63
64pub fn is_interactive_role(role: &str) -> bool {
65 INTERACTIVE_ROLES.contains(&role)
66}
67
68pub fn new_snapshot_id() -> String {
70 static COUNTER: AtomicU32 = AtomicU32::new(0);
71 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
72 let nanos = SystemTime::now()
73 .duration_since(UNIX_EPOCH)
74 .map(|d| d.as_nanos() as u64)
75 .unwrap_or(0);
76 format!("s{nanos:x}{n:x}")
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Projection {
82 Full,
84 Skeleton,
86 Content { skeleton: bool },
91}
92
93pub struct SnapshotBuilder {
100 nodes: Vec<UiNode>,
101 max_elements: usize,
102 truncated: bool,
103}
104
105#[derive(Debug, Clone, PartialEq)]
106pub struct SnapshotOutput {
107 pub snapshot_id: String,
108 pub truncated: bool,
109 pub elements: Vec<ElementOut>,
110}
111
112impl SnapshotBuilder {
113 pub fn new(max_elements: usize) -> Self {
114 Self {
115 nodes: Vec::new(),
116 max_elements,
117 truncated: false,
118 }
119 }
120
121 pub fn push(&mut self, node: UiNode) -> bool {
123 if self.nodes.len() >= self.max_elements {
124 self.truncated = true;
125 return false;
126 }
127 self.nodes.push(node);
128 true
129 }
130
131 pub fn finish(self, view: Projection) -> Result<SnapshotOutput, CtlError> {
132 let keep = match view {
133 Projection::Full => self.all_keep(),
134 Projection::Skeleton => self.skeleton_keep_set(),
135 Projection::Content { skeleton } => {
136 if !self.nodes.iter().any(|n| n.role == "Document") {
137 return Err(CtlError::new(
138 ErrorCode::NotFound,
139 "no Document (content) region in this window - retry without --view content",
140 ));
141 }
142 self.content_keep_set(skeleton)
143 }
144 };
145 let mut next_ref = 0;
146 let elements = self
147 .nodes
148 .iter()
149 .enumerate()
150 .filter(|(i, _)| keep[*i])
151 .map(|(i, n)| {
152 let ref_id = if is_interactive_role(&n.role) {
153 next_ref += 1;
154 Some(format!("@e{next_ref}"))
155 } else {
156 None
157 };
158 let _ = i;
159 ElementOut {
160 ref_id,
161 role: n.role.clone(),
162 name: n.name.clone(),
163 automation_id: n.automation_id.clone(),
164 depth: n.depth,
165 }
166 })
167 .collect();
168 Ok(SnapshotOutput {
169 snapshot_id: new_snapshot_id(),
170 truncated: self.truncated,
171 elements,
172 })
173 }
174
175 fn all_keep(&self) -> Vec<bool> {
176 vec![true; self.nodes.len()]
177 }
178
179 fn skeleton_keep_set(&self) -> Vec<bool> {
184 let mut keep = vec![false; self.nodes.len()];
185 for (i, n) in self.nodes.iter().enumerate() {
186 if is_interactive_role(&n.role) {
187 keep[i] = true;
188 let mut p = n.parent;
189 while let Some(pi) = p {
190 if keep[pi] {
191 break; }
193 keep[pi] = true;
194 p = self.nodes[pi].parent;
195 }
196 }
197 }
198 self.collapse_single_chains(&mut keep);
199 keep
200 }
201
202 fn content_keep_set(&self, skeleton: bool) -> Vec<bool> {
207 let mut in_doc = vec![false; self.nodes.len()];
208 for (i, n) in self.nodes.iter().enumerate() {
209 in_doc[i] = n.role == "Document" || n.parent.is_some_and(|p| in_doc[p]);
210 }
211 let mut keep = vec![false; self.nodes.len()];
212 for (i, n) in self.nodes.iter().enumerate() {
213 if in_doc[i] && (!skeleton || is_interactive_role(&n.role)) {
214 keep[i] = true;
215 let mut p = n.parent;
216 while let Some(pi) = p {
217 if keep[pi] {
218 break;
219 }
220 keep[pi] = true;
221 p = self.nodes[pi].parent;
222 }
223 }
224 }
225 if skeleton {
226 self.collapse_single_chains(&mut keep);
227 }
228 keep
229 }
230
231 fn collapse_single_chains(&self, keep: &mut [bool]) {
234 let mut kept_children = vec![0usize; self.nodes.len()];
235 for (i, n) in self.nodes.iter().enumerate() {
236 if keep[i]
237 && let Some(pi) = n.parent
238 {
239 kept_children[pi] += 1;
240 }
241 }
242 for i in (0..self.nodes.len()).rev() {
243 if i > 0
244 && keep[i]
245 && !is_interactive_role(&self.nodes[i].role)
246 && kept_children[i] == 1
247 {
248 keep[i] = false;
249 if let Some(pi) = self.nodes[i].parent {
250 kept_children[pi] -= 1;
251 }
252 }
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn node(depth: u32, role: &str, parent: Option<usize>) -> UiNode {
262 UiNode {
263 depth,
264 role: role.into(),
265 name: None,
266 automation_id: None,
267 parent,
268 }
269 }
270
271 #[test]
272 fn refs_are_sequential_and_interactive_only() {
273 let mut b = SnapshotBuilder::new(100);
274 b.push(node(0, "Window", None));
275 b.push(node(1, "Pane", Some(0)));
276 b.push(node(2, "Button", Some(1)));
277 b.push(node(2, "Text", Some(1)));
278 b.push(node(2, "Edit", Some(1)));
279 let out = b.finish(Projection::Full).expect("full");
280 let refs: Vec<&str> = out
281 .elements
282 .iter()
283 .filter_map(|e| e.ref_id.as_deref())
284 .collect();
285 assert_eq!(refs, vec!["@e1", "@e2"]); }
287
288 #[test]
289 fn skeleton_keeps_interactives_and_collapses_single_chains() {
290 let mut b = SnapshotBuilder::new(100);
291 b.push(node(0, "Window", None));
292 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(Projection::Skeleton).expect("skeleton");
298 let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
299 assert_eq!(roles, vec!["Window", "Button"]);
301 }
302
303 #[test]
304 fn skeleton_keeps_shared_ancestors_with_multiple_kept_children() {
305 let mut b = SnapshotBuilder::new(100);
306 b.push(node(0, "Window", None));
307 b.push(node(1, "Pane", Some(0))); b.push(node(2, "Button", Some(1)));
309 b.push(node(2, "Edit", Some(1)));
310 let out = b.finish(Projection::Skeleton).expect("skeleton");
311 let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
312 assert_eq!(roles, vec!["Window", "Pane", "Button", "Edit"]);
313 }
314
315 #[test]
318 fn content_view_drops_chrome_and_keeps_document_subtree() {
319 let mut b = SnapshotBuilder::new(100);
320 b.push(node(0, "Window", None));
321 b.push(node(1, "ToolBar", Some(0)));
322 b.push(node(2, "Button", Some(1))); b.push(node(2, "Button", Some(1)));
324 b.push(node(1, "Pane", Some(0)));
325 b.push(node(2, "Document", Some(4)));
326 b.push(node(3, "Hyperlink", Some(5)));
327 b.push(node(3, "Text", Some(5)));
328 b.push(node(3, "Hyperlink", Some(5)));
329 let out = b
330 .finish(Projection::Content { skeleton: false })
331 .expect("content");
332 let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
333 assert_eq!(
334 roles,
335 vec![
336 "Window",
337 "Pane",
338 "Document",
339 "Hyperlink",
340 "Text",
341 "Hyperlink"
342 ]
343 );
344 let refs: Vec<&str> = out
346 .elements
347 .iter()
348 .filter_map(|e| e.ref_id.as_deref())
349 .collect();
350 assert_eq!(refs, vec!["@e1", "@e2", "@e3"]);
351 }
352
353 #[test]
356 fn content_view_combined_with_skeleton_ignores_chrome_interactives() {
357 let mut b = SnapshotBuilder::new(100);
358 b.push(node(0, "Window", None));
359 b.push(node(1, "ToolBar", Some(0)));
360 b.push(node(2, "Button", Some(1))); b.push(node(1, "Pane", Some(0)));
362 b.push(node(2, "Document", Some(3)));
363 b.push(node(3, "Hyperlink", Some(4)));
364 b.push(node(3, "Text", Some(4))); let out = b
366 .finish(Projection::Content { skeleton: true })
367 .expect("content+skeleton");
368 let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
369 assert_eq!(roles, vec!["Window", "Document", "Hyperlink"]);
370 }
371
372 #[test]
374 fn content_view_without_document_is_not_found() {
375 let mut b = SnapshotBuilder::new(100);
376 b.push(node(0, "Window", None));
377 b.push(node(1, "Button", Some(0)));
378 let err = b
379 .finish(Projection::Content { skeleton: false })
380 .unwrap_err();
381 assert_eq!(err.code, ErrorCode::NotFound);
382 }
383
384 #[test]
386 fn content_view_keeps_nested_documents() {
387 let mut b = SnapshotBuilder::new(100);
388 b.push(node(0, "Window", None));
389 b.push(node(1, "Document", Some(0)));
390 b.push(node(2, "Pane", Some(1)));
391 b.push(node(3, "Document", Some(2))); b.push(node(4, "Hyperlink", Some(3)));
393 let out = b
394 .finish(Projection::Content { skeleton: false })
395 .expect("content");
396 let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
397 assert_eq!(
398 roles,
399 vec!["Window", "Document", "Pane", "Document", "Hyperlink"]
400 );
401 }
402
403 #[test]
404 fn truncation_is_flagged() {
405 let mut b = SnapshotBuilder::new(2);
406 assert!(b.push(node(0, "Window", None)));
407 assert!(b.push(node(1, "Button", Some(0))));
408 assert!(!b.push(node(1, "Button", Some(0))));
409 let out = b.finish(Projection::Full).expect("full");
410 assert!(out.truncated);
411 assert_eq!(out.elements.len(), 2);
412 }
413
414 #[test]
415 fn snapshot_ids_are_unique_within_process() {
416 let a = new_snapshot_id();
417 let b = new_snapshot_id();
418 assert_ne!(a, b);
419 assert!(a.starts_with('s'));
420 }
421
422 #[test]
423 fn chinese_names_survive_round_trip() {
424 let n = UiNode {
425 depth: 2,
426 role: "Button".into(),
427 name: Some("确定".into()),
428 automation_id: Some("OK".into()),
429 parent: None,
430 };
431 let mut b = SnapshotBuilder::new(10);
432 b.push(n);
433 let out = b.finish(Projection::Full).expect("full");
434 let json = serde_json::to_value(&out.elements[0]).unwrap();
435 assert_eq!(json["name"], "确定");
436 assert_eq!(json["ref"], "@e1");
437 }
438}