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 let none = CGEventFlags::CGEventFlagNull;
974
975 match self {
976 Action::None => {}
978 Action::LeftClick => macos::post_click(CGMouseButton::Left),
983 Action::RightClick => macos::post_click(CGMouseButton::Right),
984 Action::MiddleClick => macos::post_click(CGMouseButton::Center),
985 Action::Copy => macos::post_key(VK_C, cmd),
987 Action::Paste => macos::post_key(VK_V, cmd),
988 Action::Cut => macos::post_key(VK_X, cmd),
989 Action::Undo => macos::post_key(VK_Z, cmd),
990 Action::Redo => macos::post_key(VK_Z, cmd | shift),
991 Action::SelectAll => macos::post_key(VK_A, cmd),
992 Action::Find => macos::post_key(VK_F, cmd),
993 Action::Save => macos::post_key(VK_S, cmd),
994 Action::BrowserBack => macos::post_key(0x21, cmd),
999 Action::BrowserForward => macos::post_key(0x1E, cmd),
1000 Action::NewTab => macos::post_key(VK_T, cmd),
1001 Action::CloseTab => macos::post_key(VK_W, cmd),
1002 Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
1003 Action::NextTab => macos::post_key(VK_TAB, ctrl),
1004 Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
1005 Action::ReloadPage => macos::post_key(VK_R, cmd),
1006 Action::MissionControl => macos::mission_control(),
1013 Action::AppExpose => macos::app_expose(),
1014 Action::PreviousDesktop => macos::previous_desktop(),
1015 Action::NextDesktop => macos::next_desktop(),
1016 Action::ShowDesktop => macos::show_desktop(),
1017 Action::LaunchpadShow => macos::launchpad(),
1018 Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
1021 Action::Screenshot => macos::post_key(0x14, cmd | shift),
1023 Action::PlayPause => macos::post_media_key(0),
1026 Action::NextTrack => macos::post_media_key(1),
1027 Action::PrevTrack => macos::post_media_key(2),
1028 Action::VolumeUp => macos::post_key(0x48, none),
1030 Action::VolumeDown => macos::post_key(0x49, none),
1031 Action::MuteVolume => macos::post_key(0x4A, none),
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) fn post_click(button: CGMouseButton) {
1234 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1235 tracing::warn!("CGEventSource::new failed for click");
1236 return;
1237 };
1238 let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
1241 let (down, up) = match button {
1242 CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp),
1243 CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp),
1244 CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp),
1245 };
1246 for (kind, phase) in [(down, "down"), (up, "up")] {
1247 if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) {
1248 ev.set_integer_value_field(
1252 EventField::EVENT_SOURCE_USER_DATA,
1253 super::SYNTHETIC_EVENT_USER_DATA,
1254 );
1255 ev.post(CGEventTapLocation::HID);
1256 } else {
1257 tracing::warn!(phase, "CGEvent::new_mouse_event failed");
1258 }
1259 }
1260 }
1261
1262 pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
1264 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1265 tracing::warn!("CGEventSource::new failed");
1266 return;
1267 };
1268 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1269 tracing::warn!("CGEvent::new_keyboard_event(down) failed");
1270 return;
1271 };
1272 down.set_flags(flags);
1273 down.post(CGEventTapLocation::HID);
1274 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1275 tracing::warn!("CGEvent::new_keyboard_event(up) failed");
1276 return;
1277 };
1278 up.set_flags(flags);
1279 up.post(CGEventTapLocation::HID);
1280 }
1281
1282 pub(super) fn post_media_key(kind: i32) {
1291 let nx_key: i64 = match kind {
1293 0 => 16,
1294 1 => 17,
1295 _ => 18,
1296 };
1297 tracing::debug!(
1298 nx_key,
1299 "media key event: NSSystemDefined stub — full AppKit impl tracked in P1.x"
1300 );
1301 }
1302
1303 pub(super) fn post_scroll(action: &Action) {
1305 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1306 tracing::warn!("CGEventSource::new failed for scroll");
1307 return;
1308 };
1309 let (v, h): (i32, i32) = match action {
1310 Action::ScrollUp => (3, 0),
1311 Action::ScrollDown => (-3, 0),
1312 Action::HorizontalScrollLeft => (0, -3),
1313 Action::HorizontalScrollRight => (0, 3),
1314 _ => return,
1315 };
1316 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
1317 tracing::warn!("CGEvent::new_scroll_event failed");
1318 return;
1319 };
1320 ev.post(CGEventTapLocation::HID);
1321 }
1322
1323 pub(super) fn post_horizontal_scroll(delta: i32) {
1326 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1327 tracing::warn!("CGEventSource::new failed for thumbwheel scroll");
1328 return;
1329 };
1330 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else {
1331 tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel");
1332 return;
1333 };
1334 ev.post(CGEventTapLocation::HID);
1335 }
1336
1337 pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop};
1338 pub(super) use symbolic_hotkey::{next_desktop, previous_desktop};
1339
1340 use app_services::symbol as app_services_symbol;
1341
1342 #[allow(
1345 unsafe_code,
1346 reason = "private ApplicationServices SPI symbols are resolved via dlopen/dlsym FFI"
1347 )]
1348 mod app_services {
1349 use std::ffi::{CStr, c_char, c_int, c_void};
1350 use std::sync::OnceLock;
1351
1352 pub(super) fn symbol(symbol: &CStr) -> Option<*mut c_void> {
1356 const RTLD_LAZY: c_int = 0x1;
1357 const APP_SERVICES: &CStr =
1358 c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices";
1359 static HANDLE: OnceLock<usize> = OnceLock::new();
1360
1361 let sym = unsafe {
1365 let handle =
1366 *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize);
1367 if handle == 0 {
1368 return None;
1369 }
1370 dlsym(handle as *mut c_void, symbol.as_ptr())
1371 };
1372 (!sym.is_null()).then_some(sym)
1373 }
1374
1375 unsafe extern "C" {
1376 fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
1377 fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
1378 }
1379 }
1380
1381 #[allow(
1394 unsafe_code,
1395 reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI"
1396 )]
1397 mod dock {
1398 use std::ffi::{c_int, c_void};
1399
1400 use core_foundation::base::TCFType;
1401 use core_foundation::string::CFString;
1402
1403 use super::app_services_symbol;
1404
1405 pub(crate) fn mission_control() {
1407 send("com.apple.expose.awake");
1408 }
1409
1410 pub(crate) fn app_expose() {
1412 send("com.apple.expose.front.awake");
1413 }
1414
1415 pub(crate) fn show_desktop() {
1417 send("com.apple.showdesktop.awake");
1418 }
1419
1420 pub(crate) fn launchpad() {
1422 send("com.apple.launchpad.toggle");
1423 }
1424
1425 fn send(notification: &str) {
1427 let Some(core_dock_send) = core_dock_send_notification() else {
1428 tracing::warn!(notification, "CoreDockSendNotification unavailable");
1429 return;
1430 };
1431 let name = CFString::new(notification);
1432 let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) };
1435 if err != 0 {
1436 tracing::warn!(notification, err, "CoreDockSendNotification failed");
1437 }
1438 }
1439
1440 type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int;
1441
1442 fn core_dock_send_notification() -> Option<CoreDockSendNotificationFn> {
1445 let sym = app_services_symbol(c"CoreDockSendNotification")?;
1446 Some(unsafe { std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym) })
1448 }
1449 }
1450
1451 #[allow(
1458 unsafe_code,
1459 reason = "CGS symbolic hotkey SPI is only reachable via dlopen/dlsym FFI"
1460 )]
1461 mod symbolic_hotkey {
1462 use std::ffi::{c_int, c_uint, c_ushort, c_void};
1463
1464 use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation};
1465 use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
1466
1467 use super::app_services_symbol;
1468
1469 const SPACE_LEFT: u32 = 79;
1470 const SPACE_RIGHT: u32 = 81;
1471
1472 pub(crate) fn previous_desktop() {
1474 post_symbolic_hotkey(SPACE_LEFT);
1475 }
1476
1477 pub(crate) fn next_desktop() {
1479 post_symbolic_hotkey(SPACE_RIGHT);
1480 }
1481
1482 fn post_symbolic_hotkey(hotkey: u32) {
1483 let Some(cgs) = cgs_hotkey_api() else {
1484 tracing::warn!(hotkey, "CGS symbolic hotkey API unavailable");
1485 return;
1486 };
1487
1488 let mut key_equivalent = 0_u16;
1489 let mut virtual_key = 0_u16;
1490 let mut modifiers = 0_u32;
1491
1492 let err = unsafe {
1495 (cgs.get_value)(
1496 hotkey,
1497 &raw mut key_equivalent,
1498 &raw mut virtual_key,
1499 &raw mut modifiers,
1500 )
1501 };
1502 if err != 0 {
1503 tracing::warn!(hotkey, err, "CGSGetSymbolicHotKeyValue failed");
1504 return;
1505 }
1506
1507 let was_enabled = unsafe { (cgs.is_enabled)(hotkey) };
1510 if !was_enabled {
1511 let err = unsafe { (cgs.set_enabled)(hotkey, true) };
1514 if err != 0 {
1515 tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(true) failed");
1516 }
1517 }
1518
1519 post_key(virtual_key, modifiers);
1520
1521 if !was_enabled {
1522 let err = unsafe { (cgs.set_enabled)(hotkey, false) };
1525 if err != 0 {
1526 tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(false) failed");
1527 }
1528 }
1529 }
1530
1531 fn post_key(vk: u16, modifiers: u32) {
1532 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1533 tracing::warn!("CGEventSource::new failed for symbolic hotkey");
1534 return;
1535 };
1536 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1537 tracing::warn!(vk, "CGEvent::new_keyboard_event(down) failed");
1538 return;
1539 };
1540 let flags = CGEventFlags::from_bits_truncate(u64::from(modifiers));
1541 down.set_flags(flags);
1542 down.post(CGEventTapLocation::Session);
1543
1544 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1545 tracing::warn!(vk, "CGEvent::new_keyboard_event(up) failed");
1546 return;
1547 };
1548 up.set_flags(flags);
1549 up.post(CGEventTapLocation::Session);
1550 }
1551
1552 #[derive(Clone, Copy)]
1553 struct CgsHotkeyApi {
1554 get_value: CgsGetSymbolicHotKeyValueFn,
1555 is_enabled: CgsIsSymbolicHotKeyEnabledFn,
1556 set_enabled: CgsSetSymbolicHotKeyEnabledFn,
1557 }
1558
1559 type CgsGetSymbolicHotKeyValueFn =
1560 unsafe extern "C" fn(c_uint, *mut c_ushort, *mut c_ushort, *mut c_uint) -> c_int;
1561 type CgsIsSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint) -> bool;
1562 type CgsSetSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint, bool) -> c_int;
1563
1564 fn cgs_hotkey_api() -> Option<CgsHotkeyApi> {
1565 let get_value = app_services_symbol(c"CGSGetSymbolicHotKeyValue")?;
1566 let is_enabled = app_services_symbol(c"CGSIsSymbolicHotKeyEnabled")?;
1567 let set_enabled = app_services_symbol(c"CGSSetSymbolicHotKeyEnabled")?;
1568
1569 Some(unsafe {
1572 CgsHotkeyApi {
1573 get_value: std::mem::transmute::<*mut c_void, CgsGetSymbolicHotKeyValueFn>(
1574 get_value,
1575 ),
1576 is_enabled: std::mem::transmute::<*mut c_void, CgsIsSymbolicHotKeyEnabledFn>(
1577 is_enabled,
1578 ),
1579 set_enabled: std::mem::transmute::<*mut c_void, CgsSetSymbolicHotKeyEnabledFn>(
1580 set_enabled,
1581 ),
1582 }
1583 })
1584 }
1585 }
1586}
1587
1588#[must_use]
1602pub fn default_binding(button: ButtonId) -> Action {
1603 match button {
1604 ButtonId::LeftClick => Action::LeftClick,
1605 ButtonId::RightClick => Action::RightClick,
1606 ButtonId::MiddleClick => Action::MiddleClick,
1607 ButtonId::Back => Action::BrowserBack,
1608 ButtonId::Forward => Action::BrowserForward,
1609 ButtonId::DpiToggle => Action::CycleDpiPresets,
1610 ButtonId::Thumbwheel => Action::AppExpose,
1611 ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight,
1617 ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft,
1618 ButtonId::GestureButton => Action::MissionControl,
1619 }
1620}
1621
1622#[must_use]
1626pub fn default_gesture_binding(direction: GestureDirection) -> Action {
1627 match direction {
1628 GestureDirection::Up => Action::MissionControl,
1629 GestureDirection::Down => Action::ShowDesktop,
1630 GestureDirection::Left => Action::PrevTab,
1631 GestureDirection::Right => Action::NextTab,
1632 GestureDirection::Click => Action::AppExpose,
1633 }
1634}
1635
1636#[must_use]
1650pub fn default_binding_for(button: ButtonId) -> Binding {
1651 match button {
1652 ButtonId::GestureButton => Binding::Gesture(
1653 GestureDirection::ALL
1654 .into_iter()
1655 .map(|d| (d, default_gesture_binding(d)))
1656 .collect(),
1657 ),
1658 other => Binding::Single(default_binding(other)),
1659 }
1660}
1661
1662#[cfg(target_os = "linux")]
1669mod linux {
1670 use std::io;
1671 use std::sync::{LazyLock, Mutex};
1672
1673 use evdev::uinput::VirtualDevice;
1674 use evdev::{AttributeSet, EventType, InputEvent, KeyCode, RelativeAxisCode};
1675 use zbus::blocking::Connection as DbusConn;
1676
1677 const DEVICE_NAME: &str = "OpenLogi action injector";
1678
1679 static VIRTUAL_INPUT: LazyLock<Option<Mutex<VirtualDevice>>> = LazyLock::new(|| {
1680 build()
1681 .map(Mutex::new)
1682 .map_err(|e| tracing::warn!("failed to create uinput action device: {e}"))
1683 .ok()
1684 });
1685
1686 #[rustfmt::skip]
1687 const KEY_CAPABILITIES: &[KeyCode] = &[
1688 KeyCode::KEY_A, KeyCode::KEY_B, KeyCode::KEY_C, KeyCode::KEY_D,
1690 KeyCode::KEY_E, KeyCode::KEY_F, KeyCode::KEY_G, KeyCode::KEY_H,
1691 KeyCode::KEY_I, KeyCode::KEY_J, KeyCode::KEY_K, KeyCode::KEY_L,
1692 KeyCode::KEY_M, KeyCode::KEY_N, KeyCode::KEY_O, KeyCode::KEY_P,
1693 KeyCode::KEY_Q, KeyCode::KEY_R, KeyCode::KEY_S, KeyCode::KEY_T,
1694 KeyCode::KEY_U, KeyCode::KEY_V, KeyCode::KEY_W, KeyCode::KEY_X,
1695 KeyCode::KEY_Y, KeyCode::KEY_Z,
1696 KeyCode::KEY_0, KeyCode::KEY_1, KeyCode::KEY_2, KeyCode::KEY_3,
1698 KeyCode::KEY_4, KeyCode::KEY_5, KeyCode::KEY_6, KeyCode::KEY_7,
1699 KeyCode::KEY_8, KeyCode::KEY_9,
1700 KeyCode::KEY_MINUS, KeyCode::KEY_EQUAL, KeyCode::KEY_LEFTBRACE,
1702 KeyCode::KEY_RIGHTBRACE, KeyCode::KEY_BACKSLASH, KeyCode::KEY_SEMICOLON,
1703 KeyCode::KEY_APOSTROPHE, KeyCode::KEY_GRAVE, KeyCode::KEY_COMMA,
1704 KeyCode::KEY_DOT, KeyCode::KEY_SLASH,
1705 KeyCode::KEY_LEFT, KeyCode::KEY_RIGHT, KeyCode::KEY_UP, KeyCode::KEY_DOWN,
1707 KeyCode::KEY_HOME, KeyCode::KEY_END, KeyCode::KEY_PAGEUP, KeyCode::KEY_PAGEDOWN,
1708 KeyCode::KEY_TAB, KeyCode::KEY_ENTER, KeyCode::KEY_BACKSPACE, KeyCode::KEY_DELETE,
1709 KeyCode::KEY_ESC, KeyCode::KEY_SPACE,
1710 KeyCode::KEY_LEFTCTRL, KeyCode::KEY_LEFTSHIFT, KeyCode::KEY_LEFTALT, KeyCode::KEY_LEFTMETA,
1712 KeyCode::KEY_F1, KeyCode::KEY_F2, KeyCode::KEY_F3, KeyCode::KEY_F4,
1714 KeyCode::KEY_F5, KeyCode::KEY_F6, KeyCode::KEY_F7, KeyCode::KEY_F8,
1715 KeyCode::KEY_F9, KeyCode::KEY_F10, KeyCode::KEY_F11, KeyCode::KEY_F12,
1716 KeyCode::KEY_SYSRQ,
1718 KeyCode::KEY_PLAYPAUSE, KeyCode::KEY_NEXTSONG, KeyCode::KEY_PREVIOUSSONG,
1720 KeyCode::KEY_VOLUMEUP, KeyCode::KEY_VOLUMEDOWN, KeyCode::KEY_MUTE,
1721 KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE,
1723 ];
1724
1725 fn build() -> io::Result<VirtualDevice> {
1726 let mut keys = AttributeSet::<KeyCode>::default();
1727 for &k in KEY_CAPABILITIES {
1728 keys.insert(k);
1729 }
1730
1731 let mut axes = AttributeSet::<RelativeAxisCode>::default();
1736 for a in [RelativeAxisCode::REL_WHEEL, RelativeAxisCode::REL_HWHEEL] {
1737 axes.insert(a);
1738 }
1739
1740 VirtualDevice::builder()?
1741 .name(DEVICE_NAME)
1742 .with_keys(&keys)?
1743 .with_relative_axes(&axes)?
1744 .build()
1745 }
1746
1747 fn emit(events: &[InputEvent]) {
1748 if let Some(m) = &*VIRTUAL_INPUT {
1749 if let Ok(mut guard) = m.lock() {
1750 if let Err(e) = guard.emit(events) {
1751 tracing::warn!("uinput action emit failed: {e}");
1752 }
1753 } else {
1754 tracing::warn!("uinput action device mutex poisoned");
1755 }
1756 } else {
1757 tracing::debug!("uinput action device unavailable — action skipped");
1759 }
1760 }
1761
1762 fn syn() -> InputEvent {
1763 InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0)
1764 }
1765
1766 fn key_ev(code: KeyCode, value: i32) -> InputEvent {
1767 InputEvent::new(EventType::KEY.0, code.0, value)
1768 }
1769
1770 fn rel_ev(axis: RelativeAxisCode, value: i32) -> InputEvent {
1771 InputEvent::new(EventType::RELATIVE.0, axis.0, value)
1772 }
1773
1774 pub(super) fn press_key(mods: &[KeyCode], key: KeyCode) {
1781 let mut down: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1783 for &m in mods {
1784 down.push(key_ev(m, 1));
1785 }
1786 down.push(key_ev(key, 1));
1787 down.push(syn());
1788 emit(&down);
1789
1790 let mut up: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1792 up.push(key_ev(key, 0));
1793 for &m in mods.iter().rev() {
1794 up.push(key_ev(m, 0));
1795 }
1796 up.push(syn());
1797 emit(&up);
1798 }
1799
1800 pub(super) fn click(button: KeyCode) {
1802 emit(&[key_ev(button, 1), syn()]);
1803 emit(&[key_ev(button, 0), syn()]);
1804 }
1805
1806 pub(super) fn scroll(axis: RelativeAxisCode, value: i32) {
1808 emit(&[rel_ev(axis, value), syn()]);
1809 }
1810
1811 pub(super) fn device_node() -> Option<std::path::PathBuf> {
1819 let _ = &*VIRTUAL_INPUT;
1821 std::thread::sleep(std::time::Duration::from_millis(150));
1823 if let Some(m) = &*VIRTUAL_INPUT
1824 && let Ok(mut guard) = m.lock()
1825 {
1826 return guard.enumerate_dev_nodes_blocking().ok()?.flatten().next();
1827 }
1828 None
1829 }
1830
1831 pub(super) fn modifiers_to_keycodes(modifiers: u8) -> Vec<KeyCode> {
1837 use crate::binding::KeyCombo;
1838 let mut mods = Vec::new();
1839 if modifiers & (KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL) != 0 {
1840 mods.push(KeyCode::KEY_LEFTCTRL);
1841 }
1842 if modifiers & KeyCombo::MOD_SHIFT != 0 {
1843 mods.push(KeyCode::KEY_LEFTSHIFT);
1844 }
1845 if modifiers & KeyCombo::MOD_OPTION != 0 {
1846 mods.push(KeyCode::KEY_LEFTALT);
1847 }
1848 mods
1849 }
1850
1851 pub(super) fn macos_vk_to_linux(vk: u16) -> Option<KeyCode> {
1857 Some(match vk {
1858 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,
1932 })
1933 }
1934
1935 static SESSION_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
1938 DbusConn::session()
1939 .map_err(|e| tracing::warn!("D-Bus session bus unavailable: {e}"))
1940 .ok()
1941 });
1942
1943 static SYSTEM_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
1944 DbusConn::system()
1945 .map_err(|e| tracing::warn!("D-Bus system bus unavailable: {e}"))
1946 .ok()
1947 });
1948
1949 pub(super) fn lock_screen() {
1957 if let (Some(conn), Ok(id)) = (SYSTEM_BUS.as_ref(), std::env::var("XDG_SESSION_ID")) {
1958 match conn.call_method(
1959 Some("org.freedesktop.login1"),
1960 "/org/freedesktop/login1",
1961 Some("org.freedesktop.login1.Manager"),
1962 "LockSession",
1963 &(id.as_str(),),
1964 ) {
1965 Ok(_) => {
1966 tracing::debug!("LockScreen via logind");
1967 return;
1968 }
1969 Err(e) => tracing::warn!("logind LockSession failed: {e}"),
1970 }
1971 }
1972 tracing::debug!("LockScreen via Super+L key combo");
1974 press_key(&[KeyCode::KEY_LEFTMETA], KeyCode::KEY_L);
1975 }
1976
1977 pub(super) fn mpris_command(command: &str) {
1983 if try_mpris_command(command).is_none() {
1984 let fallback = match command {
1985 "PlayPause" => KeyCode::KEY_PLAYPAUSE,
1986 "Next" => KeyCode::KEY_NEXTSONG,
1987 "Previous" => KeyCode::KEY_PREVIOUSSONG,
1988 _ => return,
1989 };
1990 press_key(&[], fallback);
1991 }
1992 }
1993
1994 fn try_mpris_command(command: &str) -> Option<()> {
1995 let conn = SESSION_BUS.as_ref()?;
1996 let reply = conn
1997 .call_method(
1998 Some("org.freedesktop.DBus"),
1999 "/org/freedesktop/DBus",
2000 Some("org.freedesktop.DBus"),
2001 "ListNames",
2002 &(),
2003 )
2004 .ok()?;
2005 let names = reply.body().deserialize::<Vec<String>>().ok()?;
2006 let Some(player) = names
2007 .iter()
2008 .find(|n| n.starts_with("org.mpris.MediaPlayer2."))
2009 else {
2010 tracing::debug!("no MPRIS player found — {command} via XF86 key fallback");
2011 return None;
2012 };
2013 match conn.call_method(
2014 Some(player.as_str()),
2015 "/org/mpris/MediaPlayer2",
2016 Some("org.mpris.MediaPlayer2.Player"),
2017 command,
2018 &(),
2019 ) {
2020 Ok(_) => {
2021 tracing::debug!("MPRIS {command} via {player}");
2022 Some(())
2023 }
2024 Err(e) => {
2025 tracing::warn!("MPRIS {command} on {player} failed: {e}");
2028 Some(())
2029 }
2030 }
2031 }
2032}
2033
2034#[cfg_attr(
2048 not(target_os = "windows"),
2049 allow(
2050 dead_code,
2051 reason = "pure key-code table is exercised by host unit tests; its only runtime caller is the Windows-gated post_custom_shortcut"
2052 )
2053)]
2054fn mac_virtual_key_to_windows(key_code: u16) -> Option<u16> {
2055 Some(match key_code {
2056 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,
2144 })
2145}
2146
2147#[cfg(target_os = "windows")]
2148#[allow(unsafe_code, reason = "SendInput is the Win32 API for synthetic input")]
2149mod windows {
2150 use std::mem::size_of;
2151
2152 use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
2153 INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP,
2154 MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN,
2155 MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL,
2156 MOUSEINPUT, SendInput,
2157 };
2158
2159 use crate::binding::{Action, KeyCombo};
2160
2161 const WHEEL_DELTA: i32 = 120;
2162
2163 pub(super) const VK_A: u16 = 0x41;
2164 pub(super) const VK_C: u16 = 0x43;
2165 pub(super) const VK_D: u16 = 0x44;
2166 pub(super) const VK_F: u16 = 0x46;
2167 pub(super) const VK_L: u16 = 0x4C;
2168 pub(super) const VK_R: u16 = 0x52;
2169 pub(super) const VK_S: u16 = 0x53;
2170 pub(super) const VK_T: u16 = 0x54;
2171 pub(super) const VK_V: u16 = 0x56;
2172 pub(super) const VK_W: u16 = 0x57;
2173 pub(super) const VK_X: u16 = 0x58;
2174 pub(super) const VK_Y: u16 = 0x59;
2175 pub(super) const VK_Z: u16 = 0x5A;
2176 pub(super) const VK_TAB: u16 = 0x09;
2177 pub(super) const VK_LEFT: u16 = 0x25;
2178 pub(super) const VK_RIGHT: u16 = 0x27;
2179 pub(super) const VK_SHIFT: u16 = 0x10;
2180 pub(super) const VK_CONTROL: u16 = 0x11;
2181 pub(super) const VK_MENU: u16 = 0x12;
2182 pub(super) const VK_LWIN: u16 = 0x5B;
2183 pub(super) const VK_BROWSER_BACK: u16 = 0xA6;
2184 pub(super) const VK_BROWSER_FORWARD: u16 = 0xA7;
2185 pub(super) const VK_VOLUME_MUTE: u16 = 0xAD;
2186 pub(super) const VK_VOLUME_DOWN: u16 = 0xAE;
2187 pub(super) const VK_VOLUME_UP: u16 = 0xAF;
2188 pub(super) const VK_MEDIA_NEXT_TRACK: u16 = 0xB0;
2189 pub(super) const VK_MEDIA_PREV_TRACK: u16 = 0xB1;
2190 pub(super) const VK_MEDIA_PLAY_PAUSE: u16 = 0xB3;
2191
2192 #[derive(Clone, Copy)]
2193 pub(super) enum MouseButton {
2194 Left,
2195 Right,
2196 Middle,
2197 }
2198
2199 pub(super) fn post_click(button: MouseButton) {
2200 let (down, up) = match button {
2201 MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
2202 MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
2203 MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
2204 };
2205 send_inputs(&[mouse_input(down, 0), mouse_input(up, 0)]);
2206 }
2207
2208 pub(super) fn post_key(vk: u16, modifiers: &[u16]) {
2209 let mut inputs = Vec::with_capacity(modifiers.len() * 2 + 2);
2210 for modifier in modifiers {
2211 inputs.push(key_input(*modifier, false));
2212 }
2213 inputs.push(key_input(vk, false));
2214 inputs.push(key_input(vk, true));
2215 for modifier in modifiers.iter().rev() {
2216 inputs.push(key_input(*modifier, true));
2217 }
2218 send_inputs(&inputs);
2219 }
2220
2221 pub(super) fn post_scroll(action: &Action) {
2222 let (flags, data) = match action {
2223 Action::ScrollUp => (MOUSEEVENTF_WHEEL, WHEEL_DELTA),
2224 Action::ScrollDown => (MOUSEEVENTF_WHEEL, -WHEEL_DELTA),
2225 Action::HorizontalScrollLeft => (MOUSEEVENTF_HWHEEL, -WHEEL_DELTA),
2226 Action::HorizontalScrollRight => (MOUSEEVENTF_HWHEEL, WHEEL_DELTA),
2227 _ => return,
2228 };
2229 send_inputs(&[mouse_input(flags, data)]);
2230 }
2231
2232 pub(super) fn post_horizontal_scroll(delta: i32) {
2233 if delta == 0 {
2234 return;
2235 }
2236 send_inputs(&[mouse_input(
2237 MOUSEEVENTF_HWHEEL,
2238 delta.saturating_mul(WHEEL_DELTA),
2239 )]);
2240 }
2241
2242 pub(super) fn post_custom_shortcut(combo: &KeyCombo) {
2243 if combo.key_code == 0 {
2244 tracing::warn!(
2245 chord = %combo.rendered_label(),
2246 "CustomShortcut with no key code; press ignored"
2247 );
2248 return;
2249 }
2250 let Some(vk) = super::mac_virtual_key_to_windows(combo.key_code) else {
2251 tracing::warn!(
2252 key_code = combo.key_code,
2253 chord = %combo.rendered_label(),
2254 "CustomShortcut key has no Windows mapping yet; press ignored"
2255 );
2256 return;
2257 };
2258
2259 let mut modifiers = Vec::new();
2260 if combo.modifiers & KeyCombo::MOD_CMD != 0 {
2261 modifiers.push(VK_CONTROL);
2262 }
2263 if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
2264 modifiers.push(VK_SHIFT);
2265 }
2266 if combo.modifiers & KeyCombo::MOD_CTRL != 0 && !modifiers.contains(&VK_CONTROL) {
2267 modifiers.push(VK_CONTROL);
2268 }
2269 if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
2270 modifiers.push(VK_MENU);
2271 }
2272 post_key(vk, &modifiers);
2273 }
2274
2275 fn send_inputs(inputs: &[INPUT]) {
2276 let Ok(input_count) = u32::try_from(inputs.len()) else {
2277 tracing::warn!(
2278 requested = inputs.len(),
2279 "too many SendInput events requested"
2280 );
2281 return;
2282 };
2283 let Ok(input_size) = i32::try_from(size_of::<INPUT>()) else {
2284 tracing::warn!("INPUT size does not fit the Win32 SendInput contract");
2285 return;
2286 };
2287 let sent = unsafe { SendInput(input_count, inputs.as_ptr(), input_size) };
2288 if sent != input_count {
2289 tracing::warn!(
2290 requested = inputs.len(),
2291 sent,
2292 "SendInput accepted fewer events than requested"
2293 );
2294 }
2295 }
2296
2297 fn key_input(vk: u16, key_up: bool) -> INPUT {
2298 let mut flags = 0;
2299 if key_up {
2300 flags |= KEYEVENTF_KEYUP;
2301 }
2302 INPUT {
2303 r#type: INPUT_KEYBOARD,
2304 Anonymous: INPUT_0 {
2305 ki: KEYBDINPUT {
2306 wVk: vk,
2307 wScan: 0,
2308 dwFlags: flags,
2309 time: 0,
2310 dwExtraInfo: 0,
2311 },
2312 },
2313 }
2314 }
2315
2316 fn mouse_input(flags: u32, data: i32) -> INPUT {
2317 INPUT {
2318 r#type: INPUT_MOUSE,
2319 Anonymous: INPUT_0 {
2320 mi: MOUSEINPUT {
2321 dx: 0,
2322 dy: 0,
2323 mouseData: u32::from_ne_bytes(data.to_ne_bytes()),
2324 dwFlags: flags,
2325 time: 0,
2326 dwExtraInfo: 0,
2327 },
2328 },
2329 }
2330 }
2331}
2332
2333#[cfg(test)]
2334#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
2335mod tests {
2336 use std::collections::BTreeMap;
2337
2338 use serde::{Deserialize, Serialize};
2339
2340 use super::*;
2341
2342 #[derive(Serialize, Deserialize)]
2347 struct RoundtripWrapper {
2348 binding: BTreeMap<ButtonId, Action>,
2349 }
2350
2351 #[test]
2354 fn catalog_has_at_least_29_entries() {
2355 let catalog = Action::catalog();
2356 assert!(
2357 catalog.len() >= 29,
2358 "catalog has {} entries, need ≥ 29",
2359 catalog.len()
2360 );
2361 }
2362
2363 #[test]
2364 fn catalog_excludes_custom_shortcut() {
2365 let catalog = Action::catalog();
2366 for action in &catalog {
2367 assert!(
2368 !matches!(action, Action::CustomShortcut(_)),
2369 "catalog must not contain CustomShortcut"
2370 );
2371 }
2372 }
2373
2374 #[test]
2375 fn custom_shortcut_keycodes_map_across_categories() {
2376 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); }
2388
2389 #[derive(Serialize, Deserialize)]
2394 struct BindingWrapper {
2395 bindings: BTreeMap<ButtonId, Binding>,
2396 }
2397
2398 fn binding_roundtrip(bindings: BTreeMap<ButtonId, Binding>) -> BTreeMap<ButtonId, Binding> {
2399 let toml = toml::to_string_pretty(&BindingWrapper { bindings }).expect("serialize");
2400 toml::from_str::<BindingWrapper>(&toml)
2401 .expect("deserialize")
2402 .bindings
2403 }
2404
2405 #[test]
2406 fn binding_single_roundtrips_including_payload_variants() {
2407 let mut bindings = BTreeMap::new();
2408 bindings.insert(ButtonId::Back, Binding::Single(Action::BrowserBack));
2409 bindings.insert(
2410 ButtonId::DpiToggle,
2411 Binding::Single(Action::SetDpiPreset(2)),
2412 );
2413 bindings.insert(
2414 ButtonId::Forward,
2415 Binding::Single(Action::CustomShortcut(KeyCombo {
2416 modifiers: KeyCombo::MOD_CMD,
2417 key_code: 0x23,
2418 display: "⌘P".into(),
2419 })),
2420 );
2421 let back = binding_roundtrip(bindings);
2422 assert_eq!(back[&ButtonId::Back], Binding::Single(Action::BrowserBack));
2423 assert_eq!(
2424 back[&ButtonId::DpiToggle],
2425 Binding::Single(Action::SetDpiPreset(2))
2426 );
2427 assert!(matches!(
2428 back[&ButtonId::Forward],
2429 Binding::Single(Action::CustomShortcut(_))
2430 ));
2431 }
2432
2433 #[test]
2434 fn binding_gesture_roundtrips() {
2435 let mut map = BTreeMap::new();
2436 map.insert(GestureDirection::Up, Action::Copy);
2437 map.insert(GestureDirection::Click, Action::Paste);
2438 let mut bindings = BTreeMap::new();
2439 bindings.insert(ButtonId::GestureButton, Binding::Gesture(map.clone()));
2440 let back = binding_roundtrip(bindings);
2441 assert_eq!(back[&ButtonId::GestureButton], Binding::Gesture(map));
2442 }
2443
2444 #[test]
2450 fn binding_direction_keyed_table_routes_to_gesture() {
2451 for dir in GestureDirection::ALL {
2452 let toml = format!("bindings.GestureButton.{dir} = \"None\"");
2454 let parsed = toml::from_str::<BindingWrapper>(&toml).expect("deserialize");
2455 assert!(
2456 matches!(
2457 parsed.bindings[&ButtonId::GestureButton],
2458 Binding::Gesture(_)
2459 ),
2460 "a {dir}-keyed table must route to Gesture, not Single"
2461 );
2462 }
2463 }
2464
2465 #[test]
2469 fn binding_payload_action_stays_single() {
2470 let toml = "bindings.DpiToggle.SetDpiPreset = 2";
2471 let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
2472 assert_eq!(
2473 parsed.bindings[&ButtonId::DpiToggle],
2474 Binding::Single(Action::SetDpiPreset(2))
2475 );
2476 }
2477
2478 #[test]
2481 fn detect_swipe_below_threshold_keeps_accumulating() {
2482 assert_eq!(detect_swipe(40, 5), None);
2484 assert_eq!(detect_swipe(0, 0), None);
2485 }
2486
2487 #[test]
2488 fn detect_swipe_commits_clean_direction() {
2489 assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
2490 assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
2491 assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
2492 assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
2493 }
2494
2495 #[test]
2496 fn detect_swipe_rejects_diagonal() {
2497 assert_eq!(detect_swipe(60, 60), None);
2499 assert_eq!(detect_swipe(-60, -60), None);
2500 }
2501
2502 #[test]
2503 fn detect_swipe_threshold_and_cross_band_boundaries() {
2504 assert_eq!(
2507 detect_swipe(GESTURE_SWIPE_THRESHOLD, 0),
2508 Some(GestureDirection::Right)
2509 );
2510 assert_eq!(detect_swipe(GESTURE_SWIPE_THRESHOLD - 1, 0), None);
2511
2512 assert_eq!(detect_swipe(200, 69), Some(GestureDirection::Right));
2515 assert_eq!(detect_swipe(200, 71), None);
2516 assert_eq!(detect_swipe(100, 39), Some(GestureDirection::Right));
2518 assert_eq!(detect_swipe(100, 41), None);
2519 }
2520
2521 #[test]
2522 fn detect_swipe_does_not_panic_on_extreme_values() {
2523 assert_eq!(detect_swipe(i32::MAX, 0), Some(GestureDirection::Right));
2526 assert_eq!(detect_swipe(i32::MIN, 0), Some(GestureDirection::Left));
2527 assert_eq!(detect_swipe(0, i32::MAX), Some(GestureDirection::Down));
2528 assert_eq!(detect_swipe(0, i32::MIN), Some(GestureDirection::Up));
2529 assert_eq!(detect_swipe(i32::MIN, i32::MIN), None);
2531 }
2532
2533 #[test]
2536 fn accumulator_commits_a_direction_once_after_the_hold_gate() {
2537 let mut acc = SwipeAccumulator::default();
2538 acc.begin();
2539 acc.backdate_hold_for_test();
2540 assert_eq!(
2542 acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
2543 Some(GestureDirection::Right)
2544 );
2545 assert_eq!(acc.accumulate(50, 0), None);
2547 }
2548
2549 #[test]
2550 fn accumulator_does_not_commit_before_the_hold_gate() {
2551 let mut acc = SwipeAccumulator::default();
2552 acc.begin(); assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
2556 acc.backdate_hold_for_test();
2558 assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0).is_some());
2559 }
2560
2561 #[test]
2562 fn accumulator_end_reports_click_only_when_no_swipe_fired() {
2563 let mut acc = SwipeAccumulator::default();
2565 acc.begin();
2566 acc.backdate_hold_for_test();
2567 assert_eq!(acc.accumulate(2, -1), None);
2568 assert!(acc.end(), "a hold that never swiped is a click");
2569
2570 acc.begin();
2572 acc.backdate_hold_for_test();
2573 assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0).is_some());
2574 assert!(!acc.end(), "a committed swipe must not also click");
2575 }
2576
2577 #[test]
2578 fn accumulator_ignores_motion_when_not_holding() {
2579 let mut acc = SwipeAccumulator::default();
2580 assert!(!acc.is_holding());
2581 assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
2583 }
2584
2585 #[test]
2586 fn accumulator_sums_sub_threshold_deltas_until_they_commit() {
2587 let mut acc = SwipeAccumulator::default();
2591 acc.begin();
2592 acc.backdate_hold_for_test();
2593 let step = GESTURE_SWIPE_THRESHOLD / 2 - 1;
2595 assert_eq!(acc.accumulate(step, 0), None, "one step is sub-threshold");
2596 assert_eq!(acc.accumulate(step, 0), None, "two steps still under");
2597 assert_eq!(
2598 acc.accumulate(step, 0),
2599 Some(GestureDirection::Right),
2600 "the running sum finally crosses the threshold"
2601 );
2602 }
2603
2604 #[test]
2605 fn accumulator_saturates_instead_of_overflowing() {
2606 let mut acc = SwipeAccumulator::default();
2611 acc.begin();
2612 acc.backdate_hold_for_test();
2613 assert_eq!(
2614 acc.accumulate(i32::MAX, i32::MAX),
2615 None,
2616 "a diagonal never commits"
2617 );
2618 assert_eq!(
2619 acc.accumulate(i32::MAX, i32::MAX),
2620 None,
2621 "the saturating sum must not panic"
2622 );
2623 acc.begin();
2625 acc.backdate_hold_for_test();
2626 assert_eq!(acc.accumulate(i32::MAX, 0), Some(GestureDirection::Right));
2627 }
2628
2629 #[test]
2630 fn accumulator_begin_recovers_a_stale_hold() {
2631 let mut acc = SwipeAccumulator::default();
2636 acc.begin();
2637 acc.backdate_hold_for_test();
2638 assert_eq!(
2640 acc.accumulate(-(GESTURE_SWIPE_THRESHOLD + 10), 0),
2641 Some(GestureDirection::Left)
2642 );
2643 acc.begin();
2645 acc.backdate_hold_for_test();
2646 assert_eq!(
2649 acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
2650 Some(GestureDirection::Right)
2651 );
2652 }
2653
2654 #[test]
2655 fn accumulator_end_without_a_hold_is_not_a_click() {
2656 let mut acc = SwipeAccumulator::default();
2659 assert!(!acc.end(), "a release with no hold is not a click");
2660 acc.begin();
2662 assert!(acc.end(), "the held release is a click");
2663 assert!(!acc.end(), "the redundant second release is not a click");
2664 }
2665
2666 fn roundtrip(action: &Action) -> Action {
2671 let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
2672 map.insert(ButtonId::Back, action.clone());
2673 let w = RoundtripWrapper { binding: map };
2674 let s = toml::to_string(&w).expect("serialize");
2675 let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
2676 back.binding
2677 .into_values()
2678 .next()
2679 .expect("binding present after roundtrip")
2680 }
2681
2682 #[test]
2683 fn all_catalog_variants_roundtrip_toml() {
2684 for action in Action::catalog() {
2685 let back = roundtrip(&action);
2686 assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
2687 }
2688 }
2689
2690 #[test]
2691 fn custom_shortcut_roundtrips_toml() {
2692 let action = Action::CustomShortcut(KeyCombo {
2693 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
2694 key_code: 0x23, display: "⌘⇧P".into(),
2696 });
2697 assert_eq!(roundtrip(&action), action);
2698 }
2699
2700 #[test]
2701 fn key_combo_rendered_label_uses_display_when_set() {
2702 let combo = KeyCombo {
2703 modifiers: 0,
2704 key_code: 0,
2705 display: "preset".into(),
2706 };
2707 assert_eq!(combo.rendered_label(), "preset");
2708 }
2709
2710 #[test]
2711 fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
2712 let combo = KeyCombo {
2713 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
2714 key_code: 0x23, display: String::new(),
2716 };
2717 assert_eq!(combo.rendered_label(), "⇧⌘P");
2718 }
2719
2720 #[test]
2723 fn category_editing_variants() {
2724 assert_eq!(Action::Copy.category(), Category::Editing);
2725 assert_eq!(Action::Undo.category(), Category::Editing);
2726 assert_eq!(Action::SelectAll.category(), Category::Editing);
2727 assert_eq!(Action::Find.category(), Category::Editing);
2728 assert_eq!(Action::Save.category(), Category::Editing);
2729 assert_eq!(Action::Cut.category(), Category::Editing);
2730 assert_eq!(Action::Redo.category(), Category::Editing);
2731 assert_eq!(Action::Paste.category(), Category::Editing);
2732 }
2733
2734 #[test]
2735 fn category_browser_variants() {
2736 assert_eq!(Action::BrowserBack.category(), Category::Browser);
2737 assert_eq!(Action::BrowserForward.category(), Category::Browser);
2738 assert_eq!(Action::NewTab.category(), Category::Browser);
2739 assert_eq!(Action::CloseTab.category(), Category::Browser);
2740 assert_eq!(Action::ReopenTab.category(), Category::Browser);
2741 assert_eq!(Action::NextTab.category(), Category::Browser);
2742 assert_eq!(Action::PrevTab.category(), Category::Browser);
2743 assert_eq!(Action::ReloadPage.category(), Category::Browser);
2744 }
2745
2746 #[test]
2747 fn category_media_variants() {
2748 assert_eq!(Action::PlayPause.category(), Category::Media);
2749 assert_eq!(Action::NextTrack.category(), Category::Media);
2750 assert_eq!(Action::PrevTrack.category(), Category::Media);
2751 assert_eq!(Action::VolumeUp.category(), Category::Media);
2752 assert_eq!(Action::VolumeDown.category(), Category::Media);
2753 assert_eq!(Action::MuteVolume.category(), Category::Media);
2754 }
2755
2756 #[test]
2757 fn category_mouse_variants() {
2758 assert_eq!(Action::LeftClick.category(), Category::Mouse);
2759 assert_eq!(Action::RightClick.category(), Category::Mouse);
2760 assert_eq!(Action::MiddleClick.category(), Category::Mouse);
2761 }
2762
2763 #[test]
2764 fn category_dpi_variants() {
2765 assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
2766 assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
2767 }
2768
2769 #[test]
2770 fn category_scroll_variants() {
2771 assert_eq!(Action::ScrollUp.category(), Category::Scroll);
2772 assert_eq!(Action::ScrollDown.category(), Category::Scroll);
2773 assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
2774 assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
2775 }
2776
2777 #[test]
2778 fn category_navigation_variants() {
2779 assert_eq!(Action::MissionControl.category(), Category::Navigation);
2780 assert_eq!(Action::AppExpose.category(), Category::Navigation);
2781 assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
2782 assert_eq!(Action::NextDesktop.category(), Category::Navigation);
2783 assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
2784 assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
2785 }
2786
2787 #[test]
2788 fn category_system_variants() {
2789 assert_eq!(Action::LockScreen.category(), Category::System);
2790 assert_eq!(Action::Screenshot.category(), Category::System);
2791 }
2792
2793 #[test]
2796 fn category_labels_are_nonempty() {
2797 let categories = [
2798 Category::Editing,
2799 Category::Browser,
2800 Category::Media,
2801 Category::Mouse,
2802 Category::Dpi,
2803 Category::Scroll,
2804 Category::Navigation,
2805 Category::System,
2806 ];
2807 for cat in categories {
2808 assert!(!cat.label().is_empty(), "label empty for {cat:?}");
2809 }
2810 }
2811
2812 #[test]
2815 fn dpi_toggle_default_is_cycle_dpi_presets() {
2816 assert_eq!(
2817 default_binding(ButtonId::DpiToggle),
2818 Action::CycleDpiPresets
2819 );
2820 }
2821
2822 #[cfg(target_os = "linux")]
2825 mod modifier_mapping {
2826 use evdev::KeyCode;
2827
2828 use crate::binding::{KeyCombo, linux::modifiers_to_keycodes};
2829
2830 #[test]
2831 fn mod_cmd_alone_maps_to_ctrl() {
2832 assert_eq!(
2833 modifiers_to_keycodes(KeyCombo::MOD_CMD),
2834 vec![KeyCode::KEY_LEFTCTRL]
2835 );
2836 }
2837
2838 #[test]
2839 fn mod_ctrl_alone_maps_to_ctrl() {
2840 assert_eq!(
2841 modifiers_to_keycodes(KeyCombo::MOD_CTRL),
2842 vec![KeyCode::KEY_LEFTCTRL]
2843 );
2844 }
2845
2846 #[test]
2847 fn mod_cmd_and_ctrl_together_produce_single_ctrl() {
2848 assert_eq!(
2850 modifiers_to_keycodes(KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL),
2851 vec![KeyCode::KEY_LEFTCTRL]
2852 );
2853 }
2854
2855 #[test]
2856 fn all_modifiers_produce_canonical_order() {
2857 let mods = modifiers_to_keycodes(
2858 KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT | KeyCombo::MOD_OPTION,
2859 );
2860 assert_eq!(
2861 mods,
2862 vec![
2863 KeyCode::KEY_LEFTCTRL,
2864 KeyCode::KEY_LEFTSHIFT,
2865 KeyCode::KEY_LEFTALT
2866 ]
2867 );
2868 }
2869
2870 #[test]
2871 fn no_modifiers_produces_empty_vec() {
2872 assert!(modifiers_to_keycodes(0).is_empty());
2873 }
2874 }
2875
2876 #[cfg(target_os = "linux")]
2879 mod vk_mapping {
2880 use evdev::KeyCode;
2881
2882 use crate::binding::linux::macos_vk_to_linux;
2883
2884 #[test]
2885 fn common_letters_map_correctly() {
2886 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)); }
2893
2894 #[test]
2895 fn digits_map_correctly() {
2896 assert_eq!(macos_vk_to_linux(0x12), Some(KeyCode::KEY_1)); assert_eq!(macos_vk_to_linux(0x1D), Some(KeyCode::KEY_0)); }
2899
2900 #[test]
2901 fn arrow_keys_map_correctly() {
2902 assert_eq!(macos_vk_to_linux(0x7B), Some(KeyCode::KEY_LEFT));
2903 assert_eq!(macos_vk_to_linux(0x7C), Some(KeyCode::KEY_RIGHT));
2904 assert_eq!(macos_vk_to_linux(0x7D), Some(KeyCode::KEY_DOWN));
2905 assert_eq!(macos_vk_to_linux(0x7E), Some(KeyCode::KEY_UP));
2906 }
2907
2908 #[test]
2909 fn function_keys_map_correctly() {
2910 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)); }
2916
2917 #[test]
2918 fn nav_keys_map_correctly() {
2919 assert_eq!(macos_vk_to_linux(0x73), Some(KeyCode::KEY_HOME));
2920 assert_eq!(macos_vk_to_linux(0x77), Some(KeyCode::KEY_END));
2921 assert_eq!(macos_vk_to_linux(0x74), Some(KeyCode::KEY_PAGEUP));
2922 assert_eq!(macos_vk_to_linux(0x79), Some(KeyCode::KEY_PAGEDOWN));
2923 assert_eq!(macos_vk_to_linux(0x75), Some(KeyCode::KEY_DELETE));
2924 }
2925
2926 #[test]
2927 fn brackets_follow_ansi_layout() {
2928 assert_eq!(macos_vk_to_linux(0x21), Some(KeyCode::KEY_LEFTBRACE));
2930 assert_eq!(macos_vk_to_linux(0x1E), Some(KeyCode::KEY_RIGHTBRACE));
2931 }
2932
2933 #[test]
2934 fn unmapped_code_returns_none() {
2935 assert_eq!(macos_vk_to_linux(0xFF), None);
2936 assert_eq!(macos_vk_to_linux(0x34), None); }
2938 }
2939}