Skip to main content

computer_use/
computer.rs

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/// What to click.
16#[derive(Debug, Clone, Copy, PartialEq)]
17#[non_exhaustive]
18pub enum ClickTarget {
19    /// An element index from the latest [`AppState`].
20    Element(usize),
21    /// A pixel position in the latest screenshot.
22    Pixel {
23        /// Horizontal pixel.
24        x: f64,
25        /// Vertical pixel.
26        y: f64,
27    },
28    /// A point in screen coordinates (top-left origin). Needs no earlier capture.
29    Screen {
30        /// Horizontal screen point.
31        x: f64,
32        /// Vertical screen point.
33        y: f64,
34    },
35}
36
37/// How a click is performed.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
39#[non_exhaustive]
40pub enum ClickMethod {
41    /// Use the element's accessibility action when that is equivalent, otherwise synthesize
42    /// pointer events with the computer's default [`Delivery`].
43    #[default]
44    Auto,
45    /// Only use accessibility actions; requires an element target.
46    Accessibility,
47    /// Synthesize pointer events delivered to the app in the background.
48    Background,
49    /// Like `Background`, with the target window given synthetic focus first
50    /// (see [`Delivery::FocusedBackground`]).
51    FocusedBackground,
52    /// Synthesize pointer events through the global input stream.
53    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/// Options for [`Computer::click`].
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct ClickOptions {
74    /// Button to press.
75    pub button: MouseButton,
76    /// Number of clicks (2 for a double click).
77    pub count: u32,
78    /// Strategy.
79    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    /// Sets the button.
90    #[must_use]
91    pub const fn button(mut self, button: MouseButton) -> Self {
92        self.button = button;
93        self
94    }
95
96    /// Sets the click count.
97    #[must_use]
98    pub const fn count(mut self, count: u32) -> Self {
99        self.count = count;
100        self
101    }
102
103    /// Sets the method.
104    #[must_use]
105    pub const fn method(mut self, method: ClickMethod) -> Self {
106        self.method = method;
107        self
108    }
109}
110
111/// How a click was ultimately delivered.
112#[derive(Debug, Clone, PartialEq, Eq)]
113#[non_exhaustive]
114pub enum ClickOutcome {
115    /// An accessibility action was invoked.
116    Action(String),
117    /// Pointer events were synthesized.
118    Pointer(Delivery),
119}
120
121struct Session<H> {
122    state: AppState,
123    handles: Vec<H>,
124    /// The window explicitly requested for this state, if any.
125    window: Option<u64>,
126}
127
128/// A computer-use session over a [`Backend`].
129///
130/// Observe an app with [`get_app_state`](Self::get_app_state), then act on it by element index or by
131/// screenshot pixel. It remembers the latest state per app so indexes stay valid until the
132/// next capture of that app.
133///
134/// ```no_run
135/// # #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
136/// # fn main() -> computer_use::Result<()> {
137/// use computer_use::{CaptureOptions, ClickOptions, ClickTarget, Computer};
138///
139/// let mut computer = Computer::new()?;
140/// let state = computer.get_app_state("TextEdit", &CaptureOptions::default())?;
141/// println!("{}", state.render());
142///
143/// computer.click("TextEdit", ClickTarget::Element(3), ClickOptions::default())?;
144/// computer.type_text("TextEdit", "Hello from Rust")?;
145/// computer.press_key("TextEdit", "super+s")?;
146/// # Ok(())
147/// # }
148/// # #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
149/// # fn main() {}
150/// ```
151pub 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    /// Creates a session over this platform's native backend.
171    ///
172    /// # Errors
173    ///
174    /// Fails if the platform's accessibility stack cannot be initialized.
175    pub fn new() -> Result<Self> {
176        Ok(Self::with_backend(crate::NativeBackend::new()?))
177    }
178}
179
180impl<B: Backend> Computer<B> {
181    /// Creates a session over a custom backend.
182    #[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    /// Sets the default input delivery for synthesized events.
188    #[must_use]
189    pub const fn delivery(mut self, delivery: Delivery) -> Self {
190        self.delivery = delivery;
191        self
192    }
193
194    /// Sleeps this long after every action so the UI can settle before the next capture.
195    #[must_use]
196    pub const fn settle_delay(mut self, delay: Duration) -> Self {
197        self.settle_delay = delay;
198        self
199    }
200
201    /// The underlying backend, for platform-specific calls.
202    pub const fn backend(&self) -> &B {
203        &self.backend
204    }
205
206    /// Permissions currently held by this process.
207    pub fn permissions(&self) -> Permissions {
208        self.backend.permissions()
209    }
210
211    /// Prompts for missing permissions where the OS supports it.
212    pub fn request_permissions(&self) -> Permissions {
213        self.backend.request_permissions()
214    }
215
216    /// Lists running apps first (frontmost first), then installed apps, by name.
217    ///
218    /// # Errors
219    ///
220    /// Fails if the platform cannot enumerate apps.
221    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    /// Resolves an app query (name, bundle ID, path, or `pid:<n>`), preferring running apps.
234    ///
235    /// # Errors
236    ///
237    /// Returns [`Error::AppNotFound`] when nothing matches, or [`Error::AmbiguousApp`] when several
238    /// running apps match equally well.
239    pub fn resolve_app(&self, query: &str) -> Result<AppInfo> {
240        app::resolve(&self.backend.list_apps()?, query)
241    }
242
243    /// Captures the app's current window, replacing any earlier state (and element indexes)
244    /// for that app.
245    ///
246    /// # Errors
247    ///
248    /// Fails if the app cannot be found or launched, has no window, or permissions are missing.
249    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    /// The app's windows, front to back where the platform knows the order. Pass one to
285    /// [`CaptureOptions::window`] to capture it instead of the focused or main window.
286    ///
287    /// # Errors
288    ///
289    /// Fails if the app is not running or the backend cannot list windows.
290    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    /// The latest captured state of an app, if any.
296    #[must_use]
297    pub fn state(&self, app: &str) -> Option<&AppState> {
298        self.session_for(app).ok().map(|s| &s.state)
299    }
300
301    /// Discards the remembered state of one app.
302    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    /// Discards all remembered state.
309    pub fn clear(&mut self) {
310        self.sessions.clear();
311    }
312
313    /// Finds elements in the app's current window without taking a screenshot.
314    ///
315    /// The fresh tree replaces the app's remembered state, so the returned indexes can be used
316    /// with element actions. A previous screenshot is kept when the window has not moved.
317    ///
318    /// # Errors
319    ///
320    /// Fails like [`get_app_state`](Self::get_app_state) or if the query is empty.
321    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        // Stay on the window the last capture chose, if it was one other than the default.
329        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    /// Clicks an element, a screenshot pixel, or a screen point.
343    ///
344    /// # Errors
345    ///
346    /// Fails if there is no state for the app, the target is invalid, or delivery fails.
347    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    /// Invokes a named accessibility action on an element (e.g. `Show Menu`, `AXRaise`).
398    ///
399    /// # Errors
400    ///
401    /// Fails if the element does not expose the action.
402    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    /// Scrolls an element by a number of pages (fractions allowed).
418    ///
419    /// Whole pages use the element's own scroll-by-page action when it has one; otherwise
420    /// wheel events are posted at the element's center.
421    ///
422    /// # Errors
423    ///
424    /// Fails if `pages` is not positive or the element has no frame.
425    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    /// Scrolls at an element, a screenshot pixel, or a screen point, by a number of pages
454    /// (fractions allowed).
455    ///
456    /// An element target behaves like [`scroll`](Self::scroll). For a point, wheel events are
457    /// posted there; a page is 80% of the scroll area under the point when the latest state
458    /// has one, else of the captured window, else 400 points.
459    ///
460    /// # Errors
461    ///
462    /// Fails if `pages` is not positive, a pixel lies outside the screenshot, or delivery fails.
463    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    /// Drags between two screenshot pixels.
485    ///
486    /// # Errors
487    ///
488    /// Fails if there is no screenshot or a point lies outside it.
489    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    /// Drags between two screen points. Needs no earlier capture.
500    ///
501    /// # Errors
502    ///
503    /// Fails if the app is not running or delivery fails.
504    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    /// Types literal text into the app's focused element.
510    ///
511    /// With background delivery, text is inserted through accessibility when the focused
512    /// element supports it, and synthesized as keystrokes otherwise.
513    ///
514    /// # Errors
515    ///
516    /// Fails if the app is not running or delivery fails.
517    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    /// Presses a key chord in xdotool syntax, e.g. `Return`, `super+c`, `KP_0`.
533    ///
534    /// # Errors
535    ///
536    /// Fails if the chord cannot be parsed or delivered.
537    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    /// Sets the value of a settable element.
547    ///
548    /// # Errors
549    ///
550    /// Fails if the element's value is read-only.
551    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    /// Selects text inside a text element, or places the caret before or after it.
560    ///
561    /// # Errors
562    ///
563    /// Fails if the text is missing or ambiguous, or the element does not support selection.
564    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    /// Resolves a screenshot pixel (or a screen point when `screen`) and runs a pointer action
584    /// there, then settles. Screen points use the remembered window when there is one, and
585    /// need no capture otherwise.
586    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    /// The observed app that best matches `app`; several equally good matches are ambiguous.
648    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
705/// Points per page when nothing tells how large the scrolled area is.
706const FALLBACK_PAGE: f64 = 500.0;
707
708/// The wheel delta for scrolling `frame` by `pages`: a page is 80% of the frame (at least 40).
709fn 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
720/// The frame of the innermost scroll container under `point`.
721fn 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}