use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ButtonId {
LeftClick,
RightClick,
MiddleClick,
Back,
Forward,
DpiToggle,
Thumbwheel,
ThumbwheelScrollUp,
ThumbwheelScrollDown,
GestureButton,
}
impl ButtonId {
pub const ALL: [ButtonId; 10] = [
ButtonId::LeftClick,
ButtonId::RightClick,
ButtonId::MiddleClick,
ButtonId::Back,
ButtonId::Forward,
ButtonId::DpiToggle,
ButtonId::Thumbwheel,
ButtonId::ThumbwheelScrollUp,
ButtonId::ThumbwheelScrollDown,
ButtonId::GestureButton,
];
#[must_use]
pub fn label(self) -> &'static str {
match self {
ButtonId::LeftClick => "Left Click",
ButtonId::RightClick => "Right Click",
ButtonId::MiddleClick => "Middle Click",
ButtonId::Back => "Back",
ButtonId::Forward => "Forward",
ButtonId::DpiToggle => "DPI Toggle",
ButtonId::Thumbwheel => "Thumb Wheel",
ButtonId::ThumbwheelScrollUp => "Thumb Wheel Up",
ButtonId::ThumbwheelScrollDown => "Thumb Wheel Down",
ButtonId::GestureButton => "Gesture Button",
}
}
}
impl fmt::Display for ButtonId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum GestureDirection {
Up,
Down,
Left,
Right,
Click,
}
impl GestureDirection {
pub const ALL: [GestureDirection; 5] = [
GestureDirection::Up,
GestureDirection::Down,
GestureDirection::Left,
GestureDirection::Right,
GestureDirection::Click,
];
#[must_use]
pub fn label(self) -> &'static str {
match self {
GestureDirection::Up => "Up",
GestureDirection::Down => "Down",
GestureDirection::Left => "Left",
GestureDirection::Right => "Right",
GestureDirection::Click => "Click",
}
}
#[must_use]
pub fn glyph(self) -> &'static str {
match self {
GestureDirection::Up => "↑",
GestureDirection::Down => "↓",
GestureDirection::Left => "←",
GestureDirection::Right => "→",
GestureDirection::Click => "·",
}
}
}
impl fmt::Display for GestureDirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
pub const GESTURE_SWIPE_THRESHOLD: i32 = 50;
pub const GESTURE_SWIPE_DEADZONE: i32 = 40;
#[must_use]
pub fn detect_swipe(dx: i32, dy: i32) -> Option<GestureDirection> {
let (abs_x, abs_y) = (dx.abs(), dy.abs());
let dominant = abs_x.max(abs_y);
if dominant < GESTURE_SWIPE_THRESHOLD {
return None;
}
let cross_limit = GESTURE_SWIPE_DEADZONE.max(dominant * 35 / 100);
if abs_x > abs_y {
if abs_y > cross_limit {
return None;
}
Some(if dx > 0 {
GestureDirection::Right
} else {
GestureDirection::Left
})
} else {
if abs_x > cross_limit {
return None;
}
Some(if dy > 0 {
GestureDirection::Down
} else {
GestureDirection::Up
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Category {
Editing,
Browser,
Media,
Mouse,
Dpi,
Scroll,
Navigation,
System,
}
impl Category {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Category::Editing => "EDITING",
Category::Browser => "BROWSER",
Category::Media => "MEDIA",
Category::Mouse => "MOUSE",
Category::Dpi => "DPI",
Category::Scroll => "SCROLL",
Category::Navigation => "NAVIGATION",
Category::System => "SYSTEM",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Action {
None,
LeftClick,
RightClick,
MiddleClick,
Copy,
Paste,
Cut,
Undo,
Redo,
SelectAll,
Find,
Save,
BrowserBack,
BrowserForward,
NewTab,
CloseTab,
ReopenTab,
NextTab,
PrevTab,
ReloadPage,
MissionControl,
AppExpose,
PreviousDesktop,
NextDesktop,
ShowDesktop,
LaunchpadShow,
LockScreen,
Screenshot,
PlayPause,
NextTrack,
PrevTrack,
VolumeUp,
VolumeDown,
MuteVolume,
CycleDpiPresets,
SetDpiPreset(u8),
ToggleSmartShift,
ScrollUp,
ScrollDown,
HorizontalScrollLeft,
HorizontalScrollRight,
CustomShortcut(KeyCombo),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct KeyCombo {
pub modifiers: u8,
pub key_code: u16,
#[serde(default)]
pub display: String,
}
impl KeyCombo {
pub const MOD_CMD: u8 = 1 << 0;
pub const MOD_SHIFT: u8 = 1 << 1;
pub const MOD_CTRL: u8 = 1 << 2;
pub const MOD_OPTION: u8 = 1 << 3;
#[must_use]
pub fn rendered_label(&self) -> String {
if !self.display.is_empty() {
return self.display.clone();
}
let mut out = String::new();
if self.modifiers & Self::MOD_CTRL != 0 {
out.push('⌃');
}
if self.modifiers & Self::MOD_OPTION != 0 {
out.push('⌥');
}
if self.modifiers & Self::MOD_SHIFT != 0 {
out.push('⇧');
}
if self.modifiers & Self::MOD_CMD != 0 {
out.push('⌘');
}
match self.key_code {
0x00 => out.push('A'),
0x01 => out.push('S'),
0x02 => out.push('D'),
0x03 => out.push('F'),
0x06 => out.push('Z'),
0x07 => out.push('X'),
0x08 => out.push('C'),
0x09 => out.push('V'),
0x0B => out.push('B'),
0x0C => out.push('Q'),
0x0D => out.push('W'),
0x0E => out.push('E'),
0x0F => out.push('R'),
0x10 => out.push('Y'),
0x11 => out.push('T'),
0x20 => out.push('U'),
0x22 => out.push('I'),
0x1F => out.push('O'),
0x23 => out.push('P'),
_ => {
use std::fmt::Write as _;
let _ = write!(out, "key 0x{:02X}", self.key_code);
}
}
out
}
}
impl Action {
#[must_use]
pub fn label(&self) -> String {
match self {
Action::None => "Do Nothing".into(),
Action::LeftClick => "Left Click".into(),
Action::RightClick => "Right Click".into(),
Action::MiddleClick => "Middle Click".into(),
Action::Copy => "Copy".into(),
Action::Paste => "Paste".into(),
Action::Cut => "Cut".into(),
Action::Undo => "Undo".into(),
Action::Redo => "Redo".into(),
Action::SelectAll => "Select All".into(),
Action::Find => "Find".into(),
Action::Save => "Save".into(),
Action::BrowserBack => "Browser Back".into(),
Action::BrowserForward => "Browser Forward".into(),
Action::NewTab => "New Tab".into(),
Action::CloseTab => "Close Tab".into(),
Action::ReopenTab => "Reopen Tab".into(),
Action::NextTab => "Next Tab".into(),
Action::PrevTab => "Previous Tab".into(),
Action::ReloadPage => "Reload Page".into(),
Action::MissionControl => "Mission Control".into(),
Action::AppExpose => "App Exposé".into(),
Action::PreviousDesktop => "Previous Desktop".into(),
Action::NextDesktop => "Next Desktop".into(),
Action::ShowDesktop => "Show Desktop".into(),
Action::LaunchpadShow => "Launchpad".into(),
Action::LockScreen => "Lock Screen".into(),
Action::Screenshot => "Screenshot".into(),
Action::PlayPause => "Play / Pause".into(),
Action::NextTrack => "Next Track".into(),
Action::PrevTrack => "Previous Track".into(),
Action::VolumeUp => "Volume Up".into(),
Action::VolumeDown => "Volume Down".into(),
Action::MuteVolume => "Mute".into(),
Action::CycleDpiPresets => "Cycle DPI Presets".into(),
Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
Action::ToggleSmartShift => "Toggle SmartShift".into(),
Action::ScrollUp => "Scroll Up".into(),
Action::ScrollDown => "Scroll Down".into(),
Action::HorizontalScrollLeft => "Scroll Left".into(),
Action::HorizontalScrollRight => "Scroll Right".into(),
Action::CustomShortcut(combo) => combo.rendered_label(),
}
}
#[must_use]
pub fn category(&self) -> Category {
match self {
Action::LeftClick | Action::RightClick | Action::MiddleClick => Category::Mouse,
Action::Copy
| Action::Paste
| Action::Cut
| Action::Undo
| Action::Redo
| Action::SelectAll
| Action::Find
| Action::Save
| Action::CustomShortcut(_) => Category::Editing,
Action::BrowserBack
| Action::BrowserForward
| Action::NewTab
| Action::CloseTab
| Action::ReopenTab
| Action::NextTab
| Action::PrevTab
| Action::ReloadPage => Category::Browser,
Action::MissionControl
| Action::AppExpose
| Action::PreviousDesktop
| Action::NextDesktop
| Action::ShowDesktop
| Action::LaunchpadShow => Category::Navigation,
Action::None | Action::LockScreen | Action::Screenshot => Category::System,
Action::PlayPause
| Action::NextTrack
| Action::PrevTrack
| Action::VolumeUp
| Action::VolumeDown
| Action::MuteVolume => Category::Media,
Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
Category::Dpi
}
Action::ScrollUp
| Action::ScrollDown
| Action::HorizontalScrollLeft
| Action::HorizontalScrollRight => Category::Scroll,
}
}
#[must_use]
pub fn catalog() -> Vec<Action> {
vec![
Action::LeftClick,
Action::RightClick,
Action::MiddleClick,
Action::Copy,
Action::Paste,
Action::Cut,
Action::Undo,
Action::Redo,
Action::SelectAll,
Action::Find,
Action::Save,
Action::BrowserBack,
Action::BrowserForward,
Action::NewTab,
Action::CloseTab,
Action::ReopenTab,
Action::NextTab,
Action::PrevTab,
Action::ReloadPage,
Action::MissionControl,
Action::AppExpose,
Action::PreviousDesktop,
Action::NextDesktop,
Action::ShowDesktop,
Action::LaunchpadShow,
Action::None,
Action::LockScreen,
Action::Screenshot,
Action::PlayPause,
Action::NextTrack,
Action::PrevTrack,
Action::VolumeUp,
Action::VolumeDown,
Action::MuteVolume,
Action::CycleDpiPresets,
Action::ToggleSmartShift,
Action::ScrollUp,
Action::ScrollDown,
Action::HorizontalScrollLeft,
Action::HorizontalScrollRight,
]
}
pub fn execute(&self) {
#[cfg(target_os = "macos")]
self.execute_macos();
#[cfg(target_os = "linux")]
self.execute_linux();
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
tracing::warn!(
action = self.label(),
"Action::execute unsupported on this platform"
);
}
}
#[cfg(target_os = "linux")]
fn execute_linux(&self) {
use evdev::{KeyCode, RelativeAxisCode};
let ctrl = KeyCode::KEY_LEFTCTRL;
let shift = KeyCode::KEY_LEFTSHIFT;
let alt = KeyCode::KEY_LEFTALT;
match self {
Action::LeftClick => linux::click(KeyCode::BTN_LEFT),
Action::RightClick => linux::click(KeyCode::BTN_RIGHT),
Action::MiddleClick => linux::click(KeyCode::BTN_MIDDLE),
Action::Copy => linux::press_key(&[ctrl], KeyCode::KEY_C),
Action::Paste => linux::press_key(&[ctrl], KeyCode::KEY_V),
Action::Cut => linux::press_key(&[ctrl], KeyCode::KEY_X),
Action::Undo => linux::press_key(&[ctrl], KeyCode::KEY_Z),
Action::Redo => linux::press_key(&[ctrl, shift], KeyCode::KEY_Z),
Action::SelectAll => linux::press_key(&[ctrl], KeyCode::KEY_A),
Action::Find => linux::press_key(&[ctrl], KeyCode::KEY_F),
Action::Save => linux::press_key(&[ctrl], KeyCode::KEY_S),
Action::BrowserBack => linux::press_key(&[alt], KeyCode::KEY_LEFT),
Action::BrowserForward => linux::press_key(&[alt], KeyCode::KEY_RIGHT),
Action::NewTab => linux::press_key(&[ctrl], KeyCode::KEY_T),
Action::CloseTab => linux::press_key(&[ctrl], KeyCode::KEY_W),
Action::ReopenTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_T),
Action::NextTab => linux::press_key(&[ctrl], KeyCode::KEY_TAB),
Action::PrevTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_TAB),
Action::ReloadPage => linux::press_key(&[ctrl], KeyCode::KEY_R),
Action::MissionControl
| Action::AppExpose
| Action::ShowDesktop
| Action::LaunchpadShow => {
tracing::debug!(
action = self.label(),
"no Linux equivalent — action skipped"
);
}
Action::PreviousDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_LEFT),
Action::NextDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_RIGHT),
Action::LockScreen => linux::lock_screen(),
Action::Screenshot => linux::press_key(&[], KeyCode::KEY_SYSRQ),
Action::PlayPause => linux::mpris_command("PlayPause"),
Action::NextTrack => linux::mpris_command("Next"),
Action::PrevTrack => linux::mpris_command("Previous"),
Action::VolumeUp => linux::press_key(&[], KeyCode::KEY_VOLUMEUP),
Action::VolumeDown => linux::press_key(&[], KeyCode::KEY_VOLUMEDOWN),
Action::MuteVolume => linux::press_key(&[], KeyCode::KEY_MUTE),
Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
tracing::debug!(
action = self.label(),
"device action handled by hook/HID layer"
);
}
Action::ScrollUp => linux::scroll(RelativeAxisCode::REL_WHEEL, 3),
Action::ScrollDown => linux::scroll(RelativeAxisCode::REL_WHEEL, -3),
Action::HorizontalScrollLeft => linux::scroll(RelativeAxisCode::REL_HWHEEL, -3),
Action::HorizontalScrollRight => linux::scroll(RelativeAxisCode::REL_HWHEEL, 3),
Action::None => {}
Action::CustomShortcut(combo) => {
if combo.key_code == 0 {
tracing::warn!(
chord = %combo.rendered_label(),
"CustomShortcut with no key code — press ignored"
);
return;
}
let Some(key) = linux::macos_vk_to_linux(combo.key_code) else {
tracing::warn!(
key_code = combo.key_code,
"CustomShortcut key code has no Linux mapping — press ignored"
);
return;
};
linux::press_key(&linux::modifiers_to_keycodes(combo.modifiers), key);
}
}
}
#[cfg(target_os = "macos")]
fn execute_macos(&self) {
use core_graphics::event::{CGEventFlags, CGMouseButton};
let cmd = CGEventFlags::CGEventFlagCommand;
let shift = CGEventFlags::CGEventFlagShift;
let ctrl = CGEventFlags::CGEventFlagControl;
let none = CGEventFlags::CGEventFlagNull;
match self {
Action::None => {}
Action::LeftClick => macos::post_click(CGMouseButton::Left),
Action::RightClick => macos::post_click(CGMouseButton::Right),
Action::MiddleClick => macos::post_click(CGMouseButton::Center),
Action::Copy => macos::post_key(VK_C, cmd),
Action::Paste => macos::post_key(VK_V, cmd),
Action::Cut => macos::post_key(VK_X, cmd),
Action::Undo => macos::post_key(VK_Z, cmd),
Action::Redo => macos::post_key(VK_Z, cmd | shift),
Action::SelectAll => macos::post_key(VK_A, cmd),
Action::Find => macos::post_key(VK_F, cmd),
Action::Save => macos::post_key(VK_S, cmd),
Action::BrowserBack => macos::post_key(0x21, cmd),
Action::BrowserForward => macos::post_key(0x1E, cmd),
Action::NewTab => macos::post_key(VK_T, cmd),
Action::CloseTab => macos::post_key(VK_W, cmd),
Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
Action::NextTab => macos::post_key(VK_TAB, ctrl),
Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
Action::ReloadPage => macos::post_key(VK_R, cmd),
Action::MissionControl => macos::mission_control(),
Action::AppExpose => macos::app_expose(),
Action::PreviousDesktop => macos::previous_desktop(),
Action::NextDesktop => macos::next_desktop(),
Action::ShowDesktop => macos::show_desktop(),
Action::LaunchpadShow => macos::launchpad(),
Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
Action::Screenshot => macos::post_key(0x14, cmd | shift),
Action::PlayPause => macos::post_media_key(0),
Action::NextTrack => macos::post_media_key(1),
Action::PrevTrack => macos::post_media_key(2),
Action::VolumeUp => macos::post_key(0x48, none),
Action::VolumeDown => macos::post_key(0x49, none),
Action::MuteVolume => macos::post_key(0x4A, none),
Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
tracing::debug!(
action = self.label(),
"device action handled by hook/HID layer"
);
}
Action::ScrollUp
| Action::ScrollDown
| Action::HorizontalScrollLeft
| Action::HorizontalScrollRight => macos::post_scroll(self),
Action::CustomShortcut(combo) => {
if combo.key_code == 0 {
tracing::warn!(
chord = %combo.rendered_label(),
"CustomShortcut with no key code — press ignored"
);
return;
}
let mut flags = CGEventFlags::CGEventFlagNull;
if combo.modifiers & KeyCombo::MOD_CMD != 0 {
flags |= CGEventFlags::CGEventFlagCommand;
}
if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
flags |= CGEventFlags::CGEventFlagShift;
}
if combo.modifiers & KeyCombo::MOD_CTRL != 0 {
flags |= CGEventFlags::CGEventFlagControl;
}
if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
flags |= CGEventFlags::CGEventFlagAlternate;
}
macos::post_key(combo.key_code, flags);
}
}
}
}
pub fn post_horizontal_scroll(delta: i32) {
#[cfg(target_os = "macos")]
macos::post_horizontal_scroll(delta);
#[cfg(target_os = "linux")]
linux::scroll(evdev::RelativeAxisCode::REL_HWHEEL, delta);
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let _ = delta;
}
#[cfg(target_os = "linux")]
#[must_use]
pub fn action_device_path() -> Option<std::path::PathBuf> {
linux::device_node()
}
#[cfg(target_os = "macos")]
const VK_A: u16 = 0x00;
#[cfg(target_os = "macos")]
const VK_C: u16 = 0x08;
#[cfg(target_os = "macos")]
const VK_F: u16 = 0x03;
#[cfg(target_os = "macos")]
const VK_R: u16 = 0x0F;
#[cfg(target_os = "macos")]
const VK_S: u16 = 0x01;
#[cfg(target_os = "macos")]
const VK_T: u16 = 0x11;
#[cfg(target_os = "macos")]
const VK_V: u16 = 0x09;
#[cfg(target_os = "macos")]
const VK_W: u16 = 0x0D;
#[cfg(target_os = "macos")]
const VK_X: u16 = 0x07;
#[cfg(target_os = "macos")]
const VK_Z: u16 = 0x06;
#[cfg(target_os = "macos")]
const VK_TAB: u16 = 0x30;
#[cfg(target_os = "macos")]
mod macos {
use core_graphics::event::{
CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, ScrollEventUnit,
};
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
use core_graphics::geometry::CGPoint;
use crate::binding::Action;
pub(super) fn post_click(button: CGMouseButton) {
let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
tracing::warn!("CGEventSource::new failed for click");
return;
};
let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
let (down, up) = match button {
CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp),
CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp),
CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp),
};
for (kind, phase) in [(down, "down"), (up, "up")] {
if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) {
ev.post(CGEventTapLocation::HID);
} else {
tracing::warn!(phase, "CGEvent::new_mouse_event failed");
}
}
}
pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
tracing::warn!("CGEventSource::new failed");
return;
};
let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
tracing::warn!("CGEvent::new_keyboard_event(down) failed");
return;
};
down.set_flags(flags);
down.post(CGEventTapLocation::HID);
let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
tracing::warn!("CGEvent::new_keyboard_event(up) failed");
return;
};
up.set_flags(flags);
up.post(CGEventTapLocation::HID);
}
pub(super) fn post_media_key(kind: i32) {
let nx_key: i64 = match kind {
0 => 16,
1 => 17,
_ => 18,
};
tracing::debug!(
nx_key,
"media key event: NSSystemDefined stub — full AppKit impl tracked in P1.x"
);
}
pub(super) fn post_scroll(action: &Action) {
let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
tracing::warn!("CGEventSource::new failed for scroll");
return;
};
let (v, h): (i32, i32) = match action {
Action::ScrollUp => (3, 0),
Action::ScrollDown => (-3, 0),
Action::HorizontalScrollLeft => (0, -3),
Action::HorizontalScrollRight => (0, 3),
_ => return,
};
let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
tracing::warn!("CGEvent::new_scroll_event failed");
return;
};
ev.post(CGEventTapLocation::HID);
}
pub(super) fn post_horizontal_scroll(delta: i32) {
let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
tracing::warn!("CGEventSource::new failed for thumbwheel scroll");
return;
};
let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else {
tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel");
return;
};
ev.post(CGEventTapLocation::HID);
}
pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop};
pub(super) use symbolic_hotkey::{next_desktop, previous_desktop};
use app_services::symbol as app_services_symbol;
#[allow(
unsafe_code,
reason = "private ApplicationServices SPI symbols are resolved via dlopen/dlsym FFI"
)]
mod app_services {
use std::ffi::{CStr, c_char, c_int, c_void};
use std::sync::OnceLock;
pub(super) fn symbol(symbol: &CStr) -> Option<*mut c_void> {
const RTLD_LAZY: c_int = 0x1;
const APP_SERVICES: &CStr =
c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices";
static HANDLE: OnceLock<usize> = OnceLock::new();
let sym = unsafe {
let handle =
*HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize);
if handle == 0 {
return None;
}
dlsym(handle as *mut c_void, symbol.as_ptr())
};
(!sym.is_null()).then_some(sym)
}
unsafe extern "C" {
fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
}
}
#[allow(
unsafe_code,
reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI"
)]
mod dock {
use std::ffi::{c_int, c_void};
use core_foundation::base::TCFType;
use core_foundation::string::CFString;
use super::app_services_symbol;
pub(crate) fn mission_control() {
send("com.apple.expose.awake");
}
pub(crate) fn app_expose() {
send("com.apple.expose.front.awake");
}
pub(crate) fn show_desktop() {
send("com.apple.showdesktop.awake");
}
pub(crate) fn launchpad() {
send("com.apple.launchpad.toggle");
}
fn send(notification: &str) {
let Some(core_dock_send) = core_dock_send_notification() else {
tracing::warn!(notification, "CoreDockSendNotification unavailable");
return;
};
let name = CFString::new(notification);
let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) };
if err != 0 {
tracing::warn!(notification, err, "CoreDockSendNotification failed");
}
}
type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int;
fn core_dock_send_notification() -> Option<CoreDockSendNotificationFn> {
let sym = app_services_symbol(c"CoreDockSendNotification")?;
Some(unsafe { std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym) })
}
}
#[allow(
unsafe_code,
reason = "CGS symbolic hotkey SPI is only reachable via dlopen/dlsym FFI"
)]
mod symbolic_hotkey {
use std::ffi::{c_int, c_uint, c_ushort, c_void};
use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation};
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
use super::app_services_symbol;
const SPACE_LEFT: u32 = 79;
const SPACE_RIGHT: u32 = 81;
pub(crate) fn previous_desktop() {
post_symbolic_hotkey(SPACE_LEFT);
}
pub(crate) fn next_desktop() {
post_symbolic_hotkey(SPACE_RIGHT);
}
fn post_symbolic_hotkey(hotkey: u32) {
let Some(cgs) = cgs_hotkey_api() else {
tracing::warn!(hotkey, "CGS symbolic hotkey API unavailable");
return;
};
let mut key_equivalent = 0_u16;
let mut virtual_key = 0_u16;
let mut modifiers = 0_u32;
let err = unsafe {
(cgs.get_value)(
hotkey,
&raw mut key_equivalent,
&raw mut virtual_key,
&raw mut modifiers,
)
};
if err != 0 {
tracing::warn!(hotkey, err, "CGSGetSymbolicHotKeyValue failed");
return;
}
let was_enabled = unsafe { (cgs.is_enabled)(hotkey) };
if !was_enabled {
let err = unsafe { (cgs.set_enabled)(hotkey, true) };
if err != 0 {
tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(true) failed");
}
}
post_key(virtual_key, modifiers);
if !was_enabled {
let err = unsafe { (cgs.set_enabled)(hotkey, false) };
if err != 0 {
tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(false) failed");
}
}
}
fn post_key(vk: u16, modifiers: u32) {
let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
tracing::warn!("CGEventSource::new failed for symbolic hotkey");
return;
};
let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
tracing::warn!(vk, "CGEvent::new_keyboard_event(down) failed");
return;
};
let flags = CGEventFlags::from_bits_truncate(u64::from(modifiers));
down.set_flags(flags);
down.post(CGEventTapLocation::Session);
let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
tracing::warn!(vk, "CGEvent::new_keyboard_event(up) failed");
return;
};
up.set_flags(flags);
up.post(CGEventTapLocation::Session);
}
#[derive(Clone, Copy)]
struct CgsHotkeyApi {
get_value: CgsGetSymbolicHotKeyValueFn,
is_enabled: CgsIsSymbolicHotKeyEnabledFn,
set_enabled: CgsSetSymbolicHotKeyEnabledFn,
}
type CgsGetSymbolicHotKeyValueFn =
unsafe extern "C" fn(c_uint, *mut c_ushort, *mut c_ushort, *mut c_uint) -> c_int;
type CgsIsSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint) -> bool;
type CgsSetSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint, bool) -> c_int;
fn cgs_hotkey_api() -> Option<CgsHotkeyApi> {
let get_value = app_services_symbol(c"CGSGetSymbolicHotKeyValue")?;
let is_enabled = app_services_symbol(c"CGSIsSymbolicHotKeyEnabled")?;
let set_enabled = app_services_symbol(c"CGSSetSymbolicHotKeyEnabled")?;
Some(unsafe {
CgsHotkeyApi {
get_value: std::mem::transmute::<*mut c_void, CgsGetSymbolicHotKeyValueFn>(
get_value,
),
is_enabled: std::mem::transmute::<*mut c_void, CgsIsSymbolicHotKeyEnabledFn>(
is_enabled,
),
set_enabled: std::mem::transmute::<*mut c_void, CgsSetSymbolicHotKeyEnabledFn>(
set_enabled,
),
}
})
}
}
}
#[must_use]
pub fn default_binding(button: ButtonId) -> Action {
match button {
ButtonId::LeftClick => Action::LeftClick,
ButtonId::RightClick => Action::RightClick,
ButtonId::MiddleClick => Action::MiddleClick,
ButtonId::Back => Action::BrowserBack,
ButtonId::Forward => Action::BrowserForward,
ButtonId::DpiToggle => Action::CycleDpiPresets,
ButtonId::Thumbwheel => Action::AppExpose,
ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight,
ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft,
ButtonId::GestureButton => Action::MissionControl,
}
}
#[must_use]
pub fn default_gesture_binding(direction: GestureDirection) -> Action {
match direction {
GestureDirection::Up => Action::MissionControl,
GestureDirection::Down => Action::ShowDesktop,
GestureDirection::Left => Action::PrevTab,
GestureDirection::Right => Action::NextTab,
GestureDirection::Click => Action::AppExpose,
}
}
#[cfg(target_os = "linux")]
mod linux {
use std::io;
use std::sync::{LazyLock, Mutex};
use evdev::uinput::VirtualDevice;
use evdev::{AttributeSet, EventType, InputEvent, KeyCode, RelativeAxisCode};
use zbus::blocking::Connection as DbusConn;
const DEVICE_NAME: &str = "OpenLogi action injector";
static VIRTUAL_INPUT: LazyLock<Option<Mutex<VirtualDevice>>> = LazyLock::new(|| {
build()
.map(Mutex::new)
.map_err(|e| tracing::warn!("failed to create uinput action device: {e}"))
.ok()
});
#[rustfmt::skip]
const KEY_CAPABILITIES: &[KeyCode] = &[
KeyCode::KEY_A, KeyCode::KEY_B, KeyCode::KEY_C, KeyCode::KEY_D,
KeyCode::KEY_E, KeyCode::KEY_F, KeyCode::KEY_G, KeyCode::KEY_H,
KeyCode::KEY_I, KeyCode::KEY_J, KeyCode::KEY_K, KeyCode::KEY_L,
KeyCode::KEY_M, KeyCode::KEY_N, KeyCode::KEY_O, KeyCode::KEY_P,
KeyCode::KEY_Q, KeyCode::KEY_R, KeyCode::KEY_S, KeyCode::KEY_T,
KeyCode::KEY_U, KeyCode::KEY_V, KeyCode::KEY_W, KeyCode::KEY_X,
KeyCode::KEY_Y, KeyCode::KEY_Z,
KeyCode::KEY_0, KeyCode::KEY_1, KeyCode::KEY_2, KeyCode::KEY_3,
KeyCode::KEY_4, KeyCode::KEY_5, KeyCode::KEY_6, KeyCode::KEY_7,
KeyCode::KEY_8, KeyCode::KEY_9,
KeyCode::KEY_MINUS, KeyCode::KEY_EQUAL, KeyCode::KEY_LEFTBRACE,
KeyCode::KEY_RIGHTBRACE, KeyCode::KEY_BACKSLASH, KeyCode::KEY_SEMICOLON,
KeyCode::KEY_APOSTROPHE, KeyCode::KEY_GRAVE, KeyCode::KEY_COMMA,
KeyCode::KEY_DOT, KeyCode::KEY_SLASH,
KeyCode::KEY_LEFT, KeyCode::KEY_RIGHT, KeyCode::KEY_UP, KeyCode::KEY_DOWN,
KeyCode::KEY_HOME, KeyCode::KEY_END, KeyCode::KEY_PAGEUP, KeyCode::KEY_PAGEDOWN,
KeyCode::KEY_TAB, KeyCode::KEY_ENTER, KeyCode::KEY_BACKSPACE, KeyCode::KEY_DELETE,
KeyCode::KEY_ESC, KeyCode::KEY_SPACE,
KeyCode::KEY_LEFTCTRL, KeyCode::KEY_LEFTSHIFT, KeyCode::KEY_LEFTALT, KeyCode::KEY_LEFTMETA,
KeyCode::KEY_F1, KeyCode::KEY_F2, KeyCode::KEY_F3, KeyCode::KEY_F4,
KeyCode::KEY_F5, KeyCode::KEY_F6, KeyCode::KEY_F7, KeyCode::KEY_F8,
KeyCode::KEY_F9, KeyCode::KEY_F10, KeyCode::KEY_F11, KeyCode::KEY_F12,
KeyCode::KEY_SYSRQ,
KeyCode::KEY_PLAYPAUSE, KeyCode::KEY_NEXTSONG, KeyCode::KEY_PREVIOUSSONG,
KeyCode::KEY_VOLUMEUP, KeyCode::KEY_VOLUMEDOWN, KeyCode::KEY_MUTE,
KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE,
];
fn build() -> io::Result<VirtualDevice> {
let mut keys = AttributeSet::<KeyCode>::default();
for &k in KEY_CAPABILITIES {
keys.insert(k);
}
let mut axes = AttributeSet::<RelativeAxisCode>::default();
for a in [RelativeAxisCode::REL_WHEEL, RelativeAxisCode::REL_HWHEEL] {
axes.insert(a);
}
VirtualDevice::builder()?
.name(DEVICE_NAME)
.with_keys(&keys)?
.with_relative_axes(&axes)?
.build()
}
fn emit(events: &[InputEvent]) {
if let Some(m) = &*VIRTUAL_INPUT {
if let Ok(mut guard) = m.lock() {
if let Err(e) = guard.emit(events) {
tracing::warn!("uinput action emit failed: {e}");
}
} else {
tracing::warn!("uinput action device mutex poisoned");
}
} else {
tracing::debug!("uinput action device unavailable — action skipped");
}
}
fn syn() -> InputEvent {
InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0)
}
fn key_ev(code: KeyCode, value: i32) -> InputEvent {
InputEvent::new(EventType::KEY.0, code.0, value)
}
fn rel_ev(axis: RelativeAxisCode, value: i32) -> InputEvent {
InputEvent::new(EventType::RELATIVE.0, axis.0, value)
}
pub(super) fn press_key(mods: &[KeyCode], key: KeyCode) {
let mut down: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
for &m in mods {
down.push(key_ev(m, 1));
}
down.push(key_ev(key, 1));
down.push(syn());
emit(&down);
let mut up: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
up.push(key_ev(key, 0));
for &m in mods.iter().rev() {
up.push(key_ev(m, 0));
}
up.push(syn());
emit(&up);
}
pub(super) fn click(button: KeyCode) {
emit(&[key_ev(button, 1), syn()]);
emit(&[key_ev(button, 0), syn()]);
}
pub(super) fn scroll(axis: RelativeAxisCode, value: i32) {
emit(&[rel_ev(axis, value), syn()]);
}
pub(super) fn device_node() -> Option<std::path::PathBuf> {
let _ = &*VIRTUAL_INPUT;
std::thread::sleep(std::time::Duration::from_millis(150));
if let Some(m) = &*VIRTUAL_INPUT {
if let Ok(mut guard) = m.lock() {
return guard.enumerate_dev_nodes_blocking().ok()?.flatten().next();
}
}
None
}
pub(super) fn modifiers_to_keycodes(modifiers: u8) -> Vec<KeyCode> {
use crate::binding::KeyCombo;
let mut mods = Vec::new();
if modifiers & (KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL) != 0 {
mods.push(KeyCode::KEY_LEFTCTRL);
}
if modifiers & KeyCombo::MOD_SHIFT != 0 {
mods.push(KeyCode::KEY_LEFTSHIFT);
}
if modifiers & KeyCombo::MOD_OPTION != 0 {
mods.push(KeyCode::KEY_LEFTALT);
}
mods
}
pub(super) fn macos_vk_to_linux(vk: u16) -> Option<KeyCode> {
Some(match vk {
0x00 => KeyCode::KEY_A, 0x01 => KeyCode::KEY_S, 0x02 => KeyCode::KEY_D, 0x03 => KeyCode::KEY_F, 0x04 => KeyCode::KEY_H, 0x05 => KeyCode::KEY_G, 0x06 => KeyCode::KEY_Z, 0x07 => KeyCode::KEY_X, 0x08 => KeyCode::KEY_C, 0x09 => KeyCode::KEY_V, 0x0B => KeyCode::KEY_B, 0x0C => KeyCode::KEY_Q, 0x0D => KeyCode::KEY_W, 0x0E => KeyCode::KEY_E, 0x0F => KeyCode::KEY_R, 0x10 => KeyCode::KEY_Y, 0x11 => KeyCode::KEY_T, 0x12 => KeyCode::KEY_1, 0x13 => KeyCode::KEY_2, 0x14 => KeyCode::KEY_3, 0x15 => KeyCode::KEY_4, 0x16 => KeyCode::KEY_6, 0x17 => KeyCode::KEY_5, 0x18 => KeyCode::KEY_EQUAL, 0x19 => KeyCode::KEY_9, 0x1A => KeyCode::KEY_7, 0x1B => KeyCode::KEY_MINUS, 0x1C => KeyCode::KEY_8, 0x1D => KeyCode::KEY_0, 0x1E => KeyCode::KEY_RIGHTBRACE, 0x1F => KeyCode::KEY_O, 0x20 => KeyCode::KEY_U, 0x21 => KeyCode::KEY_LEFTBRACE, 0x22 => KeyCode::KEY_I, 0x23 => KeyCode::KEY_P, 0x24 => KeyCode::KEY_ENTER, 0x25 => KeyCode::KEY_L, 0x26 => KeyCode::KEY_J, 0x27 => KeyCode::KEY_APOSTROPHE, 0x28 => KeyCode::KEY_K, 0x29 => KeyCode::KEY_SEMICOLON, 0x2A => KeyCode::KEY_BACKSLASH, 0x2B => KeyCode::KEY_COMMA, 0x2C => KeyCode::KEY_SLASH, 0x2D => KeyCode::KEY_N, 0x2E => KeyCode::KEY_M, 0x2F => KeyCode::KEY_DOT, 0x30 => KeyCode::KEY_TAB, 0x31 => KeyCode::KEY_SPACE, 0x32 => KeyCode::KEY_GRAVE, 0x33 => KeyCode::KEY_BACKSPACE, 0x35 => KeyCode::KEY_ESC, 0x60 => KeyCode::KEY_F5, 0x61 => KeyCode::KEY_F6, 0x62 => KeyCode::KEY_F7, 0x63 => KeyCode::KEY_F3, 0x64 => KeyCode::KEY_F8, 0x65 => KeyCode::KEY_F9, 0x67 => KeyCode::KEY_F11, 0x6D => KeyCode::KEY_F10, 0x6F => KeyCode::KEY_F12, 0x76 => KeyCode::KEY_F4, 0x78 => KeyCode::KEY_F2, 0x7A => KeyCode::KEY_F1, 0x73 => KeyCode::KEY_HOME, 0x77 => KeyCode::KEY_END, 0x74 => KeyCode::KEY_PAGEUP, 0x79 => KeyCode::KEY_PAGEDOWN, 0x75 => KeyCode::KEY_DELETE, 0x7B => KeyCode::KEY_LEFT, 0x7C => KeyCode::KEY_RIGHT, 0x7D => KeyCode::KEY_DOWN, 0x7E => KeyCode::KEY_UP, _ => return None,
})
}
static SESSION_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
DbusConn::session()
.map_err(|e| tracing::warn!("D-Bus session bus unavailable: {e}"))
.ok()
});
static SYSTEM_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
DbusConn::system()
.map_err(|e| tracing::warn!("D-Bus system bus unavailable: {e}"))
.ok()
});
pub(super) fn lock_screen() {
if let (Some(conn), Ok(id)) = (SYSTEM_BUS.as_ref(), std::env::var("XDG_SESSION_ID")) {
match conn.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.login1.Manager"),
"LockSession",
&(id.as_str(),),
) {
Ok(_) => {
tracing::debug!("LockScreen via logind");
return;
}
Err(e) => tracing::warn!("logind LockSession failed: {e}"),
}
}
tracing::debug!("LockScreen via Super+L key combo");
press_key(&[KeyCode::KEY_LEFTMETA], KeyCode::KEY_L);
}
pub(super) fn mpris_command(command: &str) {
if try_mpris_command(command).is_none() {
let fallback = match command {
"PlayPause" => KeyCode::KEY_PLAYPAUSE,
"Next" => KeyCode::KEY_NEXTSONG,
"Previous" => KeyCode::KEY_PREVIOUSSONG,
_ => return,
};
press_key(&[], fallback);
}
}
fn try_mpris_command(command: &str) -> Option<()> {
let conn = SESSION_BUS.as_ref()?;
let reply = conn
.call_method(
Some("org.freedesktop.DBus"),
"/org/freedesktop/DBus",
Some("org.freedesktop.DBus"),
"ListNames",
&(),
)
.ok()?;
let names = reply.body().deserialize::<Vec<String>>().ok()?;
let Some(player) = names
.iter()
.find(|n| n.starts_with("org.mpris.MediaPlayer2."))
else {
tracing::debug!("no MPRIS player found — {command} via XF86 key fallback");
return None;
};
match conn.call_method(
Some(player.as_str()),
"/org/mpris/MediaPlayer2",
Some("org.mpris.MediaPlayer2.Player"),
command,
&(),
) {
Ok(_) => {
tracing::debug!("MPRIS {command} via {player}");
Some(())
}
Err(e) => {
tracing::warn!("MPRIS {command} on {player} failed: {e}");
Some(())
}
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
mod tests {
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::*;
#[derive(Serialize, Deserialize)]
struct RoundtripWrapper {
binding: BTreeMap<ButtonId, Action>,
}
#[test]
fn catalog_has_at_least_29_entries() {
let catalog = Action::catalog();
assert!(
catalog.len() >= 29,
"catalog has {} entries, need ≥ 29",
catalog.len()
);
}
#[test]
fn catalog_excludes_custom_shortcut() {
let catalog = Action::catalog();
for action in &catalog {
assert!(
!matches!(action, Action::CustomShortcut(_)),
"catalog must not contain CustomShortcut"
);
}
}
#[test]
fn detect_swipe_below_threshold_keeps_accumulating() {
assert_eq!(detect_swipe(40, 5), None);
assert_eq!(detect_swipe(0, 0), None);
}
#[test]
fn detect_swipe_commits_clean_direction() {
assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
}
#[test]
fn detect_swipe_rejects_diagonal() {
assert_eq!(detect_swipe(60, 60), None);
assert_eq!(detect_swipe(-60, -60), None);
}
fn roundtrip(action: &Action) -> Action {
let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
map.insert(ButtonId::Back, action.clone());
let w = RoundtripWrapper { binding: map };
let s = toml::to_string(&w).expect("serialize");
let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
back.binding
.into_values()
.next()
.expect("binding present after roundtrip")
}
#[test]
fn all_catalog_variants_roundtrip_toml() {
for action in Action::catalog() {
let back = roundtrip(&action);
assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
}
}
#[test]
fn custom_shortcut_roundtrips_toml() {
let action = Action::CustomShortcut(KeyCombo {
modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
key_code: 0x23, display: "⌘⇧P".into(),
});
assert_eq!(roundtrip(&action), action);
}
#[test]
fn key_combo_rendered_label_uses_display_when_set() {
let combo = KeyCombo {
modifiers: 0,
key_code: 0,
display: "preset".into(),
};
assert_eq!(combo.rendered_label(), "preset");
}
#[test]
fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
let combo = KeyCombo {
modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
key_code: 0x23, display: String::new(),
};
assert_eq!(combo.rendered_label(), "⇧⌘P");
}
#[test]
fn category_editing_variants() {
assert_eq!(Action::Copy.category(), Category::Editing);
assert_eq!(Action::Undo.category(), Category::Editing);
assert_eq!(Action::SelectAll.category(), Category::Editing);
assert_eq!(Action::Find.category(), Category::Editing);
assert_eq!(Action::Save.category(), Category::Editing);
assert_eq!(Action::Cut.category(), Category::Editing);
assert_eq!(Action::Redo.category(), Category::Editing);
assert_eq!(Action::Paste.category(), Category::Editing);
}
#[test]
fn category_browser_variants() {
assert_eq!(Action::BrowserBack.category(), Category::Browser);
assert_eq!(Action::BrowserForward.category(), Category::Browser);
assert_eq!(Action::NewTab.category(), Category::Browser);
assert_eq!(Action::CloseTab.category(), Category::Browser);
assert_eq!(Action::ReopenTab.category(), Category::Browser);
assert_eq!(Action::NextTab.category(), Category::Browser);
assert_eq!(Action::PrevTab.category(), Category::Browser);
assert_eq!(Action::ReloadPage.category(), Category::Browser);
}
#[test]
fn category_media_variants() {
assert_eq!(Action::PlayPause.category(), Category::Media);
assert_eq!(Action::NextTrack.category(), Category::Media);
assert_eq!(Action::PrevTrack.category(), Category::Media);
assert_eq!(Action::VolumeUp.category(), Category::Media);
assert_eq!(Action::VolumeDown.category(), Category::Media);
assert_eq!(Action::MuteVolume.category(), Category::Media);
}
#[test]
fn category_mouse_variants() {
assert_eq!(Action::LeftClick.category(), Category::Mouse);
assert_eq!(Action::RightClick.category(), Category::Mouse);
assert_eq!(Action::MiddleClick.category(), Category::Mouse);
}
#[test]
fn category_dpi_variants() {
assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
}
#[test]
fn category_scroll_variants() {
assert_eq!(Action::ScrollUp.category(), Category::Scroll);
assert_eq!(Action::ScrollDown.category(), Category::Scroll);
assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
}
#[test]
fn category_navigation_variants() {
assert_eq!(Action::MissionControl.category(), Category::Navigation);
assert_eq!(Action::AppExpose.category(), Category::Navigation);
assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
assert_eq!(Action::NextDesktop.category(), Category::Navigation);
assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
}
#[test]
fn category_system_variants() {
assert_eq!(Action::LockScreen.category(), Category::System);
assert_eq!(Action::Screenshot.category(), Category::System);
}
#[test]
fn category_labels_are_nonempty() {
let categories = [
Category::Editing,
Category::Browser,
Category::Media,
Category::Mouse,
Category::Dpi,
Category::Scroll,
Category::Navigation,
Category::System,
];
for cat in categories {
assert!(!cat.label().is_empty(), "label empty for {cat:?}");
}
}
#[test]
fn dpi_toggle_default_is_cycle_dpi_presets() {
assert_eq!(
default_binding(ButtonId::DpiToggle),
Action::CycleDpiPresets
);
}
#[cfg(target_os = "linux")]
mod modifier_mapping {
use evdev::KeyCode;
use crate::binding::{KeyCombo, linux::modifiers_to_keycodes};
#[test]
fn mod_cmd_alone_maps_to_ctrl() {
assert_eq!(
modifiers_to_keycodes(KeyCombo::MOD_CMD),
vec![KeyCode::KEY_LEFTCTRL]
);
}
#[test]
fn mod_ctrl_alone_maps_to_ctrl() {
assert_eq!(
modifiers_to_keycodes(KeyCombo::MOD_CTRL),
vec![KeyCode::KEY_LEFTCTRL]
);
}
#[test]
fn mod_cmd_and_ctrl_together_produce_single_ctrl() {
assert_eq!(
modifiers_to_keycodes(KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL),
vec![KeyCode::KEY_LEFTCTRL]
);
}
#[test]
fn all_modifiers_produce_canonical_order() {
let mods = modifiers_to_keycodes(
KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT | KeyCombo::MOD_OPTION,
);
assert_eq!(
mods,
vec![
KeyCode::KEY_LEFTCTRL,
KeyCode::KEY_LEFTSHIFT,
KeyCode::KEY_LEFTALT
]
);
}
#[test]
fn no_modifiers_produces_empty_vec() {
assert!(modifiers_to_keycodes(0).is_empty());
}
}
#[cfg(target_os = "linux")]
mod vk_mapping {
use evdev::KeyCode;
use crate::binding::linux::macos_vk_to_linux;
#[test]
fn common_letters_map_correctly() {
assert_eq!(macos_vk_to_linux(0x08), Some(KeyCode::KEY_C)); assert_eq!(macos_vk_to_linux(0x09), Some(KeyCode::KEY_V)); assert_eq!(macos_vk_to_linux(0x07), Some(KeyCode::KEY_X)); assert_eq!(macos_vk_to_linux(0x00), Some(KeyCode::KEY_A)); assert_eq!(macos_vk_to_linux(0x06), Some(KeyCode::KEY_Z)); assert_eq!(macos_vk_to_linux(0x0D), Some(KeyCode::KEY_W)); }
#[test]
fn digits_map_correctly() {
assert_eq!(macos_vk_to_linux(0x12), Some(KeyCode::KEY_1)); assert_eq!(macos_vk_to_linux(0x1D), Some(KeyCode::KEY_0)); }
#[test]
fn arrow_keys_map_correctly() {
assert_eq!(macos_vk_to_linux(0x7B), Some(KeyCode::KEY_LEFT));
assert_eq!(macos_vk_to_linux(0x7C), Some(KeyCode::KEY_RIGHT));
assert_eq!(macos_vk_to_linux(0x7D), Some(KeyCode::KEY_DOWN));
assert_eq!(macos_vk_to_linux(0x7E), Some(KeyCode::KEY_UP));
}
#[test]
fn function_keys_map_correctly() {
assert_eq!(macos_vk_to_linux(0x7A), Some(KeyCode::KEY_F1)); assert_eq!(macos_vk_to_linux(0x78), Some(KeyCode::KEY_F2)); assert_eq!(macos_vk_to_linux(0x76), Some(KeyCode::KEY_F4)); assert_eq!(macos_vk_to_linux(0x60), Some(KeyCode::KEY_F5)); assert_eq!(macos_vk_to_linux(0x6F), Some(KeyCode::KEY_F12)); }
#[test]
fn nav_keys_map_correctly() {
assert_eq!(macos_vk_to_linux(0x73), Some(KeyCode::KEY_HOME));
assert_eq!(macos_vk_to_linux(0x77), Some(KeyCode::KEY_END));
assert_eq!(macos_vk_to_linux(0x74), Some(KeyCode::KEY_PAGEUP));
assert_eq!(macos_vk_to_linux(0x79), Some(KeyCode::KEY_PAGEDOWN));
assert_eq!(macos_vk_to_linux(0x75), Some(KeyCode::KEY_DELETE));
}
#[test]
fn brackets_follow_ansi_layout() {
assert_eq!(macos_vk_to_linux(0x21), Some(KeyCode::KEY_LEFTBRACE));
assert_eq!(macos_vk_to_linux(0x1E), Some(KeyCode::KEY_RIGHTBRACE));
}
#[test]
fn unmapped_code_returns_none() {
assert_eq!(macos_vk_to_linux(0xFF), None);
assert_eq!(macos_vk_to_linux(0x34), None); }
}
}