diff --git a/crates/core/src/commands/snapshot.rs b/crates/core/src/commands/snapshot.rs index 42c85e9b..a08eee08 100644 --- a/crates/core/src/commands/snapshot.rs +++ b/crates/core/src/commands/snapshot.rs @@ -1,7 +1,7 @@ use crate::{ adapter::{PlatformAdapter, SnapshotSurface}, error::AppError, - snapshot, + snapshot, snapshot_ref, }; use serde_json::{json, Value}; @@ -27,8 +27,14 @@ pub fn execute(args: SnapshotArgs, adapter: &dyn PlatformAdapter) -> Result Result Result Result { let ref_count = result.refmap.len(); let tree = serde_json::to_value(&result.tree)?; let win = &result.window; diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index f0ee8224..eaa98038 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -6,8 +6,10 @@ pub mod hints; pub mod node; pub mod notification; pub mod output; +pub mod ref_alloc; pub mod refs; pub mod snapshot; +pub mod snapshot_ref; pub use action::{ Action, ActionResult, Direction, DragParams, ElementState, KeyCombo, Modifier, MouseButton, diff --git a/crates/core/src/ref_alloc.rs b/crates/core/src/ref_alloc.rs new file mode 100644 index 00000000..e6c71502 --- /dev/null +++ b/crates/core/src/ref_alloc.rs @@ -0,0 +1,63 @@ +use crate::node::AccessibilityNode; +use crate::refs::RefEntry; + +pub(crate) const INTERACTIVE_ROLES: &[&str] = &[ + "button", + "textfield", + "checkbox", + "link", + "menuitem", + "tab", + "slider", + "combobox", + "treeitem", + "cell", + "radiobutton", + "incrementor", + "menubutton", + "switch", + "colorwell", + "dockitem", +]; + +pub(crate) fn actions_for_role(role: &str) -> Vec { + match role { + "button" | "link" | "menuitem" | "tab" | "radiobutton" => vec!["Click".into()], + "textfield" | "incrementor" => vec!["Click".into(), "SetValue".into(), "SetFocus".into()], + "checkbox" => vec!["Click".into(), "Toggle".into()], + "combobox" => vec!["Click".into(), "Select".into()], + "treeitem" => vec!["Click".into(), "Expand".into(), "Collapse".into()], + "slider" => vec!["SetValue".into()], + "cell" => vec!["Click".into()], + _ => vec!["Click".into()], + } +} + +pub(crate) fn ref_entry_from_node( + node: &AccessibilityNode, + pid: i32, + source_app: Option<&str>, + root_ref: Option, +) -> RefEntry { + RefEntry { + pid, + role: node.role.clone(), + name: node.name.clone(), + value: node.value.clone(), + states: node.states.clone(), + bounds: node.bounds, + bounds_hash: node.bounds.as_ref().map(|b| b.bounds_hash()), + available_actions: actions_for_role(&node.role), + source_app: source_app.map(str::to_string), + root_ref, + } +} + +pub(crate) 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 +} diff --git a/crates/core/src/refs.rs b/crates/core/src/refs.rs index 63123828..64830dff 100644 --- a/crates/core/src/refs.rs +++ b/crates/core/src/refs.rs @@ -57,9 +57,8 @@ impl RefMap { } pub fn remove_by_root_ref(&mut self, root: &str) { - self.inner.retain(|_, entry| { - entry.root_ref.as_deref() != Some(root) - }); + self.inner + .retain(|_, entry| entry.root_ref.as_deref() != Some(root)); } pub fn save(&self) -> Result<(), AppError> { diff --git a/crates/core/src/snapshot.rs b/crates/core/src/snapshot.rs index 3ab49023..5e0eb8bd 100644 --- a/crates/core/src/snapshot.rs +++ b/crates/core/src/snapshot.rs @@ -2,28 +2,10 @@ use crate::{ adapter::{PlatformAdapter, SnapshotSurface, TreeOptions, WindowFilter}, error::AppError, node::{AccessibilityNode, WindowInfo}, - refs::{RefEntry, RefMap}, + ref_alloc::{is_collapsible, ref_entry_from_node, INTERACTIVE_ROLES}, + refs::RefMap, }; -const INTERACTIVE_ROLES: &[&str] = &[ - "button", - "textfield", - "checkbox", - "link", - "menuitem", - "tab", - "slider", - "combobox", - "treeitem", - "cell", - "radiobutton", - "incrementor", - "menubutton", - "switch", - "colorwell", - "dockitem", -]; - pub struct SnapshotResult { pub tree: AccessibilityNode, pub refmap: RefMap, @@ -148,15 +130,6 @@ pub fn append_surface_refs( 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, @@ -169,18 +142,17 @@ fn allocate_refs( let is_interactive = INTERACTIVE_ROLES.contains(&node.role.as_str()); if is_interactive { - let entry = RefEntry { - pid: window_pid, - role: node.role.clone(), - name: node.name.clone(), - value: node.value.clone(), - states: node.states.clone(), - bounds: node.bounds, - bounds_hash: node.bounds.as_ref().map(|b| b.bounds_hash()), - available_actions: actions_for_role(&node.role), - source_app: source_app.map(str::to_string), - root_ref: None, - }; + let entry = ref_entry_from_node(&node, window_pid, source_app, None); + node.ref_id = Some(refmap.allocate(entry)); + } + + let has_label = node.name.as_deref().is_some_and(|n| !n.is_empty()) + || node.description.as_deref().is_some_and(|d| !d.is_empty()); + let is_skeleton_anchor = !is_interactive && node.children_count.is_some() && has_label; + + if is_skeleton_anchor { + let mut entry = ref_entry_from_node(&node, window_pid, source_app, None); + entry.available_actions = vec![]; node.ref_id = Some(refmap.allocate(entry)); } @@ -204,7 +176,11 @@ fn allocate_refs( if compact && is_collapsible(&child) { return child.children.into_iter().next(); } - if interactive_only && child.ref_id.is_none() && child.children.is_empty() { + if interactive_only + && child.ref_id.is_none() + && child.children.is_empty() + && child.children_count.is_none() + { None } else { Some(child) @@ -215,19 +191,6 @@ fn allocate_refs( node } -fn actions_for_role(role: &str) -> Vec { - match role { - "button" | "link" | "menuitem" | "tab" | "radiobutton" => vec!["Click".into()], - "textfield" | "incrementor" => vec!["Click".into(), "SetValue".into(), "SetFocus".into()], - "checkbox" => vec!["Click".into(), "Toggle".into()], - "combobox" => vec!["Click".into(), "Select".into()], - "treeitem" => vec!["Click".into(), "Expand".into(), "Collapse".into()], - "slider" => vec!["SetValue".into()], - "cell" => vec!["Click".into()], - _ => vec!["Click".into()], - } -} - #[cfg(test)] mod tests { use super::*; @@ -353,4 +316,67 @@ mod tests { assert_eq!(result.children[0].role, "button"); assert!(result.children[0].ref_id.is_some()); } + + #[test] + fn test_skeleton_named_container_gets_ref() { + let mut container = node("group"); + container.name = Some("Sidebar".into()); + container.children_count = Some(5); + let mut root = node("window"); + root.children = vec![container]; + + let mut refmap = RefMap::new(); + let result = allocate_refs(root, &mut refmap, false, false, false, 1, Some("Test")); + + assert!(result.children[0].ref_id.is_some()); + assert_eq!(refmap.len(), 1); + let entry = refmap + .get(result.children[0].ref_id.as_deref().unwrap()) + .unwrap(); + assert!(entry.available_actions.is_empty()); + } + + #[test] + fn test_skeleton_unnamed_container_no_ref() { + let mut container = node("group"); + container.children_count = Some(5); + let mut root = node("window"); + root.children = vec![container]; + + let mut refmap = RefMap::new(); + let result = allocate_refs(root, &mut refmap, false, false, false, 1, Some("Test")); + + assert!(result.children[0].ref_id.is_none()); + assert_eq!(refmap.len(), 0); + } + + #[test] + fn test_skeleton_described_container_gets_ref() { + let mut container = node("group"); + container.description = Some("Channels and direct messages".into()); + container.children_count = Some(12); + let mut root = node("window"); + root.children = vec![container]; + + let mut refmap = RefMap::new(); + let result = allocate_refs(root, &mut refmap, false, false, false, 1, Some("Test")); + + assert!(result.children[0].ref_id.is_some()); + assert_eq!(refmap.len(), 1); + } + + #[test] + fn test_skeleton_truncated_node_survives_interactive_only() { + let mut container = node("group"); + container.name = Some("Content".into()); + container.children_count = Some(10); + let mut root = node("window"); + root.children = vec![container]; + + let mut refmap = RefMap::new(); + let result = allocate_refs(root, &mut refmap, false, true, false, 1, Some("Test")); + + assert_eq!(result.children.len(), 1); + assert_eq!(result.children[0].children_count, Some(10)); + } } diff --git a/crates/core/src/snapshot_ref.rs b/crates/core/src/snapshot_ref.rs new file mode 100644 index 00000000..303e0f6f --- /dev/null +++ b/crates/core/src/snapshot_ref.rs @@ -0,0 +1,202 @@ +use crate::{ + adapter::{PlatformAdapter, TreeOptions}, + error::AppError, + node::{AccessibilityNode, WindowInfo}, + ref_alloc::{is_collapsible, ref_entry_from_node, INTERACTIVE_ROLES}, + refs::RefMap, + snapshot::SnapshotResult, +}; + +struct DrillDownConfig<'a> { + include_bounds: bool, + interactive_only: bool, + compact: bool, + pid: i32, + source_app: Option<&'a str>, + root_ref_id: &'a str, +} + +pub fn run_from_ref( + adapter: &dyn PlatformAdapter, + opts: &TreeOptions, + root_ref_id: &str, +) -> Result { + let mut refmap = RefMap::load()?; + + let entry = refmap + .get(root_ref_id) + .ok_or_else(|| AppError::stale_ref(root_ref_id))? + .clone(); + + let handle = adapter.resolve_element(&entry)?; + + let raw_tree = adapter.get_subtree(&handle, opts)?; + + refmap.remove_by_root_ref(root_ref_id); + + let config = DrillDownConfig { + include_bounds: opts.include_bounds, + interactive_only: opts.interactive_only, + compact: opts.compact, + pid: entry.pid, + source_app: entry.source_app.as_deref(), + root_ref_id, + }; + + let mut tree = allocate_refs_with_root(raw_tree, &mut refmap, &config); + + crate::hints::add_structural_hints(&mut tree); + + refmap.save()?; + + let window = WindowInfo { + id: String::new(), + title: format!("subtree from {root_ref_id}"), + app: entry.source_app.unwrap_or_default(), + pid: entry.pid, + bounds: None, + is_focused: true, + }; + + Ok(SnapshotResult { + tree, + refmap, + window, + }) +} + +fn allocate_refs_with_root( + mut node: AccessibilityNode, + refmap: &mut RefMap, + config: &DrillDownConfig, +) -> AccessibilityNode { + let is_interactive = INTERACTIVE_ROLES.contains(&node.role.as_str()); + + if is_interactive { + let entry = ref_entry_from_node( + &node, + config.pid, + config.source_app, + Some(config.root_ref_id.to_string()), + ); + node.ref_id = Some(refmap.allocate(entry)); + } + + if !config.include_bounds { + node.bounds = None; + } + + node.children = node + .children + .into_iter() + .filter_map(|child| { + let child = allocate_refs_with_root(child, refmap, config); + if config.compact && is_collapsible(&child) { + return child.children.into_iter().next(); + } + if config.interactive_only && child.ref_id.is_none() && child.children.is_empty() { + None + } else { + Some(child) + } + }) + .collect(); + + node +} + +#[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_count: None, + children: vec![], + } + } + + #[test] + fn test_allocate_refs_with_root_tags_entries() { + let mut btn = node("button"); + btn.name = Some("Submit".into()); + let mut root = node("group"); + root.children = vec![btn]; + + let mut refmap = RefMap::new(); + let config = DrillDownConfig { + include_bounds: false, + interactive_only: false, + compact: false, + pid: 42, + source_app: Some("TestApp"), + root_ref_id: "@e5", + }; + let tree = allocate_refs_with_root(root, &mut refmap, &config); + + assert_eq!(refmap.len(), 1); + let btn_ref = tree.children[0] + .ref_id + .as_deref() + .expect("button should have ref"); + let entry = refmap.get(btn_ref).expect("entry should exist"); + assert_eq!(entry.root_ref.as_deref(), Some("@e5")); + assert_eq!(entry.pid, 42); + assert_eq!(entry.source_app.as_deref(), Some("TestApp")); + } + + #[test] + fn test_allocate_refs_with_root_respects_interactive_only() { + let btn = node("button"); + let text = node("statictext"); + let mut root = node("group"); + root.children = vec![btn, text]; + + let mut refmap = RefMap::new(); + let config = DrillDownConfig { + include_bounds: false, + interactive_only: true, + compact: false, + pid: 1, + source_app: None, + root_ref_id: "@e1", + }; + let tree = allocate_refs_with_root(root, &mut refmap, &config); + + assert_eq!(tree.children.len(), 1); + assert_eq!(tree.children[0].role, "button"); + } + + #[test] + fn test_allocate_refs_with_root_compact() { + let mut btn = node("button"); + btn.name = Some("OK".into()); + let mut wrapper = node("group"); + wrapper.children = vec![btn]; + let mut root = node("window"); + root.children = vec![wrapper]; + + let mut refmap = RefMap::new(); + let config = DrillDownConfig { + include_bounds: false, + interactive_only: false, + compact: true, + pid: 1, + source_app: None, + root_ref_id: "@e1", + }; + let tree = allocate_refs_with_root(root, &mut refmap, &config); + + assert_eq!(tree.children.len(), 1); + assert_eq!(tree.children[0].role, "button"); + } +} diff --git a/crates/macos/src/adapter.rs b/crates/macos/src/adapter.rs index adddc644..90dea6bc 100644 --- a/crates/macos/src/adapter.rs +++ b/crates/macos/src/adapter.rs @@ -51,8 +51,15 @@ impl PlatformAdapter for MacOSAdapter { .ok_or_else(|| AdapterError::element_not_found("No open alert or dialog"))?, }; let mut visited = FxHashSet::default(); - crate::tree::build_subtree(&el, 0, opts.max_depth, opts.include_bounds, &mut visited) - .ok_or_else(|| AdapterError::internal("Empty AX tree for surface")) + crate::tree::build_subtree( + &el, + 0, + opts.max_depth, + opts.include_bounds, + &mut visited, + opts.skeleton, + ) + .ok_or_else(|| AdapterError::internal("Empty AX tree for surface")) } fn execute_action( @@ -72,7 +79,7 @@ impl PlatformAdapter for MacOSAdapter { } fn list_apps(&self) -> Result, AdapterError> { - list_apps_impl() + crate::system::app_ops::list_apps_impl() } fn focus_window(&self, win: &WindowInfo) -> Result<(), AdapterError> { @@ -205,6 +212,35 @@ impl PlatformAdapter for MacOSAdapter { ) -> Result { crate::notifications::actions::notification_action(index, action_name) } + + fn get_subtree( + &self, + handle: &NativeHandle, + opts: &TreeOptions, + ) -> Result { + use crate::tree::AXElement; + use std::mem::ManuallyDrop; + + let el = ManuallyDrop::new(AXElement( + handle.as_raw() as accessibility_sys::AXUIElementRef + )); + let mut ancestors = FxHashSet::default(); + crate::tree::build_subtree( + &el, + 0, + opts.max_depth, + opts.include_bounds, + &mut ancestors, + false, + ) + .ok_or_else(|| { + AdapterError::new( + agent_desktop_core::error::ErrorCode::ElementNotFound, + "Element no longer exists in accessibility tree", + ) + .with_suggestion("Run 'snapshot' to refresh refs, then retry.") + }) + } } #[cfg(target_os = "macos")] @@ -318,80 +354,3 @@ pub(crate) fn list_windows_impl(filter: &WindowFilter) -> Result Err(AdapterError::not_supported("list_windows")) } } - -fn list_apps_impl() -> Result, AdapterError> { - #[cfg(target_os = "macos")] - { - use core_foundation::base::{CFType, TCFType}; - use core_foundation::number::CFNumber; - use core_foundation::string::CFString; - use core_foundation_sys::dictionary::CFDictionaryGetValue; - use core_graphics::display::CGDisplay; - use core_graphics::window::{ - kCGWindowLayer, kCGWindowListOptionOnScreenOnly, kCGWindowOwnerName, kCGWindowOwnerPID, - }; - - let arr = match CGDisplay::window_list_info(kCGWindowListOptionOnScreenOnly, None) { - Some(a) => a, - None => return Ok(vec![]), - }; - - let mut seen_pids = std::collections::HashSet::new(); - let mut apps = Vec::new(); - - for raw in arr.get_all_values() { - if raw.is_null() { - continue; - } - - let layer = unsafe { - let v = CFDictionaryGetValue(raw as _, kCGWindowLayer as _); - if v.is_null() { - continue; - } - CFType::wrap_under_get_rule(v as _) - .downcast::() - .and_then(|n| n.to_i64()) - .unwrap_or(99) - }; - if layer != 0 { - continue; - } - - let pid = unsafe { - let v = CFDictionaryGetValue(raw as _, kCGWindowOwnerPID as _); - if v.is_null() { - continue; - } - CFType::wrap_under_get_rule(v as _) - .downcast::() - .and_then(|n| n.to_i64()) - .unwrap_or(0) as i32 - }; - if !seen_pids.insert(pid) { - continue; - } - - let name = unsafe { - let v = CFDictionaryGetValue(raw as _, kCGWindowOwnerName as _); - if v.is_null() { - continue; - } - CFType::wrap_under_get_rule(v as _) - .downcast::() - .map(|s| s.to_string()) - }; - - if let Some(n) = name { - apps.push(AppInfo { - name: n, - pid, - bundle_id: None, - }); - } - } - Ok(apps) - } - #[cfg(not(target_os = "macos"))] - Err(AdapterError::not_supported("list_apps")) -} diff --git a/crates/macos/src/system/app_ops.rs b/crates/macos/src/system/app_ops.rs index 2bee50e5..aa7a4e6b 100644 --- a/crates/macos/src/system/app_ops.rs +++ b/crates/macos/src/system/app_ops.rs @@ -1,4 +1,8 @@ -use agent_desktop_core::{adapter::WindowFilter, error::AdapterError, node::WindowInfo}; +use agent_desktop_core::{ + adapter::WindowFilter, + error::AdapterError, + node::{AppInfo, WindowInfo}, +}; #[cfg(target_os = "macos")] pub fn pid_from_element(el: &crate::tree::AXElement) -> Option { @@ -234,3 +238,80 @@ fn try_quit_via_menu_bar(app_el: &crate::tree::AXElement) -> bool { pub fn close_app_impl(_id: &str, _force: bool) -> Result<(), AdapterError> { Err(AdapterError::not_supported("close_app")) } + +pub fn list_apps_impl() -> Result, AdapterError> { + #[cfg(target_os = "macos")] + { + use core_foundation::base::{CFType, TCFType}; + use core_foundation::number::CFNumber; + use core_foundation::string::CFString; + use core_foundation_sys::dictionary::CFDictionaryGetValue; + use core_graphics::display::CGDisplay; + use core_graphics::window::{ + kCGWindowLayer, kCGWindowListOptionOnScreenOnly, kCGWindowOwnerName, kCGWindowOwnerPID, + }; + + let arr = match CGDisplay::window_list_info(kCGWindowListOptionOnScreenOnly, None) { + Some(a) => a, + None => return Ok(vec![]), + }; + + let mut seen_pids = std::collections::HashSet::new(); + let mut apps = Vec::new(); + + for raw in arr.get_all_values() { + if raw.is_null() { + continue; + } + + let layer = unsafe { + let v = CFDictionaryGetValue(raw as _, kCGWindowLayer as _); + if v.is_null() { + continue; + } + CFType::wrap_under_get_rule(v as _) + .downcast::() + .and_then(|n| n.to_i64()) + .unwrap_or(99) + }; + if layer != 0 { + continue; + } + + let pid = unsafe { + let v = CFDictionaryGetValue(raw as _, kCGWindowOwnerPID as _); + if v.is_null() { + continue; + } + CFType::wrap_under_get_rule(v as _) + .downcast::() + .and_then(|n| n.to_i64()) + .unwrap_or(0) as i32 + }; + if !seen_pids.insert(pid) { + continue; + } + + let name = unsafe { + let v = CFDictionaryGetValue(raw as _, kCGWindowOwnerName as _); + if v.is_null() { + continue; + } + CFType::wrap_under_get_rule(v as _) + .downcast::() + .map(|s| s.to_string()) + }; + + if let Some(n) = name { + apps.push(AppInfo { + name: n, + pid, + bundle_id: None, + }); + } + } + Ok(apps) + } + #[cfg(not(target_os = "macos"))] + Err(AdapterError::not_supported("list_apps")) +} diff --git a/crates/macos/src/tree/builder.rs b/crates/macos/src/tree/builder.rs index 165f50dc..33bf96a4 100644 --- a/crates/macos/src/tree/builder.rs +++ b/crates/macos/src/tree/builder.rs @@ -2,8 +2,8 @@ use agent_desktop_core::node::AccessibilityNode; use rustc_hash::FxHashSet; use super::element::{ - copy_ax_array, copy_string_attr, element_for_pid, fetch_node_attrs, read_bounds, AXElement, - ABSOLUTE_MAX_DEPTH, + copy_ax_array, copy_string_attr, count_children, element_for_pid, fetch_node_attrs, + read_bounds, AXElement, ABSOLUTE_MAX_DEPTH, }; #[cfg(target_os = "macos")] @@ -47,6 +47,7 @@ pub fn build_subtree( max_depth: u8, _include_bounds: bool, ancestors: &mut FxHashSet, + skeleton: bool, ) -> Option { if depth > max_depth || depth >= ABSOLUTE_MAX_DEPTH { return None; @@ -83,11 +84,6 @@ pub fn build_subtree( let bounds = read_bounds(el); - let children_raw = copy_children(el, ax_role.as_deref()).unwrap_or_default(); - let name = name.or_else(|| label_from_children(&children_raw)); - - // Non-semantic groups inside web content don't cost depth budget. - // A nameless AXGroup/AXGenericElement is just a
wrapper — skip it. let is_web_wrapper = matches!( ax_role.as_deref(), Some("AXGroup") | Some("AXGenericElement") @@ -96,10 +92,49 @@ pub fn build_subtree( let child_depth = if is_web_wrapper { depth } else { depth + 1 }; + let at_skeleton_boundary = + skeleton && child_depth > max_depth && child_depth < ABSOLUTE_MAX_DEPTH; + + if at_skeleton_boundary { + let child_count = count_children(el); + let children_count = if child_count > 0 { + Some(child_count) + } else { + None + }; + let name = name.or_else(|| { + let children_raw = copy_children(el, ax_role.as_deref()).unwrap_or_default(); + label_from_children(&children_raw) + }); + ancestors.remove(&ptr_key); + return Some(AccessibilityNode { + ref_id: None, + role, + name, + value, + description, + hint: None, + states, + bounds, + children_count, + children: vec![], + }); + } + + let children_raw = copy_children(el, ax_role.as_deref()).unwrap_or_default(); + let name = name.or_else(|| label_from_children(&children_raw)); + let children = children_raw .into_iter() .filter_map(|child| { - build_subtree(&child, child_depth, max_depth, _include_bounds, ancestors) + build_subtree( + &child, + child_depth, + max_depth, + _include_bounds, + ancestors, + skeleton, + ) }) .collect(); @@ -189,6 +224,7 @@ pub fn build_subtree( _max_depth: u8, _include_bounds: bool, _visited: &mut FxHashSet, + _skeleton: bool, ) -> Option { None } diff --git a/crates/macos/src/tree/element.rs b/crates/macos/src/tree/element.rs index 7e1f417a..530b972c 100644 --- a/crates/macos/src/tree/element.rs +++ b/crates/macos/src/tree/element.rs @@ -238,6 +238,21 @@ mod imp { Some(AXElement(ptr)) } + pub fn count_children(element: &AXElement) -> u32 { + unsafe { + let mut value: core_foundation::base::CFTypeRef = std::ptr::null(); + let attr = CFString::from_static_string("AXChildren"); + let err = + AXUIElementCopyAttributeValue(element.0, attr.as_concrete_TypeRef(), &mut value); + if err != kAXErrorSuccess || value.is_null() { + return 0; + } + let count = core_foundation_sys::array::CFArrayGetCount(value as _); + CFRelease(value); + count as u32 + } + } + pub fn read_bounds(el: &AXElement) -> Option { use accessibility_sys::{ kAXPositionAttribute, kAXSizeAttribute, kAXValueTypeCGPoint, kAXValueTypeCGSize, @@ -337,6 +352,9 @@ mod imp { pub fn copy_element_attr(_el: &AXElement, _attr: &str) -> Option { None } + pub fn count_children(_element: &AXElement) -> u32 { + 0 + } pub fn read_bounds(_el: &AXElement) -> Option { None } @@ -362,5 +380,6 @@ mod imp { pub use imp::{ copy_ax_array, copy_bool_attr, copy_element_attr, copy_string_attr, copy_value_typed, - element_for_pid, fetch_node_attrs, read_bounds, resolve_element_name, AXElement, + count_children, element_for_pid, fetch_node_attrs, read_bounds, resolve_element_name, + AXElement, }; diff --git a/crates/macos/src/tree/mod.rs b/crates/macos/src/tree/mod.rs index c26c51c4..f8c6e9e8 100644 --- a/crates/macos/src/tree/mod.rs +++ b/crates/macos/src/tree/mod.rs @@ -6,8 +6,8 @@ pub mod surfaces; pub use builder::{build_subtree, window_element_for}; pub use element::{ - copy_ax_array, copy_element_attr, copy_string_attr, copy_value_typed, element_for_pid, - read_bounds, resolve_element_name, AXElement, ABSOLUTE_MAX_DEPTH, + copy_ax_array, copy_element_attr, copy_string_attr, copy_value_typed, count_children, + element_for_pid, read_bounds, resolve_element_name, AXElement, ABSOLUTE_MAX_DEPTH, }; pub use resolve::{find_element_recursive, resolve_element_impl}; pub use roles::{ax_role_to_str, is_interactive_role}; diff --git a/src/cli_args.rs b/src/cli_args.rs index e502c54b..8ac60f06 100644 --- a/src/cli_args.rs +++ b/src/cli_args.rs @@ -50,7 +50,10 @@ pub struct SnapshotArgs { pub compact: bool, #[arg(long, value_enum, default_value_t = Surface::Window, help = "Surface to snapshot")] pub surface: Surface, - #[arg(long, help = "Shallow overview with children_count on truncated containers")] + #[arg( + long, + help = "Shallow overview with children_count on truncated containers" + )] pub skeleton: bool, #[arg(long, help = "Start traversal from this ref instead of window root")] pub root: Option,