use std::thread::sleep;
use std::time::Duration;
use anyhow::{Context, Result};
use evdev::{
uinput::VirtualDevice, AbsInfo, AbsoluteAxisCode, AttributeSet, EventType, InputEvent, KeyCode,
PropType, UinputAbsSetup,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[must_use = "pointer input may be clamped; inspect requested and emitted coordinates"]
pub(crate) struct PointerLanding {
pub(crate) requested: (i32, i32),
pub(crate) emitted: (i32, i32),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct AbsPointerGeometry {
max_x: i32,
max_y: i32,
}
impl AbsPointerGeometry {
fn from_dimensions(width: i32, height: i32) -> Self {
Self {
max_x: width.max(1).saturating_sub(1),
max_y: height.max(1).saturating_sub(1),
}
}
fn axis_maxima(self) -> (i32, i32) {
(self.max_x, self.max_y)
}
fn matches_dimensions(self, width: i32, height: i32) -> bool {
self == Self::from_dimensions(width, height)
}
fn clamp_coordinates(self, x: i32, y: i32) -> (i32, i32) {
(x.clamp(0, self.max_x), y.clamp(0, self.max_y))
}
fn landing_for(self, x: i32, y: i32) -> PointerLanding {
PointerLanding {
requested: (x, y),
emitted: self.clamp_coordinates(x, y),
}
}
}
pub struct AbsPointer {
device: VirtualDevice,
geometry: AbsPointerGeometry,
}
impl AbsPointer {
pub fn create(width: i32, height: i32) -> Result<Self> {
let geometry = AbsPointerGeometry::from_dimensions(width, height);
let (max_x, max_y) = geometry.axis_maxima();
let abs_x =
UinputAbsSetup::new(AbsoluteAxisCode::ABS_X, AbsInfo::new(0, 0, max_x, 0, 0, 1));
let abs_y =
UinputAbsSetup::new(AbsoluteAxisCode::ABS_Y, AbsInfo::new(0, 0, max_y, 0, 0, 1));
let keys =
AttributeSet::from_iter([KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE]);
let props = AttributeSet::from_iter([PropType::DIRECT]);
let device = VirtualDevice::builder()
.context("uinput builder (is /dev/uinput writable?)")?
.name("computer-use-linux absolute pointer")
.with_properties(&props)?
.with_absolute_axis(&abs_x)?
.with_absolute_axis(&abs_y)?
.with_keys(&keys)?
.build()
.context("failed to create uinput absolute pointer device")?;
sleep(Duration::from_millis(500));
Ok(Self { device, geometry })
}
pub fn matches_dimensions(&self, width: i32, height: i32) -> bool {
self.geometry.matches_dimensions(width, height)
}
pub fn move_to(&mut self, x: i32, y: i32) -> Result<PointerLanding> {
let landing = self.geometry.landing_for(x, y);
let (emitted_x, emitted_y) = landing.emitted;
self.device
.emit(&[
InputEvent::new_now(EventType::ABSOLUTE.0, AbsoluteAxisCode::ABS_X.0, emitted_x),
InputEvent::new_now(EventType::ABSOLUTE.0, AbsoluteAxisCode::ABS_Y.0, emitted_y),
])
.context("failed to emit absolute motion")?;
Ok(landing)
}
pub fn click(
&mut self,
x: i32,
y: i32,
button: PointerButton,
count: u32,
) -> Result<PointerLanding> {
let landing = self.move_to(x, y)?;
sleep(Duration::from_millis(30));
let code = button.key_code();
for _ in 0..count.max(1) {
self.device
.emit(&[InputEvent::new_now(EventType::KEY.0, code, 1)])?;
sleep(Duration::from_millis(30));
self.device
.emit(&[InputEvent::new_now(EventType::KEY.0, code, 0)])?;
sleep(Duration::from_millis(40));
}
Ok(landing)
}
pub fn drag(
&mut self,
start: (i32, i32),
end: (i32, i32),
button: PointerButton,
) -> Result<()> {
let code = button.key_code();
let _start_landing = self.move_to(start.0, start.1)?;
sleep(Duration::from_millis(30));
self.device
.emit(&[InputEvent::new_now(EventType::KEY.0, code, 1)])?;
sleep(Duration::from_millis(40));
let end_landing = self.move_to(end.0, end.1);
sleep(Duration::from_millis(40));
let released = self
.device
.emit(&[InputEvent::new_now(EventType::KEY.0, code, 0)]);
let _end_landing = end_landing?;
released?;
Ok(())
}
}
#[derive(Clone, Copy, Debug)]
pub enum PointerButton {
Left,
Right,
Middle,
}
impl PointerButton {
pub fn from_name(name: Option<&str>) -> Option<Self> {
match name.unwrap_or("left").to_ascii_lowercase().as_str() {
"left" => Some(Self::Left),
"right" => Some(Self::Right),
"middle" => Some(Self::Middle),
_ => None,
}
}
fn key_code(self) -> u16 {
match self {
Self::Left => KeyCode::BTN_LEFT.0,
Self::Right => KeyCode::BTN_RIGHT.0,
Self::Middle => KeyCode::BTN_MIDDLE.0,
}
}
}
#[cfg(test)]
mod tests {
use super::{AbsPointerGeometry, PointerButton};
#[test]
fn axis_range_ends_at_last_desktop_pixel() {
let geometry = AbsPointerGeometry::from_dimensions(1920, 1080);
assert_eq!(geometry.axis_maxima(), (1919, 1079));
}
#[test]
fn stale_capture_geometry_is_rejected_before_pointer_reuse() {
let cached = AbsPointerGeometry::from_dimensions(3840, 2160);
assert!(cached.matches_dimensions(3840, 2160));
assert!(!cached.matches_dimensions(2560, 1440));
assert!(!cached.matches_dimensions(3840, 1080));
}
#[test]
fn pointer_landing_preserves_the_request_and_emitted_coordinates() {
let geometry = AbsPointerGeometry::from_dimensions(1920, 1080);
for (requested, emitted) in [
((640, 480), (640, 480)),
((1920, 1080), (1919, 1079)),
((-1, -1), (0, 0)),
((i32::MAX, i32::MAX), (1919, 1079)),
] {
let landing = geometry.landing_for(requested.0, requested.1);
assert_eq!(landing.requested, requested);
assert_eq!(landing.emitted, emitted);
}
}
#[test]
fn unsupported_buttons_fall_through_to_other_backends() {
assert!(matches!(
PointerButton::from_name(None),
Some(PointerButton::Left)
));
assert!(matches!(
PointerButton::from_name(Some("right")),
Some(PointerButton::Right)
));
assert!(matches!(
PointerButton::from_name(Some("middle")),
Some(PointerButton::Middle)
));
for button in ["side", "extra", "forward", "back"] {
assert!(
PointerButton::from_name(Some(button)).is_none(),
"{button} must fall through instead of becoming a left click"
);
}
}
}