1use std::collections::BTreeMap;
10use std::fmt;
11use std::time::Instant;
12
13use serde::{Deserialize, Serialize};
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
19pub enum ButtonId {
20 LeftClick,
21 RightClick,
22 MiddleClick,
23 Back,
24 Forward,
25 DpiToggle,
28 Thumbwheel,
33 ThumbwheelScrollUp,
36 ThumbwheelScrollDown,
38 GestureButton,
41}
42
43impl ButtonId {
44 pub const ALL: [ButtonId; 10] = [
45 ButtonId::LeftClick,
46 ButtonId::RightClick,
47 ButtonId::MiddleClick,
48 ButtonId::Back,
49 ButtonId::Forward,
50 ButtonId::DpiToggle,
51 ButtonId::Thumbwheel,
52 ButtonId::ThumbwheelScrollUp,
53 ButtonId::ThumbwheelScrollDown,
54 ButtonId::GestureButton,
55 ];
56
57 #[must_use]
65 pub fn is_os_hook_button(self) -> bool {
66 matches!(
67 self,
68 ButtonId::MiddleClick | ButtonId::Back | ButtonId::Forward
69 )
70 }
71
72 #[must_use]
74 pub fn label(self) -> &'static str {
75 match self {
76 ButtonId::LeftClick => "Left Click",
77 ButtonId::RightClick => "Right Click",
78 ButtonId::MiddleClick => "Middle Click",
79 ButtonId::Back => "Back",
80 ButtonId::Forward => "Forward",
81 ButtonId::DpiToggle => "DPI Toggle",
82 ButtonId::Thumbwheel => "Thumb Wheel",
83 ButtonId::ThumbwheelScrollUp => "Thumb Wheel Up",
84 ButtonId::ThumbwheelScrollDown => "Thumb Wheel Down",
85 ButtonId::GestureButton => "Gesture Button",
86 }
87 }
88}
89
90impl fmt::Display for ButtonId {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 f.write_str(self.label())
93 }
94}
95
96#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
104pub enum GestureDirection {
105 Up,
106 Down,
107 Left,
108 Right,
109 Click,
110}
111
112impl GestureDirection {
113 pub const ALL: [GestureDirection; 5] = [
114 GestureDirection::Up,
115 GestureDirection::Down,
116 GestureDirection::Left,
117 GestureDirection::Right,
118 GestureDirection::Click,
119 ];
120
121 #[must_use]
122 pub fn label(self) -> &'static str {
123 match self {
124 GestureDirection::Up => "Up",
125 GestureDirection::Down => "Down",
126 GestureDirection::Left => "Left",
127 GestureDirection::Right => "Right",
128 GestureDirection::Click => "Click",
129 }
130 }
131
132 #[must_use]
134 pub fn glyph(self) -> &'static str {
135 match self {
136 GestureDirection::Up => "↑",
137 GestureDirection::Down => "↓",
138 GestureDirection::Left => "←",
139 GestureDirection::Right => "→",
140 GestureDirection::Click => "·",
141 }
142 }
143}
144
145impl fmt::Display for GestureDirection {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 f.write_str(self.label())
148 }
149}
150
151pub const GESTURE_SWIPE_THRESHOLD: i32 = 50;
154pub const GESTURE_SWIPE_DEADZONE: i32 = 40;
157pub const GESTURE_HOLD_FOR_SWIPE: std::time::Duration = std::time::Duration::from_millis(160);
162
163#[must_use]
176pub fn detect_swipe(dx: i32, dy: i32) -> Option<GestureDirection> {
177 let (abs_x, abs_y) = (dx.saturating_abs(), dy.saturating_abs());
183 let dominant = abs_x.max(abs_y);
184 if dominant < GESTURE_SWIPE_THRESHOLD {
185 return None;
186 }
187 let cross_limit = GESTURE_SWIPE_DEADZONE.max(dominant.saturating_mul(35) / 100);
188 if abs_x > abs_y {
189 if abs_y > cross_limit {
190 return None;
191 }
192 Some(if dx > 0 {
193 GestureDirection::Right
194 } else {
195 GestureDirection::Left
196 })
197 } else {
198 if abs_x > cross_limit {
199 return None;
200 }
201 Some(if dy > 0 {
202 GestureDirection::Down
203 } else {
204 GestureDirection::Up
205 })
206 }
207}
208
209#[derive(Debug, Default)]
224pub struct SwipeAccumulator {
225 held_since: Option<Instant>,
228 dx: i32,
231 dy: i32,
232 fired: bool,
235}
236
237impl SwipeAccumulator {
238 pub fn begin(&mut self) {
240 self.held_since = Some(Instant::now());
241 self.dx = 0;
242 self.dy = 0;
243 self.fired = false;
244 }
245
246 #[must_use]
249 pub fn is_holding(&self) -> bool {
250 self.held_since.is_some()
251 }
252
253 pub fn accumulate(&mut self, dx: i32, dy: i32) -> Option<GestureDirection> {
258 if self.fired || self.held_since.is_none() {
259 return None;
260 }
261 self.dx = self.dx.saturating_add(dx);
262 self.dy = self.dy.saturating_add(dy);
263 let held_long_enough = self
264 .held_since
265 .is_some_and(|t| t.elapsed() >= GESTURE_HOLD_FOR_SWIPE);
266 if held_long_enough && let Some(dir) = detect_swipe(self.dx, self.dy) {
267 self.fired = true;
268 return Some(dir);
269 }
270 None
271 }
272
273 pub fn end(&mut self) -> bool {
278 let was_click = self.held_since.is_some() && !self.fired;
279 self.held_since = None;
280 was_click
281 }
282
283 #[doc(hidden)]
288 pub fn backdate_hold_for_test(&mut self) {
289 if self.held_since.is_some() {
290 self.held_since = Instant::now().checked_sub(GESTURE_HOLD_FOR_SWIPE * 2);
291 }
292 }
293}
294
295#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
300pub enum Category {
301 Editing,
303 Browser,
305 Media,
307 Mouse,
309 Dpi,
311 Scroll,
313 Navigation,
315 System,
317}
318
319impl Category {
320 #[must_use]
323 pub fn label(self) -> &'static str {
324 match self {
325 Category::Editing => "EDITING",
326 Category::Browser => "BROWSER",
327 Category::Media => "MEDIA",
328 Category::Mouse => "MOUSE",
329 Category::Dpi => "DPI",
330 Category::Scroll => "SCROLL",
331 Category::Navigation => "NAVIGATION",
332 Category::System => "SYSTEM",
333 }
334 }
335}
336
337#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
359pub enum Action {
360 None,
364
365 LeftClick,
368 RightClick,
370 MiddleClick,
372 MouseBack,
377 MouseForward,
380
381 Copy,
384 Paste,
386 Cut,
388 Undo,
390 Redo,
397 SelectAll,
399 Find,
401 Save,
403
404 BrowserBack,
407 BrowserForward,
409 NewTab,
411 CloseTab,
413 ReopenTab,
415 NextTab,
417 PrevTab,
419 ReloadPage,
421
422 MissionControl,
425 AppExpose,
427 PreviousDesktop,
429 NextDesktop,
431 ShowDesktop,
433 LaunchpadShow,
435
436 LockScreen,
443 Screenshot,
445
446 PlayPause,
449 NextTrack,
451 PrevTrack,
453 VolumeUp,
455 VolumeDown,
457 MuteVolume,
459
460 CycleDpiPresets,
463 SetDpiPreset(u8),
466 ToggleSmartShift,
468
469 ScrollUp,
472 ScrollDown,
474 HorizontalScrollLeft,
476 HorizontalScrollRight,
478
479 CustomShortcut(KeyCombo),
487}
488
489#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
501pub struct KeyCombo {
502 pub modifiers: u8,
504 pub key_code: u16,
508 #[serde(default)]
511 pub display: String,
512}
513
514impl KeyCombo {
515 pub const MOD_CMD: u8 = 1 << 0;
516 pub const MOD_SHIFT: u8 = 1 << 1;
517 pub const MOD_CTRL: u8 = 1 << 2;
518 pub const MOD_OPTION: u8 = 1 << 3;
519
520 #[must_use]
525 pub fn rendered_label(&self) -> String {
526 if !self.display.is_empty() {
527 return self.display.clone();
528 }
529 let mut out = String::new();
530 if self.modifiers & Self::MOD_CTRL != 0 {
531 out.push('⌃');
532 }
533 if self.modifiers & Self::MOD_OPTION != 0 {
534 out.push('⌥');
535 }
536 if self.modifiers & Self::MOD_SHIFT != 0 {
537 out.push('⇧');
538 }
539 if self.modifiers & Self::MOD_CMD != 0 {
540 out.push('⌘');
541 }
542 match self.key_code {
543 0x00 => out.push('A'),
544 0x01 => out.push('S'),
545 0x02 => out.push('D'),
546 0x03 => out.push('F'),
547 0x06 => out.push('Z'),
548 0x07 => out.push('X'),
549 0x08 => out.push('C'),
550 0x09 => out.push('V'),
551 0x0B => out.push('B'),
552 0x0C => out.push('Q'),
553 0x0D => out.push('W'),
554 0x0E => out.push('E'),
555 0x0F => out.push('R'),
556 0x10 => out.push('Y'),
557 0x11 => out.push('T'),
558 0x20 => out.push('U'),
559 0x22 => out.push('I'),
560 0x1F => out.push('O'),
561 0x23 => out.push('P'),
562 _ => {
563 use std::fmt::Write as _;
564 let _ = write!(out, "key 0x{:02X}", self.key_code);
565 }
566 }
567 out
568 }
569}
570
571#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
596#[serde(untagged)]
597pub enum Binding {
598 Single(Action),
600 Gesture(BTreeMap<GestureDirection, Action>),
604}
605
606impl Binding {
607 #[must_use]
614 pub fn click_action(&self) -> Action {
615 match self {
616 Binding::Single(action) => action.clone(),
617 Binding::Gesture(map) => map
618 .get(&GestureDirection::Click)
619 .cloned()
620 .unwrap_or(Action::None),
621 }
622 }
623
624 #[must_use]
627 pub fn direction_action(&self, direction: GestureDirection) -> Option<&Action> {
628 match self {
629 Binding::Single(_) => None,
630 Binding::Gesture(map) => map.get(&direction),
631 }
632 }
633
634 #[must_use]
637 pub fn is_gesture(&self) -> bool {
638 matches!(self, Binding::Gesture(_))
639 }
640
641 pub fn upgrade_to_gesture(&mut self) {
646 if let Binding::Single(action) = self {
647 let mut map = BTreeMap::new();
648 map.insert(GestureDirection::Click, action.clone());
649 *self = Binding::Gesture(map);
650 }
651 }
652
653 pub fn fill_gesture_defaults(&mut self) {
660 if let Binding::Gesture(map) = self {
661 for dir in GestureDirection::ALL {
662 map.entry(dir)
663 .or_insert_with(|| default_gesture_binding(dir));
664 }
665 }
666 }
667}
668
669impl From<Action> for Binding {
670 fn from(action: Action) -> Self {
671 Binding::Single(action)
672 }
673}
674
675impl Action {
676 #[must_use]
682 pub fn label(&self) -> String {
683 match self {
684 Action::None => "Do Nothing".into(),
685 Action::LeftClick => "Left Click".into(),
686 Action::RightClick => "Right Click".into(),
687 Action::MiddleClick => "Middle Click".into(),
688 Action::MouseBack => "Back (Button 4)".into(),
689 Action::MouseForward => "Forward (Button 5)".into(),
690 Action::Copy => "Copy".into(),
691 Action::Paste => "Paste".into(),
692 Action::Cut => "Cut".into(),
693 Action::Undo => "Undo".into(),
694 Action::Redo => "Redo".into(),
695 Action::SelectAll => "Select All".into(),
696 Action::Find => "Find".into(),
697 Action::Save => "Save".into(),
698 Action::BrowserBack => "Browser Back".into(),
699 Action::BrowserForward => "Browser Forward".into(),
700 Action::NewTab => "New Tab".into(),
701 Action::CloseTab => "Close Tab".into(),
702 Action::ReopenTab => "Reopen Tab".into(),
703 Action::NextTab => "Next Tab".into(),
704 Action::PrevTab => "Previous Tab".into(),
705 Action::ReloadPage => "Reload Page".into(),
706 Action::MissionControl => "Mission Control".into(),
707 Action::AppExpose => "App Exposé".into(),
708 Action::PreviousDesktop => "Previous Desktop".into(),
709 Action::NextDesktop => "Next Desktop".into(),
710 Action::ShowDesktop => "Show Desktop".into(),
711 Action::LaunchpadShow => "Launchpad".into(),
712 Action::LockScreen => "Lock Screen".into(),
713 Action::Screenshot => "Screenshot".into(),
714 Action::PlayPause => "Play / Pause".into(),
715 Action::NextTrack => "Next Track".into(),
716 Action::PrevTrack => "Previous Track".into(),
717 Action::VolumeUp => "Volume Up".into(),
718 Action::VolumeDown => "Volume Down".into(),
719 Action::MuteVolume => "Mute".into(),
720 Action::CycleDpiPresets => "Cycle DPI Presets".into(),
721 Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
722 Action::ToggleSmartShift => "Toggle SmartShift".into(),
723 Action::ScrollUp => "Scroll Up".into(),
724 Action::ScrollDown => "Scroll Down".into(),
725 Action::HorizontalScrollLeft => "Scroll Left".into(),
726 Action::HorizontalScrollRight => "Scroll Right".into(),
727 Action::CustomShortcut(combo) => combo.rendered_label(),
728 }
729 }
730
731 #[must_use]
733 pub fn category(&self) -> Category {
734 match self {
735 Action::LeftClick
736 | Action::RightClick
737 | Action::MiddleClick
738 | Action::MouseBack
739 | Action::MouseForward => Category::Mouse,
740 Action::Copy
743 | Action::Paste
744 | Action::Cut
745 | Action::Undo
746 | Action::Redo
747 | Action::SelectAll
748 | Action::Find
749 | Action::Save
750 | Action::CustomShortcut(_) => Category::Editing,
751 Action::BrowserBack
752 | Action::BrowserForward
753 | Action::NewTab
754 | Action::CloseTab
755 | Action::ReopenTab
756 | Action::NextTab
757 | Action::PrevTab
758 | Action::ReloadPage => Category::Browser,
759 Action::MissionControl
760 | Action::AppExpose
761 | Action::PreviousDesktop
762 | Action::NextDesktop
763 | Action::ShowDesktop
764 | Action::LaunchpadShow => Category::Navigation,
765 Action::None | Action::LockScreen | Action::Screenshot => Category::System,
766 Action::PlayPause
767 | Action::NextTrack
768 | Action::PrevTrack
769 | Action::VolumeUp
770 | Action::VolumeDown
771 | Action::MuteVolume => Category::Media,
772 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
773 Category::Dpi
774 }
775 Action::ScrollUp
776 | Action::ScrollDown
777 | Action::HorizontalScrollLeft
778 | Action::HorizontalScrollRight => Category::Scroll,
779 }
780 }
781
782 #[must_use]
787 pub fn catalog() -> Vec<Action> {
788 vec![
789 Action::LeftClick,
791 Action::RightClick,
792 Action::MiddleClick,
793 Action::MouseBack,
794 Action::MouseForward,
795 Action::Copy,
797 Action::Paste,
798 Action::Cut,
799 Action::Undo,
800 Action::Redo,
801 Action::SelectAll,
802 Action::Find,
803 Action::Save,
804 Action::BrowserBack,
806 Action::BrowserForward,
807 Action::NewTab,
808 Action::CloseTab,
809 Action::ReopenTab,
810 Action::NextTab,
811 Action::PrevTab,
812 Action::ReloadPage,
813 Action::MissionControl,
815 Action::AppExpose,
816 Action::PreviousDesktop,
817 Action::NextDesktop,
818 Action::ShowDesktop,
819 Action::LaunchpadShow,
820 Action::None,
822 Action::LockScreen,
823 Action::Screenshot,
824 Action::PlayPause,
826 Action::NextTrack,
827 Action::PrevTrack,
828 Action::VolumeUp,
829 Action::VolumeDown,
830 Action::MuteVolume,
831 Action::CycleDpiPresets,
833 Action::ToggleSmartShift,
834 Action::ScrollUp,
836 Action::ScrollDown,
837 Action::HorizontalScrollLeft,
838 Action::HorizontalScrollRight,
839 ]
840 }
841
842 pub fn execute(&self) {
869 #[cfg(target_os = "macos")]
870 self.execute_macos();
871
872 #[cfg(target_os = "linux")]
873 self.execute_linux();
874
875 #[cfg(target_os = "windows")]
876 self.execute_windows();
877
878 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
879 {
880 tracing::warn!(
881 action = self.label(),
882 "Action::execute unsupported on this platform"
883 );
884 }
885 }
886
887 #[cfg(target_os = "linux")]
889 fn execute_linux(&self) {
890 use evdev::{KeyCode, RelativeAxisCode};
891 let ctrl = KeyCode::KEY_LEFTCTRL;
892 let shift = KeyCode::KEY_LEFTSHIFT;
893 let alt = KeyCode::KEY_LEFTALT;
894 match self {
895 Action::LeftClick => linux::click(KeyCode::BTN_LEFT),
897 Action::RightClick => linux::click(KeyCode::BTN_RIGHT),
898 Action::MiddleClick => linux::click(KeyCode::BTN_MIDDLE),
899 Action::MouseBack => linux::click(KeyCode::BTN_SIDE),
902 Action::MouseForward => linux::click(KeyCode::BTN_EXTRA),
903 Action::Copy => linux::press_key(&[ctrl], KeyCode::KEY_C),
905 Action::Paste => linux::press_key(&[ctrl], KeyCode::KEY_V),
906 Action::Cut => linux::press_key(&[ctrl], KeyCode::KEY_X),
907 Action::Undo => linux::press_key(&[ctrl], KeyCode::KEY_Z),
908 Action::Redo => linux::press_key(&[ctrl, shift], KeyCode::KEY_Z),
910 Action::SelectAll => linux::press_key(&[ctrl], KeyCode::KEY_A),
911 Action::Find => linux::press_key(&[ctrl], KeyCode::KEY_F),
912 Action::Save => linux::press_key(&[ctrl], KeyCode::KEY_S),
913 Action::BrowserBack => linux::press_key(&[alt], KeyCode::KEY_LEFT),
915 Action::BrowserForward => linux::press_key(&[alt], KeyCode::KEY_RIGHT),
916 Action::NewTab => linux::press_key(&[ctrl], KeyCode::KEY_T),
917 Action::CloseTab => linux::press_key(&[ctrl], KeyCode::KEY_W),
918 Action::ReopenTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_T),
919 Action::NextTab => linux::press_key(&[ctrl], KeyCode::KEY_TAB),
920 Action::PrevTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_TAB),
921 Action::ReloadPage => linux::press_key(&[ctrl], KeyCode::KEY_R),
922 Action::MissionControl
925 | Action::AppExpose
926 | Action::ShowDesktop
927 | Action::LaunchpadShow => {
928 tracing::debug!(
929 action = self.label(),
930 "no Linux equivalent — action skipped"
931 );
932 }
933 Action::PreviousDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_LEFT),
935 Action::NextDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_RIGHT),
936 Action::LockScreen => linux::lock_screen(),
939 Action::Screenshot => linux::press_key(&[], KeyCode::KEY_SYSRQ),
940 Action::PlayPause => linux::mpris_command("PlayPause"),
944 Action::NextTrack => linux::mpris_command("Next"),
945 Action::PrevTrack => linux::mpris_command("Previous"),
946 Action::VolumeUp => linux::press_key(&[], KeyCode::KEY_VOLUMEUP),
947 Action::VolumeDown => linux::press_key(&[], KeyCode::KEY_VOLUMEDOWN),
948 Action::MuteVolume => linux::press_key(&[], KeyCode::KEY_MUTE),
949 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
951 tracing::debug!(
952 action = self.label(),
953 "device action handled by hook/HID layer"
954 );
955 }
956 Action::ScrollUp => linux::scroll(RelativeAxisCode::REL_WHEEL, 3),
958 Action::ScrollDown => linux::scroll(RelativeAxisCode::REL_WHEEL, -3),
959 Action::HorizontalScrollLeft => linux::scroll(RelativeAxisCode::REL_HWHEEL, -3),
960 Action::HorizontalScrollRight => linux::scroll(RelativeAxisCode::REL_HWHEEL, 3),
961 Action::None => {}
963 Action::CustomShortcut(combo) => {
965 if combo.key_code == 0 {
966 tracing::warn!(
967 chord = %combo.rendered_label(),
968 "CustomShortcut with no key code — press ignored"
969 );
970 return;
971 }
972 let Some(key) = linux::macos_vk_to_linux(combo.key_code) else {
973 tracing::warn!(
974 key_code = combo.key_code,
975 "CustomShortcut key code has no Linux mapping — press ignored"
976 );
977 return;
978 };
979 linux::press_key(&linux::modifiers_to_keycodes(combo.modifiers), key);
980 }
981 }
982 }
983
984 #[cfg(target_os = "macos")]
986 fn execute_macos(&self) {
987 use core_graphics::event::{CGEventFlags, CGMouseButton};
988
989 let cmd = CGEventFlags::CGEventFlagCommand;
991 let shift = CGEventFlags::CGEventFlagShift;
992 let ctrl = CGEventFlags::CGEventFlagControl;
993
994 match self {
995 Action::None => {}
997 Action::LeftClick => macos::post_click(CGMouseButton::Left),
1002 Action::RightClick => macos::post_click(CGMouseButton::Right),
1003 Action::MiddleClick => macos::post_click(CGMouseButton::Center),
1004 Action::MouseBack => macos::post_other_button(3),
1008 Action::MouseForward => macos::post_other_button(4),
1009 Action::Copy => macos::post_key(VK_C, cmd),
1011 Action::Paste => macos::post_key(VK_V, cmd),
1012 Action::Cut => macos::post_key(VK_X, cmd),
1013 Action::Undo => macos::post_key(VK_Z, cmd),
1014 Action::Redo => macos::post_key(VK_Z, cmd | shift),
1015 Action::SelectAll => macos::post_key(VK_A, cmd),
1016 Action::Find => macos::post_key(VK_F, cmd),
1017 Action::Save => macos::post_key(VK_S, cmd),
1018 Action::BrowserBack => macos::post_key(0x21, cmd),
1023 Action::BrowserForward => macos::post_key(0x1E, cmd),
1024 Action::NewTab => macos::post_key(VK_T, cmd),
1025 Action::CloseTab => macos::post_key(VK_W, cmd),
1026 Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
1027 Action::NextTab => macos::post_key(VK_TAB, ctrl),
1028 Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
1029 Action::ReloadPage => macos::post_key(VK_R, cmd),
1030 Action::MissionControl => macos::mission_control(),
1037 Action::AppExpose => macos::app_expose(),
1038 Action::PreviousDesktop => macos::previous_desktop(),
1039 Action::NextDesktop => macos::next_desktop(),
1040 Action::ShowDesktop => macos::show_desktop(),
1041 Action::LaunchpadShow => macos::launchpad(),
1042 Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
1045 Action::Screenshot => macos::post_key(0x14, cmd | shift),
1047 Action::PlayPause => macos::post_media_key(macos::NX_KEYTYPE_PLAY),
1052 Action::NextTrack => macos::post_media_key(macos::NX_KEYTYPE_NEXT),
1053 Action::PrevTrack => macos::post_media_key(macos::NX_KEYTYPE_PREVIOUS),
1054 Action::VolumeUp => macos::post_media_key(macos::NX_KEYTYPE_SOUND_UP),
1055 Action::VolumeDown => macos::post_media_key(macos::NX_KEYTYPE_SOUND_DOWN),
1056 Action::MuteVolume => macos::post_media_key(macos::NX_KEYTYPE_MUTE),
1057 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
1059 tracing::debug!(
1060 action = self.label(),
1061 "device action handled by hook/HID layer"
1062 );
1063 }
1064 Action::ScrollUp
1066 | Action::ScrollDown
1067 | Action::HorizontalScrollLeft
1068 | Action::HorizontalScrollRight => macos::post_scroll(self),
1069 Action::CustomShortcut(combo) => {
1071 if combo.key_code == 0 {
1076 tracing::warn!(
1077 chord = %combo.rendered_label(),
1078 "CustomShortcut with no key code — press ignored"
1079 );
1080 return;
1081 }
1082 let mut flags = CGEventFlags::CGEventFlagNull;
1083 if combo.modifiers & KeyCombo::MOD_CMD != 0 {
1084 flags |= CGEventFlags::CGEventFlagCommand;
1085 }
1086 if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
1087 flags |= CGEventFlags::CGEventFlagShift;
1088 }
1089 if combo.modifiers & KeyCombo::MOD_CTRL != 0 {
1090 flags |= CGEventFlags::CGEventFlagControl;
1091 }
1092 if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
1093 flags |= CGEventFlags::CGEventFlagAlternate;
1094 }
1095 macos::post_key(combo.key_code, flags);
1096 }
1097 }
1098 }
1099
1100 #[cfg(target_os = "windows")]
1104 fn execute_windows(&self) {
1105 match self {
1106 Action::LeftClick => windows::post_click(windows::MouseButton::Left),
1107 Action::RightClick => windows::post_click(windows::MouseButton::Right),
1108 Action::MiddleClick => windows::post_click(windows::MouseButton::Middle),
1109 Action::MouseBack => windows::post_click(windows::MouseButton::Back),
1110 Action::MouseForward => windows::post_click(windows::MouseButton::Forward),
1111 Action::Copy => windows::post_key(windows::VK_C, &[windows::VK_CONTROL]),
1112 Action::Paste => windows::post_key(windows::VK_V, &[windows::VK_CONTROL]),
1113 Action::Cut => windows::post_key(windows::VK_X, &[windows::VK_CONTROL]),
1114 Action::Undo => windows::post_key(windows::VK_Z, &[windows::VK_CONTROL]),
1115 Action::Redo => windows::post_key(windows::VK_Y, &[windows::VK_CONTROL]),
1116 Action::SelectAll => windows::post_key(windows::VK_A, &[windows::VK_CONTROL]),
1117 Action::Find => windows::post_key(windows::VK_F, &[windows::VK_CONTROL]),
1118 Action::Save => windows::post_key(windows::VK_S, &[windows::VK_CONTROL]),
1119 Action::BrowserBack => windows::post_key(windows::VK_BROWSER_BACK, &[]),
1120 Action::BrowserForward => windows::post_key(windows::VK_BROWSER_FORWARD, &[]),
1121 Action::NewTab => windows::post_key(windows::VK_T, &[windows::VK_CONTROL]),
1122 Action::CloseTab => windows::post_key(windows::VK_W, &[windows::VK_CONTROL]),
1123 Action::ReopenTab => {
1124 windows::post_key(windows::VK_T, &[windows::VK_CONTROL, windows::VK_SHIFT]);
1125 }
1126 Action::NextTab => windows::post_key(windows::VK_TAB, &[windows::VK_CONTROL]),
1127 Action::PrevTab => {
1128 windows::post_key(windows::VK_TAB, &[windows::VK_CONTROL, windows::VK_SHIFT]);
1129 }
1130 Action::ReloadPage => windows::post_key(windows::VK_R, &[windows::VK_CONTROL]),
1131 Action::MissionControl | Action::AppExpose => {
1132 windows::post_key(windows::VK_TAB, &[windows::VK_LWIN]);
1133 }
1134 Action::PreviousDesktop => {
1135 windows::post_key(windows::VK_LEFT, &[windows::VK_LWIN, windows::VK_CONTROL]);
1136 }
1137 Action::NextDesktop => {
1138 windows::post_key(windows::VK_RIGHT, &[windows::VK_LWIN, windows::VK_CONTROL]);
1139 }
1140 Action::ShowDesktop => windows::post_key(windows::VK_D, &[windows::VK_LWIN]),
1141 Action::LaunchpadShow => windows::post_key(windows::VK_LWIN, &[]),
1142 Action::LockScreen => windows::post_key(windows::VK_L, &[windows::VK_LWIN]),
1143 Action::Screenshot => {
1144 windows::post_key(windows::VK_S, &[windows::VK_LWIN, windows::VK_SHIFT]);
1145 }
1146 Action::PlayPause => windows::post_key(windows::VK_MEDIA_PLAY_PAUSE, &[]),
1147 Action::NextTrack => windows::post_key(windows::VK_MEDIA_NEXT_TRACK, &[]),
1148 Action::PrevTrack => windows::post_key(windows::VK_MEDIA_PREV_TRACK, &[]),
1149 Action::VolumeUp => windows::post_key(windows::VK_VOLUME_UP, &[]),
1150 Action::VolumeDown => windows::post_key(windows::VK_VOLUME_DOWN, &[]),
1151 Action::MuteVolume => windows::post_key(windows::VK_VOLUME_MUTE, &[]),
1152 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
1153 tracing::debug!(
1154 action = self.label(),
1155 "device action handled by hook/HID layer"
1156 );
1157 }
1158 Action::ScrollUp
1159 | Action::ScrollDown
1160 | Action::HorizontalScrollLeft
1161 | Action::HorizontalScrollRight => windows::post_scroll(self),
1162 Action::CustomShortcut(combo) => windows::post_custom_shortcut(combo),
1163 Action::None => {}
1164 }
1165 }
1166}
1167
1168pub fn post_horizontal_scroll(delta: i32) {
1178 #[cfg(target_os = "macos")]
1179 macos::post_horizontal_scroll(delta);
1180
1181 #[cfg(target_os = "linux")]
1186 linux::scroll(evdev::RelativeAxisCode::REL_HWHEEL, delta);
1187
1188 #[cfg(target_os = "windows")]
1189 windows::post_horizontal_scroll(delta);
1190
1191 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
1192 let _ = delta;
1193}
1194
1195#[cfg(target_os = "linux")]
1202#[must_use]
1203pub fn action_device_path() -> Option<std::path::PathBuf> {
1204 linux::device_node()
1205}
1206
1207#[cfg(target_os = "macos")]
1211const VK_A: u16 = 0x00;
1212#[cfg(target_os = "macos")]
1213const VK_C: u16 = 0x08;
1214#[cfg(target_os = "macos")]
1215const VK_F: u16 = 0x03;
1216#[cfg(target_os = "macos")]
1217const VK_R: u16 = 0x0F;
1218#[cfg(target_os = "macos")]
1219const VK_S: u16 = 0x01;
1220#[cfg(target_os = "macos")]
1221const VK_T: u16 = 0x11;
1222#[cfg(target_os = "macos")]
1223const VK_V: u16 = 0x09;
1224#[cfg(target_os = "macos")]
1225const VK_W: u16 = 0x0D;
1226#[cfg(target_os = "macos")]
1227const VK_X: u16 = 0x07;
1228#[cfg(target_os = "macos")]
1229const VK_Z: u16 = 0x06;
1230#[cfg(target_os = "macos")]
1231const VK_TAB: u16 = 0x30;
1232
1233pub const SYNTHETIC_EVENT_USER_DATA: i64 = 0x4F4C_4749;
1240
1241#[cfg(target_os = "macos")]
1243mod macos {
1244 use core_graphics::event::{
1245 CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, EventField,
1246 ScrollEventUnit,
1247 };
1248 use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
1249 use core_graphics::geometry::CGPoint;
1250
1251 use crate::binding::Action;
1252
1253 pub(super) const NX_KEYTYPE_SOUND_UP: i32 = 0;
1255 pub(super) const NX_KEYTYPE_SOUND_DOWN: i32 = 1;
1256 pub(super) const NX_KEYTYPE_MUTE: i32 = 7;
1257 pub(super) const NX_KEYTYPE_PLAY: i32 = 16;
1258 pub(super) const NX_KEYTYPE_NEXT: i32 = 17;
1259 pub(super) const NX_KEYTYPE_PREVIOUS: i32 = 18;
1260
1261 pub(super) fn post_click(button: CGMouseButton) {
1269 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1270 tracing::warn!("CGEventSource::new failed for click");
1271 return;
1272 };
1273 let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
1276 let (down, up) = match button {
1277 CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp),
1278 CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp),
1279 CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp),
1280 };
1281 for (kind, phase) in [(down, "down"), (up, "up")] {
1282 if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) {
1283 tag_synthetic(&ev);
1284 ev.post(CGEventTapLocation::HID);
1285 } else {
1286 tracing::warn!(phase, "CGEvent::new_mouse_event failed");
1287 }
1288 }
1289 }
1290
1291 pub(super) fn post_other_button(button_number: i64) {
1300 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1301 tracing::warn!("CGEventSource::new failed for extra mouse button");
1302 return;
1303 };
1304 let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
1305 for (kind, phase) in [
1306 (CGEventType::OtherMouseDown, "down"),
1307 (CGEventType::OtherMouseUp, "up"),
1308 ] {
1309 if let Ok(ev) =
1310 CGEvent::new_mouse_event(src.clone(), kind, location, CGMouseButton::Center)
1311 {
1312 ev.set_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER, button_number);
1313 tag_synthetic(&ev);
1314 ev.post(CGEventTapLocation::HID);
1315 } else {
1316 tracing::warn!(phase, "CGEvent::new_mouse_event failed for extra button");
1317 }
1318 }
1319 }
1320
1321 fn tag_synthetic(ev: &CGEvent) {
1327 ev.set_integer_value_field(
1328 EventField::EVENT_SOURCE_USER_DATA,
1329 super::SYNTHETIC_EVENT_USER_DATA,
1330 );
1331 }
1332
1333 pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
1335 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1336 tracing::warn!("CGEventSource::new failed");
1337 return;
1338 };
1339 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1340 tracing::warn!("CGEvent::new_keyboard_event(down) failed");
1341 return;
1342 };
1343 down.set_flags(flags);
1344 down.post(CGEventTapLocation::HID);
1345 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1346 tracing::warn!("CGEvent::new_keyboard_event(up) failed");
1347 return;
1348 };
1349 up.set_flags(flags);
1350 up.post(CGEventTapLocation::HID);
1351 }
1352
1353 pub(super) fn post_media_key(nx_key: i32) {
1360 use objc2::rc::autoreleasepool;
1361 use objc2_app_kit::{NSEvent, NSEventModifierFlags, NSEventType};
1362 use objc2_core_graphics::{CGEvent, CGEventTapLocation};
1363 use objc2_foundation::NSPoint;
1364
1365 const NX_SUBTYPE_AUX_CONTROL_BUTTONS: i16 = 8;
1366 const NX_KEY_DOWN: i32 = 0x0A;
1367 const NX_KEY_UP: i32 = 0x0B;
1368
1369 autoreleasepool(|_| {
1370 for (state, phase) in [(NX_KEY_DOWN, "down"), (NX_KEY_UP, "up")] {
1371 let data1 = ((nx_key << 16) | (state << 8)) as isize;
1374 let Some(ns_event) = NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
1375 NSEventType::SystemDefined,
1376 NSPoint::new(0.0, 0.0),
1377 NSEventModifierFlags::empty(),
1378 0.0,
1379 0,
1380 None,
1381 NX_SUBTYPE_AUX_CONTROL_BUTTONS,
1382 data1,
1383 0,
1384 ) else {
1385 tracing::warn!(nx_key, phase, "NSEvent::otherEventWithType failed");
1386 return;
1387 };
1388 let Some(cg_event) = ns_event.CGEvent() else {
1389 tracing::warn!(nx_key, phase, "NSEvent::CGEvent failed");
1390 return;
1391 };
1392 CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&cg_event));
1393 }
1394 });
1395 }
1396
1397 pub(super) fn post_scroll(action: &Action) {
1399 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1400 tracing::warn!("CGEventSource::new failed for scroll");
1401 return;
1402 };
1403 let (v, h): (i32, i32) = match action {
1404 Action::ScrollUp => (3, 0),
1405 Action::ScrollDown => (-3, 0),
1406 Action::HorizontalScrollLeft => (0, -3),
1407 Action::HorizontalScrollRight => (0, 3),
1408 _ => return,
1409 };
1410 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
1411 tracing::warn!("CGEvent::new_scroll_event failed");
1412 return;
1413 };
1414 ev.post(CGEventTapLocation::HID);
1415 }
1416
1417 pub(super) fn post_horizontal_scroll(delta: i32) {
1420 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1421 tracing::warn!("CGEventSource::new failed for thumbwheel scroll");
1422 return;
1423 };
1424 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else {
1425 tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel");
1426 return;
1427 };
1428 ev.post(CGEventTapLocation::HID);
1429 }
1430
1431 pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop};
1432 pub(super) use symbolic_hotkey::{next_desktop, previous_desktop};
1433
1434 use app_services::symbol as app_services_symbol;
1435
1436 #[allow(
1439 unsafe_code,
1440 reason = "private ApplicationServices SPI symbols are resolved via dlopen/dlsym FFI"
1441 )]
1442 mod app_services {
1443 use std::ffi::{CStr, c_char, c_int, c_void};
1444 use std::sync::OnceLock;
1445
1446 pub(super) fn symbol(symbol: &CStr) -> Option<*mut c_void> {
1450 const RTLD_LAZY: c_int = 0x1;
1451 const APP_SERVICES: &CStr =
1452 c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices";
1453 static HANDLE: OnceLock<usize> = OnceLock::new();
1454
1455 let sym = unsafe {
1459 let handle =
1460 *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize);
1461 if handle == 0 {
1462 return None;
1463 }
1464 dlsym(handle as *mut c_void, symbol.as_ptr())
1465 };
1466 (!sym.is_null()).then_some(sym)
1467 }
1468
1469 unsafe extern "C" {
1470 fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
1471 fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
1472 }
1473 }
1474
1475 #[allow(
1488 unsafe_code,
1489 reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI"
1490 )]
1491 mod dock {
1492 use std::ffi::{c_int, c_void};
1493
1494 use core_foundation::base::TCFType;
1495 use core_foundation::string::CFString;
1496
1497 use super::app_services_symbol;
1498
1499 pub(crate) fn mission_control() {
1501 send("com.apple.expose.awake");
1502 }
1503
1504 pub(crate) fn app_expose() {
1506 send("com.apple.expose.front.awake");
1507 }
1508
1509 pub(crate) fn show_desktop() {
1511 send("com.apple.showdesktop.awake");
1512 }
1513
1514 pub(crate) fn launchpad() {
1516 send("com.apple.launchpad.toggle");
1517 }
1518
1519 fn send(notification: &str) {
1521 let Some(core_dock_send) = core_dock_send_notification() else {
1522 tracing::warn!(notification, "CoreDockSendNotification unavailable");
1523 return;
1524 };
1525 let name = CFString::new(notification);
1526 let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) };
1529 if err != 0 {
1530 tracing::warn!(notification, err, "CoreDockSendNotification failed");
1531 }
1532 }
1533
1534 type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int;
1535
1536 fn core_dock_send_notification() -> Option<CoreDockSendNotificationFn> {
1539 let sym = app_services_symbol(c"CoreDockSendNotification")?;
1540 Some(unsafe { std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym) })
1542 }
1543 }
1544
1545 #[allow(
1552 unsafe_code,
1553 reason = "CGS symbolic hotkey SPI is only reachable via dlopen/dlsym FFI"
1554 )]
1555 mod symbolic_hotkey {
1556 use std::ffi::{c_int, c_uint, c_ushort, c_void};
1557
1558 use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation};
1559 use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
1560
1561 use super::app_services_symbol;
1562
1563 const SPACE_LEFT: u32 = 79;
1564 const SPACE_RIGHT: u32 = 81;
1565
1566 pub(crate) fn previous_desktop() {
1568 post_symbolic_hotkey(SPACE_LEFT);
1569 }
1570
1571 pub(crate) fn next_desktop() {
1573 post_symbolic_hotkey(SPACE_RIGHT);
1574 }
1575
1576 fn post_symbolic_hotkey(hotkey: u32) {
1577 let Some(cgs) = cgs_hotkey_api() else {
1578 tracing::warn!(hotkey, "CGS symbolic hotkey API unavailable");
1579 return;
1580 };
1581
1582 let mut key_equivalent = 0_u16;
1583 let mut virtual_key = 0_u16;
1584 let mut modifiers = 0_u32;
1585
1586 let err = unsafe {
1589 (cgs.get_value)(
1590 hotkey,
1591 &raw mut key_equivalent,
1592 &raw mut virtual_key,
1593 &raw mut modifiers,
1594 )
1595 };
1596 if err != 0 {
1597 tracing::warn!(hotkey, err, "CGSGetSymbolicHotKeyValue failed");
1598 return;
1599 }
1600
1601 let was_enabled = unsafe { (cgs.is_enabled)(hotkey) };
1604 if !was_enabled {
1605 let err = unsafe { (cgs.set_enabled)(hotkey, true) };
1608 if err != 0 {
1609 tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(true) failed");
1610 }
1611 }
1612
1613 post_key(virtual_key, modifiers);
1614
1615 if !was_enabled {
1616 let err = unsafe { (cgs.set_enabled)(hotkey, false) };
1619 if err != 0 {
1620 tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(false) failed");
1621 }
1622 }
1623 }
1624
1625 fn post_key(vk: u16, modifiers: u32) {
1626 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1627 tracing::warn!("CGEventSource::new failed for symbolic hotkey");
1628 return;
1629 };
1630 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1631 tracing::warn!(vk, "CGEvent::new_keyboard_event(down) failed");
1632 return;
1633 };
1634 let flags = CGEventFlags::from_bits_truncate(u64::from(modifiers));
1635 down.set_flags(flags);
1636 down.post(CGEventTapLocation::Session);
1637
1638 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1639 tracing::warn!(vk, "CGEvent::new_keyboard_event(up) failed");
1640 return;
1641 };
1642 up.set_flags(flags);
1643 up.post(CGEventTapLocation::Session);
1644 }
1645
1646 #[derive(Clone, Copy)]
1647 struct CgsHotkeyApi {
1648 get_value: CgsGetSymbolicHotKeyValueFn,
1649 is_enabled: CgsIsSymbolicHotKeyEnabledFn,
1650 set_enabled: CgsSetSymbolicHotKeyEnabledFn,
1651 }
1652
1653 type CgsGetSymbolicHotKeyValueFn =
1654 unsafe extern "C" fn(c_uint, *mut c_ushort, *mut c_ushort, *mut c_uint) -> c_int;
1655 type CgsIsSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint) -> bool;
1656 type CgsSetSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint, bool) -> c_int;
1657
1658 fn cgs_hotkey_api() -> Option<CgsHotkeyApi> {
1659 let get_value = app_services_symbol(c"CGSGetSymbolicHotKeyValue")?;
1660 let is_enabled = app_services_symbol(c"CGSIsSymbolicHotKeyEnabled")?;
1661 let set_enabled = app_services_symbol(c"CGSSetSymbolicHotKeyEnabled")?;
1662
1663 Some(unsafe {
1666 CgsHotkeyApi {
1667 get_value: std::mem::transmute::<*mut c_void, CgsGetSymbolicHotKeyValueFn>(
1668 get_value,
1669 ),
1670 is_enabled: std::mem::transmute::<*mut c_void, CgsIsSymbolicHotKeyEnabledFn>(
1671 is_enabled,
1672 ),
1673 set_enabled: std::mem::transmute::<*mut c_void, CgsSetSymbolicHotKeyEnabledFn>(
1674 set_enabled,
1675 ),
1676 }
1677 })
1678 }
1679 }
1680}
1681
1682#[must_use]
1696pub fn default_binding(button: ButtonId) -> Action {
1697 match button {
1698 ButtonId::LeftClick => Action::LeftClick,
1699 ButtonId::RightClick => Action::RightClick,
1700 ButtonId::MiddleClick => Action::MiddleClick,
1701 ButtonId::Back => Action::BrowserBack,
1702 ButtonId::Forward => Action::BrowserForward,
1703 ButtonId::DpiToggle => Action::CycleDpiPresets,
1704 ButtonId::Thumbwheel => Action::AppExpose,
1705 ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight,
1711 ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft,
1712 ButtonId::GestureButton => Action::MissionControl,
1713 }
1714}
1715
1716#[must_use]
1720pub fn default_gesture_binding(direction: GestureDirection) -> Action {
1721 match direction {
1722 GestureDirection::Up => Action::MissionControl,
1723 GestureDirection::Down => Action::ShowDesktop,
1724 GestureDirection::Left => Action::PrevTab,
1725 GestureDirection::Right => Action::NextTab,
1726 GestureDirection::Click => Action::AppExpose,
1727 }
1728}
1729
1730#[must_use]
1744pub fn default_binding_for(button: ButtonId) -> Binding {
1745 match button {
1746 ButtonId::GestureButton => Binding::Gesture(
1747 GestureDirection::ALL
1748 .into_iter()
1749 .map(|d| (d, default_gesture_binding(d)))
1750 .collect(),
1751 ),
1752 other => Binding::Single(default_binding(other)),
1753 }
1754}
1755
1756#[cfg(target_os = "linux")]
1763mod linux {
1764 use std::io;
1765 use std::sync::{LazyLock, Mutex};
1766
1767 use evdev::uinput::VirtualDevice;
1768 use evdev::{AttributeSet, EventType, InputEvent, KeyCode, RelativeAxisCode};
1769 use zbus::blocking::Connection as DbusConn;
1770
1771 const DEVICE_NAME: &str = "OpenLogi action injector";
1772
1773 static VIRTUAL_INPUT: LazyLock<Option<Mutex<VirtualDevice>>> = LazyLock::new(|| {
1774 build()
1775 .map(Mutex::new)
1776 .map_err(|e| tracing::warn!("failed to create uinput action device: {e}"))
1777 .ok()
1778 });
1779
1780 #[rustfmt::skip]
1781 const KEY_CAPABILITIES: &[KeyCode] = &[
1782 KeyCode::KEY_A, KeyCode::KEY_B, KeyCode::KEY_C, KeyCode::KEY_D,
1784 KeyCode::KEY_E, KeyCode::KEY_F, KeyCode::KEY_G, KeyCode::KEY_H,
1785 KeyCode::KEY_I, KeyCode::KEY_J, KeyCode::KEY_K, KeyCode::KEY_L,
1786 KeyCode::KEY_M, KeyCode::KEY_N, KeyCode::KEY_O, KeyCode::KEY_P,
1787 KeyCode::KEY_Q, KeyCode::KEY_R, KeyCode::KEY_S, KeyCode::KEY_T,
1788 KeyCode::KEY_U, KeyCode::KEY_V, KeyCode::KEY_W, KeyCode::KEY_X,
1789 KeyCode::KEY_Y, KeyCode::KEY_Z,
1790 KeyCode::KEY_0, KeyCode::KEY_1, KeyCode::KEY_2, KeyCode::KEY_3,
1792 KeyCode::KEY_4, KeyCode::KEY_5, KeyCode::KEY_6, KeyCode::KEY_7,
1793 KeyCode::KEY_8, KeyCode::KEY_9,
1794 KeyCode::KEY_MINUS, KeyCode::KEY_EQUAL, KeyCode::KEY_LEFTBRACE,
1796 KeyCode::KEY_RIGHTBRACE, KeyCode::KEY_BACKSLASH, KeyCode::KEY_SEMICOLON,
1797 KeyCode::KEY_APOSTROPHE, KeyCode::KEY_GRAVE, KeyCode::KEY_COMMA,
1798 KeyCode::KEY_DOT, KeyCode::KEY_SLASH,
1799 KeyCode::KEY_LEFT, KeyCode::KEY_RIGHT, KeyCode::KEY_UP, KeyCode::KEY_DOWN,
1801 KeyCode::KEY_HOME, KeyCode::KEY_END, KeyCode::KEY_PAGEUP, KeyCode::KEY_PAGEDOWN,
1802 KeyCode::KEY_TAB, KeyCode::KEY_ENTER, KeyCode::KEY_BACKSPACE, KeyCode::KEY_DELETE,
1803 KeyCode::KEY_ESC, KeyCode::KEY_SPACE,
1804 KeyCode::KEY_LEFTCTRL, KeyCode::KEY_LEFTSHIFT, KeyCode::KEY_LEFTALT, KeyCode::KEY_LEFTMETA,
1806 KeyCode::KEY_F1, KeyCode::KEY_F2, KeyCode::KEY_F3, KeyCode::KEY_F4,
1808 KeyCode::KEY_F5, KeyCode::KEY_F6, KeyCode::KEY_F7, KeyCode::KEY_F8,
1809 KeyCode::KEY_F9, KeyCode::KEY_F10, KeyCode::KEY_F11, KeyCode::KEY_F12,
1810 KeyCode::KEY_SYSRQ,
1812 KeyCode::KEY_PLAYPAUSE, KeyCode::KEY_NEXTSONG, KeyCode::KEY_PREVIOUSSONG,
1814 KeyCode::KEY_VOLUMEUP, KeyCode::KEY_VOLUMEDOWN, KeyCode::KEY_MUTE,
1815 KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE,
1818 KeyCode::BTN_SIDE, KeyCode::BTN_EXTRA,
1819 ];
1820
1821 fn build() -> io::Result<VirtualDevice> {
1822 let mut keys = AttributeSet::<KeyCode>::default();
1823 for &k in KEY_CAPABILITIES {
1824 keys.insert(k);
1825 }
1826
1827 let mut axes = AttributeSet::<RelativeAxisCode>::default();
1832 for a in [RelativeAxisCode::REL_WHEEL, RelativeAxisCode::REL_HWHEEL] {
1833 axes.insert(a);
1834 }
1835
1836 VirtualDevice::builder()?
1837 .name(DEVICE_NAME)
1838 .with_keys(&keys)?
1839 .with_relative_axes(&axes)?
1840 .build()
1841 }
1842
1843 fn emit(events: &[InputEvent]) {
1844 if let Some(m) = &*VIRTUAL_INPUT {
1845 if let Ok(mut guard) = m.lock() {
1846 if let Err(e) = guard.emit(events) {
1847 tracing::warn!("uinput action emit failed: {e}");
1848 }
1849 } else {
1850 tracing::warn!("uinput action device mutex poisoned");
1851 }
1852 } else {
1853 tracing::debug!("uinput action device unavailable — action skipped");
1855 }
1856 }
1857
1858 fn syn() -> InputEvent {
1859 InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0)
1860 }
1861
1862 fn key_ev(code: KeyCode, value: i32) -> InputEvent {
1863 InputEvent::new(EventType::KEY.0, code.0, value)
1864 }
1865
1866 fn rel_ev(axis: RelativeAxisCode, value: i32) -> InputEvent {
1867 InputEvent::new(EventType::RELATIVE.0, axis.0, value)
1868 }
1869
1870 pub(super) fn press_key(mods: &[KeyCode], key: KeyCode) {
1877 let mut down: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1879 for &m in mods {
1880 down.push(key_ev(m, 1));
1881 }
1882 down.push(key_ev(key, 1));
1883 down.push(syn());
1884 emit(&down);
1885
1886 let mut up: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1888 up.push(key_ev(key, 0));
1889 for &m in mods.iter().rev() {
1890 up.push(key_ev(m, 0));
1891 }
1892 up.push(syn());
1893 emit(&up);
1894 }
1895
1896 pub(super) fn click(button: KeyCode) {
1898 emit(&[key_ev(button, 1), syn()]);
1899 emit(&[key_ev(button, 0), syn()]);
1900 }
1901
1902 pub(super) fn scroll(axis: RelativeAxisCode, value: i32) {
1904 emit(&[rel_ev(axis, value), syn()]);
1905 }
1906
1907 pub(super) fn device_node() -> Option<std::path::PathBuf> {
1915 let _ = &*VIRTUAL_INPUT;
1917 std::thread::sleep(std::time::Duration::from_millis(150));
1919 if let Some(m) = &*VIRTUAL_INPUT
1920 && let Ok(mut guard) = m.lock()
1921 {
1922 return guard.enumerate_dev_nodes_blocking().ok()?.flatten().next();
1923 }
1924 None
1925 }
1926
1927 pub(super) fn modifiers_to_keycodes(modifiers: u8) -> Vec<KeyCode> {
1933 use crate::binding::KeyCombo;
1934 let mut mods = Vec::new();
1935 if modifiers & (KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL) != 0 {
1936 mods.push(KeyCode::KEY_LEFTCTRL);
1937 }
1938 if modifiers & KeyCombo::MOD_SHIFT != 0 {
1939 mods.push(KeyCode::KEY_LEFTSHIFT);
1940 }
1941 if modifiers & KeyCombo::MOD_OPTION != 0 {
1942 mods.push(KeyCode::KEY_LEFTALT);
1943 }
1944 mods
1945 }
1946
1947 pub(super) fn macos_vk_to_linux(vk: u16) -> Option<KeyCode> {
1953 Some(match vk {
1954 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,
2028 })
2029 }
2030
2031 static SESSION_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
2034 DbusConn::session()
2035 .map_err(|e| tracing::warn!("D-Bus session bus unavailable: {e}"))
2036 .ok()
2037 });
2038
2039 static SYSTEM_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
2040 DbusConn::system()
2041 .map_err(|e| tracing::warn!("D-Bus system bus unavailable: {e}"))
2042 .ok()
2043 });
2044
2045 pub(super) fn lock_screen() {
2053 if let (Some(conn), Ok(id)) = (SYSTEM_BUS.as_ref(), std::env::var("XDG_SESSION_ID")) {
2054 match conn.call_method(
2055 Some("org.freedesktop.login1"),
2056 "/org/freedesktop/login1",
2057 Some("org.freedesktop.login1.Manager"),
2058 "LockSession",
2059 &(id.as_str(),),
2060 ) {
2061 Ok(_) => {
2062 tracing::debug!("LockScreen via logind");
2063 return;
2064 }
2065 Err(e) => tracing::warn!("logind LockSession failed: {e}"),
2066 }
2067 }
2068 tracing::debug!("LockScreen via Super+L key combo");
2070 press_key(&[KeyCode::KEY_LEFTMETA], KeyCode::KEY_L);
2071 }
2072
2073 pub(super) fn mpris_command(command: &str) {
2079 if try_mpris_command(command).is_none() {
2080 let fallback = match command {
2081 "PlayPause" => KeyCode::KEY_PLAYPAUSE,
2082 "Next" => KeyCode::KEY_NEXTSONG,
2083 "Previous" => KeyCode::KEY_PREVIOUSSONG,
2084 _ => return,
2085 };
2086 press_key(&[], fallback);
2087 }
2088 }
2089
2090 fn try_mpris_command(command: &str) -> Option<()> {
2091 let conn = SESSION_BUS.as_ref()?;
2092 let reply = conn
2093 .call_method(
2094 Some("org.freedesktop.DBus"),
2095 "/org/freedesktop/DBus",
2096 Some("org.freedesktop.DBus"),
2097 "ListNames",
2098 &(),
2099 )
2100 .ok()?;
2101 let names = reply.body().deserialize::<Vec<String>>().ok()?;
2102 let Some(player) = names
2103 .iter()
2104 .find(|n| n.starts_with("org.mpris.MediaPlayer2."))
2105 else {
2106 tracing::debug!("no MPRIS player found — {command} via XF86 key fallback");
2107 return None;
2108 };
2109 match conn.call_method(
2110 Some(player.as_str()),
2111 "/org/mpris/MediaPlayer2",
2112 Some("org.mpris.MediaPlayer2.Player"),
2113 command,
2114 &(),
2115 ) {
2116 Ok(_) => {
2117 tracing::debug!("MPRIS {command} via {player}");
2118 Some(())
2119 }
2120 Err(e) => {
2121 tracing::warn!("MPRIS {command} on {player} failed: {e}");
2124 Some(())
2125 }
2126 }
2127 }
2128}
2129
2130#[cfg_attr(
2144 not(target_os = "windows"),
2145 allow(
2146 dead_code,
2147 reason = "pure key-code table is exercised by host unit tests; its only runtime caller is the Windows-gated post_custom_shortcut"
2148 )
2149)]
2150fn mac_virtual_key_to_windows(key_code: u16) -> Option<u16> {
2151 Some(match key_code {
2152 0x00 => 0x41, 0x0B => 0x42, 0x08 => 0x43, 0x02 => 0x44, 0x0E => 0x45, 0x03 => 0x46, 0x05 => 0x47, 0x04 => 0x48, 0x22 => 0x49, 0x26 => 0x4A, 0x28 => 0x4B, 0x25 => 0x4C, 0x2E => 0x4D, 0x2D => 0x4E, 0x1F => 0x4F, 0x23 => 0x50, 0x0C => 0x51, 0x0F => 0x52, 0x01 => 0x53, 0x11 => 0x54, 0x20 => 0x55, 0x09 => 0x56, 0x0D => 0x57, 0x07 => 0x58, 0x10 => 0x59, 0x06 => 0x5A, 0x1D => 0x30, 0x12 => 0x31, 0x13 => 0x32, 0x14 => 0x33, 0x15 => 0x34, 0x17 => 0x35, 0x16 => 0x36, 0x1A => 0x37, 0x1C => 0x38, 0x19 => 0x39, 0x1B => 0xBD, 0x18 => 0xBB, 0x21 => 0xDB, 0x1E => 0xDD, 0x2A => 0xDC, 0x29 => 0xBA, 0x27 => 0xDE, 0x2B => 0xBC, 0x2F => 0xBE, 0x2C => 0xBF, 0x32 => 0xC0, 0x24 => 0x0D, 0x30 => 0x09, 0x31 => 0x20, 0x33 => 0x08, 0x35 => 0x1B, 0x73 => 0x24, 0x77 => 0x23, 0x74 => 0x21, 0x79 => 0x22, 0x75 => 0x2E, 0x7B => 0x25, 0x7C => 0x27, 0x7D => 0x28, 0x7E => 0x26, 0x7A => 0x70, 0x78 => 0x71, 0x63 => 0x72, 0x76 => 0x73, 0x60 => 0x74, 0x61 => 0x75, 0x62 => 0x76, 0x64 => 0x77, 0x65 => 0x78, 0x6D => 0x79, 0x67 => 0x7A, 0x6F => 0x7B, 0x69 => 0x7C, 0x6B => 0x7D, 0x71 => 0x7E, 0x6A => 0x7F, 0x40 => 0x80, 0x4F => 0x81, 0x50 => 0x82, 0x5A => 0x83, _ => return None,
2240 })
2241}
2242
2243#[cfg(target_os = "windows")]
2244#[allow(unsafe_code, reason = "SendInput is the Win32 API for synthetic input")]
2245mod windows {
2246 use std::mem::size_of;
2247
2248 use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
2249 INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP,
2250 MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN,
2251 MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL,
2252 MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, SendInput,
2253 };
2254
2255 use crate::binding::{Action, KeyCombo};
2256
2257 const WHEEL_DELTA: i32 = 120;
2258
2259 pub(super) const VK_A: u16 = 0x41;
2260 pub(super) const VK_C: u16 = 0x43;
2261 pub(super) const VK_D: u16 = 0x44;
2262 pub(super) const VK_F: u16 = 0x46;
2263 pub(super) const VK_L: u16 = 0x4C;
2264 pub(super) const VK_R: u16 = 0x52;
2265 pub(super) const VK_S: u16 = 0x53;
2266 pub(super) const VK_T: u16 = 0x54;
2267 pub(super) const VK_V: u16 = 0x56;
2268 pub(super) const VK_W: u16 = 0x57;
2269 pub(super) const VK_X: u16 = 0x58;
2270 pub(super) const VK_Y: u16 = 0x59;
2271 pub(super) const VK_Z: u16 = 0x5A;
2272 pub(super) const VK_TAB: u16 = 0x09;
2273 pub(super) const VK_LEFT: u16 = 0x25;
2274 pub(super) const VK_RIGHT: u16 = 0x27;
2275 pub(super) const VK_SHIFT: u16 = 0x10;
2276 pub(super) const VK_CONTROL: u16 = 0x11;
2277 pub(super) const VK_MENU: u16 = 0x12;
2278 pub(super) const VK_LWIN: u16 = 0x5B;
2279 pub(super) const VK_BROWSER_BACK: u16 = 0xA6;
2280 pub(super) const VK_BROWSER_FORWARD: u16 = 0xA7;
2281 pub(super) const VK_VOLUME_MUTE: u16 = 0xAD;
2282 pub(super) const VK_VOLUME_DOWN: u16 = 0xAE;
2283 pub(super) const VK_VOLUME_UP: u16 = 0xAF;
2284 pub(super) const VK_MEDIA_NEXT_TRACK: u16 = 0xB0;
2285 pub(super) const VK_MEDIA_PREV_TRACK: u16 = 0xB1;
2286 pub(super) const VK_MEDIA_PLAY_PAUSE: u16 = 0xB3;
2287
2288 #[derive(Clone, Copy)]
2289 pub(super) enum MouseButton {
2290 Left,
2291 Right,
2292 Middle,
2293 Back,
2295 Forward,
2297 }
2298
2299 const XBUTTON1: i32 = 1;
2303 const XBUTTON2: i32 = 2;
2304
2305 pub(super) fn post_click(button: MouseButton) {
2306 let (down, up, data) = match button {
2307 MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, 0),
2308 MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, 0),
2309 MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, 0),
2310 MouseButton::Back => (MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, XBUTTON1),
2312 MouseButton::Forward => (MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, XBUTTON2),
2313 };
2314 send_inputs(&[mouse_input(down, data), mouse_input(up, data)]);
2315 }
2316
2317 pub(super) fn post_key(vk: u16, modifiers: &[u16]) {
2318 let mut inputs = Vec::with_capacity(modifiers.len() * 2 + 2);
2319 for modifier in modifiers {
2320 inputs.push(key_input(*modifier, false));
2321 }
2322 inputs.push(key_input(vk, false));
2323 inputs.push(key_input(vk, true));
2324 for modifier in modifiers.iter().rev() {
2325 inputs.push(key_input(*modifier, true));
2326 }
2327 send_inputs(&inputs);
2328 }
2329
2330 pub(super) fn post_scroll(action: &Action) {
2331 let (flags, data) = match action {
2332 Action::ScrollUp => (MOUSEEVENTF_WHEEL, WHEEL_DELTA),
2333 Action::ScrollDown => (MOUSEEVENTF_WHEEL, -WHEEL_DELTA),
2334 Action::HorizontalScrollLeft => (MOUSEEVENTF_HWHEEL, -WHEEL_DELTA),
2335 Action::HorizontalScrollRight => (MOUSEEVENTF_HWHEEL, WHEEL_DELTA),
2336 _ => return,
2337 };
2338 send_inputs(&[mouse_input(flags, data)]);
2339 }
2340
2341 pub(super) fn post_horizontal_scroll(delta: i32) {
2342 if delta == 0 {
2343 return;
2344 }
2345 send_inputs(&[mouse_input(
2346 MOUSEEVENTF_HWHEEL,
2347 delta.saturating_mul(WHEEL_DELTA),
2348 )]);
2349 }
2350
2351 pub(super) fn post_custom_shortcut(combo: &KeyCombo) {
2352 if combo.key_code == 0 {
2353 tracing::warn!(
2354 chord = %combo.rendered_label(),
2355 "CustomShortcut with no key code; press ignored"
2356 );
2357 return;
2358 }
2359 let Some(vk) = super::mac_virtual_key_to_windows(combo.key_code) else {
2360 tracing::warn!(
2361 key_code = combo.key_code,
2362 chord = %combo.rendered_label(),
2363 "CustomShortcut key has no Windows mapping yet; press ignored"
2364 );
2365 return;
2366 };
2367
2368 let mut modifiers = Vec::new();
2369 if combo.modifiers & KeyCombo::MOD_CMD != 0 {
2370 modifiers.push(VK_CONTROL);
2371 }
2372 if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
2373 modifiers.push(VK_SHIFT);
2374 }
2375 if combo.modifiers & KeyCombo::MOD_CTRL != 0 && !modifiers.contains(&VK_CONTROL) {
2376 modifiers.push(VK_CONTROL);
2377 }
2378 if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
2379 modifiers.push(VK_MENU);
2380 }
2381 post_key(vk, &modifiers);
2382 }
2383
2384 fn send_inputs(inputs: &[INPUT]) {
2385 let Ok(input_count) = u32::try_from(inputs.len()) else {
2386 tracing::warn!(
2387 requested = inputs.len(),
2388 "too many SendInput events requested"
2389 );
2390 return;
2391 };
2392 let Ok(input_size) = i32::try_from(size_of::<INPUT>()) else {
2393 tracing::warn!("INPUT size does not fit the Win32 SendInput contract");
2394 return;
2395 };
2396 let sent = unsafe { SendInput(input_count, inputs.as_ptr(), input_size) };
2397 if sent != input_count {
2398 tracing::warn!(
2399 requested = inputs.len(),
2400 sent,
2401 "SendInput accepted fewer events than requested"
2402 );
2403 }
2404 }
2405
2406 fn key_input(vk: u16, key_up: bool) -> INPUT {
2407 let mut flags = 0;
2408 if key_up {
2409 flags |= KEYEVENTF_KEYUP;
2410 }
2411 INPUT {
2412 r#type: INPUT_KEYBOARD,
2413 Anonymous: INPUT_0 {
2414 ki: KEYBDINPUT {
2415 wVk: vk,
2416 wScan: 0,
2417 dwFlags: flags,
2418 time: 0,
2419 dwExtraInfo: 0,
2420 },
2421 },
2422 }
2423 }
2424
2425 fn mouse_input(flags: u32, data: i32) -> INPUT {
2426 INPUT {
2427 r#type: INPUT_MOUSE,
2428 Anonymous: INPUT_0 {
2429 mi: MOUSEINPUT {
2430 dx: 0,
2431 dy: 0,
2432 mouseData: u32::from_ne_bytes(data.to_ne_bytes()),
2433 dwFlags: flags,
2434 time: 0,
2435 dwExtraInfo: 0,
2436 },
2437 },
2438 }
2439 }
2440}
2441
2442#[cfg(test)]
2443#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
2444mod tests {
2445 use std::collections::BTreeMap;
2446
2447 use serde::{Deserialize, Serialize};
2448
2449 use super::*;
2450
2451 #[derive(Serialize, Deserialize)]
2456 struct RoundtripWrapper {
2457 binding: BTreeMap<ButtonId, Action>,
2458 }
2459
2460 #[test]
2463 fn catalog_has_at_least_29_entries() {
2464 let catalog = Action::catalog();
2465 assert!(
2466 catalog.len() >= 29,
2467 "catalog has {} entries, need ≥ 29",
2468 catalog.len()
2469 );
2470 }
2471
2472 #[test]
2473 fn catalog_excludes_custom_shortcut() {
2474 let catalog = Action::catalog();
2475 for action in &catalog {
2476 assert!(
2477 !matches!(action, Action::CustomShortcut(_)),
2478 "catalog must not contain CustomShortcut"
2479 );
2480 }
2481 }
2482
2483 #[test]
2484 fn custom_shortcut_keycodes_map_across_categories() {
2485 assert_eq!(mac_virtual_key_to_windows(0x00), Some(0x41)); assert_eq!(mac_virtual_key_to_windows(0x12), Some(0x31)); assert_eq!(mac_virtual_key_to_windows(0x7A), Some(0x70)); assert_eq!(mac_virtual_key_to_windows(0x7B), Some(0x25)); assert_eq!(mac_virtual_key_to_windows(0x31), Some(0x20)); assert_eq!(mac_virtual_key_to_windows(0x29), Some(0xBA)); assert_eq!(mac_virtual_key_to_windows(0x37), None); }
2497
2498 #[derive(Serialize, Deserialize)]
2503 struct BindingWrapper {
2504 bindings: BTreeMap<ButtonId, Binding>,
2505 }
2506
2507 fn binding_roundtrip(bindings: BTreeMap<ButtonId, Binding>) -> BTreeMap<ButtonId, Binding> {
2508 let toml = toml::to_string_pretty(&BindingWrapper { bindings }).expect("serialize");
2509 toml::from_str::<BindingWrapper>(&toml)
2510 .expect("deserialize")
2511 .bindings
2512 }
2513
2514 #[test]
2515 fn binding_single_roundtrips_including_payload_variants() {
2516 let mut bindings = BTreeMap::new();
2517 bindings.insert(ButtonId::Back, Binding::Single(Action::BrowserBack));
2518 bindings.insert(
2519 ButtonId::DpiToggle,
2520 Binding::Single(Action::SetDpiPreset(2)),
2521 );
2522 bindings.insert(
2523 ButtonId::Forward,
2524 Binding::Single(Action::CustomShortcut(KeyCombo {
2525 modifiers: KeyCombo::MOD_CMD,
2526 key_code: 0x23,
2527 display: "⌘P".into(),
2528 })),
2529 );
2530 let back = binding_roundtrip(bindings);
2531 assert_eq!(back[&ButtonId::Back], Binding::Single(Action::BrowserBack));
2532 assert_eq!(
2533 back[&ButtonId::DpiToggle],
2534 Binding::Single(Action::SetDpiPreset(2))
2535 );
2536 assert!(matches!(
2537 back[&ButtonId::Forward],
2538 Binding::Single(Action::CustomShortcut(_))
2539 ));
2540 }
2541
2542 #[test]
2543 fn binding_gesture_roundtrips() {
2544 let mut map = BTreeMap::new();
2545 map.insert(GestureDirection::Up, Action::Copy);
2546 map.insert(GestureDirection::Click, Action::Paste);
2547 let mut bindings = BTreeMap::new();
2548 bindings.insert(ButtonId::GestureButton, Binding::Gesture(map.clone()));
2549 let back = binding_roundtrip(bindings);
2550 assert_eq!(back[&ButtonId::GestureButton], Binding::Gesture(map));
2551 }
2552
2553 #[test]
2559 fn binding_direction_keyed_table_routes_to_gesture() {
2560 for dir in GestureDirection::ALL {
2561 let toml = format!("bindings.GestureButton.{dir} = \"None\"");
2563 let parsed = toml::from_str::<BindingWrapper>(&toml).expect("deserialize");
2564 assert!(
2565 matches!(
2566 parsed.bindings[&ButtonId::GestureButton],
2567 Binding::Gesture(_)
2568 ),
2569 "a {dir}-keyed table must route to Gesture, not Single"
2570 );
2571 }
2572 }
2573
2574 #[test]
2578 fn binding_payload_action_stays_single() {
2579 let toml = "bindings.DpiToggle.SetDpiPreset = 2";
2580 let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
2581 assert_eq!(
2582 parsed.bindings[&ButtonId::DpiToggle],
2583 Binding::Single(Action::SetDpiPreset(2))
2584 );
2585 }
2586
2587 #[test]
2590 fn detect_swipe_below_threshold_keeps_accumulating() {
2591 assert_eq!(detect_swipe(40, 5), None);
2593 assert_eq!(detect_swipe(0, 0), None);
2594 }
2595
2596 #[test]
2597 fn detect_swipe_commits_clean_direction() {
2598 assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
2599 assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
2600 assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
2601 assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
2602 }
2603
2604 #[test]
2605 fn detect_swipe_rejects_diagonal() {
2606 assert_eq!(detect_swipe(60, 60), None);
2608 assert_eq!(detect_swipe(-60, -60), None);
2609 }
2610
2611 #[test]
2612 fn detect_swipe_threshold_and_cross_band_boundaries() {
2613 assert_eq!(
2616 detect_swipe(GESTURE_SWIPE_THRESHOLD, 0),
2617 Some(GestureDirection::Right)
2618 );
2619 assert_eq!(detect_swipe(GESTURE_SWIPE_THRESHOLD - 1, 0), None);
2620
2621 assert_eq!(detect_swipe(200, 69), Some(GestureDirection::Right));
2624 assert_eq!(detect_swipe(200, 71), None);
2625 assert_eq!(detect_swipe(100, 39), Some(GestureDirection::Right));
2627 assert_eq!(detect_swipe(100, 41), None);
2628 }
2629
2630 #[test]
2631 fn detect_swipe_does_not_panic_on_extreme_values() {
2632 assert_eq!(detect_swipe(i32::MAX, 0), Some(GestureDirection::Right));
2635 assert_eq!(detect_swipe(i32::MIN, 0), Some(GestureDirection::Left));
2636 assert_eq!(detect_swipe(0, i32::MAX), Some(GestureDirection::Down));
2637 assert_eq!(detect_swipe(0, i32::MIN), Some(GestureDirection::Up));
2638 assert_eq!(detect_swipe(i32::MIN, i32::MIN), None);
2640 }
2641
2642 #[test]
2645 fn accumulator_commits_a_direction_once_after_the_hold_gate() {
2646 let mut acc = SwipeAccumulator::default();
2647 acc.begin();
2648 acc.backdate_hold_for_test();
2649 assert_eq!(
2651 acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
2652 Some(GestureDirection::Right)
2653 );
2654 assert_eq!(acc.accumulate(50, 0), None);
2656 }
2657
2658 #[test]
2659 fn accumulator_does_not_commit_before_the_hold_gate() {
2660 let mut acc = SwipeAccumulator::default();
2661 acc.begin(); assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
2665 acc.backdate_hold_for_test();
2667 assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0).is_some());
2668 }
2669
2670 #[test]
2671 fn accumulator_end_reports_click_only_when_no_swipe_fired() {
2672 let mut acc = SwipeAccumulator::default();
2674 acc.begin();
2675 acc.backdate_hold_for_test();
2676 assert_eq!(acc.accumulate(2, -1), None);
2677 assert!(acc.end(), "a hold that never swiped is a click");
2678
2679 acc.begin();
2681 acc.backdate_hold_for_test();
2682 assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0).is_some());
2683 assert!(!acc.end(), "a committed swipe must not also click");
2684 }
2685
2686 #[test]
2687 fn accumulator_ignores_motion_when_not_holding() {
2688 let mut acc = SwipeAccumulator::default();
2689 assert!(!acc.is_holding());
2690 assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
2692 }
2693
2694 #[test]
2695 fn accumulator_sums_sub_threshold_deltas_until_they_commit() {
2696 let mut acc = SwipeAccumulator::default();
2700 acc.begin();
2701 acc.backdate_hold_for_test();
2702 let step = GESTURE_SWIPE_THRESHOLD / 2 - 1;
2704 assert_eq!(acc.accumulate(step, 0), None, "one step is sub-threshold");
2705 assert_eq!(acc.accumulate(step, 0), None, "two steps still under");
2706 assert_eq!(
2707 acc.accumulate(step, 0),
2708 Some(GestureDirection::Right),
2709 "the running sum finally crosses the threshold"
2710 );
2711 }
2712
2713 #[test]
2714 fn accumulator_saturates_instead_of_overflowing() {
2715 let mut acc = SwipeAccumulator::default();
2720 acc.begin();
2721 acc.backdate_hold_for_test();
2722 assert_eq!(
2723 acc.accumulate(i32::MAX, i32::MAX),
2724 None,
2725 "a diagonal never commits"
2726 );
2727 assert_eq!(
2728 acc.accumulate(i32::MAX, i32::MAX),
2729 None,
2730 "the saturating sum must not panic"
2731 );
2732 acc.begin();
2734 acc.backdate_hold_for_test();
2735 assert_eq!(acc.accumulate(i32::MAX, 0), Some(GestureDirection::Right));
2736 }
2737
2738 #[test]
2739 fn accumulator_begin_recovers_a_stale_hold() {
2740 let mut acc = SwipeAccumulator::default();
2745 acc.begin();
2746 acc.backdate_hold_for_test();
2747 assert_eq!(
2749 acc.accumulate(-(GESTURE_SWIPE_THRESHOLD + 10), 0),
2750 Some(GestureDirection::Left)
2751 );
2752 acc.begin();
2754 acc.backdate_hold_for_test();
2755 assert_eq!(
2758 acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
2759 Some(GestureDirection::Right)
2760 );
2761 }
2762
2763 #[test]
2764 fn accumulator_end_without_a_hold_is_not_a_click() {
2765 let mut acc = SwipeAccumulator::default();
2768 assert!(!acc.end(), "a release with no hold is not a click");
2769 acc.begin();
2771 assert!(acc.end(), "the held release is a click");
2772 assert!(!acc.end(), "the redundant second release is not a click");
2773 }
2774
2775 fn roundtrip(action: &Action) -> Action {
2780 let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
2781 map.insert(ButtonId::Back, action.clone());
2782 let w = RoundtripWrapper { binding: map };
2783 let s = toml::to_string(&w).expect("serialize");
2784 let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
2785 back.binding
2786 .into_values()
2787 .next()
2788 .expect("binding present after roundtrip")
2789 }
2790
2791 #[test]
2792 fn all_catalog_variants_roundtrip_toml() {
2793 for action in Action::catalog() {
2794 let back = roundtrip(&action);
2795 assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
2796 }
2797 }
2798
2799 #[test]
2800 fn custom_shortcut_roundtrips_toml() {
2801 let action = Action::CustomShortcut(KeyCombo {
2802 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
2803 key_code: 0x23, display: "⌘⇧P".into(),
2805 });
2806 assert_eq!(roundtrip(&action), action);
2807 }
2808
2809 #[test]
2810 fn key_combo_rendered_label_uses_display_when_set() {
2811 let combo = KeyCombo {
2812 modifiers: 0,
2813 key_code: 0,
2814 display: "preset".into(),
2815 };
2816 assert_eq!(combo.rendered_label(), "preset");
2817 }
2818
2819 #[test]
2820 fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
2821 let combo = KeyCombo {
2822 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
2823 key_code: 0x23, display: String::new(),
2825 };
2826 assert_eq!(combo.rendered_label(), "⇧⌘P");
2827 }
2828
2829 #[test]
2832 fn category_editing_variants() {
2833 assert_eq!(Action::Copy.category(), Category::Editing);
2834 assert_eq!(Action::Undo.category(), Category::Editing);
2835 assert_eq!(Action::SelectAll.category(), Category::Editing);
2836 assert_eq!(Action::Find.category(), Category::Editing);
2837 assert_eq!(Action::Save.category(), Category::Editing);
2838 assert_eq!(Action::Cut.category(), Category::Editing);
2839 assert_eq!(Action::Redo.category(), Category::Editing);
2840 assert_eq!(Action::Paste.category(), Category::Editing);
2841 }
2842
2843 #[test]
2844 fn category_browser_variants() {
2845 assert_eq!(Action::BrowserBack.category(), Category::Browser);
2846 assert_eq!(Action::BrowserForward.category(), Category::Browser);
2847 assert_eq!(Action::NewTab.category(), Category::Browser);
2848 assert_eq!(Action::CloseTab.category(), Category::Browser);
2849 assert_eq!(Action::ReopenTab.category(), Category::Browser);
2850 assert_eq!(Action::NextTab.category(), Category::Browser);
2851 assert_eq!(Action::PrevTab.category(), Category::Browser);
2852 assert_eq!(Action::ReloadPage.category(), Category::Browser);
2853 }
2854
2855 #[test]
2856 fn category_media_variants() {
2857 assert_eq!(Action::PlayPause.category(), Category::Media);
2858 assert_eq!(Action::NextTrack.category(), Category::Media);
2859 assert_eq!(Action::PrevTrack.category(), Category::Media);
2860 assert_eq!(Action::VolumeUp.category(), Category::Media);
2861 assert_eq!(Action::VolumeDown.category(), Category::Media);
2862 assert_eq!(Action::MuteVolume.category(), Category::Media);
2863 }
2864
2865 #[test]
2866 fn category_mouse_variants() {
2867 assert_eq!(Action::LeftClick.category(), Category::Mouse);
2868 assert_eq!(Action::RightClick.category(), Category::Mouse);
2869 assert_eq!(Action::MiddleClick.category(), Category::Mouse);
2870 }
2871
2872 #[test]
2873 fn category_dpi_variants() {
2874 assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
2875 assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
2876 }
2877
2878 #[test]
2879 fn category_scroll_variants() {
2880 assert_eq!(Action::ScrollUp.category(), Category::Scroll);
2881 assert_eq!(Action::ScrollDown.category(), Category::Scroll);
2882 assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
2883 assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
2884 }
2885
2886 #[test]
2887 fn category_navigation_variants() {
2888 assert_eq!(Action::MissionControl.category(), Category::Navigation);
2889 assert_eq!(Action::AppExpose.category(), Category::Navigation);
2890 assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
2891 assert_eq!(Action::NextDesktop.category(), Category::Navigation);
2892 assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
2893 assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
2894 }
2895
2896 #[test]
2897 fn category_system_variants() {
2898 assert_eq!(Action::LockScreen.category(), Category::System);
2899 assert_eq!(Action::Screenshot.category(), Category::System);
2900 }
2901
2902 #[test]
2905 fn category_labels_are_nonempty() {
2906 let categories = [
2907 Category::Editing,
2908 Category::Browser,
2909 Category::Media,
2910 Category::Mouse,
2911 Category::Dpi,
2912 Category::Scroll,
2913 Category::Navigation,
2914 Category::System,
2915 ];
2916 for cat in categories {
2917 assert!(!cat.label().is_empty(), "label empty for {cat:?}");
2918 }
2919 }
2920
2921 #[test]
2924 fn dpi_toggle_default_is_cycle_dpi_presets() {
2925 assert_eq!(
2926 default_binding(ButtonId::DpiToggle),
2927 Action::CycleDpiPresets
2928 );
2929 }
2930
2931 #[cfg(target_os = "linux")]
2934 mod modifier_mapping {
2935 use evdev::KeyCode;
2936
2937 use crate::binding::{KeyCombo, linux::modifiers_to_keycodes};
2938
2939 #[test]
2940 fn mod_cmd_alone_maps_to_ctrl() {
2941 assert_eq!(
2942 modifiers_to_keycodes(KeyCombo::MOD_CMD),
2943 vec![KeyCode::KEY_LEFTCTRL]
2944 );
2945 }
2946
2947 #[test]
2948 fn mod_ctrl_alone_maps_to_ctrl() {
2949 assert_eq!(
2950 modifiers_to_keycodes(KeyCombo::MOD_CTRL),
2951 vec![KeyCode::KEY_LEFTCTRL]
2952 );
2953 }
2954
2955 #[test]
2956 fn mod_cmd_and_ctrl_together_produce_single_ctrl() {
2957 assert_eq!(
2959 modifiers_to_keycodes(KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL),
2960 vec![KeyCode::KEY_LEFTCTRL]
2961 );
2962 }
2963
2964 #[test]
2965 fn all_modifiers_produce_canonical_order() {
2966 let mods = modifiers_to_keycodes(
2967 KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT | KeyCombo::MOD_OPTION,
2968 );
2969 assert_eq!(
2970 mods,
2971 vec![
2972 KeyCode::KEY_LEFTCTRL,
2973 KeyCode::KEY_LEFTSHIFT,
2974 KeyCode::KEY_LEFTALT
2975 ]
2976 );
2977 }
2978
2979 #[test]
2980 fn no_modifiers_produces_empty_vec() {
2981 assert!(modifiers_to_keycodes(0).is_empty());
2982 }
2983 }
2984
2985 #[cfg(target_os = "linux")]
2988 mod vk_mapping {
2989 use evdev::KeyCode;
2990
2991 use crate::binding::linux::macos_vk_to_linux;
2992
2993 #[test]
2994 fn common_letters_map_correctly() {
2995 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)); }
3002
3003 #[test]
3004 fn digits_map_correctly() {
3005 assert_eq!(macos_vk_to_linux(0x12), Some(KeyCode::KEY_1)); assert_eq!(macos_vk_to_linux(0x1D), Some(KeyCode::KEY_0)); }
3008
3009 #[test]
3010 fn arrow_keys_map_correctly() {
3011 assert_eq!(macos_vk_to_linux(0x7B), Some(KeyCode::KEY_LEFT));
3012 assert_eq!(macos_vk_to_linux(0x7C), Some(KeyCode::KEY_RIGHT));
3013 assert_eq!(macos_vk_to_linux(0x7D), Some(KeyCode::KEY_DOWN));
3014 assert_eq!(macos_vk_to_linux(0x7E), Some(KeyCode::KEY_UP));
3015 }
3016
3017 #[test]
3018 fn function_keys_map_correctly() {
3019 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)); }
3025
3026 #[test]
3027 fn nav_keys_map_correctly() {
3028 assert_eq!(macos_vk_to_linux(0x73), Some(KeyCode::KEY_HOME));
3029 assert_eq!(macos_vk_to_linux(0x77), Some(KeyCode::KEY_END));
3030 assert_eq!(macos_vk_to_linux(0x74), Some(KeyCode::KEY_PAGEUP));
3031 assert_eq!(macos_vk_to_linux(0x79), Some(KeyCode::KEY_PAGEDOWN));
3032 assert_eq!(macos_vk_to_linux(0x75), Some(KeyCode::KEY_DELETE));
3033 }
3034
3035 #[test]
3036 fn brackets_follow_ansi_layout() {
3037 assert_eq!(macos_vk_to_linux(0x21), Some(KeyCode::KEY_LEFTBRACE));
3039 assert_eq!(macos_vk_to_linux(0x1E), Some(KeyCode::KEY_RIGHTBRACE));
3040 }
3041
3042 #[test]
3043 fn unmapped_code_returns_none() {
3044 assert_eq!(macos_vk_to_linux(0xFF), None);
3045 assert_eq!(macos_vk_to_linux(0x34), None); }
3047 }
3048}