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;
10use crate::keys::KeyChord;
11use crate::screenshot::Screenshot;
12use crate::state::{AppState, CaptureOptions, ElementQuery};
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}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
32#[non_exhaustive]
33pub enum ClickMethod {
34 #[default]
37 Auto,
38 Accessibility,
40 Background,
42 FocusedBackground,
45 Global,
47}
48
49impl std::str::FromStr for ClickMethod {
50 type Err = Error;
51
52 fn from_str(s: &str) -> Result<Self> {
53 match s.trim().to_ascii_lowercase().as_str() {
54 "auto" => Ok(Self::Auto),
55 "accessibility" | "ax" => Ok(Self::Accessibility),
56 "background" => Ok(Self::Background),
57 "focused_background" | "focused-background" => Ok(Self::FocusedBackground),
58 "global" => Ok(Self::Global),
59 other => Err(Error::invalid(format!("unknown click method {other:?}"))),
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct ClickOptions {
67 pub button: MouseButton,
69 pub count: u32,
71 pub method: ClickMethod,
73}
74
75impl Default for ClickOptions {
76 fn default() -> Self {
77 Self { button: MouseButton::Left, count: 1, method: ClickMethod::Auto }
78 }
79}
80
81impl ClickOptions {
82 #[must_use]
84 pub const fn button(mut self, button: MouseButton) -> Self {
85 self.button = button;
86 self
87 }
88
89 #[must_use]
91 pub const fn count(mut self, count: u32) -> Self {
92 self.count = count;
93 self
94 }
95
96 #[must_use]
98 pub const fn method(mut self, method: ClickMethod) -> Self {
99 self.method = method;
100 self
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106#[non_exhaustive]
107pub enum ClickOutcome {
108 Action(String),
110 Pointer(Delivery),
112}
113
114struct Session<H> {
115 state: AppState,
116 handles: Vec<H>,
117}
118
119pub struct Computer<B: Backend> {
143 backend: B,
144 sessions: HashMap<String, Session<B::Handle>>,
145 aliases: HashMap<String, String>,
146 delivery: Delivery,
147 settle_delay: Duration,
148}
149
150impl<B: Backend> std::fmt::Debug for Computer<B> {
151 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 f.debug_struct("Computer")
153 .field("sessions", &self.sessions.keys().collect::<Vec<_>>())
154 .field("delivery", &self.delivery)
155 .field("settle_delay", &self.settle_delay)
156 .finish_non_exhaustive()
157 }
158}
159
160#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
161impl Computer<crate::NativeBackend> {
162 pub fn new() -> Result<Self> {
168 Ok(Self::with_backend(crate::NativeBackend::new()?))
169 }
170}
171
172impl<B: Backend> Computer<B> {
173 #[must_use]
175 pub fn with_backend(backend: B) -> Self {
176 Self {
177 backend,
178 sessions: HashMap::new(),
179 aliases: HashMap::new(),
180 delivery: Delivery::Background,
181 settle_delay: Duration::ZERO,
182 }
183 }
184
185 #[must_use]
187 pub const fn delivery(mut self, delivery: Delivery) -> Self {
188 self.delivery = delivery;
189 self
190 }
191
192 #[must_use]
194 pub const fn settle_delay(mut self, delay: Duration) -> Self {
195 self.settle_delay = delay;
196 self
197 }
198
199 pub const fn backend(&self) -> &B {
201 &self.backend
202 }
203
204 pub fn permissions(&self) -> Permissions {
206 self.backend.permissions()
207 }
208
209 pub fn request_permissions(&self) -> Permissions {
211 self.backend.request_permissions()
212 }
213
214 pub fn list_apps(&self) -> Result<Vec<AppInfo>> {
220 let mut apps = self.backend.list_apps()?;
221 apps.sort_by(|a, b| {
222 (!a.is_running, !a.is_active, a.name.to_lowercase()).cmp(&(
223 !b.is_running,
224 !b.is_active,
225 b.name.to_lowercase(),
226 ))
227 });
228 Ok(apps)
229 }
230
231 pub fn resolve_app(&self, query: &str) -> Result<AppInfo> {
237 let apps = self.backend.list_apps()?;
238 app::best_match(&apps, query).cloned().ok_or_else(|| Error::AppNotFound(query.to_owned()))
239 }
240
241 pub fn get_app_state(&mut self, app: &str, options: &CaptureOptions) -> Result<&AppState> {
248 let info = self.running_app(app, options.launch_if_needed)?;
249 let request =
250 CaptureRequest::new(options.include_tree, options.include_screenshot, options.max_nodes, options.max_depth);
251 let capture = self.backend.capture(&info, &request)?;
252 let (elements, handles, raw_to_element) = build_tree(capture.nodes, options.text_limit);
253 let focused = capture.focused.and_then(|raw| raw_to_element.get(raw).copied().flatten());
254 let screenshot = capture
255 .screenshot
256 .map(|image| Screenshot::encode(image, capture.window.frame, options.max_screenshot_dimension))
257 .transpose()?;
258 let state = AppState {
259 app: info,
260 window: capture.window,
261 elements,
262 truncated: capture.truncated,
263 tree_included: options.include_tree,
264 focused,
265 selected_text: capture.selected_text.map(|s| crate::element::sanitize(&s, options.text_limit)),
266 screenshot,
267 captured_at: SystemTime::now(),
268 notes: capture.notes,
269 };
270 let key = self.remember(app, &state.app);
271 let session = Session { state, handles };
272 Ok(&self.sessions.entry(key).insert_entry(session).into_mut().state)
273 }
274
275 #[must_use]
277 pub fn state(&self, app: &str) -> Option<&AppState> {
278 self.session_for(app).ok().map(|s| &s.state)
279 }
280
281 pub fn forget(&mut self, app: &str) {
283 if let Some(key) = self.aliases.remove(&alias_key(app)) {
284 self.sessions.remove(&key);
285 self.aliases.retain(|_, v| *v != key);
286 }
287 }
288
289 pub fn clear(&mut self) {
291 self.sessions.clear();
292 self.aliases.clear();
293 }
294
295 pub fn query(&mut self, app: &str, query: &ElementQuery, max_nodes: usize) -> Result<Vec<Element>> {
304 if query.text.as_deref().is_none_or(str::is_empty) && query.role.as_deref().is_none_or(str::is_empty) {
305 return Err(Error::invalid("query requires text and/or role"));
306 }
307 let previous = self.session_for(app).ok().map(|s| (s.state.window.frame, s.state.screenshot.clone()));
308 let options = CaptureOptions::default().include_screenshot(false).max_nodes(max_nodes).launch_if_needed(false);
309 let state = self.get_app_state(app, &options)?;
310 let matches: Vec<Element> = state.find(query).cloned().collect();
311 let frame = state.window.frame;
312 if let Some((prev_frame, Some(shot))) = previous
313 && prev_frame == frame
314 && let Ok(session) = self.session_for_mut(app)
315 {
316 session.state.screenshot = Some(shot);
317 }
318 Ok(matches)
319 }
320
321 pub fn click(&mut self, app: &str, target: ClickTarget, options: ClickOptions) -> Result<ClickOutcome> {
327 if options.count == 0 {
328 return Err(Error::invalid("click count must be at least 1"));
329 }
330 let session = self.session_for(app)?;
331 let point = match target {
332 ClickTarget::Element(index) => {
333 let element = element(session, app, index)?;
334 if matches!(options.method, ClickMethod::Auto | ClickMethod::Accessibility)
335 && let Some(action) = equivalent_action(element, options)
336 {
337 let name = action.name.clone();
338 self.backend.perform_action(&session.handles[index], &name)?;
339 self.settle();
340 return Ok(ClickOutcome::Action(name));
341 }
342 if options.method == ClickMethod::Accessibility {
343 return Err(Error::action(format!(
344 "element {index} has no accessibility action for a {:?} click",
345 options.button
346 )));
347 }
348 element
349 .frame
350 .filter(|f| !f.is_empty())
351 .map(|f| f.center())
352 .ok_or_else(|| Error::StateUnavailable(format!("element {index} has no on-screen frame")))?
353 }
354 ClickTarget::Pixel { x, y } => {
355 if options.method == ClickMethod::Accessibility {
356 return Err(Error::invalid("accessibility clicks require an element target"));
357 }
358 screenshot_point(&session.state, x, y)?
359 }
360 };
361 let delivery = match options.method {
362 ClickMethod::Global => Delivery::Global,
363 ClickMethod::Background => Delivery::Background,
364 ClickMethod::FocusedBackground => Delivery::FocusedBackground,
365 ClickMethod::Auto | ClickMethod::Accessibility => self.delivery,
366 };
367 let input = input_target(&session.state, delivery);
368 self.backend.click(input, point, options.button, options.count)?;
369 self.settle();
370 Ok(ClickOutcome::Pointer(delivery))
371 }
372
373 pub fn perform_secondary_action(&mut self, app: &str, index: usize, action: &str) -> Result<()> {
379 let session = self.session_for(app)?;
380 let element = element(session, app, index)?;
381 let found = element.action(action).ok_or_else(|| {
382 let available: Vec<&str> = element.actions.iter().map(|a| a.label.as_str()).collect();
383 Error::invalid(format!(
384 "{action:?} is not a valid action for element {index}; available: [{}]",
385 available.join(", ")
386 ))
387 })?;
388 self.backend.perform_action(&session.handles[index], &found.name)?;
389 self.settle();
390 Ok(())
391 }
392
393 pub fn scroll(&mut self, app: &str, index: usize, direction: ScrollDirection, pages: f64) -> Result<()> {
402 if !pages.is_finite() || pages <= 0.0 {
403 return Err(Error::invalid("pages must be a positive number"));
404 }
405 let session = self.session_for(app)?;
406 let element = element(session, app, index)?;
407 let by_page = format!("Scroll {} By Page", direction.as_str());
408 if pages.fract() == 0.0
409 && pages <= 100.0
410 && let Some(action) = element.action(&by_page)
411 {
412 for _ in 0..whole_pages(pages) {
413 self.backend.perform_action(&session.handles[index], &action.name)?;
414 }
415 self.settle();
416 return Ok(());
417 }
418 let frame = element
419 .frame
420 .filter(|f| !f.is_empty())
421 .ok_or_else(|| Error::StateUnavailable(format!("element {index} has no scrollable frame")))?;
422 let page_x = (frame.size.width * 0.8).max(40.0);
423 let page_y = (frame.size.height * 0.8).max(40.0);
424 let (dx, dy) = match direction {
425 ScrollDirection::Up => (0.0, -page_y * pages),
426 ScrollDirection::Down => (0.0, page_y * pages),
427 ScrollDirection::Left => (-page_x * pages, 0.0),
428 ScrollDirection::Right => (page_x * pages, 0.0),
429 };
430 let input = input_target(&session.state, self.delivery);
431 self.backend.scroll(input, frame.center(), dx, dy)?;
432 self.settle();
433 Ok(())
434 }
435
436 pub fn drag(&mut self, app: &str, from: (f64, f64), to: (f64, f64)) -> Result<()> {
442 let session = self.session_for(app)?;
443 let start = screenshot_point(&session.state, from.0, from.1)?;
444 let end = screenshot_point(&session.state, to.0, to.1)?;
445 let input = input_target(&session.state, self.delivery);
446 self.backend.drag(input, start, end)?;
447 self.settle();
448 Ok(())
449 }
450
451 pub fn type_text(&mut self, app: &str, text: &str) -> Result<()> {
460 if text.is_empty() {
461 return Ok(());
462 }
463 let (info, window) = self.input_context(app)?;
464 if self.delivery != Delivery::Global && self.backend.insert_text(&info, text)? {
465 self.settle();
466 return Ok(());
467 }
468 let target = InputTarget { app: &info, window: window.as_ref(), delivery: self.delivery };
469 self.backend.type_text(target, text)?;
470 self.settle();
471 Ok(())
472 }
473
474 pub fn press_key(&mut self, app: &str, key: &str) -> Result<()> {
480 let chord: KeyChord = key.parse()?;
481 let (info, window) = self.input_context(app)?;
482 let target = InputTarget { app: &info, window: window.as_ref(), delivery: self.delivery };
483 self.backend.press_key(target, &chord)?;
484 self.settle();
485 Ok(())
486 }
487
488 pub fn set_value(&mut self, app: &str, index: usize, value: &str) -> Result<()> {
494 let session = self.session_for(app)?;
495 element(session, app, index)?;
496 self.backend.set_value(&session.handles[index], value)?;
497 self.settle();
498 Ok(())
499 }
500
501 pub fn select_text(&mut self, app: &str, index: usize, selection: &TextSelection) -> Result<()> {
507 let session = self.session_for(app)?;
508 let cached = element(session, app, index)?;
509 let handle = &session.handles[index];
510 let value = match self.backend.value(handle) {
511 Ok(Some(value)) => value,
512 Ok(None) => return Err(Error::StateUnavailable(format!("element {index} has no text value"))),
513 Err(Error::Unsupported(_)) => cached
514 .value
515 .clone()
516 .ok_or_else(|| Error::StateUnavailable(format!("element {index} has no text value")))?,
517 Err(e) => return Err(e),
518 };
519 let range = selection.resolve(&value)?;
520 self.backend.select_text(handle, &value, range)?;
521 self.settle();
522 Ok(())
523 }
524
525 fn running_app(&self, query: &str, launch: bool) -> Result<AppInfo> {
526 let info = self.resolve_app(query)?;
527 if info.is_running {
528 Ok(info)
529 } else if launch {
530 self.backend.launch_app(&info)
531 } else {
532 Err(Error::AppNotFound(format!("{query} is not running")))
533 }
534 }
535
536 fn input_context(&self, app: &str) -> Result<(AppInfo, Option<crate::state::WindowInfo>)> {
537 if let Ok(session) = self.session_for(app) {
538 return Ok((session.state.app.clone(), Some(session.state.window.clone())));
539 }
540 Ok((self.running_app(app, false)?, None))
541 }
542
543 fn remember(&mut self, query: &str, info: &AppInfo) -> String {
544 let key = info.pid.map_or_else(|| info.reference().to_lowercase(), |pid| format!("pid:{pid}"));
545 self.aliases.insert(alias_key(query), key.clone());
546 self.aliases.insert(alias_key(info.reference()), key.clone());
547 self.aliases.insert(alias_key(&info.name), key.clone());
548 if let Some(pid) = info.pid {
549 self.aliases.insert(format!("pid:{pid}"), key.clone());
550 }
551 key
552 }
553
554 fn session_for(&self, app: &str) -> Result<&Session<B::Handle>> {
555 self.aliases
556 .get(&alias_key(app))
557 .and_then(|key| self.sessions.get(key))
558 .ok_or_else(|| Error::NoAppState(app.to_owned()))
559 }
560
561 fn session_for_mut(&mut self, app: &str) -> Result<&mut Session<B::Handle>> {
562 self.aliases
563 .get(&alias_key(app))
564 .and_then(|key| self.sessions.get_mut(key))
565 .ok_or_else(|| Error::NoAppState(app.to_owned()))
566 }
567
568 fn settle(&self) {
569 if !self.settle_delay.is_zero() {
570 thread::sleep(self.settle_delay);
571 }
572 }
573}
574
575fn alias_key(query: &str) -> String {
576 query.trim().to_lowercase()
577}
578
579fn element<'a, H>(session: &'a Session<H>, app: &str, index: usize) -> Result<&'a Element> {
580 session.state.element(index).ok_or_else(|| Error::ElementNotFound { app: app.to_owned(), index })
581}
582
583fn equivalent_action(element: &Element, options: ClickOptions) -> Option<&crate::element::Action> {
584 match (options.button, options.count) {
585 (MouseButton::Left, 1) => element.primary_action(),
586 (MouseButton::Right, 1) => element.action("Show Menu"),
587 _ => None,
588 }
589}
590
591fn screenshot_point(state: &AppState, x: f64, y: f64) -> Result<Point> {
592 let shot = state.screenshot.as_ref().ok_or_else(|| {
593 Error::StateUnavailable("no screenshot in the latest state; capture one before coordinate actions".into())
594 })?;
595 if !shot.contains_pixel(x, y) {
596 return Err(Error::invalid(format!("({x}, {y}) is outside the {}x{} screenshot", shot.width(), shot.height())));
597 }
598 Ok(shot.to_screen_point(x, y))
599}
600
601const fn input_target(state: &AppState, delivery: Delivery) -> InputTarget<'_> {
602 InputTarget { app: &state.app, window: Some(&state.window), delivery }
603}
604
605#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
606const fn whole_pages(pages: f64) -> u32 {
607 pages as u32
608}