1use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
17pub enum ButtonId {
18 LeftClick,
19 RightClick,
20 MiddleClick,
21 Back,
22 Forward,
23 DpiToggle,
26 Thumbwheel,
29 GestureButton,
32}
33
34impl ButtonId {
35 pub const ALL: [ButtonId; 8] = [
36 ButtonId::LeftClick,
37 ButtonId::RightClick,
38 ButtonId::MiddleClick,
39 ButtonId::Back,
40 ButtonId::Forward,
41 ButtonId::DpiToggle,
42 ButtonId::Thumbwheel,
43 ButtonId::GestureButton,
44 ];
45
46 #[must_use]
48 pub fn label(self) -> &'static str {
49 match self {
50 ButtonId::LeftClick => "Left Click",
51 ButtonId::RightClick => "Right Click",
52 ButtonId::MiddleClick => "Middle Click",
53 ButtonId::Back => "Back",
54 ButtonId::Forward => "Forward",
55 ButtonId::DpiToggle => "DPI Toggle",
56 ButtonId::Thumbwheel => "Thumb Wheel",
57 ButtonId::GestureButton => "Gesture Button",
58 }
59 }
60}
61
62impl fmt::Display for ButtonId {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 f.write_str(self.label())
65 }
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
76pub enum GestureDirection {
77 Up,
78 Down,
79 Left,
80 Right,
81 Click,
82}
83
84impl GestureDirection {
85 pub const ALL: [GestureDirection; 5] = [
86 GestureDirection::Up,
87 GestureDirection::Down,
88 GestureDirection::Left,
89 GestureDirection::Right,
90 GestureDirection::Click,
91 ];
92
93 #[must_use]
94 pub fn label(self) -> &'static str {
95 match self {
96 GestureDirection::Up => "Up",
97 GestureDirection::Down => "Down",
98 GestureDirection::Left => "Left",
99 GestureDirection::Right => "Right",
100 GestureDirection::Click => "Click",
101 }
102 }
103
104 #[must_use]
106 pub fn glyph(self) -> &'static str {
107 match self {
108 GestureDirection::Up => "↑",
109 GestureDirection::Down => "↓",
110 GestureDirection::Left => "←",
111 GestureDirection::Right => "→",
112 GestureDirection::Click => "·",
113 }
114 }
115}
116
117impl fmt::Display for GestureDirection {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 f.write_str(self.label())
120 }
121}
122
123pub const GESTURE_SWIPE_THRESHOLD: i32 = 50;
126pub const GESTURE_SWIPE_DEADZONE: i32 = 40;
129
130#[must_use]
143pub fn detect_swipe(dx: i32, dy: i32) -> Option<GestureDirection> {
144 let (abs_x, abs_y) = (dx.abs(), dy.abs());
145 let dominant = abs_x.max(abs_y);
146 if dominant < GESTURE_SWIPE_THRESHOLD {
147 return None;
148 }
149 let cross_limit = GESTURE_SWIPE_DEADZONE.max(dominant * 35 / 100);
150 if abs_x > abs_y {
151 if abs_y > cross_limit {
152 return None;
153 }
154 Some(if dx > 0 {
155 GestureDirection::Right
156 } else {
157 GestureDirection::Left
158 })
159 } else {
160 if abs_x > cross_limit {
161 return None;
162 }
163 Some(if dy > 0 {
164 GestureDirection::Down
165 } else {
166 GestureDirection::Up
167 })
168 }
169}
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
176pub enum Category {
177 Editing,
179 Browser,
181 Media,
183 Mouse,
185 Dpi,
187 Scroll,
189 Navigation,
191 System,
193}
194
195impl Category {
196 #[must_use]
199 pub fn label(self) -> &'static str {
200 match self {
201 Category::Editing => "EDITING",
202 Category::Browser => "BROWSER",
203 Category::Media => "MEDIA",
204 Category::Mouse => "MOUSE",
205 Category::Dpi => "DPI",
206 Category::Scroll => "SCROLL",
207 Category::Navigation => "NAVIGATION",
208 Category::System => "SYSTEM",
209 }
210 }
211}
212
213#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
235pub enum Action {
236 LeftClick,
239 RightClick,
241 MiddleClick,
243
244 Copy,
247 Paste,
249 Cut,
251 Undo,
253 Redo,
255 SelectAll,
257 Find,
259 Save,
261
262 BrowserBack,
265 BrowserForward,
267 NewTab,
269 CloseTab,
271 ReopenTab,
273 NextTab,
275 PrevTab,
277 ReloadPage,
279
280 MissionControl,
283 AppExpose,
285 PreviousDesktop,
287 NextDesktop,
289 ShowDesktop,
291 LaunchpadShow,
293
294 LockScreen,
297 Screenshot,
299
300 PlayPause,
303 NextTrack,
305 PrevTrack,
307 VolumeUp,
309 VolumeDown,
311 MuteVolume,
313
314 CycleDpiPresets,
317 SetDpiPreset(u8),
320 ToggleSmartShift,
322
323 ScrollUp,
326 ScrollDown,
328 HorizontalScrollLeft,
330 HorizontalScrollRight,
332
333 CustomShortcut(KeyCombo),
341}
342
343#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
355pub struct KeyCombo {
356 pub modifiers: u8,
358 pub key_code: u16,
361 #[serde(default)]
364 pub display: String,
365}
366
367impl KeyCombo {
368 pub const MOD_CMD: u8 = 1 << 0;
369 pub const MOD_SHIFT: u8 = 1 << 1;
370 pub const MOD_CTRL: u8 = 1 << 2;
371 pub const MOD_OPTION: u8 = 1 << 3;
372
373 #[must_use]
378 pub fn rendered_label(&self) -> String {
379 if !self.display.is_empty() {
380 return self.display.clone();
381 }
382 let mut out = String::new();
383 if self.modifiers & Self::MOD_CTRL != 0 {
384 out.push('⌃');
385 }
386 if self.modifiers & Self::MOD_OPTION != 0 {
387 out.push('⌥');
388 }
389 if self.modifiers & Self::MOD_SHIFT != 0 {
390 out.push('⇧');
391 }
392 if self.modifiers & Self::MOD_CMD != 0 {
393 out.push('⌘');
394 }
395 match self.key_code {
396 0x00 => out.push('A'),
397 0x01 => out.push('S'),
398 0x02 => out.push('D'),
399 0x03 => out.push('F'),
400 0x06 => out.push('Z'),
401 0x07 => out.push('X'),
402 0x08 => out.push('C'),
403 0x09 => out.push('V'),
404 0x0B => out.push('B'),
405 0x0C => out.push('Q'),
406 0x0D => out.push('W'),
407 0x0E => out.push('E'),
408 0x0F => out.push('R'),
409 0x10 => out.push('Y'),
410 0x11 => out.push('T'),
411 0x20 => out.push('U'),
412 0x22 => out.push('I'),
413 0x1F => out.push('O'),
414 0x23 => out.push('P'),
415 _ => {
416 use std::fmt::Write as _;
417 let _ = write!(out, "key 0x{:02X}", self.key_code);
418 }
419 }
420 out
421 }
422}
423
424impl Action {
425 #[must_use]
431 pub fn label(&self) -> String {
432 match self {
433 Action::LeftClick => "Left Click".into(),
434 Action::RightClick => "Right Click".into(),
435 Action::MiddleClick => "Middle Click".into(),
436 Action::Copy => "Copy".into(),
437 Action::Paste => "Paste".into(),
438 Action::Cut => "Cut".into(),
439 Action::Undo => "Undo".into(),
440 Action::Redo => "Redo".into(),
441 Action::SelectAll => "Select All".into(),
442 Action::Find => "Find".into(),
443 Action::Save => "Save".into(),
444 Action::BrowserBack => "Browser Back".into(),
445 Action::BrowserForward => "Browser Forward".into(),
446 Action::NewTab => "New Tab".into(),
447 Action::CloseTab => "Close Tab".into(),
448 Action::ReopenTab => "Reopen Tab".into(),
449 Action::NextTab => "Next Tab".into(),
450 Action::PrevTab => "Previous Tab".into(),
451 Action::ReloadPage => "Reload Page".into(),
452 Action::MissionControl => "Mission Control".into(),
453 Action::AppExpose => "App Exposé".into(),
454 Action::PreviousDesktop => "Previous Desktop".into(),
455 Action::NextDesktop => "Next Desktop".into(),
456 Action::ShowDesktop => "Show Desktop".into(),
457 Action::LaunchpadShow => "Launchpad".into(),
458 Action::LockScreen => "Lock Screen".into(),
459 Action::Screenshot => "Screenshot".into(),
460 Action::PlayPause => "Play / Pause".into(),
461 Action::NextTrack => "Next Track".into(),
462 Action::PrevTrack => "Previous Track".into(),
463 Action::VolumeUp => "Volume Up".into(),
464 Action::VolumeDown => "Volume Down".into(),
465 Action::MuteVolume => "Mute".into(),
466 Action::CycleDpiPresets => "Cycle DPI Presets".into(),
467 Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
468 Action::ToggleSmartShift => "Toggle SmartShift".into(),
469 Action::ScrollUp => "Scroll Up".into(),
470 Action::ScrollDown => "Scroll Down".into(),
471 Action::HorizontalScrollLeft => "Scroll Left".into(),
472 Action::HorizontalScrollRight => "Scroll Right".into(),
473 Action::CustomShortcut(combo) => combo.rendered_label(),
474 }
475 }
476
477 #[must_use]
479 pub fn category(&self) -> Category {
480 match self {
481 Action::LeftClick | Action::RightClick | Action::MiddleClick => Category::Mouse,
482 Action::Copy
485 | Action::Paste
486 | Action::Cut
487 | Action::Undo
488 | Action::Redo
489 | Action::SelectAll
490 | Action::Find
491 | Action::Save
492 | Action::CustomShortcut(_) => Category::Editing,
493 Action::BrowserBack
494 | Action::BrowserForward
495 | Action::NewTab
496 | Action::CloseTab
497 | Action::ReopenTab
498 | Action::NextTab
499 | Action::PrevTab
500 | Action::ReloadPage => Category::Browser,
501 Action::MissionControl
502 | Action::AppExpose
503 | Action::PreviousDesktop
504 | Action::NextDesktop
505 | Action::ShowDesktop
506 | Action::LaunchpadShow => Category::Navigation,
507 Action::LockScreen | Action::Screenshot => Category::System,
508 Action::PlayPause
509 | Action::NextTrack
510 | Action::PrevTrack
511 | Action::VolumeUp
512 | Action::VolumeDown
513 | Action::MuteVolume => Category::Media,
514 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
515 Category::Dpi
516 }
517 Action::ScrollUp
518 | Action::ScrollDown
519 | Action::HorizontalScrollLeft
520 | Action::HorizontalScrollRight => Category::Scroll,
521 }
522 }
523
524 #[must_use]
529 pub fn catalog() -> Vec<Action> {
530 vec![
531 Action::LeftClick,
533 Action::RightClick,
534 Action::MiddleClick,
535 Action::Copy,
537 Action::Paste,
538 Action::Cut,
539 Action::Undo,
540 Action::Redo,
541 Action::SelectAll,
542 Action::Find,
543 Action::Save,
544 Action::BrowserBack,
546 Action::BrowserForward,
547 Action::NewTab,
548 Action::CloseTab,
549 Action::ReopenTab,
550 Action::NextTab,
551 Action::PrevTab,
552 Action::ReloadPage,
553 Action::MissionControl,
555 Action::AppExpose,
556 Action::PreviousDesktop,
557 Action::NextDesktop,
558 Action::ShowDesktop,
559 Action::LaunchpadShow,
560 Action::LockScreen,
562 Action::Screenshot,
563 Action::PlayPause,
565 Action::NextTrack,
566 Action::PrevTrack,
567 Action::VolumeUp,
568 Action::VolumeDown,
569 Action::MuteVolume,
570 Action::CycleDpiPresets,
572 Action::ToggleSmartShift,
573 Action::ScrollUp,
575 Action::ScrollDown,
576 Action::HorizontalScrollLeft,
577 Action::HorizontalScrollRight,
578 ]
579 }
580
581 pub fn execute(&self) {
595 #[cfg(target_os = "macos")]
596 self.execute_macos();
597
598 #[cfg(not(target_os = "macos"))]
599 {
600 tracing::warn!(
601 action = self.label(),
602 "Action::execute unsupported on this platform"
603 );
604 }
605 }
606
607 #[cfg(target_os = "macos")]
609 fn execute_macos(&self) {
610 use core_graphics::event::{CGEventFlags, CGMouseButton};
611
612 let cmd = CGEventFlags::CGEventFlagCommand;
614 let shift = CGEventFlags::CGEventFlagShift;
615 let ctrl = CGEventFlags::CGEventFlagControl;
616 let none = CGEventFlags::CGEventFlagNull;
617
618 match self {
619 Action::LeftClick => macos::post_click(CGMouseButton::Left),
624 Action::RightClick => macos::post_click(CGMouseButton::Right),
625 Action::MiddleClick => macos::post_click(CGMouseButton::Center),
626 Action::Copy => macos::post_key(VK_C, cmd),
628 Action::Paste => macos::post_key(VK_V, cmd),
629 Action::Cut => macos::post_key(VK_X, cmd),
630 Action::Undo => macos::post_key(VK_Z, cmd),
631 Action::Redo => macos::post_key(VK_Z, cmd | shift),
632 Action::SelectAll => macos::post_key(VK_A, cmd),
633 Action::Find => macos::post_key(VK_F, cmd),
634 Action::Save => macos::post_key(VK_S, cmd),
635 Action::BrowserBack => macos::post_key(0x21, cmd),
640 Action::BrowserForward => macos::post_key(0x1E, cmd),
641 Action::NewTab => macos::post_key(VK_T, cmd),
642 Action::CloseTab => macos::post_key(VK_W, cmd),
643 Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
644 Action::NextTab => macos::post_key(VK_TAB, ctrl),
645 Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
646 Action::ReloadPage => macos::post_key(VK_R, cmd),
647 Action::MissionControl => macos::mission_control(),
654 Action::AppExpose => macos::app_expose(),
655 Action::PreviousDesktop => macos::previous_desktop(),
656 Action::NextDesktop => macos::next_desktop(),
657 Action::ShowDesktop => macos::show_desktop(),
658 Action::LaunchpadShow => macos::launchpad(),
659 Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
662 Action::Screenshot => macos::post_key(0x14, cmd | shift),
664 Action::PlayPause => macos::post_media_key(0),
667 Action::NextTrack => macos::post_media_key(1),
668 Action::PrevTrack => macos::post_media_key(2),
669 Action::VolumeUp => macos::post_key(0x48, none),
671 Action::VolumeDown => macos::post_key(0x49, none),
672 Action::MuteVolume => macos::post_key(0x4A, none),
673 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
675 tracing::debug!(
676 action = self.label(),
677 "device action handled by hook/HID layer"
678 );
679 }
680 Action::ScrollUp
682 | Action::ScrollDown
683 | Action::HorizontalScrollLeft
684 | Action::HorizontalScrollRight => macos::post_scroll(self),
685 Action::CustomShortcut(combo) => {
687 if combo.key_code == 0 {
692 tracing::warn!(
693 chord = %combo.rendered_label(),
694 "CustomShortcut with no key code — press ignored"
695 );
696 return;
697 }
698 let mut flags = CGEventFlags::CGEventFlagNull;
699 if combo.modifiers & KeyCombo::MOD_CMD != 0 {
700 flags |= CGEventFlags::CGEventFlagCommand;
701 }
702 if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
703 flags |= CGEventFlags::CGEventFlagShift;
704 }
705 if combo.modifiers & KeyCombo::MOD_CTRL != 0 {
706 flags |= CGEventFlags::CGEventFlagControl;
707 }
708 if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
709 flags |= CGEventFlags::CGEventFlagAlternate;
710 }
711 macos::post_key(combo.key_code, flags);
712 }
713 }
714 }
715}
716
717pub fn post_horizontal_scroll(delta: i32) {
727 #[cfg(target_os = "macos")]
728 macos::post_horizontal_scroll(delta);
729
730 #[cfg(not(target_os = "macos"))]
731 let _ = delta;
732}
733
734#[cfg(target_os = "macos")]
738const VK_A: u16 = 0x00;
739#[cfg(target_os = "macos")]
740const VK_C: u16 = 0x08;
741#[cfg(target_os = "macos")]
742const VK_F: u16 = 0x03;
743#[cfg(target_os = "macos")]
744const VK_R: u16 = 0x0F;
745#[cfg(target_os = "macos")]
746const VK_S: u16 = 0x01;
747#[cfg(target_os = "macos")]
748const VK_T: u16 = 0x11;
749#[cfg(target_os = "macos")]
750const VK_V: u16 = 0x09;
751#[cfg(target_os = "macos")]
752const VK_W: u16 = 0x0D;
753#[cfg(target_os = "macos")]
754const VK_X: u16 = 0x07;
755#[cfg(target_os = "macos")]
756const VK_Z: u16 = 0x06;
757#[cfg(target_os = "macos")]
758const VK_TAB: u16 = 0x30;
759
760#[cfg(target_os = "macos")]
762mod macos {
763 use core_graphics::event::{
764 CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, ScrollEventUnit,
765 };
766 use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
767 use core_graphics::geometry::CGPoint;
768
769 use crate::binding::Action;
770
771 pub(super) fn post_click(button: CGMouseButton) {
779 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
780 tracing::warn!("CGEventSource::new failed for click");
781 return;
782 };
783 let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
786 let (down, up) = match button {
787 CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp),
788 CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp),
789 CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp),
790 };
791 for (kind, phase) in [(down, "down"), (up, "up")] {
792 if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) {
793 ev.post(CGEventTapLocation::HID);
794 } else {
795 tracing::warn!(phase, "CGEvent::new_mouse_event failed");
796 }
797 }
798 }
799
800 pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
802 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
803 tracing::warn!("CGEventSource::new failed");
804 return;
805 };
806 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
807 tracing::warn!("CGEvent::new_keyboard_event(down) failed");
808 return;
809 };
810 down.set_flags(flags);
811 down.post(CGEventTapLocation::HID);
812 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
813 tracing::warn!("CGEvent::new_keyboard_event(up) failed");
814 return;
815 };
816 up.set_flags(flags);
817 up.post(CGEventTapLocation::HID);
818 }
819
820 pub(super) fn post_media_key(kind: i32) {
829 let nx_key: i64 = match kind {
831 0 => 16,
832 1 => 17,
833 _ => 18,
834 };
835 tracing::debug!(
836 nx_key,
837 "media key event: NSSystemDefined stub — full AppKit impl tracked in P1.x"
838 );
839 }
840
841 pub(super) fn post_scroll(action: &Action) {
843 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
844 tracing::warn!("CGEventSource::new failed for scroll");
845 return;
846 };
847 let (v, h): (i32, i32) = match action {
848 Action::ScrollUp => (3, 0),
849 Action::ScrollDown => (-3, 0),
850 Action::HorizontalScrollLeft => (0, -3),
851 Action::HorizontalScrollRight => (0, 3),
852 _ => return,
853 };
854 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
855 tracing::warn!("CGEvent::new_scroll_event failed");
856 return;
857 };
858 ev.post(CGEventTapLocation::HID);
859 }
860
861 pub(super) fn post_horizontal_scroll(delta: i32) {
864 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
865 tracing::warn!("CGEventSource::new failed for thumbwheel scroll");
866 return;
867 };
868 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else {
869 tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel");
870 return;
871 };
872 ev.post(CGEventTapLocation::HID);
873 }
874
875 pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop};
876 pub(super) use symbolic_hotkey::{next_desktop, previous_desktop};
877
878 use app_services::symbol as app_services_symbol;
879
880 #[allow(
883 unsafe_code,
884 reason = "private ApplicationServices SPI symbols are resolved via dlopen/dlsym FFI"
885 )]
886 mod app_services {
887 use std::ffi::{CStr, c_char, c_int, c_void};
888 use std::sync::OnceLock;
889
890 pub(super) fn symbol(symbol: &CStr) -> Option<*mut c_void> {
894 const RTLD_LAZY: c_int = 0x1;
895 const APP_SERVICES: &CStr =
896 c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices";
897 static HANDLE: OnceLock<usize> = OnceLock::new();
898
899 let sym = unsafe {
903 let handle =
904 *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize);
905 if handle == 0 {
906 return None;
907 }
908 dlsym(handle as *mut c_void, symbol.as_ptr())
909 };
910 (!sym.is_null()).then_some(sym)
911 }
912
913 unsafe extern "C" {
914 fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
915 fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
916 }
917 }
918
919 #[allow(
932 unsafe_code,
933 reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI"
934 )]
935 mod dock {
936 use std::ffi::{c_int, c_void};
937
938 use core_foundation::base::TCFType;
939 use core_foundation::string::CFString;
940
941 use super::app_services_symbol;
942
943 pub(crate) fn mission_control() {
945 send("com.apple.expose.awake");
946 }
947
948 pub(crate) fn app_expose() {
950 send("com.apple.expose.front.awake");
951 }
952
953 pub(crate) fn show_desktop() {
955 send("com.apple.showdesktop.awake");
956 }
957
958 pub(crate) fn launchpad() {
960 send("com.apple.launchpad.toggle");
961 }
962
963 fn send(notification: &str) {
965 let Some(core_dock_send) = core_dock_send_notification() else {
966 tracing::warn!(notification, "CoreDockSendNotification unavailable");
967 return;
968 };
969 let name = CFString::new(notification);
970 let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) };
973 if err != 0 {
974 tracing::warn!(notification, err, "CoreDockSendNotification failed");
975 }
976 }
977
978 type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int;
979
980 fn core_dock_send_notification() -> Option<CoreDockSendNotificationFn> {
983 let sym = app_services_symbol(c"CoreDockSendNotification")?;
984 Some(unsafe { std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym) })
986 }
987 }
988
989 #[allow(
996 unsafe_code,
997 reason = "CGS symbolic hotkey SPI is only reachable via dlopen/dlsym FFI"
998 )]
999 mod symbolic_hotkey {
1000 use std::ffi::{c_int, c_uint, c_ushort, c_void};
1001
1002 use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation};
1003 use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
1004
1005 use super::app_services_symbol;
1006
1007 const SPACE_LEFT: u32 = 79;
1008 const SPACE_RIGHT: u32 = 81;
1009
1010 pub(crate) fn previous_desktop() {
1012 post_symbolic_hotkey(SPACE_LEFT);
1013 }
1014
1015 pub(crate) fn next_desktop() {
1017 post_symbolic_hotkey(SPACE_RIGHT);
1018 }
1019
1020 fn post_symbolic_hotkey(hotkey: u32) {
1021 let Some(cgs) = cgs_hotkey_api() else {
1022 tracing::warn!(hotkey, "CGS symbolic hotkey API unavailable");
1023 return;
1024 };
1025
1026 let mut key_equivalent = 0_u16;
1027 let mut virtual_key = 0_u16;
1028 let mut modifiers = 0_u32;
1029
1030 let err = unsafe {
1033 (cgs.get_value)(
1034 hotkey,
1035 &raw mut key_equivalent,
1036 &raw mut virtual_key,
1037 &raw mut modifiers,
1038 )
1039 };
1040 if err != 0 {
1041 tracing::warn!(hotkey, err, "CGSGetSymbolicHotKeyValue failed");
1042 return;
1043 }
1044
1045 let was_enabled = unsafe { (cgs.is_enabled)(hotkey) };
1048 if !was_enabled {
1049 let err = unsafe { (cgs.set_enabled)(hotkey, true) };
1052 if err != 0 {
1053 tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(true) failed");
1054 }
1055 }
1056
1057 post_key(virtual_key, modifiers);
1058
1059 if !was_enabled {
1060 let err = unsafe { (cgs.set_enabled)(hotkey, false) };
1063 if err != 0 {
1064 tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(false) failed");
1065 }
1066 }
1067 }
1068
1069 fn post_key(vk: u16, modifiers: u32) {
1070 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1071 tracing::warn!("CGEventSource::new failed for symbolic hotkey");
1072 return;
1073 };
1074 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1075 tracing::warn!(vk, "CGEvent::new_keyboard_event(down) failed");
1076 return;
1077 };
1078 let flags = CGEventFlags::from_bits_truncate(u64::from(modifiers));
1079 down.set_flags(flags);
1080 down.post(CGEventTapLocation::Session);
1081
1082 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1083 tracing::warn!(vk, "CGEvent::new_keyboard_event(up) failed");
1084 return;
1085 };
1086 up.set_flags(flags);
1087 up.post(CGEventTapLocation::Session);
1088 }
1089
1090 #[derive(Clone, Copy)]
1091 struct CgsHotkeyApi {
1092 get_value: CgsGetSymbolicHotKeyValueFn,
1093 is_enabled: CgsIsSymbolicHotKeyEnabledFn,
1094 set_enabled: CgsSetSymbolicHotKeyEnabledFn,
1095 }
1096
1097 type CgsGetSymbolicHotKeyValueFn =
1098 unsafe extern "C" fn(c_uint, *mut c_ushort, *mut c_ushort, *mut c_uint) -> c_int;
1099 type CgsIsSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint) -> bool;
1100 type CgsSetSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint, bool) -> c_int;
1101
1102 fn cgs_hotkey_api() -> Option<CgsHotkeyApi> {
1103 let get_value = app_services_symbol(c"CGSGetSymbolicHotKeyValue")?;
1104 let is_enabled = app_services_symbol(c"CGSIsSymbolicHotKeyEnabled")?;
1105 let set_enabled = app_services_symbol(c"CGSSetSymbolicHotKeyEnabled")?;
1106
1107 Some(unsafe {
1110 CgsHotkeyApi {
1111 get_value: std::mem::transmute::<*mut c_void, CgsGetSymbolicHotKeyValueFn>(
1112 get_value,
1113 ),
1114 is_enabled: std::mem::transmute::<*mut c_void, CgsIsSymbolicHotKeyEnabledFn>(
1115 is_enabled,
1116 ),
1117 set_enabled: std::mem::transmute::<*mut c_void, CgsSetSymbolicHotKeyEnabledFn>(
1118 set_enabled,
1119 ),
1120 }
1121 })
1122 }
1123 }
1124}
1125
1126#[must_use]
1138pub fn default_binding(button: ButtonId) -> Action {
1139 match button {
1140 ButtonId::LeftClick => Action::LeftClick,
1141 ButtonId::RightClick => Action::RightClick,
1142 ButtonId::MiddleClick => Action::MiddleClick,
1143 ButtonId::Back => Action::BrowserBack,
1144 ButtonId::Forward => Action::BrowserForward,
1145 ButtonId::DpiToggle => Action::CycleDpiPresets,
1146 ButtonId::Thumbwheel => Action::AppExpose,
1147 ButtonId::GestureButton => Action::MissionControl,
1148 }
1149}
1150
1151#[must_use]
1155pub fn default_gesture_binding(direction: GestureDirection) -> Action {
1156 match direction {
1157 GestureDirection::Up => Action::MissionControl,
1158 GestureDirection::Down => Action::ShowDesktop,
1159 GestureDirection::Left => Action::PrevTab,
1160 GestureDirection::Right => Action::NextTab,
1161 GestureDirection::Click => Action::AppExpose,
1162 }
1163}
1164
1165#[cfg(test)]
1166#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
1167mod tests {
1168 use std::collections::BTreeMap;
1169
1170 use serde::{Deserialize, Serialize};
1171
1172 use super::*;
1173
1174 #[derive(Serialize, Deserialize)]
1179 struct RoundtripWrapper {
1180 binding: BTreeMap<ButtonId, Action>,
1181 }
1182
1183 #[test]
1186 fn catalog_has_at_least_29_entries() {
1187 let catalog = Action::catalog();
1188 assert!(
1189 catalog.len() >= 29,
1190 "catalog has {} entries, need ≥ 29",
1191 catalog.len()
1192 );
1193 }
1194
1195 #[test]
1196 fn catalog_excludes_custom_shortcut() {
1197 let catalog = Action::catalog();
1198 for action in &catalog {
1199 assert!(
1200 !matches!(action, Action::CustomShortcut(_)),
1201 "catalog must not contain CustomShortcut"
1202 );
1203 }
1204 }
1205
1206 #[test]
1209 fn detect_swipe_below_threshold_keeps_accumulating() {
1210 assert_eq!(detect_swipe(40, 5), None);
1212 assert_eq!(detect_swipe(0, 0), None);
1213 }
1214
1215 #[test]
1216 fn detect_swipe_commits_clean_direction() {
1217 assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
1218 assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
1219 assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
1220 assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
1221 }
1222
1223 #[test]
1224 fn detect_swipe_rejects_diagonal() {
1225 assert_eq!(detect_swipe(60, 60), None);
1227 assert_eq!(detect_swipe(-60, -60), None);
1228 }
1229
1230 fn roundtrip(action: &Action) -> Action {
1235 let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
1236 map.insert(ButtonId::Back, action.clone());
1237 let w = RoundtripWrapper { binding: map };
1238 let s = toml::to_string(&w).expect("serialize");
1239 let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
1240 back.binding
1241 .into_values()
1242 .next()
1243 .expect("binding present after roundtrip")
1244 }
1245
1246 #[test]
1247 fn all_catalog_variants_roundtrip_toml() {
1248 for action in Action::catalog() {
1249 let back = roundtrip(&action);
1250 assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
1251 }
1252 }
1253
1254 #[test]
1255 fn custom_shortcut_roundtrips_toml() {
1256 let action = Action::CustomShortcut(KeyCombo {
1257 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1258 key_code: 0x23, display: "⌘⇧P".into(),
1260 });
1261 assert_eq!(roundtrip(&action), action);
1262 }
1263
1264 #[test]
1265 fn key_combo_rendered_label_uses_display_when_set() {
1266 let combo = KeyCombo {
1267 modifiers: 0,
1268 key_code: 0,
1269 display: "preset".into(),
1270 };
1271 assert_eq!(combo.rendered_label(), "preset");
1272 }
1273
1274 #[test]
1275 fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
1276 let combo = KeyCombo {
1277 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1278 key_code: 0x23, display: String::new(),
1280 };
1281 assert_eq!(combo.rendered_label(), "⇧⌘P");
1282 }
1283
1284 #[test]
1287 fn category_editing_variants() {
1288 assert_eq!(Action::Copy.category(), Category::Editing);
1289 assert_eq!(Action::Undo.category(), Category::Editing);
1290 assert_eq!(Action::SelectAll.category(), Category::Editing);
1291 assert_eq!(Action::Find.category(), Category::Editing);
1292 assert_eq!(Action::Save.category(), Category::Editing);
1293 assert_eq!(Action::Cut.category(), Category::Editing);
1294 assert_eq!(Action::Redo.category(), Category::Editing);
1295 assert_eq!(Action::Paste.category(), Category::Editing);
1296 }
1297
1298 #[test]
1299 fn category_browser_variants() {
1300 assert_eq!(Action::BrowserBack.category(), Category::Browser);
1301 assert_eq!(Action::BrowserForward.category(), Category::Browser);
1302 assert_eq!(Action::NewTab.category(), Category::Browser);
1303 assert_eq!(Action::CloseTab.category(), Category::Browser);
1304 assert_eq!(Action::ReopenTab.category(), Category::Browser);
1305 assert_eq!(Action::NextTab.category(), Category::Browser);
1306 assert_eq!(Action::PrevTab.category(), Category::Browser);
1307 assert_eq!(Action::ReloadPage.category(), Category::Browser);
1308 }
1309
1310 #[test]
1311 fn category_media_variants() {
1312 assert_eq!(Action::PlayPause.category(), Category::Media);
1313 assert_eq!(Action::NextTrack.category(), Category::Media);
1314 assert_eq!(Action::PrevTrack.category(), Category::Media);
1315 assert_eq!(Action::VolumeUp.category(), Category::Media);
1316 assert_eq!(Action::VolumeDown.category(), Category::Media);
1317 assert_eq!(Action::MuteVolume.category(), Category::Media);
1318 }
1319
1320 #[test]
1321 fn category_mouse_variants() {
1322 assert_eq!(Action::LeftClick.category(), Category::Mouse);
1323 assert_eq!(Action::RightClick.category(), Category::Mouse);
1324 assert_eq!(Action::MiddleClick.category(), Category::Mouse);
1325 }
1326
1327 #[test]
1328 fn category_dpi_variants() {
1329 assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
1330 assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
1331 }
1332
1333 #[test]
1334 fn category_scroll_variants() {
1335 assert_eq!(Action::ScrollUp.category(), Category::Scroll);
1336 assert_eq!(Action::ScrollDown.category(), Category::Scroll);
1337 assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
1338 assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
1339 }
1340
1341 #[test]
1342 fn category_navigation_variants() {
1343 assert_eq!(Action::MissionControl.category(), Category::Navigation);
1344 assert_eq!(Action::AppExpose.category(), Category::Navigation);
1345 assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
1346 assert_eq!(Action::NextDesktop.category(), Category::Navigation);
1347 assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
1348 assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
1349 }
1350
1351 #[test]
1352 fn category_system_variants() {
1353 assert_eq!(Action::LockScreen.category(), Category::System);
1354 assert_eq!(Action::Screenshot.category(), Category::System);
1355 }
1356
1357 #[test]
1360 fn category_labels_are_nonempty() {
1361 let categories = [
1362 Category::Editing,
1363 Category::Browser,
1364 Category::Media,
1365 Category::Mouse,
1366 Category::Dpi,
1367 Category::Scroll,
1368 Category::Navigation,
1369 Category::System,
1370 ];
1371 for cat in categories {
1372 assert!(!cat.label().is_empty(), "label empty for {cat:?}");
1373 }
1374 }
1375
1376 #[test]
1379 fn dpi_toggle_default_is_cycle_dpi_presets() {
1380 assert_eq!(
1381 default_binding(ButtonId::DpiToggle),
1382 Action::CycleDpiPresets
1383 );
1384 }
1385}