1use std::fmt::{self, Write as _};
2use std::time::SystemTime;
3
4use crate::app::AppInfo;
5use crate::element::{Element, ElementStates};
6use crate::geometry::Rect;
7use crate::screenshot::Screenshot;
8
9#[derive(Debug, Clone, PartialEq, Default)]
11#[non_exhaustive]
12pub struct WindowInfo {
13 pub id: Option<u64>,
17 pub title: Option<String>,
19 pub frame: Rect,
21}
22
23impl WindowInfo {
24 #[must_use]
26 pub const fn new(id: Option<u64>, title: Option<String>, frame: Rect) -> Self {
27 Self { id, title, frame }
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35#[non_exhaustive]
36pub enum WindowSelector {
37 Id(u64),
39 Title(String),
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46#[non_exhaustive]
47#[allow(clippy::struct_excessive_bools)] pub struct CaptureOptions {
49 pub include_tree: bool,
51 pub include_screenshot: bool,
53 pub text_limit: Option<usize>,
55 pub max_nodes: usize,
57 pub max_depth: usize,
59 pub max_screenshot_dimension: Option<u32>,
61 pub launch_if_needed: bool,
63 pub window: Option<WindowSelector>,
65 pub include_menu_bar: bool,
70}
71
72impl Default for CaptureOptions {
73 fn default() -> Self {
74 Self {
75 include_tree: true,
76 include_screenshot: true,
77 text_limit: Some(500),
78 max_nodes: 1200,
79 max_depth: 64,
80 max_screenshot_dimension: Some(1280),
81 launch_if_needed: true,
82 window: None,
83 include_menu_bar: false,
84 }
85 }
86}
87
88impl CaptureOptions {
89 #[must_use]
91 pub const fn include_tree(mut self, yes: bool) -> Self {
92 self.include_tree = yes;
93 self
94 }
95
96 #[must_use]
98 pub const fn include_screenshot(mut self, yes: bool) -> Self {
99 self.include_screenshot = yes;
100 self
101 }
102
103 #[must_use]
105 pub const fn text_limit(mut self, limit: Option<usize>) -> Self {
106 self.text_limit = limit;
107 self
108 }
109
110 #[must_use]
112 pub const fn max_nodes(mut self, max: usize) -> Self {
113 self.max_nodes = max;
114 self
115 }
116
117 #[must_use]
119 pub const fn max_depth(mut self, max: usize) -> Self {
120 self.max_depth = max;
121 self
122 }
123
124 #[must_use]
126 pub const fn max_screenshot_dimension(mut self, max: Option<u32>) -> Self {
127 self.max_screenshot_dimension = max;
128 self
129 }
130
131 #[must_use]
133 pub const fn launch_if_needed(mut self, yes: bool) -> Self {
134 self.launch_if_needed = yes;
135 self
136 }
137
138 #[must_use]
140 pub fn window(mut self, window: WindowSelector) -> Self {
141 self.window = Some(window);
142 self
143 }
144
145 #[must_use]
147 pub const fn include_menu_bar(mut self, yes: bool) -> Self {
148 self.include_menu_bar = yes;
149 self
150 }
151}
152
153#[derive(Debug, Clone)]
155#[non_exhaustive]
156pub struct AppState {
157 pub app: AppInfo,
159 pub window: WindowInfo,
161 pub elements: Vec<Element>,
163 pub truncated: bool,
165 pub tree_included: bool,
167 pub focused: Option<usize>,
169 pub selected_text: Option<String>,
171 pub screenshot: Option<Screenshot>,
173 pub captured_at: SystemTime,
175 pub notes: Vec<String>,
177}
178
179impl AppState {
180 #[must_use]
182 pub fn element(&self, index: usize) -> Option<&Element> {
183 self.elements.get(index)
184 }
185
186 #[must_use]
188 pub fn focused_element(&self) -> Option<&Element> {
189 self.focused.and_then(|i| self.element(i))
190 }
191
192 pub fn find(&self, query: &ElementQuery) -> impl Iterator<Item = &Element> + use<'_> {
194 let query = query.clone();
195 let limit = query.limit;
196 self.elements.iter().filter(move |e| query.matches(e)).take(limit)
197 }
198
199 #[must_use]
201 pub fn element_at(&self, point: crate::geometry::Point) -> Option<&Element> {
202 self.elements.iter().filter(|e| e.frame.is_some_and(|f| f.contains(point))).min_by(|a, b| {
203 let rank = |e: &Element| (e.primary_action().is_none(), e.frame.map_or(f64::MAX, |f| f.area()));
204 let (ra, rb) = (rank(a), rank(b));
205 ra.0.cmp(&rb.0).then(ra.1.total_cmp(&rb.1))
206 })
207 }
208
209 #[must_use]
221 pub fn render(&self) -> String {
222 let mut out = String::new();
223 let pid = self.app.pid.map_or_else(String::new, |p| format!(" (pid {p})"));
224 let _ = writeln!(out, "App={}{pid}", self.app.reference());
225 let title = self.window.title.as_deref().filter(|t| !t.is_empty()).unwrap_or(&self.app.name);
226 let _ = write!(out, "Window: {title:?}, App: {}.", self.app.name);
227 for element in &self.elements {
228 out.push('\n');
229 for _ in 0..element.depth {
230 out.push('\t');
231 }
232 render_element(&mut out, element);
233 }
234 if !self.tree_included {
235 out.push_str("\nAccessibility tree omitted (include_tree = false); element indexes are unavailable.");
236 } else if self.truncated {
237 out.push_str("\n… tree truncated by node or depth limits.");
238 }
239 for note in &self.notes {
240 let _ = write!(out, "\n{note}");
241 }
242 if let Some(selected) = self.selected_text.as_deref().filter(|s| !s.is_empty()) {
243 let _ = write!(out, "\n\nSelected text: [{selected}]");
244 } else if let Some(focused) = self.focused_element() {
245 out.push_str("\n\nThe focused UI element is ");
246 render_element(&mut out, focused);
247 out.push('.');
248 }
249 out
250 }
251}
252
253impl fmt::Display for AppState {
254 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255 f.write_str(&self.render())
256 }
257}
258
259fn render_element(out: &mut String, e: &Element) {
260 let _ = write!(out, "{} {}", e.index, e.role);
261 let states = (e.states - ElementStates::OFFSCREEN).labels();
262 if !states.is_empty() {
263 let _ = write!(out, " ({})", states.join(", "));
264 }
265 if let Some(title) = &e.title {
266 let _ = write!(out, " {title}");
267 }
268 let mut field = |name: &str, value: Option<&String>| {
269 if let Some(v) = value.filter(|v| Some(*v) != e.title.as_ref()) {
270 let _ = write!(out, " {name}: {v}");
271 }
272 };
273 field("Description", e.description.as_ref());
274 field("Help", e.help.as_ref().filter(|h| Some(*h) != e.description.as_ref()));
275 field("URL", e.url.as_ref());
276 field("ID", e.identifier.as_ref());
277 field("Value", e.value.as_ref());
278 if e.value.is_none() {
279 field("Placeholder", e.placeholder.as_ref());
280 }
281 let secondary: Vec<&str> = e.actions.iter().filter(|a| !a.primary).map(|a| a.label.as_str()).collect();
282 if !secondary.is_empty() {
283 let _ = write!(out, " Secondary Actions: {}", secondary.join(", "));
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct ElementQuery {
290 pub text: Option<String>,
292 pub role: Option<String>,
294 pub exact: bool,
296 pub limit: usize,
298}
299
300impl Default for ElementQuery {
301 fn default() -> Self {
302 Self { text: None, role: None, exact: false, limit: 20 }
303 }
304}
305
306impl ElementQuery {
307 #[must_use]
309 pub fn text(text: impl Into<String>) -> Self {
310 Self { text: Some(text.into()), ..Self::default() }
311 }
312
313 #[must_use]
315 pub fn role(role: impl Into<String>) -> Self {
316 Self { role: Some(role.into()), ..Self::default() }
317 }
318
319 #[must_use]
321 pub fn with_role(mut self, role: impl Into<String>) -> Self {
322 self.role = Some(role.into());
323 self
324 }
325
326 #[must_use]
328 pub const fn exact(mut self, exact: bool) -> Self {
329 self.exact = exact;
330 self
331 }
332
333 #[must_use]
335 pub const fn limit(mut self, limit: usize) -> Self {
336 self.limit = limit;
337 self
338 }
339
340 #[must_use]
342 pub fn matches(&self, element: &Element) -> bool {
343 if let Some(role) = &self.role {
344 let want = normalize_role(role);
345 if normalize_role(&element.role) != want && normalize_role(&element.platform_role) != want {
346 return false;
347 }
348 }
349 if let Some(text) = &self.text {
350 let needle = text.to_lowercase();
351 let hit = element.text_fields().any(|field| {
352 let field = field.to_lowercase();
353 if self.exact { field == needle } else { field.contains(&needle) }
354 });
355 if !hit {
356 return false;
357 }
358 }
359 true
360 }
361}
362
363fn normalize_role(role: &str) -> String {
364 let lowered: String = role.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect();
365 lowered.strip_prefix("ax").map(str::to_owned).unwrap_or(lowered)
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use crate::element::{Action, ElementData, RawNode, build_tree};
372
373 pub(crate) fn sample_state() -> AppState {
374 let mut window = ElementData::new("standard window", "AXWindow");
375 window.title = Some("Doc".into());
376 let mut text = ElementData::new("text area", "AXTextArea");
377 text.value = Some("Hello world".into());
378 text.states = ElementStates::FOCUSED | ElementStates::SETTABLE;
379 text.frame = Some(Rect::new(0.0, 20.0, 200.0, 100.0));
380 let mut button = ElementData::new("button", "AXButton");
381 button.title = Some("Save".into());
382 button.actions = vec![Action::new("AXPress", true), Action::new("AXShowMenu", false)];
383 button.frame = Some(Rect::new(10.0, 130.0, 50.0, 20.0));
384 let nodes = vec![
385 RawNode { handle: (), parent: None, data: window },
386 RawNode { handle: (), parent: Some(0), data: text },
387 RawNode { handle: (), parent: Some(0), data: button },
388 ];
389 let (elements, _, _) = build_tree(nodes, Some(500));
390 AppState {
391 app: AppInfo::new("TextEdit").with_bundle_id("com.apple.TextEdit").with_pid(9),
392 window: WindowInfo::new(Some(1), Some("Doc".into()), Rect::new(0.0, 0.0, 300.0, 200.0)),
393 elements,
394 truncated: false,
395 tree_included: true,
396 focused: Some(1),
397 selected_text: None,
398 screenshot: None,
399 captured_at: SystemTime::UNIX_EPOCH,
400 notes: Vec::new(),
401 }
402 }
403
404 #[test]
405 fn renders_tree() {
406 let text = sample_state().render();
407 let expected = "App=com.apple.TextEdit (pid 9)\n\
408 Window: \"Doc\", App: TextEdit.\n\
409 0 standard window Doc\n\
410 \t1 text area (focused, settable) Value: Hello world\n\
411 \t2 button Save Secondary Actions: Show Menu\n\
412 \n\
413 The focused UI element is 1 text area (focused, settable) Value: Hello world.";
414 assert_eq!(text, expected);
415 }
416
417 #[test]
418 fn queries_by_text_and_role() {
419 let state = sample_state();
420 let q = ElementQuery::text("save");
421 assert_eq!(state.find(&q).map(|e| e.index).collect::<Vec<_>>(), vec![2]);
422 let q = ElementQuery::role("AXTextArea");
423 assert_eq!(state.find(&q).map(|e| e.index).collect::<Vec<_>>(), vec![1]);
424 let q = ElementQuery::role("button").exact(true);
425 assert_eq!(state.find(&q).count(), 1);
426 let q = ElementQuery::text("hello").exact(true);
427 assert_eq!(state.find(&q).count(), 0);
428 }
429
430 #[test]
431 fn hit_tests_prefer_actionable_elements() {
432 let state = sample_state();
433 let hit = state.element_at(crate::geometry::Point::new(20.0, 140.0));
434 assert_eq!(hit.map(|e| e.index), Some(2));
435 }
436}