1use std::collections::HashMap;
2use std::thread;
3use std::time::{Duration, SystemTime};
4
5use crate::app::{self, AppInfo};
6use crate::backend::{Backend, CaptureRequest, Delivery, InputTarget, MouseButton, Permissions, ScrollDirection};
7use crate::element::{Element, build_tree};
8use crate::error::{Error, Result};
9use crate::geometry::{Point, Rect};
10use crate::keys::KeyChord;
11use crate::screenshot::Screenshot;
12use crate::state::{AppState, CaptureOptions, ElementQuery, WindowInfo, WindowSelector};
13use crate::text::TextSelection;
14
15#[derive(Debug, Clone, Copy, PartialEq)]
17#[non_exhaustive]
18pub enum ClickTarget {
19 Element(usize),
21 Pixel {
23 x: f64,
25 y: f64,
27 },
28 Screen {
30 x: f64,
32 y: f64,
34 },
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
39#[non_exhaustive]
40pub enum ClickMethod {
41 #[default]
44 Auto,
45 Accessibility,
47 Background,
49 FocusedBackground,
52 Global,
54}
55
56impl std::str::FromStr for ClickMethod {
57 type Err = Error;
58
59 fn from_str(s: &str) -> Result<Self> {
60 match s.trim().to_ascii_lowercase().as_str() {
61 "auto" => Ok(Self::Auto),
62 "accessibility" | "ax" => Ok(Self::Accessibility),
63 "background" => Ok(Self::Background),
64 "focused_background" | "focused-background" => Ok(Self::FocusedBackground),
65 "global" => Ok(Self::Global),
66 other => Err(Error::invalid(format!("unknown click method {other:?}"))),
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct ClickOptions {
74 pub button: MouseButton,
76 pub count: u32,
78 pub method: ClickMethod,
80}
81
82impl Default for ClickOptions {
83 fn default() -> Self {
84 Self { button: MouseButton::Left, count: 1, method: ClickMethod::Auto }
85 }
86}
87
88impl ClickOptions {
89 #[must_use]
91 pub const fn button(mut self, button: MouseButton) -> Self {
92 self.button = button;
93 self
94 }
95
96 #[must_use]
98 pub const fn count(mut self, count: u32) -> Self {
99 self.count = count;
100 self
101 }
102
103 #[must_use]
105 pub const fn method(mut self, method: ClickMethod) -> Self {
106 self.method = method;
107 self
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113#[non_exhaustive]
114pub enum ClickOutcome {
115 Action(String),
117 Pointer(Delivery),
119}
120
121struct Session<H> {
122 state: AppState,
123 handles: Vec<H>,
124 window: Option<u64>,
126}
127
128pub struct Computer<B: Backend> {
152 backend: B,
153 sessions: HashMap<String, Session<B::Handle>>,
154 aliases: HashMap<String, String>,
155 delivery: Delivery,
156 settle_delay: Duration,
157}
158
159impl<B: Backend> std::fmt::Debug for Computer<B> {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 f.debug_struct("Computer")
162 .field("sessions", &self.sessions.keys().collect::<Vec<_>>())
163 .field("delivery", &self.delivery)
164 .field("settle_delay", &self.settle_delay)
165 .finish_non_exhaustive()
166 }
167}
168
169#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
170impl Computer<crate::NativeBackend> {
171 pub fn new() -> Result<Self> {
177 Ok(Self::with_backend(crate::NativeBackend::new()?))
178 }
179}
180
181impl<B: Backend> Computer<B> {
182 #[must_use]
184 pub fn with_backend(backend: B) -> Self {
185 Self {
186 backend,
187 sessions: HashMap::new(),
188 aliases: HashMap::new(),
189 delivery: Delivery::Background,
190 settle_delay: Duration::ZERO,
191 }
192 }
193
194 #[must_use]
196 pub const fn delivery(mut self, delivery: Delivery) -> Self {
197 self.delivery = delivery;
198 self
199 }
200
201 #[must_use]
203 pub const fn settle_delay(mut self, delay: Duration) -> Self {
204 self.settle_delay = delay;
205 self
206 }
207
208 pub const fn backend(&self) -> &B {
210 &self.backend
211 }
212
213 pub fn permissions(&self) -> Permissions {
215 self.backend.permissions()
216 }
217
218 pub fn request_permissions(&self) -> Permissions {
220 self.backend.request_permissions()
221 }
222
223 pub fn list_apps(&self) -> Result<Vec<AppInfo>> {
229 let mut apps = self.backend.list_apps()?;
230 apps.sort_by(|a, b| {
231 (!a.is_running, !a.is_active, a.name.to_lowercase()).cmp(&(
232 !b.is_running,
233 !b.is_active,
234 b.name.to_lowercase(),
235 ))
236 });
237 Ok(apps)
238 }
239
240 pub fn resolve_app(&self, query: &str) -> Result<AppInfo> {
246 let apps = self.backend.list_apps()?;
247 app::best_match(&apps, query).cloned().ok_or_else(|| Error::AppNotFound(query.to_owned()))
248 }
249
250 pub fn get_app_state(&mut self, app: &str, options: &CaptureOptions) -> Result<&AppState> {
257 let info = self.running_app(app, options.launch_if_needed)?;
258 let window = match &options.window {
259 None => None,
260 Some(WindowSelector::Id(id)) => Some(*id),
261 Some(WindowSelector::Title(title)) => Some(self.window_by_title(&info, title)?),
262 };
263 let request =
264 CaptureRequest::new(options.include_tree, options.include_screenshot, options.max_nodes, options.max_depth)
265 .with_window(window)
266 .with_menu_bar(options.include_menu_bar);
267 let capture = self.backend.capture(&info, &request)?;
268 let (elements, handles, raw_to_element) = build_tree(capture.nodes, options.text_limit);
269 let focused = capture.focused.and_then(|raw| raw_to_element.get(raw).copied().flatten());
270 let screenshot = capture
271 .screenshot
272 .map(|image| Screenshot::encode(image, capture.window.frame, options.max_screenshot_dimension))
273 .transpose()?;
274 let state = AppState {
275 app: info,
276 window: capture.window,
277 elements,
278 truncated: capture.truncated,
279 tree_included: options.include_tree,
280 focused,
281 selected_text: capture.selected_text.map(|s| crate::element::sanitize(&s, options.text_limit)),
282 screenshot,
283 captured_at: SystemTime::now(),
284 notes: capture.notes,
285 };
286 let key = self.remember(app, &state.app);
287 let session = Session { state, handles, window };
288 Ok(&self.sessions.entry(key).insert_entry(session).into_mut().state)
289 }
290
291 pub fn list_windows(&self, app: &str) -> Result<Vec<WindowInfo>> {
298 let info = self.running_app(app, false)?;
299 self.backend.list_windows(&info)
300 }
301
302 #[must_use]
304 pub fn state(&self, app: &str) -> Option<&AppState> {
305 self.session_for(app).ok().map(|s| &s.state)
306 }
307
308 pub fn forget(&mut self, app: &str) {
310 if let Some(key) = self.aliases.remove(&alias_key(app)) {
311 self.sessions.remove(&key);
312 self.aliases.retain(|_, v| *v != key);
313 }
314 }
315
316 pub fn clear(&mut self) {
318 self.sessions.clear();
319 self.aliases.clear();
320 }
321
322 pub fn query(&mut self, app: &str, query: &ElementQuery, max_nodes: usize) -> Result<Vec<Element>> {
331 if query.text.as_deref().is_none_or(str::is_empty) && query.role.as_deref().is_none_or(str::is_empty) {
332 return Err(Error::invalid("query requires text and/or role"));
333 }
334 let previous = self.session_for(app).ok().map(|s| (s.state.window.frame, s.state.screenshot.clone()));
335 let mut options =
336 CaptureOptions::default().include_screenshot(false).max_nodes(max_nodes).launch_if_needed(false);
337 options.window = self.session_for(app).ok().and_then(|s| s.window.map(WindowSelector::Id));
339 let state = self.get_app_state(app, &options)?;
340 let matches: Vec<Element> = state.find(query).cloned().collect();
341 let frame = state.window.frame;
342 if let Some((prev_frame, Some(shot))) = previous
343 && prev_frame == frame
344 && let Ok(session) = self.session_for_mut(app)
345 {
346 session.state.screenshot = Some(shot);
347 }
348 Ok(matches)
349 }
350
351 pub fn click(&mut self, app: &str, target: ClickTarget, options: ClickOptions) -> Result<ClickOutcome> {
357 if options.count == 0 {
358 return Err(Error::invalid("click count must be at least 1"));
359 }
360 let delivery = match options.method {
361 ClickMethod::Global => Delivery::Global,
362 ClickMethod::Background => Delivery::Background,
363 ClickMethod::FocusedBackground => Delivery::FocusedBackground,
364 ClickMethod::Auto | ClickMethod::Accessibility => self.delivery,
365 };
366 let index = match target {
367 ClickTarget::Element(index) => index,
368 ClickTarget::Pixel { x, y } | ClickTarget::Screen { x, y } => {
369 if options.method == ClickMethod::Accessibility {
370 return Err(Error::invalid("accessibility clicks require an element target"));
371 }
372 let screen = matches!(target, ClickTarget::Screen { .. });
373 self.with_pointer(app, (x, y), screen, delivery, |backend, input, point| {
374 backend.click(input, point, options.button, options.count)
375 })?;
376 return Ok(ClickOutcome::Pointer(delivery));
377 }
378 };
379 let session = self.session_for(app)?;
380 let element = element(session, app, index)?;
381 if matches!(options.method, ClickMethod::Auto | ClickMethod::Accessibility)
382 && let Some(action) = equivalent_action(element, options)
383 {
384 let name = action.name.clone();
385 self.backend.perform_action(&session.handles[index], &name)?;
386 self.settle();
387 return Ok(ClickOutcome::Action(name));
388 }
389 if options.method == ClickMethod::Accessibility {
390 return Err(Error::action(format!(
391 "element {index} has no accessibility action for a {:?} click",
392 options.button
393 )));
394 }
395 let point = element
396 .frame
397 .filter(|f| !f.is_empty())
398 .map(|f| f.center())
399 .ok_or_else(|| Error::StateUnavailable(format!("element {index} has no on-screen frame")))?;
400 let input = input_target(&session.state, delivery);
401 self.backend.click(input, point, options.button, options.count)?;
402 self.settle();
403 Ok(ClickOutcome::Pointer(delivery))
404 }
405
406 pub fn perform_secondary_action(&mut self, app: &str, index: usize, action: &str) -> Result<()> {
412 let session = self.session_for(app)?;
413 let element = element(session, app, index)?;
414 let found = element.action(action).ok_or_else(|| {
415 let available: Vec<&str> = element.actions.iter().map(|a| a.label.as_str()).collect();
416 Error::invalid(format!(
417 "{action:?} is not a valid action for element {index}; available: [{}]",
418 available.join(", ")
419 ))
420 })?;
421 self.backend.perform_action(&session.handles[index], &found.name)?;
422 self.settle();
423 Ok(())
424 }
425
426 pub fn scroll(&mut self, app: &str, index: usize, direction: ScrollDirection, pages: f64) -> Result<()> {
435 if !pages.is_finite() || pages <= 0.0 {
436 return Err(Error::invalid("pages must be a positive number"));
437 }
438 let session = self.session_for(app)?;
439 let element = element(session, app, index)?;
440 let by_page = format!("Scroll {} By Page", direction.as_str());
441 if pages.fract() == 0.0
442 && pages <= 100.0
443 && let Some(action) = element.action(&by_page)
444 {
445 for _ in 0..whole_pages(pages) {
446 self.backend.perform_action(&session.handles[index], &action.name)?;
447 }
448 self.settle();
449 return Ok(());
450 }
451 let frame = element
452 .frame
453 .filter(|f| !f.is_empty())
454 .ok_or_else(|| Error::StateUnavailable(format!("element {index} has no scrollable frame")))?;
455 let (dx, dy) = wheel_delta(frame, direction, pages);
456 let input = input_target(&session.state, self.delivery);
457 self.backend.scroll(input, frame.center(), dx, dy)?;
458 self.settle();
459 Ok(())
460 }
461
462 pub fn scroll_at(&mut self, app: &str, target: ClickTarget, direction: ScrollDirection, pages: f64) -> Result<()> {
473 let (x, y, screen) = match target {
474 ClickTarget::Element(index) => return self.scroll(app, index, direction, pages),
475 ClickTarget::Pixel { x, y } => (x, y, false),
476 ClickTarget::Screen { x, y } => (x, y, true),
477 };
478 if !pages.is_finite() || pages <= 0.0 {
479 return Err(Error::invalid("pages must be a positive number"));
480 }
481 let state = self.session_for(app).ok().map(|s| &s.state);
482 let page_frame = |point: Point| {
483 state.and_then(|s| scroll_area_at(s, point).or(Some(s.window.frame))).filter(|f| !f.is_empty())
484 };
485 let delivery = self.delivery;
486 self.with_pointer(app, (x, y), screen, delivery, |backend, input, point| {
487 let frame = page_frame(point).unwrap_or(Rect::new(0.0, 0.0, FALLBACK_PAGE, FALLBACK_PAGE));
488 let (dx, dy) = wheel_delta(frame, direction, pages);
489 backend.scroll(input, point, dx, dy)
490 })
491 }
492
493 pub fn drag(&mut self, app: &str, from: (f64, f64), to: (f64, f64)) -> Result<()> {
499 let session = self.session_for(app)?;
500 let start = screenshot_point(&session.state, from.0, from.1)?;
501 let end = screenshot_point(&session.state, to.0, to.1)?;
502 let input = input_target(&session.state, self.delivery);
503 self.backend.drag(input, start, end)?;
504 self.settle();
505 Ok(())
506 }
507
508 pub fn drag_screen(&mut self, app: &str, from: Point, to: Point) -> Result<()> {
514 let delivery = self.delivery;
515 self.with_pointer(app, (from.x, from.y), true, delivery, |backend, input, start| backend.drag(input, start, to))
516 }
517
518 pub fn type_text(&mut self, app: &str, text: &str) -> Result<()> {
527 if text.is_empty() {
528 return Ok(());
529 }
530 let (info, window) = self.input_context(app)?;
531 if self.delivery != Delivery::Global && self.backend.insert_text(&info, text)? {
532 self.settle();
533 return Ok(());
534 }
535 let target = InputTarget { app: &info, window: window.as_ref(), delivery: self.delivery };
536 self.backend.type_text(target, text)?;
537 self.settle();
538 Ok(())
539 }
540
541 pub fn press_key(&mut self, app: &str, key: &str) -> Result<()> {
547 let chord: KeyChord = key.parse()?;
548 let (info, window) = self.input_context(app)?;
549 let target = InputTarget { app: &info, window: window.as_ref(), delivery: self.delivery };
550 self.backend.press_key(target, &chord)?;
551 self.settle();
552 Ok(())
553 }
554
555 pub fn set_value(&mut self, app: &str, index: usize, value: &str) -> Result<()> {
561 let session = self.session_for(app)?;
562 element(session, app, index)?;
563 self.backend.set_value(&session.handles[index], value)?;
564 self.settle();
565 Ok(())
566 }
567
568 pub fn select_text(&mut self, app: &str, index: usize, selection: &TextSelection) -> Result<()> {
574 let session = self.session_for(app)?;
575 let cached = element(session, app, index)?;
576 let handle = &session.handles[index];
577 let value = match self.backend.value(handle) {
578 Ok(Some(value)) => value,
579 Ok(None) => return Err(Error::StateUnavailable(format!("element {index} has no text value"))),
580 Err(Error::Unsupported(_)) => cached
581 .value
582 .clone()
583 .ok_or_else(|| Error::StateUnavailable(format!("element {index} has no text value")))?,
584 Err(e) => return Err(e),
585 };
586 let range = selection.resolve(&value)?;
587 self.backend.select_text(handle, &value, range)?;
588 self.settle();
589 Ok(())
590 }
591
592 fn with_pointer(
596 &self,
597 app: &str,
598 (x, y): (f64, f64),
599 screen: bool,
600 delivery: Delivery,
601 act: impl FnOnce(&B, InputTarget<'_>, Point) -> Result<()>,
602 ) -> Result<()> {
603 if screen {
604 if !x.is_finite() || !y.is_finite() {
605 return Err(Error::invalid(format!("({x}, {y}) is not a screen point")));
606 }
607 let (info, window) = self.input_context(app)?;
608 act(&self.backend, InputTarget { app: &info, window: window.as_ref(), delivery }, Point::new(x, y))?;
609 } else {
610 let session = self.session_for(app)?;
611 let point = screenshot_point(&session.state, x, y)?;
612 act(&self.backend, input_target(&session.state, delivery), point)?;
613 }
614 self.settle();
615 Ok(())
616 }
617
618 fn window_by_title(&self, app: &AppInfo, title: &str) -> Result<u64> {
619 let wanted = title.trim().to_lowercase();
620 let windows = self.backend.list_windows(app)?;
621 let titled = |w: &&WindowInfo| w.id.is_some() && w.title.is_some();
622 let lower = |w: &WindowInfo| w.title.as_deref().unwrap_or_default().to_lowercase();
623 windows
624 .iter()
625 .filter(titled)
626 .find(|w| lower(w) == wanted)
627 .or_else(|| windows.iter().filter(titled).find(|w| lower(w).contains(&wanted)))
628 .and_then(|w| w.id)
629 .ok_or_else(|| Error::WindowNotFound(format!("{} (no window titled {title:?})", app.name)))
630 }
631
632 fn running_app(&self, query: &str, launch: bool) -> Result<AppInfo> {
633 let info = self.resolve_app(query)?;
634 if info.is_running {
635 Ok(info)
636 } else if launch {
637 self.backend.launch_app(&info)
638 } else {
639 Err(Error::AppNotFound(format!("{query} is not running")))
640 }
641 }
642
643 fn input_context(&self, app: &str) -> Result<(AppInfo, Option<WindowInfo>)> {
644 if let Ok(session) = self.session_for(app) {
645 return Ok((session.state.app.clone(), Some(session.state.window.clone())));
646 }
647 Ok((self.running_app(app, false)?, None))
648 }
649
650 fn remember(&mut self, query: &str, info: &AppInfo) -> String {
651 let key = info.pid.map_or_else(|| info.reference().to_lowercase(), |pid| format!("pid:{pid}"));
652 self.aliases.insert(alias_key(query), key.clone());
653 self.aliases.insert(alias_key(info.reference()), key.clone());
654 self.aliases.insert(alias_key(&info.name), key.clone());
655 if let Some(pid) = info.pid {
656 self.aliases.insert(format!("pid:{pid}"), key.clone());
657 }
658 key
659 }
660
661 fn session_for(&self, app: &str) -> Result<&Session<B::Handle>> {
662 self.aliases
663 .get(&alias_key(app))
664 .and_then(|key| self.sessions.get(key))
665 .ok_or_else(|| Error::NoAppState(app.to_owned()))
666 }
667
668 fn session_for_mut(&mut self, app: &str) -> Result<&mut Session<B::Handle>> {
669 self.aliases
670 .get(&alias_key(app))
671 .and_then(|key| self.sessions.get_mut(key))
672 .ok_or_else(|| Error::NoAppState(app.to_owned()))
673 }
674
675 fn settle(&self) {
676 if !self.settle_delay.is_zero() {
677 thread::sleep(self.settle_delay);
678 }
679 }
680}
681
682fn alias_key(query: &str) -> String {
683 query.trim().to_lowercase()
684}
685
686fn element<'a, H>(session: &'a Session<H>, app: &str, index: usize) -> Result<&'a Element> {
687 session.state.element(index).ok_or_else(|| Error::ElementNotFound { app: app.to_owned(), index })
688}
689
690fn equivalent_action(element: &Element, options: ClickOptions) -> Option<&crate::element::Action> {
691 match (options.button, options.count) {
692 (MouseButton::Left, 1) => element.primary_action(),
693 (MouseButton::Right, 1) => element.action("Show Menu"),
694 _ => None,
695 }
696}
697
698fn screenshot_point(state: &AppState, x: f64, y: f64) -> Result<Point> {
699 let shot = state.screenshot.as_ref().ok_or_else(|| {
700 Error::StateUnavailable("no screenshot in the latest state; capture one before coordinate actions".into())
701 })?;
702 if !shot.contains_pixel(x, y) {
703 return Err(Error::invalid(format!("({x}, {y}) is outside the {}x{} screenshot", shot.width(), shot.height())));
704 }
705 Ok(shot.to_screen_point(x, y))
706}
707
708const FALLBACK_PAGE: f64 = 500.0;
710
711fn wheel_delta(frame: Rect, direction: ScrollDirection, pages: f64) -> (f64, f64) {
713 let page_x = (frame.size.width * 0.8).max(40.0);
714 let page_y = (frame.size.height * 0.8).max(40.0);
715 match direction {
716 ScrollDirection::Up => (0.0, -page_y * pages),
717 ScrollDirection::Down => (0.0, page_y * pages),
718 ScrollDirection::Left => (-page_x * pages, 0.0),
719 ScrollDirection::Right => (page_x * pages, 0.0),
720 }
721}
722
723fn scroll_area_at(state: &AppState, point: Point) -> Option<Rect> {
725 state
726 .elements
727 .iter()
728 .filter(|e| e.role.contains("scroll") && !e.role.contains("bar"))
729 .filter_map(|e| e.frame)
730 .filter(|f| !f.is_empty() && f.contains(point))
731 .min_by(|a, b| a.area().total_cmp(&b.area()))
732}
733
734const fn input_target(state: &AppState, delivery: Delivery) -> InputTarget<'_> {
735 InputTarget { app: &state.app, window: Some(&state.window), delivery }
736}
737
738#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
739const fn whole_pages(pages: f64) -> u32 {
740 pages as u32
741}