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,
GestureButton,
}
impl ButtonId {
pub const ALL: [ButtonId; 8] = [
ButtonId::LeftClick,
ButtonId::RightClick,
ButtonId::MiddleClick,
ButtonId::Back,
ButtonId::Forward,
ButtonId::DpiToggle,
ButtonId::Thumbwheel,
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::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 {
LeftClick,
RightClick,
MiddleClick,
Copy,
Paste,
Cut,
Undo,
Redo,
SelectAll,
Find,
Save,
BrowserBack,
BrowserForward,
NewTab,
CloseTab,
ReopenTab,
NextTab,
PrevTab,
ReloadPage,
MissionControl,
AppExpose,
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::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::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::ShowDesktop
| Action::LaunchpadShow => Category::Navigation,
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::ShowDesktop,
Action::LaunchpadShow,
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(not(target_os = "macos"))]
{
tracing::warn!(
action = self.label(),
"Action::execute unsupported on this platform"
);
}
}
#[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::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::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(not(target_os = "macos"))]
let _ = delta;
}
#[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};
#[allow(
unsafe_code,
reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI"
)]
mod dock {
use std::ffi::{CStr, c_char, c_int, c_void};
use std::sync::OnceLock;
use core_foundation::base::TCFType;
use core_foundation::string::CFString;
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> {
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, c"CoreDockSendNotification".as_ptr())
};
(!sym.is_null()).then(|| unsafe {
std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(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;
}
}
}
#[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::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(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::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
);
}
}