use std::collections::HashMap;
use std::thread;
use std::time::{Duration, SystemTime};
use crate::app::{self, AppInfo};
use crate::backend::{Backend, CaptureRequest, Delivery, InputTarget, MouseButton, Permissions, ScrollDirection};
use crate::element::{Element, build_tree};
use crate::error::{Error, Result};
use crate::geometry::{Point, Rect};
use crate::keys::KeyChord;
use crate::screenshot::Screenshot;
use crate::state::{AppState, CaptureOptions, ElementQuery, WindowInfo, WindowSelector};
use crate::text::TextSelection;
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum ClickTarget {
Element(usize),
Pixel {
x: f64,
y: f64,
},
Screen {
x: f64,
y: f64,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum ClickMethod {
#[default]
Auto,
Accessibility,
Background,
FocusedBackground,
Global,
}
impl std::str::FromStr for ClickMethod {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"accessibility" | "ax" => Ok(Self::Accessibility),
"background" => Ok(Self::Background),
"focused_background" | "focused-background" => Ok(Self::FocusedBackground),
"global" => Ok(Self::Global),
other => Err(Error::invalid(format!("unknown click method {other:?}"))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClickOptions {
pub button: MouseButton,
pub count: u32,
pub method: ClickMethod,
}
impl Default for ClickOptions {
fn default() -> Self {
Self { button: MouseButton::Left, count: 1, method: ClickMethod::Auto }
}
}
impl ClickOptions {
#[must_use]
pub const fn button(mut self, button: MouseButton) -> Self {
self.button = button;
self
}
#[must_use]
pub const fn count(mut self, count: u32) -> Self {
self.count = count;
self
}
#[must_use]
pub const fn method(mut self, method: ClickMethod) -> Self {
self.method = method;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ClickOutcome {
Action(String),
Pointer(Delivery),
}
struct Session<H> {
state: AppState,
handles: Vec<H>,
window: Option<u64>,
}
pub struct Computer<B: Backend> {
backend: B,
sessions: HashMap<String, Session<B::Handle>>,
aliases: HashMap<String, String>,
delivery: Delivery,
settle_delay: Duration,
}
impl<B: Backend> std::fmt::Debug for Computer<B> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Computer")
.field("sessions", &self.sessions.keys().collect::<Vec<_>>())
.field("delivery", &self.delivery)
.field("settle_delay", &self.settle_delay)
.finish_non_exhaustive()
}
}
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
impl Computer<crate::NativeBackend> {
pub fn new() -> Result<Self> {
Ok(Self::with_backend(crate::NativeBackend::new()?))
}
}
impl<B: Backend> Computer<B> {
#[must_use]
pub fn with_backend(backend: B) -> Self {
Self {
backend,
sessions: HashMap::new(),
aliases: HashMap::new(),
delivery: Delivery::Background,
settle_delay: Duration::ZERO,
}
}
#[must_use]
pub const fn delivery(mut self, delivery: Delivery) -> Self {
self.delivery = delivery;
self
}
#[must_use]
pub const fn settle_delay(mut self, delay: Duration) -> Self {
self.settle_delay = delay;
self
}
pub const fn backend(&self) -> &B {
&self.backend
}
pub fn permissions(&self) -> Permissions {
self.backend.permissions()
}
pub fn request_permissions(&self) -> Permissions {
self.backend.request_permissions()
}
pub fn list_apps(&self) -> Result<Vec<AppInfo>> {
let mut apps = self.backend.list_apps()?;
apps.sort_by(|a, b| {
(!a.is_running, !a.is_active, a.name.to_lowercase()).cmp(&(
!b.is_running,
!b.is_active,
b.name.to_lowercase(),
))
});
Ok(apps)
}
pub fn resolve_app(&self, query: &str) -> Result<AppInfo> {
let apps = self.backend.list_apps()?;
app::best_match(&apps, query).cloned().ok_or_else(|| Error::AppNotFound(query.to_owned()))
}
pub fn get_app_state(&mut self, app: &str, options: &CaptureOptions) -> Result<&AppState> {
let info = self.running_app(app, options.launch_if_needed)?;
let window = match &options.window {
None => None,
Some(WindowSelector::Id(id)) => Some(*id),
Some(WindowSelector::Title(title)) => Some(self.window_by_title(&info, title)?),
};
let request =
CaptureRequest::new(options.include_tree, options.include_screenshot, options.max_nodes, options.max_depth)
.with_window(window)
.with_menu_bar(options.include_menu_bar);
let capture = self.backend.capture(&info, &request)?;
let (elements, handles, raw_to_element) = build_tree(capture.nodes, options.text_limit);
let focused = capture.focused.and_then(|raw| raw_to_element.get(raw).copied().flatten());
let screenshot = capture
.screenshot
.map(|image| Screenshot::encode(image, capture.window.frame, options.max_screenshot_dimension))
.transpose()?;
let state = AppState {
app: info,
window: capture.window,
elements,
truncated: capture.truncated,
tree_included: options.include_tree,
focused,
selected_text: capture.selected_text.map(|s| crate::element::sanitize(&s, options.text_limit)),
screenshot,
captured_at: SystemTime::now(),
notes: capture.notes,
};
let key = self.remember(app, &state.app);
let session = Session { state, handles, window };
Ok(&self.sessions.entry(key).insert_entry(session).into_mut().state)
}
pub fn list_windows(&self, app: &str) -> Result<Vec<WindowInfo>> {
let info = self.running_app(app, false)?;
self.backend.list_windows(&info)
}
#[must_use]
pub fn state(&self, app: &str) -> Option<&AppState> {
self.session_for(app).ok().map(|s| &s.state)
}
pub fn forget(&mut self, app: &str) {
if let Some(key) = self.aliases.remove(&alias_key(app)) {
self.sessions.remove(&key);
self.aliases.retain(|_, v| *v != key);
}
}
pub fn clear(&mut self) {
self.sessions.clear();
self.aliases.clear();
}
pub fn query(&mut self, app: &str, query: &ElementQuery, max_nodes: usize) -> Result<Vec<Element>> {
if query.text.as_deref().is_none_or(str::is_empty) && query.role.as_deref().is_none_or(str::is_empty) {
return Err(Error::invalid("query requires text and/or role"));
}
let previous = self.session_for(app).ok().map(|s| (s.state.window.frame, s.state.screenshot.clone()));
let mut options =
CaptureOptions::default().include_screenshot(false).max_nodes(max_nodes).launch_if_needed(false);
options.window = self.session_for(app).ok().and_then(|s| s.window.map(WindowSelector::Id));
let state = self.get_app_state(app, &options)?;
let matches: Vec<Element> = state.find(query).cloned().collect();
let frame = state.window.frame;
if let Some((prev_frame, Some(shot))) = previous
&& prev_frame == frame
&& let Ok(session) = self.session_for_mut(app)
{
session.state.screenshot = Some(shot);
}
Ok(matches)
}
pub fn click(&mut self, app: &str, target: ClickTarget, options: ClickOptions) -> Result<ClickOutcome> {
if options.count == 0 {
return Err(Error::invalid("click count must be at least 1"));
}
let delivery = match options.method {
ClickMethod::Global => Delivery::Global,
ClickMethod::Background => Delivery::Background,
ClickMethod::FocusedBackground => Delivery::FocusedBackground,
ClickMethod::Auto | ClickMethod::Accessibility => self.delivery,
};
let index = match target {
ClickTarget::Element(index) => index,
ClickTarget::Pixel { x, y } | ClickTarget::Screen { x, y } => {
if options.method == ClickMethod::Accessibility {
return Err(Error::invalid("accessibility clicks require an element target"));
}
let screen = matches!(target, ClickTarget::Screen { .. });
self.with_pointer(app, (x, y), screen, delivery, |backend, input, point| {
backend.click(input, point, options.button, options.count)
})?;
return Ok(ClickOutcome::Pointer(delivery));
}
};
let session = self.session_for(app)?;
let element = element(session, app, index)?;
if matches!(options.method, ClickMethod::Auto | ClickMethod::Accessibility)
&& let Some(action) = equivalent_action(element, options)
{
let name = action.name.clone();
self.backend.perform_action(&session.handles[index], &name)?;
self.settle();
return Ok(ClickOutcome::Action(name));
}
if options.method == ClickMethod::Accessibility {
return Err(Error::action(format!(
"element {index} has no accessibility action for a {:?} click",
options.button
)));
}
let point = element
.frame
.filter(|f| !f.is_empty())
.map(|f| f.center())
.ok_or_else(|| Error::StateUnavailable(format!("element {index} has no on-screen frame")))?;
let input = input_target(&session.state, delivery);
self.backend.click(input, point, options.button, options.count)?;
self.settle();
Ok(ClickOutcome::Pointer(delivery))
}
pub fn perform_secondary_action(&mut self, app: &str, index: usize, action: &str) -> Result<()> {
let session = self.session_for(app)?;
let element = element(session, app, index)?;
let found = element.action(action).ok_or_else(|| {
let available: Vec<&str> = element.actions.iter().map(|a| a.label.as_str()).collect();
Error::invalid(format!(
"{action:?} is not a valid action for element {index}; available: [{}]",
available.join(", ")
))
})?;
self.backend.perform_action(&session.handles[index], &found.name)?;
self.settle();
Ok(())
}
pub fn scroll(&mut self, app: &str, index: usize, direction: ScrollDirection, pages: f64) -> Result<()> {
if !pages.is_finite() || pages <= 0.0 {
return Err(Error::invalid("pages must be a positive number"));
}
let session = self.session_for(app)?;
let element = element(session, app, index)?;
let by_page = format!("Scroll {} By Page", direction.as_str());
if pages.fract() == 0.0
&& pages <= 100.0
&& let Some(action) = element.action(&by_page)
{
for _ in 0..whole_pages(pages) {
self.backend.perform_action(&session.handles[index], &action.name)?;
}
self.settle();
return Ok(());
}
let frame = element
.frame
.filter(|f| !f.is_empty())
.ok_or_else(|| Error::StateUnavailable(format!("element {index} has no scrollable frame")))?;
let (dx, dy) = wheel_delta(frame, direction, pages);
let input = input_target(&session.state, self.delivery);
self.backend.scroll(input, frame.center(), dx, dy)?;
self.settle();
Ok(())
}
pub fn scroll_at(&mut self, app: &str, target: ClickTarget, direction: ScrollDirection, pages: f64) -> Result<()> {
let (x, y, screen) = match target {
ClickTarget::Element(index) => return self.scroll(app, index, direction, pages),
ClickTarget::Pixel { x, y } => (x, y, false),
ClickTarget::Screen { x, y } => (x, y, true),
};
if !pages.is_finite() || pages <= 0.0 {
return Err(Error::invalid("pages must be a positive number"));
}
let state = self.session_for(app).ok().map(|s| &s.state);
let page_frame = |point: Point| {
state.and_then(|s| scroll_area_at(s, point).or(Some(s.window.frame))).filter(|f| !f.is_empty())
};
let delivery = self.delivery;
self.with_pointer(app, (x, y), screen, delivery, |backend, input, point| {
let frame = page_frame(point).unwrap_or(Rect::new(0.0, 0.0, FALLBACK_PAGE, FALLBACK_PAGE));
let (dx, dy) = wheel_delta(frame, direction, pages);
backend.scroll(input, point, dx, dy)
})
}
pub fn drag(&mut self, app: &str, from: (f64, f64), to: (f64, f64)) -> Result<()> {
let session = self.session_for(app)?;
let start = screenshot_point(&session.state, from.0, from.1)?;
let end = screenshot_point(&session.state, to.0, to.1)?;
let input = input_target(&session.state, self.delivery);
self.backend.drag(input, start, end)?;
self.settle();
Ok(())
}
pub fn drag_screen(&mut self, app: &str, from: Point, to: Point) -> Result<()> {
let delivery = self.delivery;
self.with_pointer(app, (from.x, from.y), true, delivery, |backend, input, start| backend.drag(input, start, to))
}
pub fn type_text(&mut self, app: &str, text: &str) -> Result<()> {
if text.is_empty() {
return Ok(());
}
let (info, window) = self.input_context(app)?;
if self.delivery != Delivery::Global && self.backend.insert_text(&info, text)? {
self.settle();
return Ok(());
}
let target = InputTarget { app: &info, window: window.as_ref(), delivery: self.delivery };
self.backend.type_text(target, text)?;
self.settle();
Ok(())
}
pub fn press_key(&mut self, app: &str, key: &str) -> Result<()> {
let chord: KeyChord = key.parse()?;
let (info, window) = self.input_context(app)?;
let target = InputTarget { app: &info, window: window.as_ref(), delivery: self.delivery };
self.backend.press_key(target, &chord)?;
self.settle();
Ok(())
}
pub fn set_value(&mut self, app: &str, index: usize, value: &str) -> Result<()> {
let session = self.session_for(app)?;
element(session, app, index)?;
self.backend.set_value(&session.handles[index], value)?;
self.settle();
Ok(())
}
pub fn select_text(&mut self, app: &str, index: usize, selection: &TextSelection) -> Result<()> {
let session = self.session_for(app)?;
let cached = element(session, app, index)?;
let handle = &session.handles[index];
let value = match self.backend.value(handle) {
Ok(Some(value)) => value,
Ok(None) => return Err(Error::StateUnavailable(format!("element {index} has no text value"))),
Err(Error::Unsupported(_)) => cached
.value
.clone()
.ok_or_else(|| Error::StateUnavailable(format!("element {index} has no text value")))?,
Err(e) => return Err(e),
};
let range = selection.resolve(&value)?;
self.backend.select_text(handle, &value, range)?;
self.settle();
Ok(())
}
fn with_pointer(
&self,
app: &str,
(x, y): (f64, f64),
screen: bool,
delivery: Delivery,
act: impl FnOnce(&B, InputTarget<'_>, Point) -> Result<()>,
) -> Result<()> {
if screen {
if !x.is_finite() || !y.is_finite() {
return Err(Error::invalid(format!("({x}, {y}) is not a screen point")));
}
let (info, window) = self.input_context(app)?;
act(&self.backend, InputTarget { app: &info, window: window.as_ref(), delivery }, Point::new(x, y))?;
} else {
let session = self.session_for(app)?;
let point = screenshot_point(&session.state, x, y)?;
act(&self.backend, input_target(&session.state, delivery), point)?;
}
self.settle();
Ok(())
}
fn window_by_title(&self, app: &AppInfo, title: &str) -> Result<u64> {
let wanted = title.trim().to_lowercase();
let windows = self.backend.list_windows(app)?;
let titled = |w: &&WindowInfo| w.id.is_some() && w.title.is_some();
let lower = |w: &WindowInfo| w.title.as_deref().unwrap_or_default().to_lowercase();
windows
.iter()
.filter(titled)
.find(|w| lower(w) == wanted)
.or_else(|| windows.iter().filter(titled).find(|w| lower(w).contains(&wanted)))
.and_then(|w| w.id)
.ok_or_else(|| Error::WindowNotFound(format!("{} (no window titled {title:?})", app.name)))
}
fn running_app(&self, query: &str, launch: bool) -> Result<AppInfo> {
let info = self.resolve_app(query)?;
if info.is_running {
Ok(info)
} else if launch {
self.backend.launch_app(&info)
} else {
Err(Error::AppNotFound(format!("{query} is not running")))
}
}
fn input_context(&self, app: &str) -> Result<(AppInfo, Option<WindowInfo>)> {
if let Ok(session) = self.session_for(app) {
return Ok((session.state.app.clone(), Some(session.state.window.clone())));
}
Ok((self.running_app(app, false)?, None))
}
fn remember(&mut self, query: &str, info: &AppInfo) -> String {
let key = info.pid.map_or_else(|| info.reference().to_lowercase(), |pid| format!("pid:{pid}"));
self.aliases.insert(alias_key(query), key.clone());
self.aliases.insert(alias_key(info.reference()), key.clone());
self.aliases.insert(alias_key(&info.name), key.clone());
if let Some(pid) = info.pid {
self.aliases.insert(format!("pid:{pid}"), key.clone());
}
key
}
fn session_for(&self, app: &str) -> Result<&Session<B::Handle>> {
self.aliases
.get(&alias_key(app))
.and_then(|key| self.sessions.get(key))
.ok_or_else(|| Error::NoAppState(app.to_owned()))
}
fn session_for_mut(&mut self, app: &str) -> Result<&mut Session<B::Handle>> {
self.aliases
.get(&alias_key(app))
.and_then(|key| self.sessions.get_mut(key))
.ok_or_else(|| Error::NoAppState(app.to_owned()))
}
fn settle(&self) {
if !self.settle_delay.is_zero() {
thread::sleep(self.settle_delay);
}
}
}
fn alias_key(query: &str) -> String {
query.trim().to_lowercase()
}
fn element<'a, H>(session: &'a Session<H>, app: &str, index: usize) -> Result<&'a Element> {
session.state.element(index).ok_or_else(|| Error::ElementNotFound { app: app.to_owned(), index })
}
fn equivalent_action(element: &Element, options: ClickOptions) -> Option<&crate::element::Action> {
match (options.button, options.count) {
(MouseButton::Left, 1) => element.primary_action(),
(MouseButton::Right, 1) => element.action("Show Menu"),
_ => None,
}
}
fn screenshot_point(state: &AppState, x: f64, y: f64) -> Result<Point> {
let shot = state.screenshot.as_ref().ok_or_else(|| {
Error::StateUnavailable("no screenshot in the latest state; capture one before coordinate actions".into())
})?;
if !shot.contains_pixel(x, y) {
return Err(Error::invalid(format!("({x}, {y}) is outside the {}x{} screenshot", shot.width(), shot.height())));
}
Ok(shot.to_screen_point(x, y))
}
const FALLBACK_PAGE: f64 = 500.0;
fn wheel_delta(frame: Rect, direction: ScrollDirection, pages: f64) -> (f64, f64) {
let page_x = (frame.size.width * 0.8).max(40.0);
let page_y = (frame.size.height * 0.8).max(40.0);
match direction {
ScrollDirection::Up => (0.0, -page_y * pages),
ScrollDirection::Down => (0.0, page_y * pages),
ScrollDirection::Left => (-page_x * pages, 0.0),
ScrollDirection::Right => (page_x * pages, 0.0),
}
}
fn scroll_area_at(state: &AppState, point: Point) -> Option<Rect> {
state
.elements
.iter()
.filter(|e| e.role.contains("scroll") && !e.role.contains("bar"))
.filter_map(|e| e.frame)
.filter(|f| !f.is_empty() && f.contains(point))
.min_by(|a, b| a.area().total_cmp(&b.area()))
}
const fn input_target(state: &AppState, delivery: Delivery) -> InputTarget<'_> {
InputTarget { app: &state.app, window: Some(&state.window), delivery }
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
const fn whole_pages(pages: f64) -> u32 {
pages as u32
}