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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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
170pub 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
189fn 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 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}