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
373 Copy,
376 Paste,
378 Cut,
380 Undo,
382 Redo,
389 SelectAll,
391 Find,
393 Save,
395
396 BrowserBack,
399 BrowserForward,
401 NewTab,
403 CloseTab,
405 ReopenTab,
407 NextTab,
409 PrevTab,
411 ReloadPage,
413
414 MissionControl,
417 AppExpose,
419 PreviousDesktop,
421 NextDesktop,
423 ShowDesktop,
425 LaunchpadShow,
427
428 LockScreen,
435 Screenshot,
437
438 PlayPause,
441 NextTrack,
443 PrevTrack,
445 VolumeUp,
447 VolumeDown,
449 MuteVolume,
451
452 CycleDpiPresets,
455 SetDpiPreset(u8),
458 ToggleSmartShift,
460
461 ScrollUp,
464 ScrollDown,
466 HorizontalScrollLeft,
468 HorizontalScrollRight,
470
471 CustomShortcut(KeyCombo),
479}
480
481#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
493pub struct KeyCombo {
494 pub modifiers: u8,
496 pub key_code: u16,
500 #[serde(default)]
503 pub display: String,
504}
505
506impl KeyCombo {
507 pub const MOD_CMD: u8 = 1 << 0;
508 pub const MOD_SHIFT: u8 = 1 << 1;
509 pub const MOD_CTRL: u8 = 1 << 2;
510 pub const MOD_OPTION: u8 = 1 << 3;
511
512 #[must_use]
517 pub fn rendered_label(&self) -> String {
518 if !self.display.is_empty() {
519 return self.display.clone();
520 }
521 let mut out = String::new();
522 if self.modifiers & Self::MOD_CTRL != 0 {
523 out.push('⌃');
524 }
525 if self.modifiers & Self::MOD_OPTION != 0 {
526 out.push('⌥');
527 }
528 if self.modifiers & Self::MOD_SHIFT != 0 {
529 out.push('⇧');
530 }
531 if self.modifiers & Self::MOD_CMD != 0 {
532 out.push('⌘');
533 }
534 match self.key_code {
535 0x00 => out.push('A'),
536 0x01 => out.push('S'),
537 0x02 => out.push('D'),
538 0x03 => out.push('F'),
539 0x06 => out.push('Z'),
540 0x07 => out.push('X'),
541 0x08 => out.push('C'),
542 0x09 => out.push('V'),
543 0x0B => out.push('B'),
544 0x0C => out.push('Q'),
545 0x0D => out.push('W'),
546 0x0E => out.push('E'),
547 0x0F => out.push('R'),
548 0x10 => out.push('Y'),
549 0x11 => out.push('T'),
550 0x20 => out.push('U'),
551 0x22 => out.push('I'),
552 0x1F => out.push('O'),
553 0x23 => out.push('P'),
554 _ => {
555 use std::fmt::Write as _;
556 let _ = write!(out, "key 0x{:02X}", self.key_code);
557 }
558 }
559 out
560 }
561}
562
563#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
588#[serde(untagged)]
589pub enum Binding {
590 Single(Action),
592 Gesture(BTreeMap<GestureDirection, Action>),
596}
597
598impl Binding {
599 #[must_use]
606 pub fn click_action(&self) -> Action {
607 match self {
608 Binding::Single(action) => action.clone(),
609 Binding::Gesture(map) => map
610 .get(&GestureDirection::Click)
611 .cloned()
612 .unwrap_or(Action::None),
613 }
614 }
615
616 #[must_use]
619 pub fn direction_action(&self, direction: GestureDirection) -> Option<&Action> {
620 match self {
621 Binding::Single(_) => None,
622 Binding::Gesture(map) => map.get(&direction),
623 }
624 }
625
626 #[must_use]
629 pub fn is_gesture(&self) -> bool {
630 matches!(self, Binding::Gesture(_))
631 }
632
633 pub fn upgrade_to_gesture(&mut self) {
638 if let Binding::Single(action) = self {
639 let mut map = BTreeMap::new();
640 map.insert(GestureDirection::Click, action.clone());
641 *self = Binding::Gesture(map);
642 }
643 }
644
645 pub fn fill_gesture_defaults(&mut self) {
652 if let Binding::Gesture(map) = self {
653 for dir in GestureDirection::ALL {
654 map.entry(dir)
655 .or_insert_with(|| default_gesture_binding(dir));
656 }
657 }
658 }
659}
660
661impl From<Action> for Binding {
662 fn from(action: Action) -> Self {
663 Binding::Single(action)
664 }
665}
666
667impl Action {
668 #[must_use]
674 pub fn label(&self) -> String {
675 match self {
676 Action::None => "Do Nothing".into(),
677 Action::LeftClick => "Left Click".into(),
678 Action::RightClick => "Right Click".into(),
679 Action::MiddleClick => "Middle Click".into(),
680 Action::Copy => "Copy".into(),
681 Action::Paste => "Paste".into(),
682 Action::Cut => "Cut".into(),
683 Action::Undo => "Undo".into(),
684 Action::Redo => "Redo".into(),
685 Action::SelectAll => "Select All".into(),
686 Action::Find => "Find".into(),
687 Action::Save => "Save".into(),
688 Action::BrowserBack => "Browser Back".into(),
689 Action::BrowserForward => "Browser Forward".into(),
690 Action::NewTab => "New Tab".into(),
691 Action::CloseTab => "Close Tab".into(),
692 Action::ReopenTab => "Reopen Tab".into(),
693 Action::NextTab => "Next Tab".into(),
694 Action::PrevTab => "Previous Tab".into(),
695 Action::ReloadPage => "Reload Page".into(),
696 Action::MissionControl => "Mission Control".into(),
697 Action::AppExpose => "App Exposé".into(),
698 Action::PreviousDesktop => "Previous Desktop".into(),
699 Action::NextDesktop => "Next Desktop".into(),
700 Action::ShowDesktop => "Show Desktop".into(),
701 Action::LaunchpadShow => "Launchpad".into(),
702 Action::LockScreen => "Lock Screen".into(),
703 Action::Screenshot => "Screenshot".into(),
704 Action::PlayPause => "Play / Pause".into(),
705 Action::NextTrack => "Next Track".into(),
706 Action::PrevTrack => "Previous Track".into(),
707 Action::VolumeUp => "Volume Up".into(),
708 Action::VolumeDown => "Volume Down".into(),
709 Action::MuteVolume => "Mute".into(),
710 Action::CycleDpiPresets => "Cycle DPI Presets".into(),
711 Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
712 Action::ToggleSmartShift => "Toggle SmartShift".into(),
713 Action::ScrollUp => "Scroll Up".into(),
714 Action::ScrollDown => "Scroll Down".into(),
715 Action::HorizontalScrollLeft => "Scroll Left".into(),
716 Action::HorizontalScrollRight => "Scroll Right".into(),
717 Action::CustomShortcut(combo) => combo.rendered_label(),
718 }
719 }
720
721 #[must_use]
723 pub fn category(&self) -> Category {
724 match self {
725 Action::LeftClick | Action::RightClick | Action::MiddleClick => Category::Mouse,
726 Action::Copy
729 | Action::Paste
730 | Action::Cut
731 | Action::Undo
732 | Action::Redo
733 | Action::SelectAll
734 | Action::Find
735 | Action::Save
736 | Action::CustomShortcut(_) => Category::Editing,
737 Action::BrowserBack
738 | Action::BrowserForward
739 | Action::NewTab
740 | Action::CloseTab
741 | Action::ReopenTab
742 | Action::NextTab
743 | Action::PrevTab
744 | Action::ReloadPage => Category::Browser,
745 Action::MissionControl
746 | Action::AppExpose
747 | Action::PreviousDesktop
748 | Action::NextDesktop
749 | Action::ShowDesktop
750 | Action::LaunchpadShow => Category::Navigation,
751 Action::None | Action::LockScreen | Action::Screenshot => Category::System,
752 Action::PlayPause
753 | Action::NextTrack
754 | Action::PrevTrack
755 | Action::VolumeUp
756 | Action::VolumeDown
757 | Action::MuteVolume => Category::Media,
758 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
759 Category::Dpi
760 }
761 Action::ScrollUp
762 | Action::ScrollDown
763 | Action::HorizontalScrollLeft
764 | Action::HorizontalScrollRight => Category::Scroll,
765 }
766 }
767
768 #[must_use]
773 pub fn catalog() -> Vec<Action> {
774 vec![
775 Action::LeftClick,
777 Action::RightClick,
778 Action::MiddleClick,
779 Action::Copy,
781 Action::Paste,
782 Action::Cut,
783 Action::Undo,
784 Action::Redo,
785 Action::SelectAll,
786 Action::Find,
787 Action::Save,
788 Action::BrowserBack,
790 Action::BrowserForward,
791 Action::NewTab,
792 Action::CloseTab,
793 Action::ReopenTab,
794 Action::NextTab,
795 Action::PrevTab,
796 Action::ReloadPage,
797 Action::MissionControl,
799 Action::AppExpose,
800 Action::PreviousDesktop,
801 Action::NextDesktop,
802 Action::ShowDesktop,
803 Action::LaunchpadShow,
804 Action::None,
806 Action::LockScreen,
807 Action::Screenshot,
808 Action::PlayPause,
810 Action::NextTrack,
811 Action::PrevTrack,
812 Action::VolumeUp,
813 Action::VolumeDown,
814 Action::MuteVolume,
815 Action::CycleDpiPresets,
817 Action::ToggleSmartShift,
818 Action::ScrollUp,
820 Action::ScrollDown,
821 Action::HorizontalScrollLeft,
822 Action::HorizontalScrollRight,
823 ]
824 }
825
826 pub fn execute(&self) {
853 #[cfg(target_os = "macos")]
854 self.execute_macos();
855
856 #[cfg(target_os = "linux")]
857 self.execute_linux();
858
859 #[cfg(target_os = "windows")]
860 self.execute_windows();
861
862 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
863 {
864 tracing::warn!(
865 action = self.label(),
866 "Action::execute unsupported on this platform"
867 );
868 }
869 }
870
871 #[cfg(target_os = "linux")]
873 fn execute_linux(&self) {
874 use evdev::{KeyCode, RelativeAxisCode};
875 let ctrl = KeyCode::KEY_LEFTCTRL;
876 let shift = KeyCode::KEY_LEFTSHIFT;
877 let alt = KeyCode::KEY_LEFTALT;
878 match self {
879 Action::LeftClick => linux::click(KeyCode::BTN_LEFT),
881 Action::RightClick => linux::click(KeyCode::BTN_RIGHT),
882 Action::MiddleClick => linux::click(KeyCode::BTN_MIDDLE),
883 Action::Copy => linux::press_key(&[ctrl], KeyCode::KEY_C),
885 Action::Paste => linux::press_key(&[ctrl], KeyCode::KEY_V),
886 Action::Cut => linux::press_key(&[ctrl], KeyCode::KEY_X),
887 Action::Undo => linux::press_key(&[ctrl], KeyCode::KEY_Z),
888 Action::Redo => linux::press_key(&[ctrl, shift], KeyCode::KEY_Z),
890 Action::SelectAll => linux::press_key(&[ctrl], KeyCode::KEY_A),
891 Action::Find => linux::press_key(&[ctrl], KeyCode::KEY_F),
892 Action::Save => linux::press_key(&[ctrl], KeyCode::KEY_S),
893 Action::BrowserBack => linux::press_key(&[alt], KeyCode::KEY_LEFT),
895 Action::BrowserForward => linux::press_key(&[alt], KeyCode::KEY_RIGHT),
896 Action::NewTab => linux::press_key(&[ctrl], KeyCode::KEY_T),
897 Action::CloseTab => linux::press_key(&[ctrl], KeyCode::KEY_W),
898 Action::ReopenTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_T),
899 Action::NextTab => linux::press_key(&[ctrl], KeyCode::KEY_TAB),
900 Action::PrevTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_TAB),
901 Action::ReloadPage => linux::press_key(&[ctrl], KeyCode::KEY_R),
902 Action::MissionControl
905 | Action::AppExpose
906 | Action::ShowDesktop
907 | Action::LaunchpadShow => {
908 tracing::debug!(
909 action = self.label(),
910 "no Linux equivalent — action skipped"
911 );
912 }
913 Action::PreviousDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_LEFT),
915 Action::NextDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_RIGHT),
916 Action::LockScreen => linux::lock_screen(),
919 Action::Screenshot => linux::press_key(&[], KeyCode::KEY_SYSRQ),
920 Action::PlayPause => linux::mpris_command("PlayPause"),
924 Action::NextTrack => linux::mpris_command("Next"),
925 Action::PrevTrack => linux::mpris_command("Previous"),
926 Action::VolumeUp => linux::press_key(&[], KeyCode::KEY_VOLUMEUP),
927 Action::VolumeDown => linux::press_key(&[], KeyCode::KEY_VOLUMEDOWN),
928 Action::MuteVolume => linux::press_key(&[], KeyCode::KEY_MUTE),
929 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
931 tracing::debug!(
932 action = self.label(),
933 "device action handled by hook/HID layer"
934 );
935 }
936 Action::ScrollUp => linux::scroll(RelativeAxisCode::REL_WHEEL, 3),
938 Action::ScrollDown => linux::scroll(RelativeAxisCode::REL_WHEEL, -3),
939 Action::HorizontalScrollLeft => linux::scroll(RelativeAxisCode::REL_HWHEEL, -3),
940 Action::HorizontalScrollRight => linux::scroll(RelativeAxisCode::REL_HWHEEL, 3),
941 Action::None => {}
943 Action::CustomShortcut(combo) => {
945 if combo.key_code == 0 {
946 tracing::warn!(
947 chord = %combo.rendered_label(),
948 "CustomShortcut with no key code — press ignored"
949 );
950 return;
951 }
952 let Some(key) = linux::macos_vk_to_linux(combo.key_code) else {
953 tracing::warn!(
954 key_code = combo.key_code,
955 "CustomShortcut key code has no Linux mapping — press ignored"
956 );
957 return;
958 };
959 linux::press_key(&linux::modifiers_to_keycodes(combo.modifiers), key);
960 }
961 }
962 }
963
964 #[cfg(target_os = "macos")]
966 fn execute_macos(&self) {
967 use core_graphics::event::{CGEventFlags, CGMouseButton};
968
969 let cmd = CGEventFlags::CGEventFlagCommand;
971 let shift = CGEventFlags::CGEventFlagShift;
972 let ctrl = CGEventFlags::CGEventFlagControl;
973
974 match self {
975 Action::None => {}
977 Action::LeftClick => macos::post_click(CGMouseButton::Left),
982 Action::RightClick => macos::post_click(CGMouseButton::Right),
983 Action::MiddleClick => macos::post_click(CGMouseButton::Center),
984 Action::Copy => macos::post_key(VK_C, cmd),
986 Action::Paste => macos::post_key(VK_V, cmd),
987 Action::Cut => macos::post_key(VK_X, cmd),
988 Action::Undo => macos::post_key(VK_Z, cmd),
989 Action::Redo => macos::post_key(VK_Z, cmd | shift),
990 Action::SelectAll => macos::post_key(VK_A, cmd),
991 Action::Find => macos::post_key(VK_F, cmd),
992 Action::Save => macos::post_key(VK_S, cmd),
993 Action::BrowserBack => macos::post_key(0x21, cmd),
998 Action::BrowserForward => macos::post_key(0x1E, cmd),
999 Action::NewTab => macos::post_key(VK_T, cmd),
1000 Action::CloseTab => macos::post_key(VK_W, cmd),
1001 Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
1002 Action::NextTab => macos::post_key(VK_TAB, ctrl),
1003 Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
1004 Action::ReloadPage => macos::post_key(VK_R, cmd),
1005 Action::MissionControl => macos::mission_control(),
1012 Action::AppExpose => macos::app_expose(),
1013 Action::PreviousDesktop => macos::previous_desktop(),
1014 Action::NextDesktop => macos::next_desktop(),
1015 Action::ShowDesktop => macos::show_desktop(),
1016 Action::LaunchpadShow => macos::launchpad(),
1017 Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
1020 Action::Screenshot => macos::post_key(0x14, cmd | shift),
1022 Action::PlayPause => macos::post_media_key(macos::NX_KEYTYPE_PLAY),
1027 Action::NextTrack => macos::post_media_key(macos::NX_KEYTYPE_NEXT),
1028 Action::PrevTrack => macos::post_media_key(macos::NX_KEYTYPE_PREVIOUS),
1029 Action::VolumeUp => macos::post_media_key(macos::NX_KEYTYPE_SOUND_UP),
1030 Action::VolumeDown => macos::post_media_key(macos::NX_KEYTYPE_SOUND_DOWN),
1031 Action::MuteVolume => macos::post_media_key(macos::NX_KEYTYPE_MUTE),
1032 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
1034 tracing::debug!(
1035 action = self.label(),
1036 "device action handled by hook/HID layer"
1037 );
1038 }
1039 Action::ScrollUp
1041 | Action::ScrollDown
1042 | Action::HorizontalScrollLeft
1043 | Action::HorizontalScrollRight => macos::post_scroll(self),
1044 Action::CustomShortcut(combo) => {
1046 if combo.key_code == 0 {
1051 tracing::warn!(
1052 chord = %combo.rendered_label(),
1053 "CustomShortcut with no key code — press ignored"
1054 );
1055 return;
1056 }
1057 let mut flags = CGEventFlags::CGEventFlagNull;
1058 if combo.modifiers & KeyCombo::MOD_CMD != 0 {
1059 flags |= CGEventFlags::CGEventFlagCommand;
1060 }
1061 if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
1062 flags |= CGEventFlags::CGEventFlagShift;
1063 }
1064 if combo.modifiers & KeyCombo::MOD_CTRL != 0 {
1065 flags |= CGEventFlags::CGEventFlagControl;
1066 }
1067 if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
1068 flags |= CGEventFlags::CGEventFlagAlternate;
1069 }
1070 macos::post_key(combo.key_code, flags);
1071 }
1072 }
1073 }
1074
1075 #[cfg(target_os = "windows")]
1079 fn execute_windows(&self) {
1080 match self {
1081 Action::LeftClick => windows::post_click(windows::MouseButton::Left),
1082 Action::RightClick => windows::post_click(windows::MouseButton::Right),
1083 Action::MiddleClick => windows::post_click(windows::MouseButton::Middle),
1084 Action::Copy => windows::post_key(windows::VK_C, &[windows::VK_CONTROL]),
1085 Action::Paste => windows::post_key(windows::VK_V, &[windows::VK_CONTROL]),
1086 Action::Cut => windows::post_key(windows::VK_X, &[windows::VK_CONTROL]),
1087 Action::Undo => windows::post_key(windows::VK_Z, &[windows::VK_CONTROL]),
1088 Action::Redo => windows::post_key(windows::VK_Y, &[windows::VK_CONTROL]),
1089 Action::SelectAll => windows::post_key(windows::VK_A, &[windows::VK_CONTROL]),
1090 Action::Find => windows::post_key(windows::VK_F, &[windows::VK_CONTROL]),
1091 Action::Save => windows::post_key(windows::VK_S, &[windows::VK_CONTROL]),
1092 Action::BrowserBack => windows::post_key(windows::VK_BROWSER_BACK, &[]),
1093 Action::BrowserForward => windows::post_key(windows::VK_BROWSER_FORWARD, &[]),
1094 Action::NewTab => windows::post_key(windows::VK_T, &[windows::VK_CONTROL]),
1095 Action::CloseTab => windows::post_key(windows::VK_W, &[windows::VK_CONTROL]),
1096 Action::ReopenTab => {
1097 windows::post_key(windows::VK_T, &[windows::VK_CONTROL, windows::VK_SHIFT]);
1098 }
1099 Action::NextTab => windows::post_key(windows::VK_TAB, &[windows::VK_CONTROL]),
1100 Action::PrevTab => {
1101 windows::post_key(windows::VK_TAB, &[windows::VK_CONTROL, windows::VK_SHIFT]);
1102 }
1103 Action::ReloadPage => windows::post_key(windows::VK_R, &[windows::VK_CONTROL]),
1104 Action::MissionControl | Action::AppExpose => {
1105 windows::post_key(windows::VK_TAB, &[windows::VK_LWIN]);
1106 }
1107 Action::PreviousDesktop => {
1108 windows::post_key(windows::VK_LEFT, &[windows::VK_LWIN, windows::VK_CONTROL]);
1109 }
1110 Action::NextDesktop => {
1111 windows::post_key(windows::VK_RIGHT, &[windows::VK_LWIN, windows::VK_CONTROL]);
1112 }
1113 Action::ShowDesktop => windows::post_key(windows::VK_D, &[windows::VK_LWIN]),
1114 Action::LaunchpadShow => windows::post_key(windows::VK_LWIN, &[]),
1115 Action::LockScreen => windows::post_key(windows::VK_L, &[windows::VK_LWIN]),
1116 Action::Screenshot => {
1117 windows::post_key(windows::VK_S, &[windows::VK_LWIN, windows::VK_SHIFT]);
1118 }
1119 Action::PlayPause => windows::post_key(windows::VK_MEDIA_PLAY_PAUSE, &[]),
1120 Action::NextTrack => windows::post_key(windows::VK_MEDIA_NEXT_TRACK, &[]),
1121 Action::PrevTrack => windows::post_key(windows::VK_MEDIA_PREV_TRACK, &[]),
1122 Action::VolumeUp => windows::post_key(windows::VK_VOLUME_UP, &[]),
1123 Action::VolumeDown => windows::post_key(windows::VK_VOLUME_DOWN, &[]),
1124 Action::MuteVolume => windows::post_key(windows::VK_VOLUME_MUTE, &[]),
1125 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
1126 tracing::debug!(
1127 action = self.label(),
1128 "device action handled by hook/HID layer"
1129 );
1130 }
1131 Action::ScrollUp
1132 | Action::ScrollDown
1133 | Action::HorizontalScrollLeft
1134 | Action::HorizontalScrollRight => windows::post_scroll(self),
1135 Action::CustomShortcut(combo) => windows::post_custom_shortcut(combo),
1136 Action::None => {}
1137 }
1138 }
1139}
1140
1141pub fn post_horizontal_scroll(delta: i32) {
1151 #[cfg(target_os = "macos")]
1152 macos::post_horizontal_scroll(delta);
1153
1154 #[cfg(target_os = "linux")]
1159 linux::scroll(evdev::RelativeAxisCode::REL_HWHEEL, delta);
1160
1161 #[cfg(target_os = "windows")]
1162 windows::post_horizontal_scroll(delta);
1163
1164 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
1165 let _ = delta;
1166}
1167
1168#[cfg(target_os = "linux")]
1175#[must_use]
1176pub fn action_device_path() -> Option<std::path::PathBuf> {
1177 linux::device_node()
1178}
1179
1180#[cfg(target_os = "macos")]
1184const VK_A: u16 = 0x00;
1185#[cfg(target_os = "macos")]
1186const VK_C: u16 = 0x08;
1187#[cfg(target_os = "macos")]
1188const VK_F: u16 = 0x03;
1189#[cfg(target_os = "macos")]
1190const VK_R: u16 = 0x0F;
1191#[cfg(target_os = "macos")]
1192const VK_S: u16 = 0x01;
1193#[cfg(target_os = "macos")]
1194const VK_T: u16 = 0x11;
1195#[cfg(target_os = "macos")]
1196const VK_V: u16 = 0x09;
1197#[cfg(target_os = "macos")]
1198const VK_W: u16 = 0x0D;
1199#[cfg(target_os = "macos")]
1200const VK_X: u16 = 0x07;
1201#[cfg(target_os = "macos")]
1202const VK_Z: u16 = 0x06;
1203#[cfg(target_os = "macos")]
1204const VK_TAB: u16 = 0x30;
1205
1206pub const SYNTHETIC_EVENT_USER_DATA: i64 = 0x4F4C_4749;
1213
1214#[cfg(target_os = "macos")]
1216mod macos {
1217 use core_graphics::event::{
1218 CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, EventField,
1219 ScrollEventUnit,
1220 };
1221 use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
1222 use core_graphics::geometry::CGPoint;
1223
1224 use crate::binding::Action;
1225
1226 pub(super) const NX_KEYTYPE_SOUND_UP: i32 = 0;
1228 pub(super) const NX_KEYTYPE_SOUND_DOWN: i32 = 1;
1229 pub(super) const NX_KEYTYPE_MUTE: i32 = 7;
1230 pub(super) const NX_KEYTYPE_PLAY: i32 = 16;
1231 pub(super) const NX_KEYTYPE_NEXT: i32 = 17;
1232 pub(super) const NX_KEYTYPE_PREVIOUS: i32 = 18;
1233
1234 pub(super) fn post_click(button: CGMouseButton) {
1242 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1243 tracing::warn!("CGEventSource::new failed for click");
1244 return;
1245 };
1246 let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
1249 let (down, up) = match button {
1250 CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp),
1251 CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp),
1252 CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp),
1253 };
1254 for (kind, phase) in [(down, "down"), (up, "up")] {
1255 if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) {
1256 ev.set_integer_value_field(
1260 EventField::EVENT_SOURCE_USER_DATA,
1261 super::SYNTHETIC_EVENT_USER_DATA,
1262 );
1263 ev.post(CGEventTapLocation::HID);
1264 } else {
1265 tracing::warn!(phase, "CGEvent::new_mouse_event failed");
1266 }
1267 }
1268 }
1269
1270 pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
1272 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1273 tracing::warn!("CGEventSource::new failed");
1274 return;
1275 };
1276 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1277 tracing::warn!("CGEvent::new_keyboard_event(down) failed");
1278 return;
1279 };
1280 down.set_flags(flags);
1281 down.post(CGEventTapLocation::HID);
1282 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1283 tracing::warn!("CGEvent::new_keyboard_event(up) failed");
1284 return;
1285 };
1286 up.set_flags(flags);
1287 up.post(CGEventTapLocation::HID);
1288 }
1289
1290 pub(super) fn post_media_key(nx_key: i32) {
1297 use objc2::rc::autoreleasepool;
1298 use objc2_app_kit::{NSEvent, NSEventModifierFlags, NSEventType};
1299 use objc2_core_graphics::{CGEvent, CGEventTapLocation};
1300 use objc2_foundation::NSPoint;
1301
1302 const NX_SUBTYPE_AUX_CONTROL_BUTTONS: i16 = 8;
1303 const NX_KEY_DOWN: i32 = 0x0A;
1304 const NX_KEY_UP: i32 = 0x0B;
1305
1306 autoreleasepool(|_| {
1307 for (state, phase) in [(NX_KEY_DOWN, "down"), (NX_KEY_UP, "up")] {
1308 let data1 = ((nx_key << 16) | (state << 8)) as isize;
1311 let Some(ns_event) = NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
1312 NSEventType::SystemDefined,
1313 NSPoint::new(0.0, 0.0),
1314 NSEventModifierFlags::empty(),
1315 0.0,
1316 0,
1317 None,
1318 NX_SUBTYPE_AUX_CONTROL_BUTTONS,
1319 data1,
1320 0,
1321 ) else {
1322 tracing::warn!(nx_key, phase, "NSEvent::otherEventWithType failed");
1323 return;
1324 };
1325 let Some(cg_event) = ns_event.CGEvent() else {
1326 tracing::warn!(nx_key, phase, "NSEvent::CGEvent failed");
1327 return;
1328 };
1329 CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&cg_event));
1330 }
1331 });
1332 }
1333
1334 pub(super) fn post_scroll(action: &Action) {
1336 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1337 tracing::warn!("CGEventSource::new failed for scroll");
1338 return;
1339 };
1340 let (v, h): (i32, i32) = match action {
1341 Action::ScrollUp => (3, 0),
1342 Action::ScrollDown => (-3, 0),
1343 Action::HorizontalScrollLeft => (0, -3),
1344 Action::HorizontalScrollRight => (0, 3),
1345 _ => return,
1346 };
1347 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
1348 tracing::warn!("CGEvent::new_scroll_event failed");
1349 return;
1350 };
1351 ev.post(CGEventTapLocation::HID);
1352 }
1353
1354 pub(super) fn post_horizontal_scroll(delta: i32) {
1357 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1358 tracing::warn!("CGEventSource::new failed for thumbwheel scroll");
1359 return;
1360 };
1361 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else {
1362 tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel");
1363 return;
1364 };
1365 ev.post(CGEventTapLocation::HID);
1366 }
1367
1368 pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop};
1369 pub(super) use symbolic_hotkey::{next_desktop, previous_desktop};
1370
1371 use app_services::symbol as app_services_symbol;
1372
1373 #[allow(
1376 unsafe_code,
1377 reason = "private ApplicationServices SPI symbols are resolved via dlopen/dlsym FFI"
1378 )]
1379 mod app_services {
1380 use std::ffi::{CStr, c_char, c_int, c_void};
1381 use std::sync::OnceLock;
1382
1383 pub(super) fn symbol(symbol: &CStr) -> Option<*mut c_void> {
1387 const RTLD_LAZY: c_int = 0x1;
1388 const APP_SERVICES: &CStr =
1389 c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices";
1390 static HANDLE: OnceLock<usize> = OnceLock::new();
1391
1392 let sym = unsafe {
1396 let handle =
1397 *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize);
1398 if handle == 0 {
1399 return None;
1400 }
1401 dlsym(handle as *mut c_void, symbol.as_ptr())
1402 };
1403 (!sym.is_null()).then_some(sym)
1404 }
1405
1406 unsafe extern "C" {
1407 fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
1408 fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
1409 }
1410 }
1411
1412 #[allow(
1425 unsafe_code,
1426 reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI"
1427 )]
1428 mod dock {
1429 use std::ffi::{c_int, c_void};
1430
1431 use core_foundation::base::TCFType;
1432 use core_foundation::string::CFString;
1433
1434 use super::app_services_symbol;
1435
1436 pub(crate) fn mission_control() {
1438 send("com.apple.expose.awake");
1439 }
1440
1441 pub(crate) fn app_expose() {
1443 send("com.apple.expose.front.awake");
1444 }
1445
1446 pub(crate) fn show_desktop() {
1448 send("com.apple.showdesktop.awake");
1449 }
1450
1451 pub(crate) fn launchpad() {
1453 send("com.apple.launchpad.toggle");
1454 }
1455
1456 fn send(notification: &str) {
1458 let Some(core_dock_send) = core_dock_send_notification() else {
1459 tracing::warn!(notification, "CoreDockSendNotification unavailable");
1460 return;
1461 };
1462 let name = CFString::new(notification);
1463 let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) };
1466 if err != 0 {
1467 tracing::warn!(notification, err, "CoreDockSendNotification failed");
1468 }
1469 }
1470
1471 type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int;
1472
1473 fn core_dock_send_notification() -> Option<CoreDockSendNotificationFn> {
1476 let sym = app_services_symbol(c"CoreDockSendNotification")?;
1477 Some(unsafe { std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym) })
1479 }
1480 }
1481
1482 #[allow(
1489 unsafe_code,
1490 reason = "CGS symbolic hotkey SPI is only reachable via dlopen/dlsym FFI"
1491 )]
1492 mod symbolic_hotkey {
1493 use std::ffi::{c_int, c_uint, c_ushort, c_void};
1494
1495 use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation};
1496 use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
1497
1498 use super::app_services_symbol;
1499
1500 const SPACE_LEFT: u32 = 79;
1501 const SPACE_RIGHT: u32 = 81;
1502
1503 pub(crate) fn previous_desktop() {
1505 post_symbolic_hotkey(SPACE_LEFT);
1506 }
1507
1508 pub(crate) fn next_desktop() {
1510 post_symbolic_hotkey(SPACE_RIGHT);
1511 }
1512
1513 fn post_symbolic_hotkey(hotkey: u32) {
1514 let Some(cgs) = cgs_hotkey_api() else {
1515 tracing::warn!(hotkey, "CGS symbolic hotkey API unavailable");
1516 return;
1517 };
1518
1519 let mut key_equivalent = 0_u16;
1520 let mut virtual_key = 0_u16;
1521 let mut modifiers = 0_u32;
1522
1523 let err = unsafe {
1526 (cgs.get_value)(
1527 hotkey,
1528 &raw mut key_equivalent,
1529 &raw mut virtual_key,
1530 &raw mut modifiers,
1531 )
1532 };
1533 if err != 0 {
1534 tracing::warn!(hotkey, err, "CGSGetSymbolicHotKeyValue failed");
1535 return;
1536 }
1537
1538 let was_enabled = unsafe { (cgs.is_enabled)(hotkey) };
1541 if !was_enabled {
1542 let err = unsafe { (cgs.set_enabled)(hotkey, true) };
1545 if err != 0 {
1546 tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(true) failed");
1547 }
1548 }
1549
1550 post_key(virtual_key, modifiers);
1551
1552 if !was_enabled {
1553 let err = unsafe { (cgs.set_enabled)(hotkey, false) };
1556 if err != 0 {
1557 tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(false) failed");
1558 }
1559 }
1560 }
1561
1562 fn post_key(vk: u16, modifiers: u32) {
1563 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1564 tracing::warn!("CGEventSource::new failed for symbolic hotkey");
1565 return;
1566 };
1567 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1568 tracing::warn!(vk, "CGEvent::new_keyboard_event(down) failed");
1569 return;
1570 };
1571 let flags = CGEventFlags::from_bits_truncate(u64::from(modifiers));
1572 down.set_flags(flags);
1573 down.post(CGEventTapLocation::Session);
1574
1575 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1576 tracing::warn!(vk, "CGEvent::new_keyboard_event(up) failed");
1577 return;
1578 };
1579 up.set_flags(flags);
1580 up.post(CGEventTapLocation::Session);
1581 }
1582
1583 #[derive(Clone, Copy)]
1584 struct CgsHotkeyApi {
1585 get_value: CgsGetSymbolicHotKeyValueFn,
1586 is_enabled: CgsIsSymbolicHotKeyEnabledFn,
1587 set_enabled: CgsSetSymbolicHotKeyEnabledFn,
1588 }
1589
1590 type CgsGetSymbolicHotKeyValueFn =
1591 unsafe extern "C" fn(c_uint, *mut c_ushort, *mut c_ushort, *mut c_uint) -> c_int;
1592 type CgsIsSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint) -> bool;
1593 type CgsSetSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint, bool) -> c_int;
1594
1595 fn cgs_hotkey_api() -> Option<CgsHotkeyApi> {
1596 let get_value = app_services_symbol(c"CGSGetSymbolicHotKeyValue")?;
1597 let is_enabled = app_services_symbol(c"CGSIsSymbolicHotKeyEnabled")?;
1598 let set_enabled = app_services_symbol(c"CGSSetSymbolicHotKeyEnabled")?;
1599
1600 Some(unsafe {
1603 CgsHotkeyApi {
1604 get_value: std::mem::transmute::<*mut c_void, CgsGetSymbolicHotKeyValueFn>(
1605 get_value,
1606 ),
1607 is_enabled: std::mem::transmute::<*mut c_void, CgsIsSymbolicHotKeyEnabledFn>(
1608 is_enabled,
1609 ),
1610 set_enabled: std::mem::transmute::<*mut c_void, CgsSetSymbolicHotKeyEnabledFn>(
1611 set_enabled,
1612 ),
1613 }
1614 })
1615 }
1616 }
1617}
1618
1619#[must_use]
1633pub fn default_binding(button: ButtonId) -> Action {
1634 match button {
1635 ButtonId::LeftClick => Action::LeftClick,
1636 ButtonId::RightClick => Action::RightClick,
1637 ButtonId::MiddleClick => Action::MiddleClick,
1638 ButtonId::Back => Action::BrowserBack,
1639 ButtonId::Forward => Action::BrowserForward,
1640 ButtonId::DpiToggle => Action::CycleDpiPresets,
1641 ButtonId::Thumbwheel => Action::AppExpose,
1642 ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight,
1648 ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft,
1649 ButtonId::GestureButton => Action::MissionControl,
1650 }
1651}
1652
1653#[must_use]
1657pub fn default_gesture_binding(direction: GestureDirection) -> Action {
1658 match direction {
1659 GestureDirection::Up => Action::MissionControl,
1660 GestureDirection::Down => Action::ShowDesktop,
1661 GestureDirection::Left => Action::PrevTab,
1662 GestureDirection::Right => Action::NextTab,
1663 GestureDirection::Click => Action::AppExpose,
1664 }
1665}
1666
1667#[must_use]
1681pub fn default_binding_for(button: ButtonId) -> Binding {
1682 match button {
1683 ButtonId::GestureButton => Binding::Gesture(
1684 GestureDirection::ALL
1685 .into_iter()
1686 .map(|d| (d, default_gesture_binding(d)))
1687 .collect(),
1688 ),
1689 other => Binding::Single(default_binding(other)),
1690 }
1691}
1692
1693#[cfg(target_os = "linux")]
1700mod linux {
1701 use std::io;
1702 use std::sync::{LazyLock, Mutex};
1703
1704 use evdev::uinput::VirtualDevice;
1705 use evdev::{AttributeSet, EventType, InputEvent, KeyCode, RelativeAxisCode};
1706 use zbus::blocking::Connection as DbusConn;
1707
1708 const DEVICE_NAME: &str = "OpenLogi action injector";
1709
1710 static VIRTUAL_INPUT: LazyLock<Option<Mutex<VirtualDevice>>> = LazyLock::new(|| {
1711 build()
1712 .map(Mutex::new)
1713 .map_err(|e| tracing::warn!("failed to create uinput action device: {e}"))
1714 .ok()
1715 });
1716
1717 #[rustfmt::skip]
1718 const KEY_CAPABILITIES: &[KeyCode] = &[
1719 KeyCode::KEY_A, KeyCode::KEY_B, KeyCode::KEY_C, KeyCode::KEY_D,
1721 KeyCode::KEY_E, KeyCode::KEY_F, KeyCode::KEY_G, KeyCode::KEY_H,
1722 KeyCode::KEY_I, KeyCode::KEY_J, KeyCode::KEY_K, KeyCode::KEY_L,
1723 KeyCode::KEY_M, KeyCode::KEY_N, KeyCode::KEY_O, KeyCode::KEY_P,
1724 KeyCode::KEY_Q, KeyCode::KEY_R, KeyCode::KEY_S, KeyCode::KEY_T,
1725 KeyCode::KEY_U, KeyCode::KEY_V, KeyCode::KEY_W, KeyCode::KEY_X,
1726 KeyCode::KEY_Y, KeyCode::KEY_Z,
1727 KeyCode::KEY_0, KeyCode::KEY_1, KeyCode::KEY_2, KeyCode::KEY_3,
1729 KeyCode::KEY_4, KeyCode::KEY_5, KeyCode::KEY_6, KeyCode::KEY_7,
1730 KeyCode::KEY_8, KeyCode::KEY_9,
1731 KeyCode::KEY_MINUS, KeyCode::KEY_EQUAL, KeyCode::KEY_LEFTBRACE,
1733 KeyCode::KEY_RIGHTBRACE, KeyCode::KEY_BACKSLASH, KeyCode::KEY_SEMICOLON,
1734 KeyCode::KEY_APOSTROPHE, KeyCode::KEY_GRAVE, KeyCode::KEY_COMMA,
1735 KeyCode::KEY_DOT, KeyCode::KEY_SLASH,
1736 KeyCode::KEY_LEFT, KeyCode::KEY_RIGHT, KeyCode::KEY_UP, KeyCode::KEY_DOWN,
1738 KeyCode::KEY_HOME, KeyCode::KEY_END, KeyCode::KEY_PAGEUP, KeyCode::KEY_PAGEDOWN,
1739 KeyCode::KEY_TAB, KeyCode::KEY_ENTER, KeyCode::KEY_BACKSPACE, KeyCode::KEY_DELETE,
1740 KeyCode::KEY_ESC, KeyCode::KEY_SPACE,
1741 KeyCode::KEY_LEFTCTRL, KeyCode::KEY_LEFTSHIFT, KeyCode::KEY_LEFTALT, KeyCode::KEY_LEFTMETA,
1743 KeyCode::KEY_F1, KeyCode::KEY_F2, KeyCode::KEY_F3, KeyCode::KEY_F4,
1745 KeyCode::KEY_F5, KeyCode::KEY_F6, KeyCode::KEY_F7, KeyCode::KEY_F8,
1746 KeyCode::KEY_F9, KeyCode::KEY_F10, KeyCode::KEY_F11, KeyCode::KEY_F12,
1747 KeyCode::KEY_SYSRQ,
1749 KeyCode::KEY_PLAYPAUSE, KeyCode::KEY_NEXTSONG, KeyCode::KEY_PREVIOUSSONG,
1751 KeyCode::KEY_VOLUMEUP, KeyCode::KEY_VOLUMEDOWN, KeyCode::KEY_MUTE,
1752 KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE,
1754 ];
1755
1756 fn build() -> io::Result<VirtualDevice> {
1757 let mut keys = AttributeSet::<KeyCode>::default();
1758 for &k in KEY_CAPABILITIES {
1759 keys.insert(k);
1760 }
1761
1762 let mut axes = AttributeSet::<RelativeAxisCode>::default();
1767 for a in [RelativeAxisCode::REL_WHEEL, RelativeAxisCode::REL_HWHEEL] {
1768 axes.insert(a);
1769 }
1770
1771 VirtualDevice::builder()?
1772 .name(DEVICE_NAME)
1773 .with_keys(&keys)?
1774 .with_relative_axes(&axes)?
1775 .build()
1776 }
1777
1778 fn emit(events: &[InputEvent]) {
1779 if let Some(m) = &*VIRTUAL_INPUT {
1780 if let Ok(mut guard) = m.lock() {
1781 if let Err(e) = guard.emit(events) {
1782 tracing::warn!("uinput action emit failed: {e}");
1783 }
1784 } else {
1785 tracing::warn!("uinput action device mutex poisoned");
1786 }
1787 } else {
1788 tracing::debug!("uinput action device unavailable — action skipped");
1790 }
1791 }
1792
1793 fn syn() -> InputEvent {
1794 InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0)
1795 }
1796
1797 fn key_ev(code: KeyCode, value: i32) -> InputEvent {
1798 InputEvent::new(EventType::KEY.0, code.0, value)
1799 }
1800
1801 fn rel_ev(axis: RelativeAxisCode, value: i32) -> InputEvent {
1802 InputEvent::new(EventType::RELATIVE.0, axis.0, value)
1803 }
1804
1805 pub(super) fn press_key(mods: &[KeyCode], key: KeyCode) {
1812 let mut down: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1814 for &m in mods {
1815 down.push(key_ev(m, 1));
1816 }
1817 down.push(key_ev(key, 1));
1818 down.push(syn());
1819 emit(&down);
1820
1821 let mut up: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1823 up.push(key_ev(key, 0));
1824 for &m in mods.iter().rev() {
1825 up.push(key_ev(m, 0));
1826 }
1827 up.push(syn());
1828 emit(&up);
1829 }
1830
1831 pub(super) fn click(button: KeyCode) {
1833 emit(&[key_ev(button, 1), syn()]);
1834 emit(&[key_ev(button, 0), syn()]);
1835 }
1836
1837 pub(super) fn scroll(axis: RelativeAxisCode, value: i32) {
1839 emit(&[rel_ev(axis, value), syn()]);
1840 }
1841
1842 pub(super) fn device_node() -> Option<std::path::PathBuf> {
1850 let _ = &*VIRTUAL_INPUT;
1852 std::thread::sleep(std::time::Duration::from_millis(150));
1854 if let Some(m) = &*VIRTUAL_INPUT
1855 && let Ok(mut guard) = m.lock()
1856 {
1857 return guard.enumerate_dev_nodes_blocking().ok()?.flatten().next();
1858 }
1859 None
1860 }
1861
1862 pub(super) fn modifiers_to_keycodes(modifiers: u8) -> Vec<KeyCode> {
1868 use crate::binding::KeyCombo;
1869 let mut mods = Vec::new();
1870 if modifiers & (KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL) != 0 {
1871 mods.push(KeyCode::KEY_LEFTCTRL);
1872 }
1873 if modifiers & KeyCombo::MOD_SHIFT != 0 {
1874 mods.push(KeyCode::KEY_LEFTSHIFT);
1875 }
1876 if modifiers & KeyCombo::MOD_OPTION != 0 {
1877 mods.push(KeyCode::KEY_LEFTALT);
1878 }
1879 mods
1880 }
1881
1882 pub(super) fn macos_vk_to_linux(vk: u16) -> Option<KeyCode> {
1888 Some(match vk {
1889 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,
1963 })
1964 }
1965
1966 static SESSION_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
1969 DbusConn::session()
1970 .map_err(|e| tracing::warn!("D-Bus session bus unavailable: {e}"))
1971 .ok()
1972 });
1973
1974 static SYSTEM_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
1975 DbusConn::system()
1976 .map_err(|e| tracing::warn!("D-Bus system bus unavailable: {e}"))
1977 .ok()
1978 });
1979
1980 pub(super) fn lock_screen() {
1988 if let (Some(conn), Ok(id)) = (SYSTEM_BUS.as_ref(), std::env::var("XDG_SESSION_ID")) {
1989 match conn.call_method(
1990 Some("org.freedesktop.login1"),
1991 "/org/freedesktop/login1",
1992 Some("org.freedesktop.login1.Manager"),
1993 "LockSession",
1994 &(id.as_str(),),
1995 ) {
1996 Ok(_) => {
1997 tracing::debug!("LockScreen via logind");
1998 return;
1999 }
2000 Err(e) => tracing::warn!("logind LockSession failed: {e}"),
2001 }
2002 }
2003 tracing::debug!("LockScreen via Super+L key combo");
2005 press_key(&[KeyCode::KEY_LEFTMETA], KeyCode::KEY_L);
2006 }
2007
2008 pub(super) fn mpris_command(command: &str) {
2014 if try_mpris_command(command).is_none() {
2015 let fallback = match command {
2016 "PlayPause" => KeyCode::KEY_PLAYPAUSE,
2017 "Next" => KeyCode::KEY_NEXTSONG,
2018 "Previous" => KeyCode::KEY_PREVIOUSSONG,
2019 _ => return,
2020 };
2021 press_key(&[], fallback);
2022 }
2023 }
2024
2025 fn try_mpris_command(command: &str) -> Option<()> {
2026 let conn = SESSION_BUS.as_ref()?;
2027 let reply = conn
2028 .call_method(
2029 Some("org.freedesktop.DBus"),
2030 "/org/freedesktop/DBus",
2031 Some("org.freedesktop.DBus"),
2032 "ListNames",
2033 &(),
2034 )
2035 .ok()?;
2036 let names = reply.body().deserialize::<Vec<String>>().ok()?;
2037 let Some(player) = names
2038 .iter()
2039 .find(|n| n.starts_with("org.mpris.MediaPlayer2."))
2040 else {
2041 tracing::debug!("no MPRIS player found — {command} via XF86 key fallback");
2042 return None;
2043 };
2044 match conn.call_method(
2045 Some(player.as_str()),
2046 "/org/mpris/MediaPlayer2",
2047 Some("org.mpris.MediaPlayer2.Player"),
2048 command,
2049 &(),
2050 ) {
2051 Ok(_) => {
2052 tracing::debug!("MPRIS {command} via {player}");
2053 Some(())
2054 }
2055 Err(e) => {
2056 tracing::warn!("MPRIS {command} on {player} failed: {e}");
2059 Some(())
2060 }
2061 }
2062 }
2063}
2064
2065#[cfg_attr(
2079 not(target_os = "windows"),
2080 allow(
2081 dead_code,
2082 reason = "pure key-code table is exercised by host unit tests; its only runtime caller is the Windows-gated post_custom_shortcut"
2083 )
2084)]
2085fn mac_virtual_key_to_windows(key_code: u16) -> Option<u16> {
2086 Some(match key_code {
2087 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,
2175 })
2176}
2177
2178#[cfg(target_os = "windows")]
2179#[allow(unsafe_code, reason = "SendInput is the Win32 API for synthetic input")]
2180mod windows {
2181 use std::mem::size_of;
2182
2183 use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
2184 INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP,
2185 MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN,
2186 MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL,
2187 MOUSEINPUT, SendInput,
2188 };
2189
2190 use crate::binding::{Action, KeyCombo};
2191
2192 const WHEEL_DELTA: i32 = 120;
2193
2194 pub(super) const VK_A: u16 = 0x41;
2195 pub(super) const VK_C: u16 = 0x43;
2196 pub(super) const VK_D: u16 = 0x44;
2197 pub(super) const VK_F: u16 = 0x46;
2198 pub(super) const VK_L: u16 = 0x4C;
2199 pub(super) const VK_R: u16 = 0x52;
2200 pub(super) const VK_S: u16 = 0x53;
2201 pub(super) const VK_T: u16 = 0x54;
2202 pub(super) const VK_V: u16 = 0x56;
2203 pub(super) const VK_W: u16 = 0x57;
2204 pub(super) const VK_X: u16 = 0x58;
2205 pub(super) const VK_Y: u16 = 0x59;
2206 pub(super) const VK_Z: u16 = 0x5A;
2207 pub(super) const VK_TAB: u16 = 0x09;
2208 pub(super) const VK_LEFT: u16 = 0x25;
2209 pub(super) const VK_RIGHT: u16 = 0x27;
2210 pub(super) const VK_SHIFT: u16 = 0x10;
2211 pub(super) const VK_CONTROL: u16 = 0x11;
2212 pub(super) const VK_MENU: u16 = 0x12;
2213 pub(super) const VK_LWIN: u16 = 0x5B;
2214 pub(super) const VK_BROWSER_BACK: u16 = 0xA6;
2215 pub(super) const VK_BROWSER_FORWARD: u16 = 0xA7;
2216 pub(super) const VK_VOLUME_MUTE: u16 = 0xAD;
2217 pub(super) const VK_VOLUME_DOWN: u16 = 0xAE;
2218 pub(super) const VK_VOLUME_UP: u16 = 0xAF;
2219 pub(super) const VK_MEDIA_NEXT_TRACK: u16 = 0xB0;
2220 pub(super) const VK_MEDIA_PREV_TRACK: u16 = 0xB1;
2221 pub(super) const VK_MEDIA_PLAY_PAUSE: u16 = 0xB3;
2222
2223 #[derive(Clone, Copy)]
2224 pub(super) enum MouseButton {
2225 Left,
2226 Right,
2227 Middle,
2228 }
2229
2230 pub(super) fn post_click(button: MouseButton) {
2231 let (down, up) = match button {
2232 MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
2233 MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
2234 MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
2235 };
2236 send_inputs(&[mouse_input(down, 0), mouse_input(up, 0)]);
2237 }
2238
2239 pub(super) fn post_key(vk: u16, modifiers: &[u16]) {
2240 let mut inputs = Vec::with_capacity(modifiers.len() * 2 + 2);
2241 for modifier in modifiers {
2242 inputs.push(key_input(*modifier, false));
2243 }
2244 inputs.push(key_input(vk, false));
2245 inputs.push(key_input(vk, true));
2246 for modifier in modifiers.iter().rev() {
2247 inputs.push(key_input(*modifier, true));
2248 }
2249 send_inputs(&inputs);
2250 }
2251
2252 pub(super) fn post_scroll(action: &Action) {
2253 let (flags, data) = match action {
2254 Action::ScrollUp => (MOUSEEVENTF_WHEEL, WHEEL_DELTA),
2255 Action::ScrollDown => (MOUSEEVENTF_WHEEL, -WHEEL_DELTA),
2256 Action::HorizontalScrollLeft => (MOUSEEVENTF_HWHEEL, -WHEEL_DELTA),
2257 Action::HorizontalScrollRight => (MOUSEEVENTF_HWHEEL, WHEEL_DELTA),
2258 _ => return,
2259 };
2260 send_inputs(&[mouse_input(flags, data)]);
2261 }
2262
2263 pub(super) fn post_horizontal_scroll(delta: i32) {
2264 if delta == 0 {
2265 return;
2266 }
2267 send_inputs(&[mouse_input(
2268 MOUSEEVENTF_HWHEEL,
2269 delta.saturating_mul(WHEEL_DELTA),
2270 )]);
2271 }
2272
2273 pub(super) fn post_custom_shortcut(combo: &KeyCombo) {
2274 if combo.key_code == 0 {
2275 tracing::warn!(
2276 chord = %combo.rendered_label(),
2277 "CustomShortcut with no key code; press ignored"
2278 );
2279 return;
2280 }
2281 let Some(vk) = super::mac_virtual_key_to_windows(combo.key_code) else {
2282 tracing::warn!(
2283 key_code = combo.key_code,
2284 chord = %combo.rendered_label(),
2285 "CustomShortcut key has no Windows mapping yet; press ignored"
2286 );
2287 return;
2288 };
2289
2290 let mut modifiers = Vec::new();
2291 if combo.modifiers & KeyCombo::MOD_CMD != 0 {
2292 modifiers.push(VK_CONTROL);
2293 }
2294 if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
2295 modifiers.push(VK_SHIFT);
2296 }
2297 if combo.modifiers & KeyCombo::MOD_CTRL != 0 && !modifiers.contains(&VK_CONTROL) {
2298 modifiers.push(VK_CONTROL);
2299 }
2300 if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
2301 modifiers.push(VK_MENU);
2302 }
2303 post_key(vk, &modifiers);
2304 }
2305
2306 fn send_inputs(inputs: &[INPUT]) {
2307 let Ok(input_count) = u32::try_from(inputs.len()) else {
2308 tracing::warn!(
2309 requested = inputs.len(),
2310 "too many SendInput events requested"
2311 );
2312 return;
2313 };
2314 let Ok(input_size) = i32::try_from(size_of::<INPUT>()) else {
2315 tracing::warn!("INPUT size does not fit the Win32 SendInput contract");
2316 return;
2317 };
2318 let sent = unsafe { SendInput(input_count, inputs.as_ptr(), input_size) };
2319 if sent != input_count {
2320 tracing::warn!(
2321 requested = inputs.len(),
2322 sent,
2323 "SendInput accepted fewer events than requested"
2324 );
2325 }
2326 }
2327
2328 fn key_input(vk: u16, key_up: bool) -> INPUT {
2329 let mut flags = 0;
2330 if key_up {
2331 flags |= KEYEVENTF_KEYUP;
2332 }
2333 INPUT {
2334 r#type: INPUT_KEYBOARD,
2335 Anonymous: INPUT_0 {
2336 ki: KEYBDINPUT {
2337 wVk: vk,
2338 wScan: 0,
2339 dwFlags: flags,
2340 time: 0,
2341 dwExtraInfo: 0,
2342 },
2343 },
2344 }
2345 }
2346
2347 fn mouse_input(flags: u32, data: i32) -> INPUT {
2348 INPUT {
2349 r#type: INPUT_MOUSE,
2350 Anonymous: INPUT_0 {
2351 mi: MOUSEINPUT {
2352 dx: 0,
2353 dy: 0,
2354 mouseData: u32::from_ne_bytes(data.to_ne_bytes()),
2355 dwFlags: flags,
2356 time: 0,
2357 dwExtraInfo: 0,
2358 },
2359 },
2360 }
2361 }
2362}
2363
2364#[cfg(test)]
2365#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
2366mod tests {
2367 use std::collections::BTreeMap;
2368
2369 use serde::{Deserialize, Serialize};
2370
2371 use super::*;
2372
2373 #[derive(Serialize, Deserialize)]
2378 struct RoundtripWrapper {
2379 binding: BTreeMap<ButtonId, Action>,
2380 }
2381
2382 #[test]
2385 fn catalog_has_at_least_29_entries() {
2386 let catalog = Action::catalog();
2387 assert!(
2388 catalog.len() >= 29,
2389 "catalog has {} entries, need ≥ 29",
2390 catalog.len()
2391 );
2392 }
2393
2394 #[test]
2395 fn catalog_excludes_custom_shortcut() {
2396 let catalog = Action::catalog();
2397 for action in &catalog {
2398 assert!(
2399 !matches!(action, Action::CustomShortcut(_)),
2400 "catalog must not contain CustomShortcut"
2401 );
2402 }
2403 }
2404
2405 #[test]
2406 fn custom_shortcut_keycodes_map_across_categories() {
2407 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); }
2419
2420 #[derive(Serialize, Deserialize)]
2425 struct BindingWrapper {
2426 bindings: BTreeMap<ButtonId, Binding>,
2427 }
2428
2429 fn binding_roundtrip(bindings: BTreeMap<ButtonId, Binding>) -> BTreeMap<ButtonId, Binding> {
2430 let toml = toml::to_string_pretty(&BindingWrapper { bindings }).expect("serialize");
2431 toml::from_str::<BindingWrapper>(&toml)
2432 .expect("deserialize")
2433 .bindings
2434 }
2435
2436 #[test]
2437 fn binding_single_roundtrips_including_payload_variants() {
2438 let mut bindings = BTreeMap::new();
2439 bindings.insert(ButtonId::Back, Binding::Single(Action::BrowserBack));
2440 bindings.insert(
2441 ButtonId::DpiToggle,
2442 Binding::Single(Action::SetDpiPreset(2)),
2443 );
2444 bindings.insert(
2445 ButtonId::Forward,
2446 Binding::Single(Action::CustomShortcut(KeyCombo {
2447 modifiers: KeyCombo::MOD_CMD,
2448 key_code: 0x23,
2449 display: "⌘P".into(),
2450 })),
2451 );
2452 let back = binding_roundtrip(bindings);
2453 assert_eq!(back[&ButtonId::Back], Binding::Single(Action::BrowserBack));
2454 assert_eq!(
2455 back[&ButtonId::DpiToggle],
2456 Binding::Single(Action::SetDpiPreset(2))
2457 );
2458 assert!(matches!(
2459 back[&ButtonId::Forward],
2460 Binding::Single(Action::CustomShortcut(_))
2461 ));
2462 }
2463
2464 #[test]
2465 fn binding_gesture_roundtrips() {
2466 let mut map = BTreeMap::new();
2467 map.insert(GestureDirection::Up, Action::Copy);
2468 map.insert(GestureDirection::Click, Action::Paste);
2469 let mut bindings = BTreeMap::new();
2470 bindings.insert(ButtonId::GestureButton, Binding::Gesture(map.clone()));
2471 let back = binding_roundtrip(bindings);
2472 assert_eq!(back[&ButtonId::GestureButton], Binding::Gesture(map));
2473 }
2474
2475 #[test]
2481 fn binding_direction_keyed_table_routes_to_gesture() {
2482 for dir in GestureDirection::ALL {
2483 let toml = format!("bindings.GestureButton.{dir} = \"None\"");
2485 let parsed = toml::from_str::<BindingWrapper>(&toml).expect("deserialize");
2486 assert!(
2487 matches!(
2488 parsed.bindings[&ButtonId::GestureButton],
2489 Binding::Gesture(_)
2490 ),
2491 "a {dir}-keyed table must route to Gesture, not Single"
2492 );
2493 }
2494 }
2495
2496 #[test]
2500 fn binding_payload_action_stays_single() {
2501 let toml = "bindings.DpiToggle.SetDpiPreset = 2";
2502 let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
2503 assert_eq!(
2504 parsed.bindings[&ButtonId::DpiToggle],
2505 Binding::Single(Action::SetDpiPreset(2))
2506 );
2507 }
2508
2509 #[test]
2512 fn detect_swipe_below_threshold_keeps_accumulating() {
2513 assert_eq!(detect_swipe(40, 5), None);
2515 assert_eq!(detect_swipe(0, 0), None);
2516 }
2517
2518 #[test]
2519 fn detect_swipe_commits_clean_direction() {
2520 assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
2521 assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
2522 assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
2523 assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
2524 }
2525
2526 #[test]
2527 fn detect_swipe_rejects_diagonal() {
2528 assert_eq!(detect_swipe(60, 60), None);
2530 assert_eq!(detect_swipe(-60, -60), None);
2531 }
2532
2533 #[test]
2534 fn detect_swipe_threshold_and_cross_band_boundaries() {
2535 assert_eq!(
2538 detect_swipe(GESTURE_SWIPE_THRESHOLD, 0),
2539 Some(GestureDirection::Right)
2540 );
2541 assert_eq!(detect_swipe(GESTURE_SWIPE_THRESHOLD - 1, 0), None);
2542
2543 assert_eq!(detect_swipe(200, 69), Some(GestureDirection::Right));
2546 assert_eq!(detect_swipe(200, 71), None);
2547 assert_eq!(detect_swipe(100, 39), Some(GestureDirection::Right));
2549 assert_eq!(detect_swipe(100, 41), None);
2550 }
2551
2552 #[test]
2553 fn detect_swipe_does_not_panic_on_extreme_values() {
2554 assert_eq!(detect_swipe(i32::MAX, 0), Some(GestureDirection::Right));
2557 assert_eq!(detect_swipe(i32::MIN, 0), Some(GestureDirection::Left));
2558 assert_eq!(detect_swipe(0, i32::MAX), Some(GestureDirection::Down));
2559 assert_eq!(detect_swipe(0, i32::MIN), Some(GestureDirection::Up));
2560 assert_eq!(detect_swipe(i32::MIN, i32::MIN), None);
2562 }
2563
2564 #[test]
2567 fn accumulator_commits_a_direction_once_after_the_hold_gate() {
2568 let mut acc = SwipeAccumulator::default();
2569 acc.begin();
2570 acc.backdate_hold_for_test();
2571 assert_eq!(
2573 acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
2574 Some(GestureDirection::Right)
2575 );
2576 assert_eq!(acc.accumulate(50, 0), None);
2578 }
2579
2580 #[test]
2581 fn accumulator_does_not_commit_before_the_hold_gate() {
2582 let mut acc = SwipeAccumulator::default();
2583 acc.begin(); assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
2587 acc.backdate_hold_for_test();
2589 assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0).is_some());
2590 }
2591
2592 #[test]
2593 fn accumulator_end_reports_click_only_when_no_swipe_fired() {
2594 let mut acc = SwipeAccumulator::default();
2596 acc.begin();
2597 acc.backdate_hold_for_test();
2598 assert_eq!(acc.accumulate(2, -1), None);
2599 assert!(acc.end(), "a hold that never swiped is a click");
2600
2601 acc.begin();
2603 acc.backdate_hold_for_test();
2604 assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0).is_some());
2605 assert!(!acc.end(), "a committed swipe must not also click");
2606 }
2607
2608 #[test]
2609 fn accumulator_ignores_motion_when_not_holding() {
2610 let mut acc = SwipeAccumulator::default();
2611 assert!(!acc.is_holding());
2612 assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
2614 }
2615
2616 #[test]
2617 fn accumulator_sums_sub_threshold_deltas_until_they_commit() {
2618 let mut acc = SwipeAccumulator::default();
2622 acc.begin();
2623 acc.backdate_hold_for_test();
2624 let step = GESTURE_SWIPE_THRESHOLD / 2 - 1;
2626 assert_eq!(acc.accumulate(step, 0), None, "one step is sub-threshold");
2627 assert_eq!(acc.accumulate(step, 0), None, "two steps still under");
2628 assert_eq!(
2629 acc.accumulate(step, 0),
2630 Some(GestureDirection::Right),
2631 "the running sum finally crosses the threshold"
2632 );
2633 }
2634
2635 #[test]
2636 fn accumulator_saturates_instead_of_overflowing() {
2637 let mut acc = SwipeAccumulator::default();
2642 acc.begin();
2643 acc.backdate_hold_for_test();
2644 assert_eq!(
2645 acc.accumulate(i32::MAX, i32::MAX),
2646 None,
2647 "a diagonal never commits"
2648 );
2649 assert_eq!(
2650 acc.accumulate(i32::MAX, i32::MAX),
2651 None,
2652 "the saturating sum must not panic"
2653 );
2654 acc.begin();
2656 acc.backdate_hold_for_test();
2657 assert_eq!(acc.accumulate(i32::MAX, 0), Some(GestureDirection::Right));
2658 }
2659
2660 #[test]
2661 fn accumulator_begin_recovers_a_stale_hold() {
2662 let mut acc = SwipeAccumulator::default();
2667 acc.begin();
2668 acc.backdate_hold_for_test();
2669 assert_eq!(
2671 acc.accumulate(-(GESTURE_SWIPE_THRESHOLD + 10), 0),
2672 Some(GestureDirection::Left)
2673 );
2674 acc.begin();
2676 acc.backdate_hold_for_test();
2677 assert_eq!(
2680 acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
2681 Some(GestureDirection::Right)
2682 );
2683 }
2684
2685 #[test]
2686 fn accumulator_end_without_a_hold_is_not_a_click() {
2687 let mut acc = SwipeAccumulator::default();
2690 assert!(!acc.end(), "a release with no hold is not a click");
2691 acc.begin();
2693 assert!(acc.end(), "the held release is a click");
2694 assert!(!acc.end(), "the redundant second release is not a click");
2695 }
2696
2697 fn roundtrip(action: &Action) -> Action {
2702 let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
2703 map.insert(ButtonId::Back, action.clone());
2704 let w = RoundtripWrapper { binding: map };
2705 let s = toml::to_string(&w).expect("serialize");
2706 let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
2707 back.binding
2708 .into_values()
2709 .next()
2710 .expect("binding present after roundtrip")
2711 }
2712
2713 #[test]
2714 fn all_catalog_variants_roundtrip_toml() {
2715 for action in Action::catalog() {
2716 let back = roundtrip(&action);
2717 assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
2718 }
2719 }
2720
2721 #[test]
2722 fn custom_shortcut_roundtrips_toml() {
2723 let action = Action::CustomShortcut(KeyCombo {
2724 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
2725 key_code: 0x23, display: "⌘⇧P".into(),
2727 });
2728 assert_eq!(roundtrip(&action), action);
2729 }
2730
2731 #[test]
2732 fn key_combo_rendered_label_uses_display_when_set() {
2733 let combo = KeyCombo {
2734 modifiers: 0,
2735 key_code: 0,
2736 display: "preset".into(),
2737 };
2738 assert_eq!(combo.rendered_label(), "preset");
2739 }
2740
2741 #[test]
2742 fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
2743 let combo = KeyCombo {
2744 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
2745 key_code: 0x23, display: String::new(),
2747 };
2748 assert_eq!(combo.rendered_label(), "⇧⌘P");
2749 }
2750
2751 #[test]
2754 fn category_editing_variants() {
2755 assert_eq!(Action::Copy.category(), Category::Editing);
2756 assert_eq!(Action::Undo.category(), Category::Editing);
2757 assert_eq!(Action::SelectAll.category(), Category::Editing);
2758 assert_eq!(Action::Find.category(), Category::Editing);
2759 assert_eq!(Action::Save.category(), Category::Editing);
2760 assert_eq!(Action::Cut.category(), Category::Editing);
2761 assert_eq!(Action::Redo.category(), Category::Editing);
2762 assert_eq!(Action::Paste.category(), Category::Editing);
2763 }
2764
2765 #[test]
2766 fn category_browser_variants() {
2767 assert_eq!(Action::BrowserBack.category(), Category::Browser);
2768 assert_eq!(Action::BrowserForward.category(), Category::Browser);
2769 assert_eq!(Action::NewTab.category(), Category::Browser);
2770 assert_eq!(Action::CloseTab.category(), Category::Browser);
2771 assert_eq!(Action::ReopenTab.category(), Category::Browser);
2772 assert_eq!(Action::NextTab.category(), Category::Browser);
2773 assert_eq!(Action::PrevTab.category(), Category::Browser);
2774 assert_eq!(Action::ReloadPage.category(), Category::Browser);
2775 }
2776
2777 #[test]
2778 fn category_media_variants() {
2779 assert_eq!(Action::PlayPause.category(), Category::Media);
2780 assert_eq!(Action::NextTrack.category(), Category::Media);
2781 assert_eq!(Action::PrevTrack.category(), Category::Media);
2782 assert_eq!(Action::VolumeUp.category(), Category::Media);
2783 assert_eq!(Action::VolumeDown.category(), Category::Media);
2784 assert_eq!(Action::MuteVolume.category(), Category::Media);
2785 }
2786
2787 #[test]
2788 fn category_mouse_variants() {
2789 assert_eq!(Action::LeftClick.category(), Category::Mouse);
2790 assert_eq!(Action::RightClick.category(), Category::Mouse);
2791 assert_eq!(Action::MiddleClick.category(), Category::Mouse);
2792 }
2793
2794 #[test]
2795 fn category_dpi_variants() {
2796 assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
2797 assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
2798 }
2799
2800 #[test]
2801 fn category_scroll_variants() {
2802 assert_eq!(Action::ScrollUp.category(), Category::Scroll);
2803 assert_eq!(Action::ScrollDown.category(), Category::Scroll);
2804 assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
2805 assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
2806 }
2807
2808 #[test]
2809 fn category_navigation_variants() {
2810 assert_eq!(Action::MissionControl.category(), Category::Navigation);
2811 assert_eq!(Action::AppExpose.category(), Category::Navigation);
2812 assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
2813 assert_eq!(Action::NextDesktop.category(), Category::Navigation);
2814 assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
2815 assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
2816 }
2817
2818 #[test]
2819 fn category_system_variants() {
2820 assert_eq!(Action::LockScreen.category(), Category::System);
2821 assert_eq!(Action::Screenshot.category(), Category::System);
2822 }
2823
2824 #[test]
2827 fn category_labels_are_nonempty() {
2828 let categories = [
2829 Category::Editing,
2830 Category::Browser,
2831 Category::Media,
2832 Category::Mouse,
2833 Category::Dpi,
2834 Category::Scroll,
2835 Category::Navigation,
2836 Category::System,
2837 ];
2838 for cat in categories {
2839 assert!(!cat.label().is_empty(), "label empty for {cat:?}");
2840 }
2841 }
2842
2843 #[test]
2846 fn dpi_toggle_default_is_cycle_dpi_presets() {
2847 assert_eq!(
2848 default_binding(ButtonId::DpiToggle),
2849 Action::CycleDpiPresets
2850 );
2851 }
2852
2853 #[cfg(target_os = "linux")]
2856 mod modifier_mapping {
2857 use evdev::KeyCode;
2858
2859 use crate::binding::{KeyCombo, linux::modifiers_to_keycodes};
2860
2861 #[test]
2862 fn mod_cmd_alone_maps_to_ctrl() {
2863 assert_eq!(
2864 modifiers_to_keycodes(KeyCombo::MOD_CMD),
2865 vec![KeyCode::KEY_LEFTCTRL]
2866 );
2867 }
2868
2869 #[test]
2870 fn mod_ctrl_alone_maps_to_ctrl() {
2871 assert_eq!(
2872 modifiers_to_keycodes(KeyCombo::MOD_CTRL),
2873 vec![KeyCode::KEY_LEFTCTRL]
2874 );
2875 }
2876
2877 #[test]
2878 fn mod_cmd_and_ctrl_together_produce_single_ctrl() {
2879 assert_eq!(
2881 modifiers_to_keycodes(KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL),
2882 vec![KeyCode::KEY_LEFTCTRL]
2883 );
2884 }
2885
2886 #[test]
2887 fn all_modifiers_produce_canonical_order() {
2888 let mods = modifiers_to_keycodes(
2889 KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT | KeyCombo::MOD_OPTION,
2890 );
2891 assert_eq!(
2892 mods,
2893 vec![
2894 KeyCode::KEY_LEFTCTRL,
2895 KeyCode::KEY_LEFTSHIFT,
2896 KeyCode::KEY_LEFTALT
2897 ]
2898 );
2899 }
2900
2901 #[test]
2902 fn no_modifiers_produces_empty_vec() {
2903 assert!(modifiers_to_keycodes(0).is_empty());
2904 }
2905 }
2906
2907 #[cfg(target_os = "linux")]
2910 mod vk_mapping {
2911 use evdev::KeyCode;
2912
2913 use crate::binding::linux::macos_vk_to_linux;
2914
2915 #[test]
2916 fn common_letters_map_correctly() {
2917 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)); }
2924
2925 #[test]
2926 fn digits_map_correctly() {
2927 assert_eq!(macos_vk_to_linux(0x12), Some(KeyCode::KEY_1)); assert_eq!(macos_vk_to_linux(0x1D), Some(KeyCode::KEY_0)); }
2930
2931 #[test]
2932 fn arrow_keys_map_correctly() {
2933 assert_eq!(macos_vk_to_linux(0x7B), Some(KeyCode::KEY_LEFT));
2934 assert_eq!(macos_vk_to_linux(0x7C), Some(KeyCode::KEY_RIGHT));
2935 assert_eq!(macos_vk_to_linux(0x7D), Some(KeyCode::KEY_DOWN));
2936 assert_eq!(macos_vk_to_linux(0x7E), Some(KeyCode::KEY_UP));
2937 }
2938
2939 #[test]
2940 fn function_keys_map_correctly() {
2941 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)); }
2947
2948 #[test]
2949 fn nav_keys_map_correctly() {
2950 assert_eq!(macos_vk_to_linux(0x73), Some(KeyCode::KEY_HOME));
2951 assert_eq!(macos_vk_to_linux(0x77), Some(KeyCode::KEY_END));
2952 assert_eq!(macos_vk_to_linux(0x74), Some(KeyCode::KEY_PAGEUP));
2953 assert_eq!(macos_vk_to_linux(0x79), Some(KeyCode::KEY_PAGEDOWN));
2954 assert_eq!(macos_vk_to_linux(0x75), Some(KeyCode::KEY_DELETE));
2955 }
2956
2957 #[test]
2958 fn brackets_follow_ansi_layout() {
2959 assert_eq!(macos_vk_to_linux(0x21), Some(KeyCode::KEY_LEFTBRACE));
2961 assert_eq!(macos_vk_to_linux(0x1E), Some(KeyCode::KEY_RIGHTBRACE));
2962 }
2963
2964 #[test]
2965 fn unmapped_code_returns_none() {
2966 assert_eq!(macos_vk_to_linux(0xFF), None);
2967 assert_eq!(macos_vk_to_linux(0x34), None); }
2969 }
2970}