diff --git a/crates/core/src/commands/snapshot.rs b/crates/core/src/commands/snapshot.rs index 6c4f067..af124ce 100644 --- a/crates/core/src/commands/snapshot.rs +++ b/crates/core/src/commands/snapshot.rs @@ -17,11 +17,12 @@ pub struct SnapshotArgs { pub fn execute(args: SnapshotArgs, adapter: &dyn PlatformAdapter) -> Result { tracing::debug!( - "tree: snapshot app={:?} window_id={:?} max_depth={} interactive_only={}", + "tree: snapshot app={:?} window_id={:?} max_depth={} interactive_only={} compact={}", args.app.as_deref().unwrap_or("(focused)"), args.window_id.as_deref().unwrap_or("(auto)"), args.max_depth, - args.interactive_only + args.interactive_only, + args.compact ); let opts = crate::adapter::TreeOptions { diff --git a/crates/core/src/snapshot.rs b/crates/core/src/snapshot.rs index bd30b55..80d097e 100644 --- a/crates/core/src/snapshot.rs +++ b/crates/core/src/snapshot.rs @@ -99,6 +99,7 @@ pub fn build( &mut refmap, opts.include_bounds, opts.interactive_only, + opts.compact, window.pid, Some(window.app.as_str()), ); @@ -142,16 +143,26 @@ pub fn append_surface_refs( }; let raw_tree = adapter.get_tree(&window, &opts).ok()?; let mut refmap = RefMap::load().ok()?; - let tree = allocate_refs(raw_tree, &mut refmap, false, true, pid, source_app); + let tree = allocate_refs(raw_tree, &mut refmap, false, true, false, pid, source_app); refmap.save().ok()?; Some(tree) } +fn is_collapsible(node: &AccessibilityNode) -> bool { + node.ref_id.is_none() + && node.name.as_deref().is_none_or(str::is_empty) + && node.value.as_deref().is_none_or(str::is_empty) + && node.description.as_deref().is_none_or(str::is_empty) + && node.states.is_empty() + && node.children.len() == 1 +} + fn allocate_refs( mut node: AccessibilityNode, refmap: &mut RefMap, include_bounds: bool, interactive_only: bool, + compact: bool, window_pid: i32, source_app: Option<&str>, ) -> AccessibilityNode { @@ -185,9 +196,13 @@ fn allocate_refs( refmap, include_bounds, interactive_only, + compact, window_pid, source_app, ); + if compact && is_collapsible(&child) { + return child.children.into_iter().next(); + } if interactive_only && child.ref_id.is_none() && child.children.is_empty() { None } else { @@ -211,3 +226,129 @@ fn actions_for_role(role: &str) -> Vec { _ => vec!["Click".into()], } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::node::AccessibilityNode; + + fn node(role: &str) -> AccessibilityNode { + AccessibilityNode { + ref_id: None, + role: role.into(), + name: None, + value: None, + description: None, + hint: None, + states: vec![], + bounds: None, + children: vec![], + } + } + + fn run_compact(tree: AccessibilityNode) -> AccessibilityNode { + let mut refmap = RefMap::new(); + allocate_refs(tree, &mut refmap, false, false, true, 1, Some("Test")) + } + + fn run_compact_interactive(tree: AccessibilityNode) -> AccessibilityNode { + let mut refmap = RefMap::new(); + allocate_refs(tree, &mut refmap, false, true, true, 1, Some("Test")) + } + + #[test] + fn test_compact_collapses_single_child_chain() { + let mut btn = node("button"); + btn.name = Some("Send".into()); + let mut g1 = node("group"); + g1.children = vec![btn]; + let mut g2 = node("group"); + g2.children = vec![g1]; + let mut root = node("window"); + root.children = vec![g2]; + + let result = run_compact(root); + assert_eq!(result.role, "window"); + assert_eq!(result.children.len(), 1); + assert_eq!(result.children[0].role, "button"); + assert_eq!(result.children[0].name.as_deref(), Some("Send")); + } + + #[test] + fn test_compact_preserves_named_containers() { + let btn = node("button"); + let mut named = node("group"); + named.name = Some("Sidebar".into()); + named.children = vec![btn]; + let mut root = node("window"); + root.children = vec![named]; + + let result = run_compact(root); + assert_eq!(result.children.len(), 1); + assert_eq!(result.children[0].role, "group"); + assert_eq!(result.children[0].name.as_deref(), Some("Sidebar")); + } + + #[test] + fn test_compact_preserves_description() { + let btn = node("button"); + let mut desc_node = node("group"); + desc_node.description = Some("toolbar".into()); + desc_node.children = vec![btn]; + let mut root = node("window"); + root.children = vec![desc_node]; + + let result = run_compact(root); + assert_eq!(result.children.len(), 1); + assert_eq!(result.children[0].role, "group"); + assert_eq!(result.children[0].description.as_deref(), Some("toolbar")); + } + + #[test] + fn test_compact_preserves_states() { + let btn = node("button"); + let mut disabled = node("group"); + disabled.states = vec!["disabled".into()]; + disabled.children = vec![btn]; + let mut root = node("window"); + root.children = vec![disabled]; + + let result = run_compact(root); + assert_eq!(result.children.len(), 1); + assert_eq!(result.children[0].role, "group"); + assert_eq!(result.children[0].states, vec!["disabled"]); + } + + #[test] + fn test_compact_preserves_multi_child() { + let btn = node("button"); + let tf = node("textfield"); + let mut group = node("group"); + group.children = vec![btn, tf]; + let mut root = node("window"); + root.children = vec![group]; + + let result = run_compact(root); + assert_eq!(result.children.len(), 1); + assert_eq!(result.children[0].role, "group"); + assert_eq!(result.children[0].children.len(), 2); + } + + #[test] + fn test_compact_with_interactive_only() { + let mut btn = node("button"); + btn.name = Some("OK".into()); + let text = node("statictext"); + let mut g1 = node("group"); + g1.children = vec![btn]; + let mut g2 = node("group"); + g2.children = vec![text]; + let mut root = node("window"); + root.children = vec![g1, g2]; + + let result = run_compact_interactive(root); + assert_eq!(result.children.len(), 1); + assert_eq!(result.children[0].role, "button"); + assert!(result.children[0].ref_id.is_some()); + } +} diff --git a/src/cli_args.rs b/src/cli_args.rs index 5ad2192..cff8caf 100644 --- a/src/cli_args.rs +++ b/src/cli_args.rs @@ -43,7 +43,10 @@ pub struct SnapshotArgs { pub include_bounds: bool, #[arg(long, short = 'i', help = "Include interactive elements only")] pub interactive_only: bool, - #[arg(long, help = "Omit empty structural nodes from output")] + #[arg( + long, + help = "Collapse single-child unnamed nodes to reduce tree depth" + )] pub compact: bool, #[arg(long, value_enum, default_value_t = Surface::Window, help = "Surface to snapshot")] pub surface: Surface,