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 delivery: Delivery,
155 settle_delay: Duration,
156}
157
158impl<B: Backend> std::fmt::Debug for Computer<B> {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 f.debug_struct("Computer")
161 .field("sessions", &self.sessions.keys().collect::<Vec<_>>())
162 .field("delivery", &self.delivery)
163 .field("settle_delay", &self.settle_delay)
164 .finish_non_exhaustive()
165 }
166}
167
168#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
169impl Computer<crate::NativeBackend> {
170 pub fn new() -> Result<Self> {
176 Ok(Self::with_backend(crate::NativeBackend::new()?))
177 }
178}
179
180impl<B: Backend> Computer<B> {
181 #[must_use]
183 pub fn with_backend(backend: B) -> Self {
184 Self { backend, sessions: HashMap::new(), delivery: Delivery::Background, settle_delay: Duration::ZERO }
185 }
186
187 #[must_use]
189 pub const fn delivery(mut self, delivery: Delivery) -> Self {
190 self.delivery = delivery;
191 self
192 }
193
194 #[must_use]
196 pub const fn settle_delay(mut self, delay: Duration) -> Self {
197 self.settle_delay = delay;
198 self
199 }
200
201 pub const fn backend(&self) -> &B {
203 &self.backend
204 }
205
206 pub fn permissions(&self) -> Permissions {
208 self.backend.permissions()
209 }
210
211 pub fn request_permissions(&self) -> Permissions {
213 self.backend.request_permissions()
214 }
215
216 pub fn list_apps(&self) -> Result<Vec<AppInfo>> {
222 let mut apps = self.backend.list_apps()?;
223 apps.sort_by(|a, b| {
224 (!a.is_running, !a.is_active, a.name.to_lowercase()).cmp(&(
225 !b.is_running,
226 !b.is_active,
227 b.name.to_lowercase(),
228 ))
229 });
230 Ok(apps)
231 }
232
233 pub fn resolve_app(&self, query: &str) -> Result<AppInfo> {
240 app::resolve(&self.backend.list_apps()?, query)
241 }
242
243 pub fn get_app_state(&mut self, app: &str, options: &CaptureOptions) -> Result<&AppState> {
250 let info = self.running_app(app, options.launch_if_needed)?;
251 let window = match &options.window {
252 None => None,
253 Some(WindowSelector::Id(id)) => Some(*id),
254 Some(WindowSelector::Title(title)) => Some(self.window_by_title(&info, title)?),
255 };
256 let request =
257 CaptureRequest::new(options.include_tree, options.include_screenshot, options.max_nodes, options.max_depth)
258 .with_window(window)
259 .with_menu_bar(options.include_menu_bar);
260 let capture = self.backend.capture(&info, &request)?;
261 let (elements, handles, raw_to_element) = build_tree(capture.nodes, options.text_limit);
262 let focused = capture.focused.and_then(|raw| raw_to_element.get(raw).copied().flatten());
263 let screenshot = capture
264 .screenshot
265 .map(|image| Screenshot::encode(image, capture.window.frame, options.max_screenshot_dimension))
266 .transpose()?;
267 let state = AppState {
268 app: info,
269 window: capture.window,
270 elements,
271 truncated: capture.truncated,
272 tree_included: options.include_tree,
273 focused,
274 selected_text: capture.selected_text.map(|s| crate::element::sanitize(&s, options.text_limit)),
275 screenshot,
276 captured_at: SystemTime::now(),
277 notes: capture.notes,
278 };
279 let key = state.app.pid.map_or_else(|| state.app.reference().to_lowercase(), |pid| format!("pid:{pid}"));
280 let session = Session { state, handles, window };
281 Ok(&self.sessions.entry(key).insert_entry(session).into_mut().state)
282 }
283
284 pub fn list_windows(&self, app: &str) -> Result<Vec<WindowInfo>> {
291 let info = self.running_app(app, false)?;
292 self.backend.list_windows(&info)
293 }
294
295 #[must_use]
297 pub fn state(&self, app: &str) -> Option<&AppState> {
298 self.session_for(app).ok().map(|s| &s.state)
299 }
300
301 pub fn forget(&mut self, app: &str) {
303 if let Ok(key) = self.session_key(app) {
304 self.sessions.remove(&key);
305 }
306 }
307
308 pub fn clear(&mut self) {
310 self.sessions.clear();
311 }
312
313 pub fn query(&mut self, app: &str, query: &ElementQuery, max_nodes: usize) -> Result<Vec<Element>> {
322 if query.text.as_deref().is_none_or(str::is_empty) && query.role.as_deref().is_none_or(str::is_empty) {
323 return Err(Error::invalid("query requires text and/or role"));
324 }
325 let previous = self.session_for(app).ok().map(|s| (s.state.window.frame, s.state.screenshot.clone()));
326 let mut options =
327 CaptureOptions::default().include_screenshot(false).max_nodes(max_nodes).launch_if_needed(false);
328 options.window = self.session_for(app).ok().and_then(|s| s.window.map(WindowSelector::Id));
330 let state = self.get_app_state(app, &options)?;
331 let matches: Vec<Element> = state.find(query).cloned().collect();
332 let frame = state.window.frame;
333 if let Some((prev_frame, Some(shot))) = previous
334 && prev_frame == frame
335 && let Ok(session) = self.session_for_mut(app)
336 {
337 session.state.screenshot = Some(shot);
338 }
339 Ok(matches)
340 }
341
342 pub fn click(&mut self, app: &str, target: ClickTarget, options: ClickOptions) -> Result<ClickOutcome> {
348 if options.count == 0 {
349 return Err(Error::invalid("click count must be at least 1"));
350 }
351 let delivery = match options.method {
352 ClickMethod::Global => Delivery::Global,
353 ClickMethod::Background => Delivery::Background,
354 ClickMethod::FocusedBackground => Delivery::FocusedBackground,
355 ClickMethod::Auto | ClickMethod::Accessibility => self.delivery,
356 };
357 let index = match target {
358 ClickTarget::Element(index) => index,
359 ClickTarget::Pixel { x, y } | ClickTarget::Screen { x, y } => {
360 if options.method == ClickMethod::Accessibility {
361 return Err(Error::invalid("accessibility clicks require an element target"));
362 }
363 let screen = matches!(target, ClickTarget::Screen { .. });
364 self.with_pointer(app, (x, y), screen, delivery, |backend, input, point| {
365 backend.click(input, point, options.button, options.count)
366 })?;
367 return Ok(ClickOutcome::Pointer(delivery));
368 }
369 };
370 let session = self.session_for(app)?;
371 let element = element(session, app, index)?;
372 if matches!(options.method, ClickMethod::Auto | ClickMethod::Accessibility)
373 && let Some(action) = equivalent_action(element, options)
374 {
375 let name = action.name.clone();
376 self.backend.perform_action(&session.handles[index], &name)?;
377 self.settle();
378 return Ok(ClickOutcome::Action(name));
379 }
380 if options.method == ClickMethod::Accessibility {
381 return Err(Error::action(format!(
382 "element {index} has no accessibility action for a {:?} click",
383 options.button
384 )));
385 }
386 let point = element
387 .frame
388 .filter(|f| !f.is_empty())
389 .map(|f| f.center())
390 .ok_or_else(|| Error::StateUnavailable(format!("element {index} has no on-screen frame")))?;
391 let input = input_target(&session.state, delivery);
392 self.backend.click(input, point, options.button, options.count)?;
393 self.settle();
394 Ok(ClickOutcome::Pointer(delivery))
395 }
396
397 pub fn perform_secondary_action(&mut self, app: &str, index: usize, action: &str) -> Result<()> {
403 let session = self.session_for(app)?;
404 let element = element(session, app, index)?;
405 let found = element.action(action).ok_or_else(|| {
406 let available: Vec<&str> = element.actions.iter().map(|a| a.label.as_str()).collect();
407 Error::invalid(format!(
408 "{action:?} is not a valid action for element {index}; available: [{}]",
409 available.join(", ")
410 ))
411 })?;
412 self.backend.perform_action(&session.handles[index], &found.name)?;
413 self.settle();
414 Ok(())
415 }
416
417 pub fn scroll(&mut self, app: &str, index: usize, direction: ScrollDirection, pages: f64) -> Result<()> {
426 if !pages.is_finite() || pages <= 0.0 {
427 return Err(Error::invalid("pages must be a positive number"));
428 }
429 let session = self.session_for(app)?;
430 let element = element(session, app, index)?;
431 let by_page = format!("Scroll {} By Page", direction.as_str());
432 if pages.fract() == 0.0
433 && pages <= 100.0
434 && let Some(action) = element.action(&by_page)
435 {
436 for _ in 0..whole_pages(pages) {
437 self.backend.perform_action(&session.handles[index], &action.name)?;
438 }
439 self.settle();
440 return Ok(());
441 }
442 let frame = element
443 .frame
444 .filter(|f| !f.is_empty())
445 .ok_or_else(|| Error::StateUnavailable(format!("element {index} has no scrollable frame")))?;
446 let (dx, dy) = wheel_delta(frame, direction, pages);
447 let input = input_target(&session.state, self.delivery);
448 self.backend.scroll(input, frame.center(), dx, dy)?;
449 self.settle();
450 Ok(())
451 }
452
453 pub fn scroll_at(&mut self, app: &str, target: ClickTarget, direction: ScrollDirection, pages: f64) -> Result<()> {
464 let (x, y, screen) = match target {
465 ClickTarget::Element(index) => return self.scroll(app, index, direction, pages),
466 ClickTarget::Pixel { x, y } => (x, y, false),
467 ClickTarget::Screen { x, y } => (x, y, true),
468 };
469 if !pages.is_finite() || pages <= 0.0 {
470 return Err(Error::invalid("pages must be a positive number"));
471 }
472 let state = self.session_for(app).ok().map(|s| &s.state);
473 let page_frame = |point: Point| {
474 state.and_then(|s| scroll_area_at(s, point).or(Some(s.window.frame))).filter(|f| !f.is_empty())
475 };
476 let delivery = self.delivery;
477 self.with_pointer(app, (x, y), screen, delivery, |backend, input, point| {
478 let frame = page_frame(point).unwrap_or(Rect::new(0.0, 0.0, FALLBACK_PAGE, FALLBACK_PAGE));
479 let (dx, dy) = wheel_delta(frame, direction, pages);
480 backend.scroll(input, point, dx, dy)
481 })
482 }
483
484 pub fn drag(&mut self, app: &str, from: (f64, f64), to: (f64, f64)) -> Result<()> {
490 let session = self.session_for(app)?;
491 let start = screenshot_point(&session.state, from.0, from.1)?;
492 let end = screenshot_point(&session.state, to.0, to.1)?;
493 let input = input_target(&session.state, self.delivery);
494 self.backend.drag(input, start, end)?;
495 self.settle();
496 Ok(())
497 }
498
499 pub fn drag_screen(&mut self, app: &str, from: Point, to: Point) -> Result<()> {
505 let delivery = self.delivery;
506 self.with_pointer(app, (from.x, from.y), true, delivery, |backend, input, start| backend.drag(input, start, to))
507 }
508
509 pub fn type_text(&mut self, app: &str, text: &str) -> Result<()> {
518 if text.is_empty() {
519 return Ok(());
520 }
521 let (info, window) = self.input_context(app)?;
522 if self.delivery != Delivery::Global && self.backend.insert_text(&info, text)? {
523 self.settle();
524 return Ok(());
525 }
526 let target = InputTarget { app: &info, window: window.as_ref(), delivery: self.delivery };
527 self.backend.type_text(target, text)?;
528 self.settle();
529 Ok(())
530 }
531
532 pub fn press_key(&mut self, app: &str, key: &str) -> Result<()> {
538 let chord: KeyChord = key.parse()?;
539 let (info, window) = self.input_context(app)?;
540 let target = InputTarget { app: &info, window: window.as_ref(), delivery: self.delivery };
541 self.backend.press_key(target, &chord)?;
542 self.settle();
543 Ok(())
544 }
545
546 pub fn set_value(&mut self, app: &str, index: usize, value: &str) -> Result<()> {
552 let session = self.session_for(app)?;
553 element(session, app, index)?;
554 self.backend.set_value(&session.handles[index], value)?;
555 self.settle();
556 Ok(())
557 }
558
559 pub fn select_text(&mut self, app: &str, index: usize, selection: &TextSelection) -> Result<()> {
565 let session = self.session_for(app)?;
566 let cached = element(session, app, index)?;
567 let handle = &session.handles[index];
568 let value = match self.backend.value(handle) {
569 Ok(Some(value)) => value,
570 Ok(None) => return Err(Error::StateUnavailable(format!("element {index} has no text value"))),
571 Err(Error::Unsupported(_)) => cached
572 .value
573 .clone()
574 .ok_or_else(|| Error::StateUnavailable(format!("element {index} has no text value")))?,
575 Err(e) => return Err(e),
576 };
577 let range = selection.resolve(&value)?;
578 self.backend.select_text(handle, &value, range)?;
579 self.settle();
580 Ok(())
581 }
582
583 fn with_pointer(
587 &self,
588 app: &str,
589 (x, y): (f64, f64),
590 screen: bool,
591 delivery: Delivery,
592 act: impl FnOnce(&B, InputTarget<'_>, Point) -> Result<()>,
593 ) -> Result<()> {
594 if screen {
595 if !x.is_finite() || !y.is_finite() {
596 return Err(Error::invalid(format!("({x}, {y}) is not a screen point")));
597 }
598 let (info, window) = self.input_context(app)?;
599 act(&self.backend, InputTarget { app: &info, window: window.as_ref(), delivery }, Point::new(x, y))?;
600 } else {
601 let session = self.session_for(app)?;
602 let point = screenshot_point(&session.state, x, y)?;
603 act(&self.backend, input_target(&session.state, delivery), point)?;
604 }
605 self.settle();
606 Ok(())
607 }
608
609 fn window_by_title(&self, app: &AppInfo, title: &str) -> Result<u64> {
610 let wanted = title.trim().to_lowercase();
611 let windows = self.backend.list_windows(app)?;
612 let titled = |w: &&WindowInfo| w.id.is_some() && w.title.is_some();
613 let lower = |w: &WindowInfo| w.title.as_deref().unwrap_or_default().to_lowercase();
614 let mut matches: Vec<&WindowInfo> = windows.iter().filter(titled).filter(|w| lower(w) == wanted).collect();
615 if matches.is_empty() {
616 matches = windows.iter().filter(titled).filter(|w| lower(w).contains(&wanted)).collect();
617 }
618 match matches.as_slice() {
619 [] => Err(Error::WindowNotFound(format!("{} (no window titled {title:?})", app.name))),
620 [window] => window.id.ok_or_else(|| Error::WindowNotFound(app.name.clone())),
621 _ => Err(Error::AmbiguousWindow {
622 title: title.to_owned(),
623 candidates: matches.into_iter().cloned().collect(),
624 }),
625 }
626 }
627
628 fn running_app(&self, query: &str, launch: bool) -> Result<AppInfo> {
629 let info = self.resolve_app(query)?;
630 if info.is_running {
631 Ok(info)
632 } else if launch {
633 self.backend.launch_app(&info)
634 } else {
635 Err(Error::AppNotFound(format!("{query} is not running")))
636 }
637 }
638
639 fn input_context(&self, app: &str) -> Result<(AppInfo, Option<WindowInfo>)> {
640 match self.session_for(app) {
641 Ok(session) => Ok((session.state.app.clone(), Some(session.state.window.clone()))),
642 Err(Error::NoAppState(_)) => Ok((self.running_app(app, false)?, None)),
643 Err(error) => Err(error),
644 }
645 }
646
647 fn session_key(&self, app: &str) -> Result<String> {
649 let mut matches: Vec<_> = self
650 .sessions
651 .iter()
652 .filter_map(|(key, s)| Some((s.state.app.match_score(app)?, key, &s.state.app)))
653 .collect();
654 matches.sort_by_key(|(score, _, info)| (*score, info.pid));
655 let best: Vec<_> = matches.iter().take_while(|(score, ..)| *score == matches[0].0).collect();
656 match best.as_slice() {
657 [] => Err(Error::NoAppState(app.to_owned())),
658 [(_, key, _)] => Ok((*key).clone()),
659 _ => Err(Error::AmbiguousApp {
660 query: app.to_owned(),
661 candidates: best.iter().map(|(_, _, info)| (*info).clone()).collect(),
662 }),
663 }
664 }
665
666 fn session_for(&self, app: &str) -> Result<&Session<B::Handle>> {
667 let key = self.session_key(app)?;
668 Ok(&self.sessions[&key])
669 }
670
671 fn session_for_mut(&mut self, app: &str) -> Result<&mut Session<B::Handle>> {
672 let key = self.session_key(app)?;
673 self.sessions.get_mut(&key).ok_or_else(|| Error::NoAppState(app.to_owned()))
674 }
675
676 fn settle(&self) {
677 if !self.settle_delay.is_zero() {
678 thread::sleep(self.settle_delay);
679 }
680 }
681}
682
683fn element<'a, H>(session: &'a Session<H>, app: &str, index: usize) -> Result<&'a Element> {
684 session.state.element(index).ok_or_else(|| Error::ElementNotFound { app: app.to_owned(), index })
685}
686
687fn equivalent_action(element: &Element, options: ClickOptions) -> Option<&crate::element::Action> {
688 match (options.button, options.count) {
689 (MouseButton::Left, 1) => element.primary_action(),
690 (MouseButton::Right, 1) => element.action("Show Menu"),
691 _ => None,
692 }
693}
694
695fn screenshot_point(state: &AppState, x: f64, y: f64) -> Result<Point> {
696 let shot = state.screenshot.as_ref().ok_or_else(|| {
697 Error::StateUnavailable("no screenshot in the latest state; capture one before coordinate actions".into())
698 })?;
699 if !shot.contains_pixel(x, y) {
700 return Err(Error::invalid(format!("({x}, {y}) is outside the {}x{} screenshot", shot.width(), shot.height())));
701 }
702 Ok(shot.to_screen_point(x, y))
703}
704
705const FALLBACK_PAGE: f64 = 500.0;
707
708fn wheel_delta(frame: Rect, direction: ScrollDirection, pages: f64) -> (f64, f64) {
710 let page_x = (frame.size.width * 0.8).max(40.0);
711 let page_y = (frame.size.height * 0.8).max(40.0);
712 match direction {
713 ScrollDirection::Up => (0.0, -page_y * pages),
714 ScrollDirection::Down => (0.0, page_y * pages),
715 ScrollDirection::Left => (-page_x * pages, 0.0),
716 ScrollDirection::Right => (page_x * pages, 0.0),
717 }
718}
719
720fn scroll_area_at(state: &AppState, point: Point) -> Option<Rect> {
722 state
723 .elements
724 .iter()
725 .filter(|e| e.role.contains("scroll") && !e.role.contains("bar"))
726 .filter_map(|e| e.frame)
727 .filter(|f| !f.is_empty() && f.contains(point))
728 .min_by(|a, b| a.area().total_cmp(&b.area()))
729}
730
731const fn input_target(state: &AppState, delivery: Delivery) -> InputTarget<'_> {
732 InputTarget { app: &state.app, window: Some(&state.window), delivery }
733}
734
735#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
736const fn whole_pages(pages: f64) -> u32 {
737 pages as u32
738}