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,
31 ThumbwheelScrollUp,
34 ThumbwheelScrollDown,
36 GestureButton,
39}
40
41impl ButtonId {
42 pub const ALL: [ButtonId; 10] = [
43 ButtonId::LeftClick,
44 ButtonId::RightClick,
45 ButtonId::MiddleClick,
46 ButtonId::Back,
47 ButtonId::Forward,
48 ButtonId::DpiToggle,
49 ButtonId::Thumbwheel,
50 ButtonId::ThumbwheelScrollUp,
51 ButtonId::ThumbwheelScrollDown,
52 ButtonId::GestureButton,
53 ];
54
55 #[must_use]
57 pub fn label(self) -> &'static str {
58 match self {
59 ButtonId::LeftClick => "Left Click",
60 ButtonId::RightClick => "Right Click",
61 ButtonId::MiddleClick => "Middle Click",
62 ButtonId::Back => "Back",
63 ButtonId::Forward => "Forward",
64 ButtonId::DpiToggle => "DPI Toggle",
65 ButtonId::Thumbwheel => "Thumb Wheel",
66 ButtonId::ThumbwheelScrollUp => "Thumb Wheel Up",
67 ButtonId::ThumbwheelScrollDown => "Thumb Wheel Down",
68 ButtonId::GestureButton => "Gesture Button",
69 }
70 }
71}
72
73impl fmt::Display for ButtonId {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 f.write_str(self.label())
76 }
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
87pub enum GestureDirection {
88 Up,
89 Down,
90 Left,
91 Right,
92 Click,
93}
94
95impl GestureDirection {
96 pub const ALL: [GestureDirection; 5] = [
97 GestureDirection::Up,
98 GestureDirection::Down,
99 GestureDirection::Left,
100 GestureDirection::Right,
101 GestureDirection::Click,
102 ];
103
104 #[must_use]
105 pub fn label(self) -> &'static str {
106 match self {
107 GestureDirection::Up => "Up",
108 GestureDirection::Down => "Down",
109 GestureDirection::Left => "Left",
110 GestureDirection::Right => "Right",
111 GestureDirection::Click => "Click",
112 }
113 }
114
115 #[must_use]
117 pub fn glyph(self) -> &'static str {
118 match self {
119 GestureDirection::Up => "↑",
120 GestureDirection::Down => "↓",
121 GestureDirection::Left => "←",
122 GestureDirection::Right => "→",
123 GestureDirection::Click => "·",
124 }
125 }
126}
127
128impl fmt::Display for GestureDirection {
129 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130 f.write_str(self.label())
131 }
132}
133
134pub const GESTURE_SWIPE_THRESHOLD: i32 = 50;
137pub const GESTURE_SWIPE_DEADZONE: i32 = 40;
140
141#[must_use]
154pub fn detect_swipe(dx: i32, dy: i32) -> Option<GestureDirection> {
155 let (abs_x, abs_y) = (dx.abs(), dy.abs());
156 let dominant = abs_x.max(abs_y);
157 if dominant < GESTURE_SWIPE_THRESHOLD {
158 return None;
159 }
160 let cross_limit = GESTURE_SWIPE_DEADZONE.max(dominant * 35 / 100);
161 if abs_x > abs_y {
162 if abs_y > cross_limit {
163 return None;
164 }
165 Some(if dx > 0 {
166 GestureDirection::Right
167 } else {
168 GestureDirection::Left
169 })
170 } else {
171 if abs_x > cross_limit {
172 return None;
173 }
174 Some(if dy > 0 {
175 GestureDirection::Down
176 } else {
177 GestureDirection::Up
178 })
179 }
180}
181
182#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
187pub enum Category {
188 Editing,
190 Browser,
192 Media,
194 Mouse,
196 Dpi,
198 Scroll,
200 Navigation,
202 System,
204}
205
206impl Category {
207 #[must_use]
210 pub fn label(self) -> &'static str {
211 match self {
212 Category::Editing => "EDITING",
213 Category::Browser => "BROWSER",
214 Category::Media => "MEDIA",
215 Category::Mouse => "MOUSE",
216 Category::Dpi => "DPI",
217 Category::Scroll => "SCROLL",
218 Category::Navigation => "NAVIGATION",
219 Category::System => "SYSTEM",
220 }
221 }
222}
223
224#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
246pub enum Action {
247 None,
251
252 LeftClick,
255 RightClick,
257 MiddleClick,
259
260 Copy,
263 Paste,
265 Cut,
267 Undo,
269 Redo,
276 SelectAll,
278 Find,
280 Save,
282
283 BrowserBack,
286 BrowserForward,
288 NewTab,
290 CloseTab,
292 ReopenTab,
294 NextTab,
296 PrevTab,
298 ReloadPage,
300
301 MissionControl,
304 AppExpose,
306 PreviousDesktop,
308 NextDesktop,
310 ShowDesktop,
312 LaunchpadShow,
314
315 LockScreen,
322 Screenshot,
324
325 PlayPause,
328 NextTrack,
330 PrevTrack,
332 VolumeUp,
334 VolumeDown,
336 MuteVolume,
338
339 CycleDpiPresets,
342 SetDpiPreset(u8),
345 ToggleSmartShift,
347
348 ScrollUp,
351 ScrollDown,
353 HorizontalScrollLeft,
355 HorizontalScrollRight,
357
358 CustomShortcut(KeyCombo),
366}
367
368#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
380pub struct KeyCombo {
381 pub modifiers: u8,
383 pub key_code: u16,
387 #[serde(default)]
390 pub display: String,
391}
392
393impl KeyCombo {
394 pub const MOD_CMD: u8 = 1 << 0;
395 pub const MOD_SHIFT: u8 = 1 << 1;
396 pub const MOD_CTRL: u8 = 1 << 2;
397 pub const MOD_OPTION: u8 = 1 << 3;
398
399 #[must_use]
404 pub fn rendered_label(&self) -> String {
405 if !self.display.is_empty() {
406 return self.display.clone();
407 }
408 let mut out = String::new();
409 if self.modifiers & Self::MOD_CTRL != 0 {
410 out.push('⌃');
411 }
412 if self.modifiers & Self::MOD_OPTION != 0 {
413 out.push('⌥');
414 }
415 if self.modifiers & Self::MOD_SHIFT != 0 {
416 out.push('⇧');
417 }
418 if self.modifiers & Self::MOD_CMD != 0 {
419 out.push('⌘');
420 }
421 match self.key_code {
422 0x00 => out.push('A'),
423 0x01 => out.push('S'),
424 0x02 => out.push('D'),
425 0x03 => out.push('F'),
426 0x06 => out.push('Z'),
427 0x07 => out.push('X'),
428 0x08 => out.push('C'),
429 0x09 => out.push('V'),
430 0x0B => out.push('B'),
431 0x0C => out.push('Q'),
432 0x0D => out.push('W'),
433 0x0E => out.push('E'),
434 0x0F => out.push('R'),
435 0x10 => out.push('Y'),
436 0x11 => out.push('T'),
437 0x20 => out.push('U'),
438 0x22 => out.push('I'),
439 0x1F => out.push('O'),
440 0x23 => out.push('P'),
441 _ => {
442 use std::fmt::Write as _;
443 let _ = write!(out, "key 0x{:02X}", self.key_code);
444 }
445 }
446 out
447 }
448}
449
450impl Action {
451 #[must_use]
457 pub fn label(&self) -> String {
458 match self {
459 Action::None => "Do Nothing".into(),
460 Action::LeftClick => "Left Click".into(),
461 Action::RightClick => "Right Click".into(),
462 Action::MiddleClick => "Middle Click".into(),
463 Action::Copy => "Copy".into(),
464 Action::Paste => "Paste".into(),
465 Action::Cut => "Cut".into(),
466 Action::Undo => "Undo".into(),
467 Action::Redo => "Redo".into(),
468 Action::SelectAll => "Select All".into(),
469 Action::Find => "Find".into(),
470 Action::Save => "Save".into(),
471 Action::BrowserBack => "Browser Back".into(),
472 Action::BrowserForward => "Browser Forward".into(),
473 Action::NewTab => "New Tab".into(),
474 Action::CloseTab => "Close Tab".into(),
475 Action::ReopenTab => "Reopen Tab".into(),
476 Action::NextTab => "Next Tab".into(),
477 Action::PrevTab => "Previous Tab".into(),
478 Action::ReloadPage => "Reload Page".into(),
479 Action::MissionControl => "Mission Control".into(),
480 Action::AppExpose => "App Exposé".into(),
481 Action::PreviousDesktop => "Previous Desktop".into(),
482 Action::NextDesktop => "Next Desktop".into(),
483 Action::ShowDesktop => "Show Desktop".into(),
484 Action::LaunchpadShow => "Launchpad".into(),
485 Action::LockScreen => "Lock Screen".into(),
486 Action::Screenshot => "Screenshot".into(),
487 Action::PlayPause => "Play / Pause".into(),
488 Action::NextTrack => "Next Track".into(),
489 Action::PrevTrack => "Previous Track".into(),
490 Action::VolumeUp => "Volume Up".into(),
491 Action::VolumeDown => "Volume Down".into(),
492 Action::MuteVolume => "Mute".into(),
493 Action::CycleDpiPresets => "Cycle DPI Presets".into(),
494 Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
495 Action::ToggleSmartShift => "Toggle SmartShift".into(),
496 Action::ScrollUp => "Scroll Up".into(),
497 Action::ScrollDown => "Scroll Down".into(),
498 Action::HorizontalScrollLeft => "Scroll Left".into(),
499 Action::HorizontalScrollRight => "Scroll Right".into(),
500 Action::CustomShortcut(combo) => combo.rendered_label(),
501 }
502 }
503
504 #[must_use]
506 pub fn category(&self) -> Category {
507 match self {
508 Action::LeftClick | Action::RightClick | Action::MiddleClick => Category::Mouse,
509 Action::Copy
512 | Action::Paste
513 | Action::Cut
514 | Action::Undo
515 | Action::Redo
516 | Action::SelectAll
517 | Action::Find
518 | Action::Save
519 | Action::CustomShortcut(_) => Category::Editing,
520 Action::BrowserBack
521 | Action::BrowserForward
522 | Action::NewTab
523 | Action::CloseTab
524 | Action::ReopenTab
525 | Action::NextTab
526 | Action::PrevTab
527 | Action::ReloadPage => Category::Browser,
528 Action::MissionControl
529 | Action::AppExpose
530 | Action::PreviousDesktop
531 | Action::NextDesktop
532 | Action::ShowDesktop
533 | Action::LaunchpadShow => Category::Navigation,
534 Action::None | Action::LockScreen | Action::Screenshot => Category::System,
535 Action::PlayPause
536 | Action::NextTrack
537 | Action::PrevTrack
538 | Action::VolumeUp
539 | Action::VolumeDown
540 | Action::MuteVolume => Category::Media,
541 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
542 Category::Dpi
543 }
544 Action::ScrollUp
545 | Action::ScrollDown
546 | Action::HorizontalScrollLeft
547 | Action::HorizontalScrollRight => Category::Scroll,
548 }
549 }
550
551 #[must_use]
556 pub fn catalog() -> Vec<Action> {
557 vec![
558 Action::LeftClick,
560 Action::RightClick,
561 Action::MiddleClick,
562 Action::Copy,
564 Action::Paste,
565 Action::Cut,
566 Action::Undo,
567 Action::Redo,
568 Action::SelectAll,
569 Action::Find,
570 Action::Save,
571 Action::BrowserBack,
573 Action::BrowserForward,
574 Action::NewTab,
575 Action::CloseTab,
576 Action::ReopenTab,
577 Action::NextTab,
578 Action::PrevTab,
579 Action::ReloadPage,
580 Action::MissionControl,
582 Action::AppExpose,
583 Action::PreviousDesktop,
584 Action::NextDesktop,
585 Action::ShowDesktop,
586 Action::LaunchpadShow,
587 Action::None,
589 Action::LockScreen,
590 Action::Screenshot,
591 Action::PlayPause,
593 Action::NextTrack,
594 Action::PrevTrack,
595 Action::VolumeUp,
596 Action::VolumeDown,
597 Action::MuteVolume,
598 Action::CycleDpiPresets,
600 Action::ToggleSmartShift,
601 Action::ScrollUp,
603 Action::ScrollDown,
604 Action::HorizontalScrollLeft,
605 Action::HorizontalScrollRight,
606 ]
607 }
608
609 pub fn execute(&self) {
630 #[cfg(target_os = "macos")]
631 self.execute_macos();
632
633 #[cfg(target_os = "linux")]
634 self.execute_linux();
635
636 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
637 {
638 tracing::warn!(
639 action = self.label(),
640 "Action::execute unsupported on this platform"
641 );
642 }
643 }
644
645 #[cfg(target_os = "linux")]
647 fn execute_linux(&self) {
648 use evdev::{KeyCode, RelativeAxisCode};
649 let ctrl = KeyCode::KEY_LEFTCTRL;
650 let shift = KeyCode::KEY_LEFTSHIFT;
651 let alt = KeyCode::KEY_LEFTALT;
652 match self {
653 Action::LeftClick => linux::click(KeyCode::BTN_LEFT),
655 Action::RightClick => linux::click(KeyCode::BTN_RIGHT),
656 Action::MiddleClick => linux::click(KeyCode::BTN_MIDDLE),
657 Action::Copy => linux::press_key(&[ctrl], KeyCode::KEY_C),
659 Action::Paste => linux::press_key(&[ctrl], KeyCode::KEY_V),
660 Action::Cut => linux::press_key(&[ctrl], KeyCode::KEY_X),
661 Action::Undo => linux::press_key(&[ctrl], KeyCode::KEY_Z),
662 Action::Redo => linux::press_key(&[ctrl, shift], KeyCode::KEY_Z),
664 Action::SelectAll => linux::press_key(&[ctrl], KeyCode::KEY_A),
665 Action::Find => linux::press_key(&[ctrl], KeyCode::KEY_F),
666 Action::Save => linux::press_key(&[ctrl], KeyCode::KEY_S),
667 Action::BrowserBack => linux::press_key(&[alt], KeyCode::KEY_LEFT),
669 Action::BrowserForward => linux::press_key(&[alt], KeyCode::KEY_RIGHT),
670 Action::NewTab => linux::press_key(&[ctrl], KeyCode::KEY_T),
671 Action::CloseTab => linux::press_key(&[ctrl], KeyCode::KEY_W),
672 Action::ReopenTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_T),
673 Action::NextTab => linux::press_key(&[ctrl], KeyCode::KEY_TAB),
674 Action::PrevTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_TAB),
675 Action::ReloadPage => linux::press_key(&[ctrl], KeyCode::KEY_R),
676 Action::MissionControl
679 | Action::AppExpose
680 | Action::ShowDesktop
681 | Action::LaunchpadShow => {
682 tracing::debug!(
683 action = self.label(),
684 "no Linux equivalent — action skipped"
685 );
686 }
687 Action::PreviousDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_LEFT),
689 Action::NextDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_RIGHT),
690 Action::LockScreen => linux::lock_screen(),
693 Action::Screenshot => linux::press_key(&[], KeyCode::KEY_SYSRQ),
694 Action::PlayPause => linux::mpris_command("PlayPause"),
698 Action::NextTrack => linux::mpris_command("Next"),
699 Action::PrevTrack => linux::mpris_command("Previous"),
700 Action::VolumeUp => linux::press_key(&[], KeyCode::KEY_VOLUMEUP),
701 Action::VolumeDown => linux::press_key(&[], KeyCode::KEY_VOLUMEDOWN),
702 Action::MuteVolume => linux::press_key(&[], KeyCode::KEY_MUTE),
703 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
705 tracing::debug!(
706 action = self.label(),
707 "device action handled by hook/HID layer"
708 );
709 }
710 Action::ScrollUp => linux::scroll(RelativeAxisCode::REL_WHEEL, 3),
712 Action::ScrollDown => linux::scroll(RelativeAxisCode::REL_WHEEL, -3),
713 Action::HorizontalScrollLeft => linux::scroll(RelativeAxisCode::REL_HWHEEL, -3),
714 Action::HorizontalScrollRight => linux::scroll(RelativeAxisCode::REL_HWHEEL, 3),
715 Action::None => {}
717 Action::CustomShortcut(combo) => {
719 if combo.key_code == 0 {
720 tracing::warn!(
721 chord = %combo.rendered_label(),
722 "CustomShortcut with no key code — press ignored"
723 );
724 return;
725 }
726 let Some(key) = linux::macos_vk_to_linux(combo.key_code) else {
727 tracing::warn!(
728 key_code = combo.key_code,
729 "CustomShortcut key code has no Linux mapping — press ignored"
730 );
731 return;
732 };
733 linux::press_key(&linux::modifiers_to_keycodes(combo.modifiers), key);
734 }
735 }
736 }
737
738 #[cfg(target_os = "macos")]
740 fn execute_macos(&self) {
741 use core_graphics::event::{CGEventFlags, CGMouseButton};
742
743 let cmd = CGEventFlags::CGEventFlagCommand;
745 let shift = CGEventFlags::CGEventFlagShift;
746 let ctrl = CGEventFlags::CGEventFlagControl;
747 let none = CGEventFlags::CGEventFlagNull;
748
749 match self {
750 Action::None => {}
752 Action::LeftClick => macos::post_click(CGMouseButton::Left),
757 Action::RightClick => macos::post_click(CGMouseButton::Right),
758 Action::MiddleClick => macos::post_click(CGMouseButton::Center),
759 Action::Copy => macos::post_key(VK_C, cmd),
761 Action::Paste => macos::post_key(VK_V, cmd),
762 Action::Cut => macos::post_key(VK_X, cmd),
763 Action::Undo => macos::post_key(VK_Z, cmd),
764 Action::Redo => macos::post_key(VK_Z, cmd | shift),
765 Action::SelectAll => macos::post_key(VK_A, cmd),
766 Action::Find => macos::post_key(VK_F, cmd),
767 Action::Save => macos::post_key(VK_S, cmd),
768 Action::BrowserBack => macos::post_key(0x21, cmd),
773 Action::BrowserForward => macos::post_key(0x1E, cmd),
774 Action::NewTab => macos::post_key(VK_T, cmd),
775 Action::CloseTab => macos::post_key(VK_W, cmd),
776 Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
777 Action::NextTab => macos::post_key(VK_TAB, ctrl),
778 Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
779 Action::ReloadPage => macos::post_key(VK_R, cmd),
780 Action::MissionControl => macos::mission_control(),
787 Action::AppExpose => macos::app_expose(),
788 Action::PreviousDesktop => macos::previous_desktop(),
789 Action::NextDesktop => macos::next_desktop(),
790 Action::ShowDesktop => macos::show_desktop(),
791 Action::LaunchpadShow => macos::launchpad(),
792 Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
795 Action::Screenshot => macos::post_key(0x14, cmd | shift),
797 Action::PlayPause => macos::post_media_key(0),
800 Action::NextTrack => macos::post_media_key(1),
801 Action::PrevTrack => macos::post_media_key(2),
802 Action::VolumeUp => macos::post_key(0x48, none),
804 Action::VolumeDown => macos::post_key(0x49, none),
805 Action::MuteVolume => macos::post_key(0x4A, none),
806 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
808 tracing::debug!(
809 action = self.label(),
810 "device action handled by hook/HID layer"
811 );
812 }
813 Action::ScrollUp
815 | Action::ScrollDown
816 | Action::HorizontalScrollLeft
817 | Action::HorizontalScrollRight => macos::post_scroll(self),
818 Action::CustomShortcut(combo) => {
820 if combo.key_code == 0 {
825 tracing::warn!(
826 chord = %combo.rendered_label(),
827 "CustomShortcut with no key code — press ignored"
828 );
829 return;
830 }
831 let mut flags = CGEventFlags::CGEventFlagNull;
832 if combo.modifiers & KeyCombo::MOD_CMD != 0 {
833 flags |= CGEventFlags::CGEventFlagCommand;
834 }
835 if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
836 flags |= CGEventFlags::CGEventFlagShift;
837 }
838 if combo.modifiers & KeyCombo::MOD_CTRL != 0 {
839 flags |= CGEventFlags::CGEventFlagControl;
840 }
841 if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
842 flags |= CGEventFlags::CGEventFlagAlternate;
843 }
844 macos::post_key(combo.key_code, flags);
845 }
846 }
847 }
848}
849
850pub fn post_horizontal_scroll(delta: i32) {
860 #[cfg(target_os = "macos")]
861 macos::post_horizontal_scroll(delta);
862
863 #[cfg(target_os = "linux")]
868 linux::scroll(evdev::RelativeAxisCode::REL_HWHEEL, delta);
869
870 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
871 let _ = delta;
872}
873
874#[cfg(target_os = "linux")]
881#[must_use]
882pub fn action_device_path() -> Option<std::path::PathBuf> {
883 linux::device_node()
884}
885
886#[cfg(target_os = "macos")]
890const VK_A: u16 = 0x00;
891#[cfg(target_os = "macos")]
892const VK_C: u16 = 0x08;
893#[cfg(target_os = "macos")]
894const VK_F: u16 = 0x03;
895#[cfg(target_os = "macos")]
896const VK_R: u16 = 0x0F;
897#[cfg(target_os = "macos")]
898const VK_S: u16 = 0x01;
899#[cfg(target_os = "macos")]
900const VK_T: u16 = 0x11;
901#[cfg(target_os = "macos")]
902const VK_V: u16 = 0x09;
903#[cfg(target_os = "macos")]
904const VK_W: u16 = 0x0D;
905#[cfg(target_os = "macos")]
906const VK_X: u16 = 0x07;
907#[cfg(target_os = "macos")]
908const VK_Z: u16 = 0x06;
909#[cfg(target_os = "macos")]
910const VK_TAB: u16 = 0x30;
911
912#[cfg(target_os = "macos")]
914mod macos {
915 use core_graphics::event::{
916 CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, ScrollEventUnit,
917 };
918 use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
919 use core_graphics::geometry::CGPoint;
920
921 use crate::binding::Action;
922
923 pub(super) fn post_click(button: CGMouseButton) {
931 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
932 tracing::warn!("CGEventSource::new failed for click");
933 return;
934 };
935 let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
938 let (down, up) = match button {
939 CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp),
940 CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp),
941 CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp),
942 };
943 for (kind, phase) in [(down, "down"), (up, "up")] {
944 if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) {
945 ev.post(CGEventTapLocation::HID);
946 } else {
947 tracing::warn!(phase, "CGEvent::new_mouse_event failed");
948 }
949 }
950 }
951
952 pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
954 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
955 tracing::warn!("CGEventSource::new failed");
956 return;
957 };
958 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
959 tracing::warn!("CGEvent::new_keyboard_event(down) failed");
960 return;
961 };
962 down.set_flags(flags);
963 down.post(CGEventTapLocation::HID);
964 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
965 tracing::warn!("CGEvent::new_keyboard_event(up) failed");
966 return;
967 };
968 up.set_flags(flags);
969 up.post(CGEventTapLocation::HID);
970 }
971
972 pub(super) fn post_media_key(kind: i32) {
981 let nx_key: i64 = match kind {
983 0 => 16,
984 1 => 17,
985 _ => 18,
986 };
987 tracing::debug!(
988 nx_key,
989 "media key event: NSSystemDefined stub — full AppKit impl tracked in P1.x"
990 );
991 }
992
993 pub(super) fn post_scroll(action: &Action) {
995 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
996 tracing::warn!("CGEventSource::new failed for scroll");
997 return;
998 };
999 let (v, h): (i32, i32) = match action {
1000 Action::ScrollUp => (3, 0),
1001 Action::ScrollDown => (-3, 0),
1002 Action::HorizontalScrollLeft => (0, -3),
1003 Action::HorizontalScrollRight => (0, 3),
1004 _ => return,
1005 };
1006 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
1007 tracing::warn!("CGEvent::new_scroll_event failed");
1008 return;
1009 };
1010 ev.post(CGEventTapLocation::HID);
1011 }
1012
1013 pub(super) fn post_horizontal_scroll(delta: i32) {
1016 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1017 tracing::warn!("CGEventSource::new failed for thumbwheel scroll");
1018 return;
1019 };
1020 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else {
1021 tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel");
1022 return;
1023 };
1024 ev.post(CGEventTapLocation::HID);
1025 }
1026
1027 pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop};
1028 pub(super) use symbolic_hotkey::{next_desktop, previous_desktop};
1029
1030 use app_services::symbol as app_services_symbol;
1031
1032 #[allow(
1035 unsafe_code,
1036 reason = "private ApplicationServices SPI symbols are resolved via dlopen/dlsym FFI"
1037 )]
1038 mod app_services {
1039 use std::ffi::{CStr, c_char, c_int, c_void};
1040 use std::sync::OnceLock;
1041
1042 pub(super) fn symbol(symbol: &CStr) -> Option<*mut c_void> {
1046 const RTLD_LAZY: c_int = 0x1;
1047 const APP_SERVICES: &CStr =
1048 c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices";
1049 static HANDLE: OnceLock<usize> = OnceLock::new();
1050
1051 let sym = unsafe {
1055 let handle =
1056 *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize);
1057 if handle == 0 {
1058 return None;
1059 }
1060 dlsym(handle as *mut c_void, symbol.as_ptr())
1061 };
1062 (!sym.is_null()).then_some(sym)
1063 }
1064
1065 unsafe extern "C" {
1066 fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
1067 fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
1068 }
1069 }
1070
1071 #[allow(
1084 unsafe_code,
1085 reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI"
1086 )]
1087 mod dock {
1088 use std::ffi::{c_int, c_void};
1089
1090 use core_foundation::base::TCFType;
1091 use core_foundation::string::CFString;
1092
1093 use super::app_services_symbol;
1094
1095 pub(crate) fn mission_control() {
1097 send("com.apple.expose.awake");
1098 }
1099
1100 pub(crate) fn app_expose() {
1102 send("com.apple.expose.front.awake");
1103 }
1104
1105 pub(crate) fn show_desktop() {
1107 send("com.apple.showdesktop.awake");
1108 }
1109
1110 pub(crate) fn launchpad() {
1112 send("com.apple.launchpad.toggle");
1113 }
1114
1115 fn send(notification: &str) {
1117 let Some(core_dock_send) = core_dock_send_notification() else {
1118 tracing::warn!(notification, "CoreDockSendNotification unavailable");
1119 return;
1120 };
1121 let name = CFString::new(notification);
1122 let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) };
1125 if err != 0 {
1126 tracing::warn!(notification, err, "CoreDockSendNotification failed");
1127 }
1128 }
1129
1130 type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int;
1131
1132 fn core_dock_send_notification() -> Option<CoreDockSendNotificationFn> {
1135 let sym = app_services_symbol(c"CoreDockSendNotification")?;
1136 Some(unsafe { std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym) })
1138 }
1139 }
1140
1141 #[allow(
1148 unsafe_code,
1149 reason = "CGS symbolic hotkey SPI is only reachable via dlopen/dlsym FFI"
1150 )]
1151 mod symbolic_hotkey {
1152 use std::ffi::{c_int, c_uint, c_ushort, c_void};
1153
1154 use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation};
1155 use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
1156
1157 use super::app_services_symbol;
1158
1159 const SPACE_LEFT: u32 = 79;
1160 const SPACE_RIGHT: u32 = 81;
1161
1162 pub(crate) fn previous_desktop() {
1164 post_symbolic_hotkey(SPACE_LEFT);
1165 }
1166
1167 pub(crate) fn next_desktop() {
1169 post_symbolic_hotkey(SPACE_RIGHT);
1170 }
1171
1172 fn post_symbolic_hotkey(hotkey: u32) {
1173 let Some(cgs) = cgs_hotkey_api() else {
1174 tracing::warn!(hotkey, "CGS symbolic hotkey API unavailable");
1175 return;
1176 };
1177
1178 let mut key_equivalent = 0_u16;
1179 let mut virtual_key = 0_u16;
1180 let mut modifiers = 0_u32;
1181
1182 let err = unsafe {
1185 (cgs.get_value)(
1186 hotkey,
1187 &raw mut key_equivalent,
1188 &raw mut virtual_key,
1189 &raw mut modifiers,
1190 )
1191 };
1192 if err != 0 {
1193 tracing::warn!(hotkey, err, "CGSGetSymbolicHotKeyValue failed");
1194 return;
1195 }
1196
1197 let was_enabled = unsafe { (cgs.is_enabled)(hotkey) };
1200 if !was_enabled {
1201 let err = unsafe { (cgs.set_enabled)(hotkey, true) };
1204 if err != 0 {
1205 tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(true) failed");
1206 }
1207 }
1208
1209 post_key(virtual_key, modifiers);
1210
1211 if !was_enabled {
1212 let err = unsafe { (cgs.set_enabled)(hotkey, false) };
1215 if err != 0 {
1216 tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(false) failed");
1217 }
1218 }
1219 }
1220
1221 fn post_key(vk: u16, modifiers: u32) {
1222 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1223 tracing::warn!("CGEventSource::new failed for symbolic hotkey");
1224 return;
1225 };
1226 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1227 tracing::warn!(vk, "CGEvent::new_keyboard_event(down) failed");
1228 return;
1229 };
1230 let flags = CGEventFlags::from_bits_truncate(u64::from(modifiers));
1231 down.set_flags(flags);
1232 down.post(CGEventTapLocation::Session);
1233
1234 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1235 tracing::warn!(vk, "CGEvent::new_keyboard_event(up) failed");
1236 return;
1237 };
1238 up.set_flags(flags);
1239 up.post(CGEventTapLocation::Session);
1240 }
1241
1242 #[derive(Clone, Copy)]
1243 struct CgsHotkeyApi {
1244 get_value: CgsGetSymbolicHotKeyValueFn,
1245 is_enabled: CgsIsSymbolicHotKeyEnabledFn,
1246 set_enabled: CgsSetSymbolicHotKeyEnabledFn,
1247 }
1248
1249 type CgsGetSymbolicHotKeyValueFn =
1250 unsafe extern "C" fn(c_uint, *mut c_ushort, *mut c_ushort, *mut c_uint) -> c_int;
1251 type CgsIsSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint) -> bool;
1252 type CgsSetSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint, bool) -> c_int;
1253
1254 fn cgs_hotkey_api() -> Option<CgsHotkeyApi> {
1255 let get_value = app_services_symbol(c"CGSGetSymbolicHotKeyValue")?;
1256 let is_enabled = app_services_symbol(c"CGSIsSymbolicHotKeyEnabled")?;
1257 let set_enabled = app_services_symbol(c"CGSSetSymbolicHotKeyEnabled")?;
1258
1259 Some(unsafe {
1262 CgsHotkeyApi {
1263 get_value: std::mem::transmute::<*mut c_void, CgsGetSymbolicHotKeyValueFn>(
1264 get_value,
1265 ),
1266 is_enabled: std::mem::transmute::<*mut c_void, CgsIsSymbolicHotKeyEnabledFn>(
1267 is_enabled,
1268 ),
1269 set_enabled: std::mem::transmute::<*mut c_void, CgsSetSymbolicHotKeyEnabledFn>(
1270 set_enabled,
1271 ),
1272 }
1273 })
1274 }
1275 }
1276}
1277
1278#[must_use]
1290pub fn default_binding(button: ButtonId) -> Action {
1291 match button {
1292 ButtonId::LeftClick => Action::LeftClick,
1293 ButtonId::RightClick => Action::RightClick,
1294 ButtonId::MiddleClick => Action::MiddleClick,
1295 ButtonId::Back => Action::BrowserBack,
1296 ButtonId::Forward => Action::BrowserForward,
1297 ButtonId::DpiToggle => Action::CycleDpiPresets,
1298 ButtonId::Thumbwheel => Action::AppExpose,
1299 ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight,
1305 ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft,
1306 ButtonId::GestureButton => Action::MissionControl,
1307 }
1308}
1309
1310#[must_use]
1314pub fn default_gesture_binding(direction: GestureDirection) -> Action {
1315 match direction {
1316 GestureDirection::Up => Action::MissionControl,
1317 GestureDirection::Down => Action::ShowDesktop,
1318 GestureDirection::Left => Action::PrevTab,
1319 GestureDirection::Right => Action::NextTab,
1320 GestureDirection::Click => Action::AppExpose,
1321 }
1322}
1323
1324#[cfg(target_os = "linux")]
1331mod linux {
1332 use std::io;
1333 use std::sync::{LazyLock, Mutex};
1334
1335 use evdev::uinput::VirtualDevice;
1336 use evdev::{AttributeSet, EventType, InputEvent, KeyCode, RelativeAxisCode};
1337 use zbus::blocking::Connection as DbusConn;
1338
1339 const DEVICE_NAME: &str = "OpenLogi action injector";
1340
1341 static VIRTUAL_INPUT: LazyLock<Option<Mutex<VirtualDevice>>> = LazyLock::new(|| {
1342 build()
1343 .map(Mutex::new)
1344 .map_err(|e| tracing::warn!("failed to create uinput action device: {e}"))
1345 .ok()
1346 });
1347
1348 #[rustfmt::skip]
1349 const KEY_CAPABILITIES: &[KeyCode] = &[
1350 KeyCode::KEY_A, KeyCode::KEY_B, KeyCode::KEY_C, KeyCode::KEY_D,
1352 KeyCode::KEY_E, KeyCode::KEY_F, KeyCode::KEY_G, KeyCode::KEY_H,
1353 KeyCode::KEY_I, KeyCode::KEY_J, KeyCode::KEY_K, KeyCode::KEY_L,
1354 KeyCode::KEY_M, KeyCode::KEY_N, KeyCode::KEY_O, KeyCode::KEY_P,
1355 KeyCode::KEY_Q, KeyCode::KEY_R, KeyCode::KEY_S, KeyCode::KEY_T,
1356 KeyCode::KEY_U, KeyCode::KEY_V, KeyCode::KEY_W, KeyCode::KEY_X,
1357 KeyCode::KEY_Y, KeyCode::KEY_Z,
1358 KeyCode::KEY_0, KeyCode::KEY_1, KeyCode::KEY_2, KeyCode::KEY_3,
1360 KeyCode::KEY_4, KeyCode::KEY_5, KeyCode::KEY_6, KeyCode::KEY_7,
1361 KeyCode::KEY_8, KeyCode::KEY_9,
1362 KeyCode::KEY_MINUS, KeyCode::KEY_EQUAL, KeyCode::KEY_LEFTBRACE,
1364 KeyCode::KEY_RIGHTBRACE, KeyCode::KEY_BACKSLASH, KeyCode::KEY_SEMICOLON,
1365 KeyCode::KEY_APOSTROPHE, KeyCode::KEY_GRAVE, KeyCode::KEY_COMMA,
1366 KeyCode::KEY_DOT, KeyCode::KEY_SLASH,
1367 KeyCode::KEY_LEFT, KeyCode::KEY_RIGHT, KeyCode::KEY_UP, KeyCode::KEY_DOWN,
1369 KeyCode::KEY_HOME, KeyCode::KEY_END, KeyCode::KEY_PAGEUP, KeyCode::KEY_PAGEDOWN,
1370 KeyCode::KEY_TAB, KeyCode::KEY_ENTER, KeyCode::KEY_BACKSPACE, KeyCode::KEY_DELETE,
1371 KeyCode::KEY_ESC, KeyCode::KEY_SPACE,
1372 KeyCode::KEY_LEFTCTRL, KeyCode::KEY_LEFTSHIFT, KeyCode::KEY_LEFTALT, KeyCode::KEY_LEFTMETA,
1374 KeyCode::KEY_F1, KeyCode::KEY_F2, KeyCode::KEY_F3, KeyCode::KEY_F4,
1376 KeyCode::KEY_F5, KeyCode::KEY_F6, KeyCode::KEY_F7, KeyCode::KEY_F8,
1377 KeyCode::KEY_F9, KeyCode::KEY_F10, KeyCode::KEY_F11, KeyCode::KEY_F12,
1378 KeyCode::KEY_SYSRQ,
1380 KeyCode::KEY_PLAYPAUSE, KeyCode::KEY_NEXTSONG, KeyCode::KEY_PREVIOUSSONG,
1382 KeyCode::KEY_VOLUMEUP, KeyCode::KEY_VOLUMEDOWN, KeyCode::KEY_MUTE,
1383 KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE,
1385 ];
1386
1387 fn build() -> io::Result<VirtualDevice> {
1388 let mut keys = AttributeSet::<KeyCode>::default();
1389 for &k in KEY_CAPABILITIES {
1390 keys.insert(k);
1391 }
1392
1393 let mut axes = AttributeSet::<RelativeAxisCode>::default();
1398 for a in [RelativeAxisCode::REL_WHEEL, RelativeAxisCode::REL_HWHEEL] {
1399 axes.insert(a);
1400 }
1401
1402 VirtualDevice::builder()?
1403 .name(DEVICE_NAME)
1404 .with_keys(&keys)?
1405 .with_relative_axes(&axes)?
1406 .build()
1407 }
1408
1409 fn emit(events: &[InputEvent]) {
1410 if let Some(m) = &*VIRTUAL_INPUT {
1411 if let Ok(mut guard) = m.lock() {
1412 if let Err(e) = guard.emit(events) {
1413 tracing::warn!("uinput action emit failed: {e}");
1414 }
1415 } else {
1416 tracing::warn!("uinput action device mutex poisoned");
1417 }
1418 } else {
1419 tracing::debug!("uinput action device unavailable — action skipped");
1421 }
1422 }
1423
1424 fn syn() -> InputEvent {
1425 InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0)
1426 }
1427
1428 fn key_ev(code: KeyCode, value: i32) -> InputEvent {
1429 InputEvent::new(EventType::KEY.0, code.0, value)
1430 }
1431
1432 fn rel_ev(axis: RelativeAxisCode, value: i32) -> InputEvent {
1433 InputEvent::new(EventType::RELATIVE.0, axis.0, value)
1434 }
1435
1436 pub(super) fn press_key(mods: &[KeyCode], key: KeyCode) {
1443 let mut down: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1445 for &m in mods {
1446 down.push(key_ev(m, 1));
1447 }
1448 down.push(key_ev(key, 1));
1449 down.push(syn());
1450 emit(&down);
1451
1452 let mut up: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1454 up.push(key_ev(key, 0));
1455 for &m in mods.iter().rev() {
1456 up.push(key_ev(m, 0));
1457 }
1458 up.push(syn());
1459 emit(&up);
1460 }
1461
1462 pub(super) fn click(button: KeyCode) {
1464 emit(&[key_ev(button, 1), syn()]);
1465 emit(&[key_ev(button, 0), syn()]);
1466 }
1467
1468 pub(super) fn scroll(axis: RelativeAxisCode, value: i32) {
1470 emit(&[rel_ev(axis, value), syn()]);
1471 }
1472
1473 pub(super) fn device_node() -> Option<std::path::PathBuf> {
1481 let _ = &*VIRTUAL_INPUT;
1483 std::thread::sleep(std::time::Duration::from_millis(150));
1485 if let Some(m) = &*VIRTUAL_INPUT {
1486 if let Ok(mut guard) = m.lock() {
1487 return guard.enumerate_dev_nodes_blocking().ok()?.flatten().next();
1488 }
1489 }
1490 None
1491 }
1492
1493 pub(super) fn modifiers_to_keycodes(modifiers: u8) -> Vec<KeyCode> {
1499 use crate::binding::KeyCombo;
1500 let mut mods = Vec::new();
1501 if modifiers & (KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL) != 0 {
1502 mods.push(KeyCode::KEY_LEFTCTRL);
1503 }
1504 if modifiers & KeyCombo::MOD_SHIFT != 0 {
1505 mods.push(KeyCode::KEY_LEFTSHIFT);
1506 }
1507 if modifiers & KeyCombo::MOD_OPTION != 0 {
1508 mods.push(KeyCode::KEY_LEFTALT);
1509 }
1510 mods
1511 }
1512
1513 pub(super) fn macos_vk_to_linux(vk: u16) -> Option<KeyCode> {
1519 Some(match vk {
1520 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,
1594 })
1595 }
1596
1597 static SESSION_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
1600 DbusConn::session()
1601 .map_err(|e| tracing::warn!("D-Bus session bus unavailable: {e}"))
1602 .ok()
1603 });
1604
1605 static SYSTEM_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
1606 DbusConn::system()
1607 .map_err(|e| tracing::warn!("D-Bus system bus unavailable: {e}"))
1608 .ok()
1609 });
1610
1611 pub(super) fn lock_screen() {
1619 if let (Some(conn), Ok(id)) = (SYSTEM_BUS.as_ref(), std::env::var("XDG_SESSION_ID")) {
1620 match conn.call_method(
1621 Some("org.freedesktop.login1"),
1622 "/org/freedesktop/login1",
1623 Some("org.freedesktop.login1.Manager"),
1624 "LockSession",
1625 &(id.as_str(),),
1626 ) {
1627 Ok(_) => {
1628 tracing::debug!("LockScreen via logind");
1629 return;
1630 }
1631 Err(e) => tracing::warn!("logind LockSession failed: {e}"),
1632 }
1633 }
1634 tracing::debug!("LockScreen via Super+L key combo");
1636 press_key(&[KeyCode::KEY_LEFTMETA], KeyCode::KEY_L);
1637 }
1638
1639 pub(super) fn mpris_command(command: &str) {
1645 if try_mpris_command(command).is_none() {
1646 let fallback = match command {
1647 "PlayPause" => KeyCode::KEY_PLAYPAUSE,
1648 "Next" => KeyCode::KEY_NEXTSONG,
1649 "Previous" => KeyCode::KEY_PREVIOUSSONG,
1650 _ => return,
1651 };
1652 press_key(&[], fallback);
1653 }
1654 }
1655
1656 fn try_mpris_command(command: &str) -> Option<()> {
1657 let conn = SESSION_BUS.as_ref()?;
1658 let reply = conn
1659 .call_method(
1660 Some("org.freedesktop.DBus"),
1661 "/org/freedesktop/DBus",
1662 Some("org.freedesktop.DBus"),
1663 "ListNames",
1664 &(),
1665 )
1666 .ok()?;
1667 let names = reply.body().deserialize::<Vec<String>>().ok()?;
1668 let Some(player) = names
1669 .iter()
1670 .find(|n| n.starts_with("org.mpris.MediaPlayer2."))
1671 else {
1672 tracing::debug!("no MPRIS player found — {command} via XF86 key fallback");
1673 return None;
1674 };
1675 match conn.call_method(
1676 Some(player.as_str()),
1677 "/org/mpris/MediaPlayer2",
1678 Some("org.mpris.MediaPlayer2.Player"),
1679 command,
1680 &(),
1681 ) {
1682 Ok(_) => {
1683 tracing::debug!("MPRIS {command} via {player}");
1684 Some(())
1685 }
1686 Err(e) => {
1687 tracing::warn!("MPRIS {command} on {player} failed: {e}");
1690 Some(())
1691 }
1692 }
1693 }
1694}
1695
1696#[cfg(test)]
1697#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
1698mod tests {
1699 use std::collections::BTreeMap;
1700
1701 use serde::{Deserialize, Serialize};
1702
1703 use super::*;
1704
1705 #[derive(Serialize, Deserialize)]
1710 struct RoundtripWrapper {
1711 binding: BTreeMap<ButtonId, Action>,
1712 }
1713
1714 #[test]
1717 fn catalog_has_at_least_29_entries() {
1718 let catalog = Action::catalog();
1719 assert!(
1720 catalog.len() >= 29,
1721 "catalog has {} entries, need ≥ 29",
1722 catalog.len()
1723 );
1724 }
1725
1726 #[test]
1727 fn catalog_excludes_custom_shortcut() {
1728 let catalog = Action::catalog();
1729 for action in &catalog {
1730 assert!(
1731 !matches!(action, Action::CustomShortcut(_)),
1732 "catalog must not contain CustomShortcut"
1733 );
1734 }
1735 }
1736
1737 #[test]
1740 fn detect_swipe_below_threshold_keeps_accumulating() {
1741 assert_eq!(detect_swipe(40, 5), None);
1743 assert_eq!(detect_swipe(0, 0), None);
1744 }
1745
1746 #[test]
1747 fn detect_swipe_commits_clean_direction() {
1748 assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
1749 assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
1750 assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
1751 assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
1752 }
1753
1754 #[test]
1755 fn detect_swipe_rejects_diagonal() {
1756 assert_eq!(detect_swipe(60, 60), None);
1758 assert_eq!(detect_swipe(-60, -60), None);
1759 }
1760
1761 fn roundtrip(action: &Action) -> Action {
1766 let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
1767 map.insert(ButtonId::Back, action.clone());
1768 let w = RoundtripWrapper { binding: map };
1769 let s = toml::to_string(&w).expect("serialize");
1770 let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
1771 back.binding
1772 .into_values()
1773 .next()
1774 .expect("binding present after roundtrip")
1775 }
1776
1777 #[test]
1778 fn all_catalog_variants_roundtrip_toml() {
1779 for action in Action::catalog() {
1780 let back = roundtrip(&action);
1781 assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
1782 }
1783 }
1784
1785 #[test]
1786 fn custom_shortcut_roundtrips_toml() {
1787 let action = Action::CustomShortcut(KeyCombo {
1788 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1789 key_code: 0x23, display: "⌘⇧P".into(),
1791 });
1792 assert_eq!(roundtrip(&action), action);
1793 }
1794
1795 #[test]
1796 fn key_combo_rendered_label_uses_display_when_set() {
1797 let combo = KeyCombo {
1798 modifiers: 0,
1799 key_code: 0,
1800 display: "preset".into(),
1801 };
1802 assert_eq!(combo.rendered_label(), "preset");
1803 }
1804
1805 #[test]
1806 fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
1807 let combo = KeyCombo {
1808 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1809 key_code: 0x23, display: String::new(),
1811 };
1812 assert_eq!(combo.rendered_label(), "⇧⌘P");
1813 }
1814
1815 #[test]
1818 fn category_editing_variants() {
1819 assert_eq!(Action::Copy.category(), Category::Editing);
1820 assert_eq!(Action::Undo.category(), Category::Editing);
1821 assert_eq!(Action::SelectAll.category(), Category::Editing);
1822 assert_eq!(Action::Find.category(), Category::Editing);
1823 assert_eq!(Action::Save.category(), Category::Editing);
1824 assert_eq!(Action::Cut.category(), Category::Editing);
1825 assert_eq!(Action::Redo.category(), Category::Editing);
1826 assert_eq!(Action::Paste.category(), Category::Editing);
1827 }
1828
1829 #[test]
1830 fn category_browser_variants() {
1831 assert_eq!(Action::BrowserBack.category(), Category::Browser);
1832 assert_eq!(Action::BrowserForward.category(), Category::Browser);
1833 assert_eq!(Action::NewTab.category(), Category::Browser);
1834 assert_eq!(Action::CloseTab.category(), Category::Browser);
1835 assert_eq!(Action::ReopenTab.category(), Category::Browser);
1836 assert_eq!(Action::NextTab.category(), Category::Browser);
1837 assert_eq!(Action::PrevTab.category(), Category::Browser);
1838 assert_eq!(Action::ReloadPage.category(), Category::Browser);
1839 }
1840
1841 #[test]
1842 fn category_media_variants() {
1843 assert_eq!(Action::PlayPause.category(), Category::Media);
1844 assert_eq!(Action::NextTrack.category(), Category::Media);
1845 assert_eq!(Action::PrevTrack.category(), Category::Media);
1846 assert_eq!(Action::VolumeUp.category(), Category::Media);
1847 assert_eq!(Action::VolumeDown.category(), Category::Media);
1848 assert_eq!(Action::MuteVolume.category(), Category::Media);
1849 }
1850
1851 #[test]
1852 fn category_mouse_variants() {
1853 assert_eq!(Action::LeftClick.category(), Category::Mouse);
1854 assert_eq!(Action::RightClick.category(), Category::Mouse);
1855 assert_eq!(Action::MiddleClick.category(), Category::Mouse);
1856 }
1857
1858 #[test]
1859 fn category_dpi_variants() {
1860 assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
1861 assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
1862 }
1863
1864 #[test]
1865 fn category_scroll_variants() {
1866 assert_eq!(Action::ScrollUp.category(), Category::Scroll);
1867 assert_eq!(Action::ScrollDown.category(), Category::Scroll);
1868 assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
1869 assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
1870 }
1871
1872 #[test]
1873 fn category_navigation_variants() {
1874 assert_eq!(Action::MissionControl.category(), Category::Navigation);
1875 assert_eq!(Action::AppExpose.category(), Category::Navigation);
1876 assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
1877 assert_eq!(Action::NextDesktop.category(), Category::Navigation);
1878 assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
1879 assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
1880 }
1881
1882 #[test]
1883 fn category_system_variants() {
1884 assert_eq!(Action::LockScreen.category(), Category::System);
1885 assert_eq!(Action::Screenshot.category(), Category::System);
1886 }
1887
1888 #[test]
1891 fn category_labels_are_nonempty() {
1892 let categories = [
1893 Category::Editing,
1894 Category::Browser,
1895 Category::Media,
1896 Category::Mouse,
1897 Category::Dpi,
1898 Category::Scroll,
1899 Category::Navigation,
1900 Category::System,
1901 ];
1902 for cat in categories {
1903 assert!(!cat.label().is_empty(), "label empty for {cat:?}");
1904 }
1905 }
1906
1907 #[test]
1910 fn dpi_toggle_default_is_cycle_dpi_presets() {
1911 assert_eq!(
1912 default_binding(ButtonId::DpiToggle),
1913 Action::CycleDpiPresets
1914 );
1915 }
1916
1917 #[cfg(target_os = "linux")]
1920 mod modifier_mapping {
1921 use evdev::KeyCode;
1922
1923 use crate::binding::{KeyCombo, linux::modifiers_to_keycodes};
1924
1925 #[test]
1926 fn mod_cmd_alone_maps_to_ctrl() {
1927 assert_eq!(
1928 modifiers_to_keycodes(KeyCombo::MOD_CMD),
1929 vec![KeyCode::KEY_LEFTCTRL]
1930 );
1931 }
1932
1933 #[test]
1934 fn mod_ctrl_alone_maps_to_ctrl() {
1935 assert_eq!(
1936 modifiers_to_keycodes(KeyCombo::MOD_CTRL),
1937 vec![KeyCode::KEY_LEFTCTRL]
1938 );
1939 }
1940
1941 #[test]
1942 fn mod_cmd_and_ctrl_together_produce_single_ctrl() {
1943 assert_eq!(
1945 modifiers_to_keycodes(KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL),
1946 vec![KeyCode::KEY_LEFTCTRL]
1947 );
1948 }
1949
1950 #[test]
1951 fn all_modifiers_produce_canonical_order() {
1952 let mods = modifiers_to_keycodes(
1953 KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT | KeyCombo::MOD_OPTION,
1954 );
1955 assert_eq!(
1956 mods,
1957 vec![
1958 KeyCode::KEY_LEFTCTRL,
1959 KeyCode::KEY_LEFTSHIFT,
1960 KeyCode::KEY_LEFTALT
1961 ]
1962 );
1963 }
1964
1965 #[test]
1966 fn no_modifiers_produces_empty_vec() {
1967 assert!(modifiers_to_keycodes(0).is_empty());
1968 }
1969 }
1970
1971 #[cfg(target_os = "linux")]
1974 mod vk_mapping {
1975 use evdev::KeyCode;
1976
1977 use crate::binding::linux::macos_vk_to_linux;
1978
1979 #[test]
1980 fn common_letters_map_correctly() {
1981 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)); }
1988
1989 #[test]
1990 fn digits_map_correctly() {
1991 assert_eq!(macos_vk_to_linux(0x12), Some(KeyCode::KEY_1)); assert_eq!(macos_vk_to_linux(0x1D), Some(KeyCode::KEY_0)); }
1994
1995 #[test]
1996 fn arrow_keys_map_correctly() {
1997 assert_eq!(macos_vk_to_linux(0x7B), Some(KeyCode::KEY_LEFT));
1998 assert_eq!(macos_vk_to_linux(0x7C), Some(KeyCode::KEY_RIGHT));
1999 assert_eq!(macos_vk_to_linux(0x7D), Some(KeyCode::KEY_DOWN));
2000 assert_eq!(macos_vk_to_linux(0x7E), Some(KeyCode::KEY_UP));
2001 }
2002
2003 #[test]
2004 fn function_keys_map_correctly() {
2005 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)); }
2011
2012 #[test]
2013 fn nav_keys_map_correctly() {
2014 assert_eq!(macos_vk_to_linux(0x73), Some(KeyCode::KEY_HOME));
2015 assert_eq!(macos_vk_to_linux(0x77), Some(KeyCode::KEY_END));
2016 assert_eq!(macos_vk_to_linux(0x74), Some(KeyCode::KEY_PAGEUP));
2017 assert_eq!(macos_vk_to_linux(0x79), Some(KeyCode::KEY_PAGEDOWN));
2018 assert_eq!(macos_vk_to_linux(0x75), Some(KeyCode::KEY_DELETE));
2019 }
2020
2021 #[test]
2022 fn brackets_follow_ansi_layout() {
2023 assert_eq!(macos_vk_to_linux(0x21), Some(KeyCode::KEY_LEFTBRACE));
2025 assert_eq!(macos_vk_to_linux(0x1E), Some(KeyCode::KEY_RIGHTBRACE));
2026 }
2027
2028 #[test]
2029 fn unmapped_code_returns_none() {
2030 assert_eq!(macos_vk_to_linux(0xFF), None);
2031 assert_eq!(macos_vk_to_linux(0x34), None); }
2033 }
2034}