Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use objc2_core_graphics::{
|
||||
CGEvent, CGEventFlags, CGEventSource, CGEventSourceStateID, CGEventTapLocation, CGKeyCode,
|
||||
};
|
||||
|
||||
use super::keycode_cache;
|
||||
use crate::Key;
|
||||
|
||||
/// Manages keyboard state and posts keyboard events to the system.
|
||||
pub struct Keyboard {
|
||||
/// Cache of character-to-keycode mappings for the current keyboard layout.
|
||||
cache: HashMap<char, CGKeyCode>,
|
||||
}
|
||||
|
||||
impl Keyboard {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: keycode_cache::build_cache(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a key down event for the given key.
|
||||
pub fn key_down(&self, key: &Key) -> Result<(), String> {
|
||||
post_key_down(self.resolve_keycode(key)?)
|
||||
}
|
||||
|
||||
/// Sends a key up event for the given key.
|
||||
pub fn key_up(&self, key: &Key) -> Result<(), String> {
|
||||
post_key_up(self.resolve_keycode(key)?)
|
||||
}
|
||||
|
||||
/// Simulates typing text by sending Quartz events.
|
||||
pub fn type_text(&self, text: &str) -> Result<(), String> {
|
||||
let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState);
|
||||
|
||||
// Send one character at a time for better compatibility with various applications.
|
||||
for ch in text.chars() {
|
||||
// For now, send each character using the unicode method. This is easier than using
|
||||
// virtual key codes, but may not be supported in all applications.
|
||||
//
|
||||
// TODO(vorporeal): when sending an ASCII character, send it using virtual key codes
|
||||
// for better compatibility.
|
||||
type_unicode_char(ch, source.as_deref())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves a Key to a CGKeyCode.
|
||||
///
|
||||
/// The key can be:
|
||||
/// - A keycode (platform-specific virtual keycode)
|
||||
/// - A character (looked up via the current keyboard layout)
|
||||
fn resolve_keycode(&self, key: &Key) -> Result<CGKeyCode, String> {
|
||||
match key {
|
||||
Key::Keycode(code) => CGKeyCode::try_from(*code).map_err(|_| {
|
||||
format!(
|
||||
"Invalid keycode {code}: must be in range 0..={}",
|
||||
CGKeyCode::MAX
|
||||
)
|
||||
}),
|
||||
Key::Char(ch) => self
|
||||
.cache
|
||||
.get(ch)
|
||||
.copied()
|
||||
.ok_or_else(|| format!("No keycode found for character '{}'", ch)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Posts a key down event for the given virtual keycode.
|
||||
fn post_key_down(keycode: CGKeyCode) -> Result<(), String> {
|
||||
let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState);
|
||||
let event = CGEvent::new_keyboard_event(source.as_deref(), keycode, true)
|
||||
.ok_or_else(|| format!("Failed to create key down event for keycode {}", keycode))?;
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Posts a key up event for the given virtual keycode.
|
||||
fn post_key_up(keycode: CGKeyCode) -> Result<(), String> {
|
||||
let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState);
|
||||
let event = CGEvent::new_keyboard_event(source.as_deref(), keycode, false)
|
||||
.ok_or_else(|| format!("Failed to create key up event for keycode {}", keycode))?;
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generates a Quartz event signifying the typing of a single Unicode character.
|
||||
fn type_unicode_char(ch: char, source: Option<&CGEventSource>) -> Result<(), String> {
|
||||
let mut buf = [0u16; 2];
|
||||
let encoded = ch.encode_utf16(&mut buf);
|
||||
|
||||
// Create a key down event (virtual key code 0 is used as a placeholder).
|
||||
let key_down = CGEvent::new_keyboard_event(source, 0, true)
|
||||
.ok_or("Failed to create key down event for TypeText.")?;
|
||||
|
||||
// Set the unicode string on the event.
|
||||
// Safety: encoded is a valid UTF-16 buffer with the correct length.
|
||||
unsafe {
|
||||
CGEvent::keyboard_set_unicode_string(
|
||||
Some(&key_down),
|
||||
encoded.len() as u64,
|
||||
encoded.as_ptr(),
|
||||
);
|
||||
}
|
||||
|
||||
// Clear any modifier flags that might interfere.
|
||||
CGEvent::set_flags(Some(&key_down), CGEventFlags::empty());
|
||||
|
||||
// Post the key down event.
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_down));
|
||||
|
||||
// Create and post a corresponding key up event.
|
||||
let key_up = CGEvent::new_keyboard_event(source, 0, false)
|
||||
.ok_or("Failed to create key up event for TypeText.")?;
|
||||
CGEvent::set_flags(Some(&key_up), CGEventFlags::empty());
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_up));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Character-to-keycode cache builder.
|
||||
//!
|
||||
//! This module provides a way to translate characters to macOS virtual keycodes.
|
||||
//! The translation depends on the current keyboard layout.
|
||||
//!
|
||||
//! The Carbon APIs used for translation (`TISCopyCurrentKeyboardInputSource`,
|
||||
//! `UCKeyTranslate`) are not thread-safe, so we build the cache on the main thread
|
||||
//! using GCD dispatch.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use core_foundation::base::{CFType, CFTypeRef, TCFType};
|
||||
use core_foundation::data::CFData;
|
||||
use dispatch2::run_on_main;
|
||||
use objc2_core_graphics::CGKeyCode;
|
||||
|
||||
// Carbon Text Input Services types and functions.
|
||||
#[allow(non_camel_case_types)]
|
||||
type TISInputSourceRef = CFTypeRef;
|
||||
|
||||
#[link(name = "Carbon", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn TISCopyCurrentKeyboardInputSource() -> TISInputSourceRef;
|
||||
fn TISCopyCurrentKeyboardLayoutInputSource() -> TISInputSourceRef;
|
||||
fn TISGetInputSourceProperty(source: TISInputSourceRef, key: CFTypeRef) -> CFTypeRef;
|
||||
|
||||
// Property key for getting the keyboard layout data.
|
||||
static kTISPropertyUnicodeKeyLayoutData: CFTypeRef;
|
||||
|
||||
fn LMGetKbdType() -> u8;
|
||||
}
|
||||
|
||||
// Unicode Utilities types and functions.
|
||||
#[repr(C)]
|
||||
#[allow(non_camel_case_types)]
|
||||
struct UCKeyboardLayout {
|
||||
_opaque: [u8; 0],
|
||||
}
|
||||
|
||||
#[link(name = "Carbon", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn UCKeyTranslate(
|
||||
layout: *const UCKeyboardLayout,
|
||||
virtual_key_code: u16,
|
||||
key_action: u16,
|
||||
modifier_key_state: u32,
|
||||
keyboard_type: u32,
|
||||
key_translate_options: u32,
|
||||
dead_key_state: *mut u32,
|
||||
max_string_length: usize,
|
||||
actual_string_length: *mut usize,
|
||||
unicode_string: *mut u16,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
// UCKeyTranslate constants.
|
||||
const K_UC_KEY_ACTION_DOWN: u16 = 0;
|
||||
const K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_BIT: u32 = 1 << 0;
|
||||
|
||||
// Shift modifier for UCKeyTranslate (shift bit position is 1 in the modifier state).
|
||||
const SHIFT_MODIFIER: u32 = 1 << 1;
|
||||
|
||||
/// Builds a character-to-keycode cache for the current keyboard layout.
|
||||
///
|
||||
/// This function dispatches to the main thread to call Carbon APIs safely.
|
||||
/// TODO(QUALITY-271): Store the modifier keys as well.
|
||||
pub fn build_cache() -> HashMap<char, CGKeyCode> {
|
||||
run_on_main(|_| build_cache_on_main_thread())
|
||||
}
|
||||
|
||||
/// Builds the cache on the main thread where Carbon APIs are safe to call.
|
||||
fn build_cache_on_main_thread() -> HashMap<char, CGKeyCode> {
|
||||
let mut cache = HashMap::new();
|
||||
|
||||
// Get the keyboard layout data.
|
||||
let layout_data = unsafe { get_keyboard_layout_data() };
|
||||
let Some(layout_data) = layout_data else {
|
||||
log::warn!("Failed to get keyboard layout data for keycode cache");
|
||||
return cache;
|
||||
};
|
||||
|
||||
let layout_ptr = layout_data.as_ptr() as *const UCKeyboardLayout;
|
||||
let keyboard_type = unsafe { LMGetKbdType() } as u32;
|
||||
|
||||
// Iterate through all possible keycodes (0-127) and build the mapping.
|
||||
for keycode in 0u16..128 {
|
||||
// Get the character for this keycode without modifiers.
|
||||
if let Some(ch) = translate_keycode(layout_ptr, keycode, 0, keyboard_type)
|
||||
&& !is_control_char(ch)
|
||||
{
|
||||
cache.entry(ch).or_insert(keycode as CGKeyCode);
|
||||
}
|
||||
|
||||
// Get the character for this keycode with shift held.
|
||||
if let Some(ch) = translate_keycode(layout_ptr, keycode, SHIFT_MODIFIER, keyboard_type)
|
||||
&& !is_control_char(ch)
|
||||
{
|
||||
cache.entry(ch).or_insert(keycode as CGKeyCode);
|
||||
}
|
||||
}
|
||||
|
||||
cache
|
||||
}
|
||||
|
||||
/// Gets the keyboard layout data from the current input source.
|
||||
unsafe fn get_keyboard_layout_data() -> Option<CFData> {
|
||||
// TISCopy* functions follow CF "Copy" semantics - caller owns the reference.
|
||||
// Wrap in CFType so they're released when dropped.
|
||||
let source = unsafe { CFType::wrap_under_create_rule(TISCopyCurrentKeyboardInputSource()) };
|
||||
let mut layout_data = unsafe {
|
||||
TISGetInputSourceProperty(source.as_CFTypeRef(), kTISPropertyUnicodeKeyLayoutData)
|
||||
};
|
||||
|
||||
// Some keyboard layouts (e.g., Japanese, Chinese) don't have layout data on the
|
||||
// regular input source. Try the keyboard layout input source instead.
|
||||
let _layout_source;
|
||||
if layout_data.is_null() {
|
||||
// Keep this alive until we're done with layout_data.
|
||||
_layout_source =
|
||||
unsafe { CFType::wrap_under_create_rule(TISCopyCurrentKeyboardLayoutInputSource()) };
|
||||
layout_data = unsafe {
|
||||
TISGetInputSourceProperty(
|
||||
_layout_source.as_CFTypeRef(),
|
||||
kTISPropertyUnicodeKeyLayoutData,
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
if layout_data.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The returned CFData is not retained, so we need to retain it.
|
||||
Some(unsafe { CFData::wrap_under_get_rule(layout_data as _) })
|
||||
}
|
||||
|
||||
/// Translates a keycode to a character using UCKeyTranslate.
|
||||
fn translate_keycode(
|
||||
layout: *const UCKeyboardLayout,
|
||||
keycode: u16,
|
||||
modifier_state: u32,
|
||||
keyboard_type: u32,
|
||||
) -> Option<char> {
|
||||
let mut dead_key_state: u32 = 0;
|
||||
let mut string_length: usize = 0;
|
||||
let mut unicode_string = [0u16; 4];
|
||||
|
||||
let result = unsafe {
|
||||
UCKeyTranslate(
|
||||
layout,
|
||||
keycode,
|
||||
K_UC_KEY_ACTION_DOWN,
|
||||
modifier_state,
|
||||
keyboard_type,
|
||||
K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_BIT,
|
||||
&mut dead_key_state,
|
||||
unicode_string.len(),
|
||||
&mut string_length,
|
||||
unicode_string.as_mut_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
if result != 0 || string_length == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Convert the first UTF-16 code unit to a char.
|
||||
// We only handle single-code-unit characters for simplicity.
|
||||
char::decode_utf16(unicode_string[..string_length].iter().copied())
|
||||
.next()
|
||||
.and_then(|r| r.ok())
|
||||
}
|
||||
|
||||
/// Returns true if the character is a control character (non-printable).
|
||||
fn is_control_char(ch: char) -> bool {
|
||||
// C0 control characters (0x00-0x1F) and C1 control characters (0x7F-0x9F)
|
||||
let code = ch as u32;
|
||||
code <= 0x1F || (0x7F..=0x9F).contains(&code)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
mod keyboard;
|
||||
mod keycode_cache;
|
||||
mod mouse;
|
||||
mod screenshot;
|
||||
mod util;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use warpui::r#async::Timer;
|
||||
|
||||
use crate::{Action, ActionResult, Options};
|
||||
|
||||
pub fn is_supported_on_current_platform() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub struct Actor {
|
||||
keyboard: keyboard::Keyboard,
|
||||
mouse: mouse::Mouse,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
keyboard: keyboard::Keyboard::new(),
|
||||
mouse: mouse::Mouse::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl super::Actor for Actor {
|
||||
fn platform(&self) -> Option<super::Platform> {
|
||||
Some(super::Platform::Mac)
|
||||
}
|
||||
|
||||
async fn perform_actions(
|
||||
&mut self,
|
||||
actions: &[Action],
|
||||
options: Options,
|
||||
) -> Result<ActionResult, String> {
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::Wait(duration) => {
|
||||
Timer::after(*duration).await;
|
||||
}
|
||||
Action::MouseDown { button, at } => {
|
||||
self.mouse.move_to(*at).await?;
|
||||
self.mouse.button_down(button)?;
|
||||
}
|
||||
Action::MouseUp { button } => self.mouse.button_up(button)?,
|
||||
Action::MouseMove { to } => self.mouse.move_to(*to).await?,
|
||||
Action::MouseWheel {
|
||||
at,
|
||||
direction,
|
||||
distance,
|
||||
} => {
|
||||
self.mouse.move_to(*at).await?;
|
||||
self.mouse.scroll(direction, distance)?;
|
||||
}
|
||||
Action::TypeText { text } => {
|
||||
self.keyboard.type_text(text)?;
|
||||
}
|
||||
Action::KeyDown { key } => {
|
||||
self.keyboard.key_down(key)?;
|
||||
}
|
||||
Action::KeyUp { key } => {
|
||||
self.keyboard.key_up(key)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let screenshot = if let Some(params) = options.screenshot_params {
|
||||
Some(screenshot::take(params)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ActionResult {
|
||||
screenshot,
|
||||
cursor_position: Some(self.mouse.current_position()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
use instant::Instant;
|
||||
use std::time::Duration;
|
||||
|
||||
use objc2_core_foundation::CGPoint;
|
||||
use objc2_core_graphics::{
|
||||
CGEvent, CGEventSource, CGEventSourceStateID, CGEventTapLocation, CGEventType, CGMouseButton,
|
||||
CGScrollEventUnit,
|
||||
};
|
||||
use pathfinder_geometry::vector::Vector2I;
|
||||
use warpui::r#async::Timer;
|
||||
|
||||
use crate::{MouseButton, ScrollDirection, ScrollDistance};
|
||||
|
||||
use super::util::main_display_scale_factor;
|
||||
|
||||
const POSITION_POLL_INTERVAL: Duration = Duration::from_micros(500);
|
||||
const POSITION_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Converts physical coordinates to CGEvent point coordinates.
|
||||
///
|
||||
/// On Retina/HiDPI displays, physical coordinates differ from the "point" coordinates
|
||||
/// used by macOS APIs like CGEvent. This function scales physical coordinates down
|
||||
/// by the display's backing scale factor.
|
||||
pub fn to_cgpoint(target: Vector2I) -> CGPoint {
|
||||
let scale = main_display_scale_factor();
|
||||
CGPoint {
|
||||
x: target.x() as f64 / scale,
|
||||
y: target.y() as f64 / scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts CGEvent point coordinates to physical coordinates.
|
||||
pub fn from_cgpoint(point: CGPoint) -> Vector2I {
|
||||
let scale = main_display_scale_factor();
|
||||
Vector2I::new((point.x * scale) as i32, (point.y * scale) as i32)
|
||||
}
|
||||
|
||||
/// Manages mouse state and posts mouse events to the system.
|
||||
pub struct Mouse {
|
||||
held_buttons: HeldButtons,
|
||||
}
|
||||
|
||||
impl Mouse {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
held_buttons: HeldButtons::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn move_to(&mut self, target: Vector2I) -> Result<(), String> {
|
||||
let (event_type, cg_button) = if let Some(held) = self.held_buttons.primary_down() {
|
||||
(mouse_dragged_event_type(&held), (&held).into())
|
||||
} else {
|
||||
(CGEventType::MouseMoved, CGMouseButton::Left)
|
||||
};
|
||||
|
||||
self.post_event(event_type, to_cgpoint(target), cg_button)?;
|
||||
self.wait_for_position(target).await
|
||||
}
|
||||
|
||||
pub fn button_down(&mut self, button: &MouseButton) -> Result<(), String> {
|
||||
let point = self.current_position_cgpoint()?;
|
||||
self.held_buttons.set_down(button, true);
|
||||
self.post_event(mouse_down_event_type(button), point, button.into())
|
||||
}
|
||||
|
||||
pub fn button_up(&mut self, button: &MouseButton) -> Result<(), String> {
|
||||
let point = self.current_position_cgpoint()?;
|
||||
self.held_buttons.set_down(button, false);
|
||||
self.post_event(mouse_up_event_type(button), point, button.into())
|
||||
}
|
||||
|
||||
pub fn current_position(&mut self) -> Result<Vector2I, String> {
|
||||
let cg_point = self.current_position_cgpoint()?;
|
||||
Ok(from_cgpoint(cg_point))
|
||||
}
|
||||
|
||||
/// Scrolls the mouse wheel in the given direction by the given distance.
|
||||
pub fn scroll(
|
||||
&mut self,
|
||||
direction: &ScrollDirection,
|
||||
distance: &ScrollDistance,
|
||||
) -> Result<(), String> {
|
||||
let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState);
|
||||
|
||||
// Determine scroll unit and amount based on distance type.
|
||||
let (unit, amount) = match distance {
|
||||
ScrollDistance::Pixels(pixels) => (CGScrollEventUnit::Pixel, *pixels),
|
||||
ScrollDistance::Clicks(clicks) => (CGScrollEventUnit::Line, *clicks),
|
||||
};
|
||||
|
||||
// Determine which axis and sign to use based on direction.
|
||||
// Positive values scroll up/left, negative values scroll down/right.
|
||||
let (wheel1, wheel2) = match direction {
|
||||
ScrollDirection::Up => (amount, 0),
|
||||
ScrollDirection::Down => (-amount, 0),
|
||||
ScrollDirection::Left => (0, amount),
|
||||
ScrollDirection::Right => (0, -amount),
|
||||
};
|
||||
|
||||
// The function signature is:
|
||||
// new_scroll_wheel_event2(source, units, wheel_count, wheel1, wheel2, wheel3)
|
||||
// wheel_count indicates how many wheel values are valid (1, 2, or 3).
|
||||
let wheel_count = if wheel2 != 0 { 2 } else { 1 };
|
||||
let event = CGEvent::new_scroll_wheel_event2(
|
||||
source.as_deref(),
|
||||
unit,
|
||||
wheel_count,
|
||||
wheel1,
|
||||
wheel2,
|
||||
0,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to create scroll wheel event (direction={:?}, distance={:?}). \
|
||||
The cause is unknown.",
|
||||
direction, distance
|
||||
)
|
||||
})?;
|
||||
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Private implementation details.
|
||||
impl Mouse {
|
||||
/// Waits for the mouse to reach the target position, polling until it arrives
|
||||
/// or times out.
|
||||
async fn wait_for_position(&mut self, target: Vector2I) -> Result<(), String> {
|
||||
let start = Instant::now();
|
||||
|
||||
loop {
|
||||
let current = self.current_position()?;
|
||||
if current == target {
|
||||
return Ok(());
|
||||
}
|
||||
if start.elapsed() >= POSITION_TIMEOUT {
|
||||
log::warn!(
|
||||
"Mouse position wait timed out. Target: ({}, {}), Current: ({}, {})",
|
||||
target.x(),
|
||||
target.y(),
|
||||
current.x(),
|
||||
current.y()
|
||||
);
|
||||
return Err(format!(
|
||||
"Timed out waiting for mouse to move to ({}, {}). Current position: ({}, {})",
|
||||
target.x(),
|
||||
target.y(),
|
||||
current.x(),
|
||||
current.y()
|
||||
));
|
||||
}
|
||||
Timer::after(POSITION_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn current_position_cgpoint(&mut self) -> Result<CGPoint, String> {
|
||||
let event = CGEvent::new(None)
|
||||
.ok_or("Failed to query current cursor position. The cause is unknown.")?;
|
||||
let pos = CGEvent::location(Some(&event));
|
||||
Ok(pos)
|
||||
}
|
||||
|
||||
fn post_event(
|
||||
&mut self,
|
||||
event_type: CGEventType,
|
||||
point: CGPoint,
|
||||
button: CGMouseButton,
|
||||
) -> Result<(), String> {
|
||||
let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState);
|
||||
|
||||
let event = CGEvent::new_mouse_event(source.as_deref(), event_type, point, button)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to create mouse event (type={:?}, position=({}, {}), button={:?}). \
|
||||
The cause is unknown.",
|
||||
event_type, point.x, point.y, button
|
||||
)
|
||||
})?;
|
||||
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Button state tracking
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct HeldButtons {
|
||||
left: bool,
|
||||
right: bool,
|
||||
middle: bool,
|
||||
back: bool,
|
||||
forward: bool,
|
||||
}
|
||||
|
||||
impl HeldButtons {
|
||||
/// Returns the "primary" held button (preferring left > right > middle).
|
||||
fn primary_down(self) -> Option<MouseButton> {
|
||||
if self.left {
|
||||
Some(MouseButton::Left)
|
||||
} else if self.right {
|
||||
Some(MouseButton::Right)
|
||||
} else if self.middle {
|
||||
Some(MouseButton::Middle)
|
||||
} else if self.back {
|
||||
Some(MouseButton::Back)
|
||||
} else if self.forward {
|
||||
Some(MouseButton::Forward)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn set_down(&mut self, button: &MouseButton, down: bool) {
|
||||
match button {
|
||||
MouseButton::Left => self.left = down,
|
||||
MouseButton::Right => self.right = down,
|
||||
MouseButton::Middle => self.middle = down,
|
||||
MouseButton::Back => self.back = down,
|
||||
MouseButton::Forward => self.forward = down,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Event type helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
impl From<&MouseButton> for CGMouseButton {
|
||||
fn from(button: &MouseButton) -> Self {
|
||||
match button {
|
||||
MouseButton::Left => CGMouseButton::Left,
|
||||
MouseButton::Right => CGMouseButton::Right,
|
||||
MouseButton::Middle => CGMouseButton::Center,
|
||||
MouseButton::Back => CGMouseButton(3),
|
||||
MouseButton::Forward => CGMouseButton(4),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_down_event_type(button: &MouseButton) -> CGEventType {
|
||||
match button {
|
||||
MouseButton::Left => CGEventType::LeftMouseDown,
|
||||
MouseButton::Right => CGEventType::RightMouseDown,
|
||||
MouseButton::Middle | MouseButton::Back | MouseButton::Forward => {
|
||||
CGEventType::OtherMouseDown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_up_event_type(button: &MouseButton) -> CGEventType {
|
||||
match button {
|
||||
MouseButton::Left => CGEventType::LeftMouseUp,
|
||||
MouseButton::Right => CGEventType::RightMouseUp,
|
||||
MouseButton::Middle | MouseButton::Back | MouseButton::Forward => CGEventType::OtherMouseUp,
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_dragged_event_type(button: &MouseButton) -> CGEventType {
|
||||
match button {
|
||||
MouseButton::Left => CGEventType::LeftMouseDragged,
|
||||
MouseButton::Right => CGEventType::RightMouseDragged,
|
||||
MouseButton::Middle | MouseButton::Back | MouseButton::Forward => {
|
||||
CGEventType::OtherMouseDragged
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use command::blocking::Command;
|
||||
|
||||
use super::util::main_display_scale_factor;
|
||||
use crate::ScreenshotParams;
|
||||
|
||||
/// Captures a screenshot of the main display using the built-in macOS
|
||||
/// `screencapture` CLI.
|
||||
pub fn take(params: ScreenshotParams) -> Result<crate::Screenshot, String> {
|
||||
let output_dir = tempfile::tempdir()
|
||||
.map_err(|e| format!("Failed to create temporary directory for screenshot: {e}"))?;
|
||||
let output_path = output_dir.path().join("screenshot.png");
|
||||
|
||||
let mut cmd = Command::new("/usr/sbin/screencapture");
|
||||
cmd.args([
|
||||
"-x", // Do not play sounds.
|
||||
"-tpng", // Capture to PNG format.
|
||||
"-m", // Only capture the main display (not all displays).
|
||||
]);
|
||||
|
||||
if let Some(region) = params.region {
|
||||
region.validate()?;
|
||||
// -R x,y,w,h captures a specific rectangle in point coordinates.
|
||||
// Convert from physical pixel coordinates to point coordinates.
|
||||
let scale = main_display_scale_factor();
|
||||
let x = (region.top_left.x() as f64 / scale) as i32;
|
||||
let y = (region.top_left.y() as f64 / scale) as i32;
|
||||
let w = ((region.bottom_right.x() - region.top_left.x()) as f64 / scale) as i32;
|
||||
let h = ((region.bottom_right.y() - region.top_left.y()) as f64 / scale) as i32;
|
||||
cmd.arg("-R").arg(format!("{x},{y},{w},{h}"));
|
||||
}
|
||||
|
||||
let output = cmd
|
||||
.arg(&output_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run screencapture: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
format!("exit code {}", output.status)
|
||||
} else {
|
||||
format!("exit code {}: {}", output.status, stderr.trim())
|
||||
};
|
||||
return Err(format!("screencapture failed with {detail}"));
|
||||
}
|
||||
|
||||
crate::screenshot_utils::load_and_process_screenshot(&output_path, params)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/// Returns the backing scale factor of the main display.
|
||||
///
|
||||
/// This is used to convert between pixel coordinates (as returned by screenshot tools)
|
||||
/// and point coordinates (as used by CGEvent and screencapture).
|
||||
pub fn main_display_scale_factor() -> f64 {
|
||||
use dispatch2::run_on_main;
|
||||
use objc2_app_kit::NSScreen;
|
||||
|
||||
run_on_main(|mtm| {
|
||||
NSScreen::mainScreen(mtm)
|
||||
.map(|screen| screen.backingScaleFactor())
|
||||
.unwrap_or(1.0)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user