use anyhow::Result;
use pixelactions_core::flow::Axis;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Button {
Left,
}
pub trait Injector {
fn move_to(&mut self, x: f64, y: f64) -> Result<()>;
fn click(&mut self, button: Button) -> Result<()>;
fn double_click(&mut self, button: Button) -> Result<()>;
fn press(&mut self, button: Button) -> Result<()>;
fn release(&mut self, button: Button) -> Result<()>;
fn text(&mut self, text: &str) -> Result<()>;
fn chord(&mut self, chord: &str) -> Result<()>;
fn scroll(&mut self, amount: i32, axis: Axis) -> Result<()>;
fn cursor(&mut self) -> Result<(f64, f64)>;
fn probe(&mut self) -> Result<()>;
}
#[cfg(test)]
#[derive(Debug, Clone, PartialEq)]
pub struct Recording {
pub events: Vec<String>,
pub cursor: (f64, f64),
}
#[cfg(test)]
impl Default for Recording {
fn default() -> Self {
Self {
events: Vec::new(),
cursor: (400.0, 300.0),
}
}
}
#[cfg(test)]
impl Injector for Recording {
fn move_to(&mut self, x: f64, y: f64) -> Result<()> {
self.events.push(format!("move {x:.0},{y:.0}"));
Ok(())
}
fn click(&mut self, _button: Button) -> Result<()> {
self.events.push("click".into());
Ok(())
}
fn double_click(&mut self, _button: Button) -> Result<()> {
self.events.push("double_click".into());
Ok(())
}
fn press(&mut self, _button: Button) -> Result<()> {
self.events.push("press".into());
Ok(())
}
fn release(&mut self, _button: Button) -> Result<()> {
self.events.push("release".into());
Ok(())
}
fn text(&mut self, text: &str) -> Result<()> {
self.events.push(format!("text {text}"));
Ok(())
}
fn chord(&mut self, chord: &str) -> Result<()> {
self.events.push(format!("chord {chord}"));
Ok(())
}
fn scroll(&mut self, amount: i32, axis: Axis) -> Result<()> {
let way = match axis {
Axis::Vertical => "v",
Axis::Horizontal => "h",
};
self.events.push(format!("scroll {way}{amount}"));
Ok(())
}
fn cursor(&mut self) -> Result<(f64, f64)> {
Ok(self.cursor)
}
fn probe(&mut self) -> Result<()> {
self.events.push("probe".into());
Ok(())
}
}
#[cfg(target_os = "macos")]
pub use platform::RealInjector;
#[cfg(target_os = "macos")]
mod platform {
use anyhow::{Context, Result, anyhow};
use enigo::{
Axis as EnigoAxis, Button as EnigoButton, Coordinate, Direction, Enigo, Key, Keyboard,
Mouse, Settings,
};
use pixelactions_core::flow::Axis;
use super::{Button, Injector};
pub struct RealInjector {
enigo: Enigo,
}
impl RealInjector {
pub fn new() -> Result<Self> {
let enigo = Enigo::new(&Settings::default()).map_err(|e| {
anyhow!(
"cannot synthesize input: {e}. On macOS this usually means the \
Accessibility permission is missing — grant it under System Settings \
> Privacy & Security > Accessibility for the terminal running \
pixelactions, then try again"
)
})?;
Ok(Self { enigo })
}
}
fn to_enigo(button: Button) -> EnigoButton {
match button {
Button::Left => EnigoButton::Left,
}
}
fn key_for(token: &str) -> Result<Key> {
let key = match token.to_ascii_lowercase().as_str() {
"cmd" | "command" | "meta" | "super" => Key::Meta,
"ctrl" | "control" => Key::Control,
"alt" | "option" | "opt" => Key::Alt,
"shift" => Key::Shift,
"tab" => Key::Tab,
"enter" | "return" => Key::Return,
"esc" | "escape" => Key::Escape,
"space" => Key::Space,
"backspace" | "delete" => Key::Backspace,
"up" => Key::UpArrow,
"down" => Key::DownArrow,
"left" => Key::LeftArrow,
"right" => Key::RightArrow,
other => {
let mut chars = other.chars();
let first = chars.next().context("empty key in chord")?;
if chars.next().is_some() {
return Err(anyhow!(
"unknown key {other:?} in chord — use a single character or a \
named key (cmd, ctrl, alt, shift, tab, enter, esc, space, \
backspace, arrows)"
));
}
Key::Unicode(first)
}
};
Ok(key)
}
impl Injector for RealInjector {
fn move_to(&mut self, x: f64, y: f64) -> Result<()> {
self.enigo
.move_mouse(x as i32, y as i32, Coordinate::Abs)
.map_err(|e| anyhow!("move to ({x:.0}, {y:.0}) failed: {e}"))
}
fn click(&mut self, button: Button) -> Result<()> {
self.enigo
.button(to_enigo(button), Direction::Click)
.map_err(|e| anyhow!("click failed: {e}"))
}
fn double_click(&mut self, button: Button) -> Result<()> {
self.click(button)?;
std::thread::sleep(std::time::Duration::from_millis(40));
self.click(button)
}
fn press(&mut self, button: Button) -> Result<()> {
self.enigo
.button(to_enigo(button), Direction::Press)
.map_err(|e| anyhow!("press failed: {e}"))
}
fn release(&mut self, button: Button) -> Result<()> {
self.enigo
.button(to_enigo(button), Direction::Release)
.map_err(|e| anyhow!("release failed: {e}"))
}
fn text(&mut self, text: &str) -> Result<()> {
self.enigo
.text(text)
.map_err(|e| anyhow!("typing failed: {e}"))
}
fn scroll(&mut self, amount: i32, axis: Axis) -> Result<()> {
let axis = match axis {
Axis::Vertical => EnigoAxis::Vertical,
Axis::Horizontal => EnigoAxis::Horizontal,
};
self.enigo
.scroll(amount, axis)
.map_err(|e| anyhow!("cannot scroll: {e}"))
}
fn cursor(&mut self) -> Result<(f64, f64)> {
let (x, y) = self
.enigo
.location()
.map_err(|e| anyhow!("cannot read the cursor position: {e}"))?;
Ok((f64::from(x), f64::from(y)))
}
fn probe(&mut self) -> Result<()> {
let (x, y) = self
.enigo
.location()
.map_err(|e| anyhow!("cannot read the cursor position: {e}"))?;
let target = (x + 1, y);
self.enigo
.move_mouse(target.0, target.1, Coordinate::Abs)
.map_err(|e| anyhow!("cannot move the cursor: {e}"))?;
std::thread::sleep(std::time::Duration::from_millis(60));
let after = self
.enigo
.location()
.map_err(|e| anyhow!("cannot read the cursor position: {e}"))?;
let _ = self.enigo.move_mouse(x, y, Coordinate::Abs);
if after == (x, y) {
return Err(anyhow!(
"the cursor did not move — macOS accepted the event and discarded it, \
which is what happens without the Accessibility permission. Grant it \
under System Settings > Privacy & Security > Accessibility for the \
application running pixelactions (your terminal, if you launched it \
from one), then quit and reopen that application"
));
}
Ok(())
}
fn chord(&mut self, chord: &str) -> Result<()> {
let (modifiers, key) = pixelactions_core::chord::split(chord)?;
let mut held = Vec::new();
for token in &modifiers {
let modifier = key_for(token)?;
self.enigo
.key(modifier, Direction::Press)
.map_err(|e| anyhow!("holding {token} failed: {e}"))?;
held.push(modifier);
}
let result = self
.enigo
.key(key_for(key)?, Direction::Click)
.map_err(|e| anyhow!("pressing {key} failed: {e}"));
for modifier in held.into_iter().rev() {
let _ = self.enigo.key(modifier, Direction::Release);
}
result
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_recording_injector_captures_order() {
let mut injector = Recording::default();
injector.move_to(10.4, 20.6).expect("recorded");
injector.click(Button::Left).expect("recorded");
injector.text("hi").expect("recorded");
assert_eq!(injector.events, vec!["move 10,21", "click", "text hi"]);
}
}