Skip to main content

robost_input/
lib.rs

1use enigo::{Axis, Button, Enigo, Key, Keyboard, Mouse, Settings};
2use robost_template::ScreenPoint;
3use thiserror::Error;
4use tracing::instrument;
5
6#[derive(Debug, Error)]
7pub enum InputError {
8    #[error("enigo error: {0}")]
9    Enigo(#[from] enigo::NewConError),
10    #[error("input send error: {0}")]
11    Send(String),
12    #[error("window focus error: {0}")]
13    Focus(String),
14}
15
16pub type Result<T> = std::result::Result<T, InputError>;
17
18/// Scroll direction for `InputController::scroll`.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ScrollDir {
21    Up,
22    Down,
23    Left,
24    Right,
25}
26
27pub struct InputController {
28    enigo: Enigo,
29}
30
31impl InputController {
32    pub fn new() -> Result<Self> {
33        Ok(Self {
34            enigo: Enigo::new(&Settings::default())?,
35        })
36    }
37
38    /// Move mouse and left-click at `point` (screen-global coords).
39    #[instrument(name = "click", fields(x = point.x, y = point.y), skip(self))]
40    pub fn click(&mut self, point: ScreenPoint) -> Result<()> {
41        self.move_to(point)?;
42        self.enigo
43            .button(Button::Left, enigo::Direction::Click)
44            .map_err(|e| InputError::Send(e.to_string()))?;
45        Ok(())
46    }
47
48    /// Move mouse and right-click at `point`.
49    #[instrument(name = "right_click", fields(x = point.x, y = point.y), skip(self))]
50    pub fn right_click(&mut self, point: ScreenPoint) -> Result<()> {
51        self.move_to(point)?;
52        self.enigo
53            .button(Button::Right, enigo::Direction::Click)
54            .map_err(|e| InputError::Send(e.to_string()))?;
55        Ok(())
56    }
57
58    /// Move mouse and double-click at `point`.
59    #[instrument(name = "double_click", fields(x = point.x, y = point.y), skip(self))]
60    pub fn double_click(&mut self, point: ScreenPoint) -> Result<()> {
61        self.move_to(point)?;
62        self.enigo
63            .button(Button::Left, enigo::Direction::Click)
64            .map_err(|e| InputError::Send(e.to_string()))?;
65        self.enigo
66            .button(Button::Left, enigo::Direction::Click)
67            .map_err(|e| InputError::Send(e.to_string()))?;
68        Ok(())
69    }
70
71    /// Move the mouse cursor to `point` (screen-global coords).
72    #[instrument(name = "move_mouse", fields(x = point.x, y = point.y), skip(self))]
73    pub fn move_mouse(&mut self, point: ScreenPoint) -> Result<()> {
74        self.move_to(point)
75    }
76
77    /// Click and drag from `from` to `to`, holding the button for `hold_ms`.
78    #[instrument(name = "drag", fields(fx = from.x, fy = from.y, tx = to.x, ty = to.y), skip(self))]
79    pub fn drag(&mut self, from: ScreenPoint, to: ScreenPoint, hold_ms: u64) -> Result<()> {
80        self.move_to(from)?;
81        self.enigo
82            .button(Button::Left, enigo::Direction::Press)
83            .map_err(|e| InputError::Send(e.to_string()))?;
84        if hold_ms > 0 {
85            std::thread::sleep(std::time::Duration::from_millis(hold_ms));
86        }
87        self.move_to(to)?;
88        self.enigo
89            .button(Button::Left, enigo::Direction::Release)
90            .map_err(|e| InputError::Send(e.to_string()))?;
91        Ok(())
92    }
93
94    /// Scroll in the given direction by `amount` units.
95    #[instrument(name = "scroll", fields(?direction, amount), skip(self))]
96    pub fn scroll(&mut self, direction: ScrollDir, amount: i32) -> Result<()> {
97        let (axis, length) = match direction {
98            ScrollDir::Up => (Axis::Vertical, -amount),
99            ScrollDir::Down => (Axis::Vertical, amount),
100            ScrollDir::Left => (Axis::Horizontal, -amount),
101            ScrollDir::Right => (Axis::Horizontal, amount),
102        };
103        self.enigo
104            .scroll(length, axis)
105            .map_err(|e| InputError::Send(e.to_string()))
106    }
107
108    fn move_to(&mut self, point: ScreenPoint) -> Result<()> {
109        self.enigo
110            .move_mouse(point.x, point.y, enigo::Coordinate::Abs)
111            .map_err(|e| InputError::Send(e.to_string()))
112    }
113
114    /// Type a plain-text string.
115    #[instrument(name = "type_text", skip(self, text))]
116    pub fn type_text(&mut self, text: &str) -> Result<()> {
117        self.enigo
118            .text(text)
119            .map_err(|e| InputError::Send(e.to_string()))?;
120        Ok(())
121    }
122
123    /// Press a single key (tap: down + up).
124    #[instrument(name = "press_key", fields(?key), skip(self))]
125    pub fn press_key(&mut self, key: Key) -> Result<()> {
126        self.enigo
127            .key(key, enigo::Direction::Click)
128            .map_err(|e| InputError::Send(e.to_string()))?;
129        Ok(())
130    }
131
132    /// Press a key combination.
133    /// All keys except the last are held as modifiers (Press); the last is clicked
134    /// (Click = down + up); then modifiers are released in reverse order.
135    #[instrument(name = "key_combo", fields(?keys), skip(self))]
136    pub fn key_combo(&mut self, keys: &[Key]) -> Result<()> {
137        if keys.is_empty() {
138            return Ok(());
139        }
140        let (modifiers, tail) = keys.split_at(keys.len() - 1);
141        let main = tail[0];
142
143        for &m in modifiers {
144            self.enigo
145                .key(m, enigo::Direction::Press)
146                .map_err(|e| InputError::Send(e.to_string()))?;
147        }
148        self.enigo
149            .key(main, enigo::Direction::Click)
150            .map_err(|e| InputError::Send(e.to_string()))?;
151        for &m in modifiers.iter().rev() {
152            self.enigo
153                .key(m, enigo::Direction::Release)
154                .map_err(|e| InputError::Send(e.to_string()))?;
155        }
156        Ok(())
157    }
158
159    /// Bring the window whose title contains `title` to the foreground,
160    /// then perform `action`. Ensures input reaches the right window.
161    pub fn with_focus<F, T>(&mut self, title: &str, action: F) -> Result<T>
162    where
163        F: FnOnce(&mut Self) -> Result<T>,
164    {
165        focus_window(title)?;
166        action(self)
167    }
168}
169
170/// Perform a window management action (focus, maximize, minimize, close).
171///
172/// `action` must be one of `"focus"`, `"maximize"`, `"minimize"`, `"close"`.
173/// Uses platform-specific APIs: Win32 on Windows, AppleScript on macOS, wmctrl on Linux.
174pub fn control_window(title: &str, action: &str) -> Result<()> {
175    #[cfg(windows)]
176    {
177        windows_control(title, action)
178    }
179    #[cfg(target_os = "macos")]
180    {
181        macos_control(title, action)
182    }
183    #[cfg(all(not(windows), not(target_os = "macos")))]
184    {
185        linux_control(title, action)
186    }
187}
188
189/// Platform-specific window focus implementation.
190fn focus_window(title: &str) -> Result<()> {
191    #[cfg(windows)]
192    {
193        windows_focus(title)
194    }
195    #[cfg(target_os = "macos")]
196    {
197        macos_focus(title)
198    }
199    #[cfg(all(not(windows), not(target_os = "macos")))]
200    {
201        linux_focus(title)
202    }
203}
204
205#[cfg(windows)]
206fn windows_focus(title: &str) -> Result<()> {
207    windows_control(title, "focus")
208}
209
210#[cfg(windows)]
211fn windows_control(title: &str, action: &str) -> Result<()> {
212    use windows::core::PCWSTR;
213    use windows::Win32::Foundation::{LPARAM, WPARAM};
214    use windows::Win32::UI::WindowsAndMessaging::{
215        FindWindowW, PostMessageW, SetForegroundWindow, ShowWindow, SW_MAXIMIZE, SW_MINIMIZE,
216        WM_CLOSE,
217    };
218
219    let wide: Vec<u16> = title.encode_utf16().chain(std::iter::once(0)).collect();
220    let hwnd = unsafe { FindWindowW(PCWSTR::null(), PCWSTR(wide.as_ptr())) }
221        .map_err(|_| InputError::Focus(format!("window not found: {title}")))?;
222    match action {
223        "focus" => {
224            let _ = unsafe { SetForegroundWindow(hwnd) };
225        }
226        "maximize" => {
227            let _ = unsafe { ShowWindow(hwnd, SW_MAXIMIZE) };
228        }
229        "minimize" => {
230            let _ = unsafe { ShowWindow(hwnd, SW_MINIMIZE) };
231        }
232        "close" => {
233            unsafe { PostMessageW(Some(hwnd), WM_CLOSE, WPARAM(0), LPARAM(0)) }
234                .map_err(|e| InputError::Focus(e.to_string()))?;
235        }
236        other => return Err(InputError::Focus(format!("unknown window action: {other}"))),
237    }
238    Ok(())
239}
240
241#[cfg(target_os = "macos")]
242fn macos_focus(title: &str) -> Result<()> {
243    macos_control(title, "focus")
244}
245
246#[cfg(target_os = "macos")]
247fn macos_control(title: &str, action: &str) -> Result<()> {
248    let script = match action {
249        "focus" => format!(
250            r#"tell application "System Events"
251                set frontApp to first application process whose (name of windows) contains "{title}"
252                set frontmost of frontApp to true
253            end tell"#
254        ),
255        "maximize" => format!(
256            r#"tell application "System Events"
257                set frontApp to first application process whose (name of windows) contains "{title}"
258                set frontmost of frontApp to true
259                tell window 1 of frontApp to set zoomed to true
260            end tell"#
261        ),
262        "minimize" => format!(
263            r#"tell application "System Events"
264                set frontApp to first application process whose (name of windows) contains "{title}"
265                tell window 1 of frontApp to set miniaturized to true
266            end tell"#
267        ),
268        "close" => format!(
269            r#"tell application "System Events"
270                set frontApp to first application process whose (name of windows) contains "{title}"
271                tell window 1 of frontApp to close
272            end tell"#
273        ),
274        other => return Err(InputError::Focus(format!("unknown window action: {other}"))),
275    };
276    let status = std::process::Command::new("osascript")
277        .arg("-e")
278        .arg(&script)
279        .status()
280        .map_err(|e| InputError::Focus(e.to_string()))?;
281    if !status.success() {
282        return Err(InputError::Focus(format!(
283            "osascript failed: {action} on '{title}'"
284        )));
285    }
286    Ok(())
287}
288
289#[cfg(all(not(windows), not(target_os = "macos")))]
290fn linux_focus(title: &str) -> Result<()> {
291    linux_control(title, "focus")
292}
293
294#[cfg(all(not(windows), not(target_os = "macos")))]
295fn linux_control(title: &str, action: &str) -> Result<()> {
296    // Requires wmctrl to be installed (apt install wmctrl).
297    let args: &[&str] = match action {
298        "focus" => &["-a", title],
299        "maximize" => &["-r", title, "-b", "add,maximized_vert,maximized_horz"],
300        "minimize" => &["-r", title, "-b", "add,hidden"],
301        "close" => &["-c", title],
302        other => return Err(InputError::Focus(format!("unknown window action: {other}"))),
303    };
304    let status = std::process::Command::new("wmctrl")
305        .args(args)
306        .status()
307        .map_err(|e| InputError::Focus(e.to_string()))?;
308    if !status.success() {
309        return Err(InputError::Focus(format!(
310            "wmctrl failed: {action} on '{title}'"
311        )));
312    }
313    Ok(())
314}