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) {
847 #[cfg(target_os = "macos")]
848 self.execute_macos();
849
850 #[cfg(target_os = "linux")]
851 self.execute_linux();
852
853 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
854 {
855 tracing::warn!(
856 action = self.label(),
857 "Action::execute unsupported on this platform"
858 );
859 }
860 }
861
862 #[cfg(target_os = "linux")]
864 fn execute_linux(&self) {
865 use evdev::{KeyCode, RelativeAxisCode};
866 let ctrl = KeyCode::KEY_LEFTCTRL;
867 let shift = KeyCode::KEY_LEFTSHIFT;
868 let alt = KeyCode::KEY_LEFTALT;
869 match self {
870 Action::LeftClick => linux::click(KeyCode::BTN_LEFT),
872 Action::RightClick => linux::click(KeyCode::BTN_RIGHT),
873 Action::MiddleClick => linux::click(KeyCode::BTN_MIDDLE),
874 Action::Copy => linux::press_key(&[ctrl], KeyCode::KEY_C),
876 Action::Paste => linux::press_key(&[ctrl], KeyCode::KEY_V),
877 Action::Cut => linux::press_key(&[ctrl], KeyCode::KEY_X),
878 Action::Undo => linux::press_key(&[ctrl], KeyCode::KEY_Z),
879 Action::Redo => linux::press_key(&[ctrl, shift], KeyCode::KEY_Z),
881 Action::SelectAll => linux::press_key(&[ctrl], KeyCode::KEY_A),
882 Action::Find => linux::press_key(&[ctrl], KeyCode::KEY_F),
883 Action::Save => linux::press_key(&[ctrl], KeyCode::KEY_S),
884 Action::BrowserBack => linux::press_key(&[alt], KeyCode::KEY_LEFT),
886 Action::BrowserForward => linux::press_key(&[alt], KeyCode::KEY_RIGHT),
887 Action::NewTab => linux::press_key(&[ctrl], KeyCode::KEY_T),
888 Action::CloseTab => linux::press_key(&[ctrl], KeyCode::KEY_W),
889 Action::ReopenTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_T),
890 Action::NextTab => linux::press_key(&[ctrl], KeyCode::KEY_TAB),
891 Action::PrevTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_TAB),
892 Action::ReloadPage => linux::press_key(&[ctrl], KeyCode::KEY_R),
893 Action::MissionControl
896 | Action::AppExpose
897 | Action::ShowDesktop
898 | Action::LaunchpadShow => {
899 tracing::debug!(
900 action = self.label(),
901 "no Linux equivalent — action skipped"
902 );
903 }
904 Action::PreviousDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_LEFT),
906 Action::NextDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_RIGHT),
907 Action::LockScreen => linux::lock_screen(),
910 Action::Screenshot => linux::press_key(&[], KeyCode::KEY_SYSRQ),
911 Action::PlayPause => linux::mpris_command("PlayPause"),
915 Action::NextTrack => linux::mpris_command("Next"),
916 Action::PrevTrack => linux::mpris_command("Previous"),
917 Action::VolumeUp => linux::press_key(&[], KeyCode::KEY_VOLUMEUP),
918 Action::VolumeDown => linux::press_key(&[], KeyCode::KEY_VOLUMEDOWN),
919 Action::MuteVolume => linux::press_key(&[], KeyCode::KEY_MUTE),
920 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
922 tracing::debug!(
923 action = self.label(),
924 "device action handled by hook/HID layer"
925 );
926 }
927 Action::ScrollUp => linux::scroll(RelativeAxisCode::REL_WHEEL, 3),
929 Action::ScrollDown => linux::scroll(RelativeAxisCode::REL_WHEEL, -3),
930 Action::HorizontalScrollLeft => linux::scroll(RelativeAxisCode::REL_HWHEEL, -3),
931 Action::HorizontalScrollRight => linux::scroll(RelativeAxisCode::REL_HWHEEL, 3),
932 Action::None => {}
934 Action::CustomShortcut(combo) => {
936 if combo.key_code == 0 {
937 tracing::warn!(
938 chord = %combo.rendered_label(),
939 "CustomShortcut with no key code — press ignored"
940 );
941 return;
942 }
943 let Some(key) = linux::macos_vk_to_linux(combo.key_code) else {
944 tracing::warn!(
945 key_code = combo.key_code,
946 "CustomShortcut key code has no Linux mapping — press ignored"
947 );
948 return;
949 };
950 linux::press_key(&linux::modifiers_to_keycodes(combo.modifiers), key);
951 }
952 }
953 }
954
955 #[cfg(target_os = "macos")]
957 fn execute_macos(&self) {
958 use core_graphics::event::{CGEventFlags, CGMouseButton};
959
960 let cmd = CGEventFlags::CGEventFlagCommand;
962 let shift = CGEventFlags::CGEventFlagShift;
963 let ctrl = CGEventFlags::CGEventFlagControl;
964 let none = CGEventFlags::CGEventFlagNull;
965
966 match self {
967 Action::None => {}
969 Action::LeftClick => macos::post_click(CGMouseButton::Left),
974 Action::RightClick => macos::post_click(CGMouseButton::Right),
975 Action::MiddleClick => macos::post_click(CGMouseButton::Center),
976 Action::Copy => macos::post_key(VK_C, cmd),
978 Action::Paste => macos::post_key(VK_V, cmd),
979 Action::Cut => macos::post_key(VK_X, cmd),
980 Action::Undo => macos::post_key(VK_Z, cmd),
981 Action::Redo => macos::post_key(VK_Z, cmd | shift),
982 Action::SelectAll => macos::post_key(VK_A, cmd),
983 Action::Find => macos::post_key(VK_F, cmd),
984 Action::Save => macos::post_key(VK_S, cmd),
985 Action::BrowserBack => macos::post_key(0x21, cmd),
990 Action::BrowserForward => macos::post_key(0x1E, cmd),
991 Action::NewTab => macos::post_key(VK_T, cmd),
992 Action::CloseTab => macos::post_key(VK_W, cmd),
993 Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
994 Action::NextTab => macos::post_key(VK_TAB, ctrl),
995 Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
996 Action::ReloadPage => macos::post_key(VK_R, cmd),
997 Action::MissionControl => macos::mission_control(),
1004 Action::AppExpose => macos::app_expose(),
1005 Action::PreviousDesktop => macos::previous_desktop(),
1006 Action::NextDesktop => macos::next_desktop(),
1007 Action::ShowDesktop => macos::show_desktop(),
1008 Action::LaunchpadShow => macos::launchpad(),
1009 Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
1012 Action::Screenshot => macos::post_key(0x14, cmd | shift),
1014 Action::PlayPause => macos::post_media_key(0),
1017 Action::NextTrack => macos::post_media_key(1),
1018 Action::PrevTrack => macos::post_media_key(2),
1019 Action::VolumeUp => macos::post_key(0x48, none),
1021 Action::VolumeDown => macos::post_key(0x49, none),
1022 Action::MuteVolume => macos::post_key(0x4A, none),
1023 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
1025 tracing::debug!(
1026 action = self.label(),
1027 "device action handled by hook/HID layer"
1028 );
1029 }
1030 Action::ScrollUp
1032 | Action::ScrollDown
1033 | Action::HorizontalScrollLeft
1034 | Action::HorizontalScrollRight => macos::post_scroll(self),
1035 Action::CustomShortcut(combo) => {
1037 if combo.key_code == 0 {
1042 tracing::warn!(
1043 chord = %combo.rendered_label(),
1044 "CustomShortcut with no key code — press ignored"
1045 );
1046 return;
1047 }
1048 let mut flags = CGEventFlags::CGEventFlagNull;
1049 if combo.modifiers & KeyCombo::MOD_CMD != 0 {
1050 flags |= CGEventFlags::CGEventFlagCommand;
1051 }
1052 if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
1053 flags |= CGEventFlags::CGEventFlagShift;
1054 }
1055 if combo.modifiers & KeyCombo::MOD_CTRL != 0 {
1056 flags |= CGEventFlags::CGEventFlagControl;
1057 }
1058 if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
1059 flags |= CGEventFlags::CGEventFlagAlternate;
1060 }
1061 macos::post_key(combo.key_code, flags);
1062 }
1063 }
1064 }
1065}
1066
1067pub fn post_horizontal_scroll(delta: i32) {
1077 #[cfg(target_os = "macos")]
1078 macos::post_horizontal_scroll(delta);
1079
1080 #[cfg(target_os = "linux")]
1085 linux::scroll(evdev::RelativeAxisCode::REL_HWHEEL, delta);
1086
1087 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1088 let _ = delta;
1089}
1090
1091#[cfg(target_os = "linux")]
1098#[must_use]
1099pub fn action_device_path() -> Option<std::path::PathBuf> {
1100 linux::device_node()
1101}
1102
1103#[cfg(target_os = "macos")]
1107const VK_A: u16 = 0x00;
1108#[cfg(target_os = "macos")]
1109const VK_C: u16 = 0x08;
1110#[cfg(target_os = "macos")]
1111const VK_F: u16 = 0x03;
1112#[cfg(target_os = "macos")]
1113const VK_R: u16 = 0x0F;
1114#[cfg(target_os = "macos")]
1115const VK_S: u16 = 0x01;
1116#[cfg(target_os = "macos")]
1117const VK_T: u16 = 0x11;
1118#[cfg(target_os = "macos")]
1119const VK_V: u16 = 0x09;
1120#[cfg(target_os = "macos")]
1121const VK_W: u16 = 0x0D;
1122#[cfg(target_os = "macos")]
1123const VK_X: u16 = 0x07;
1124#[cfg(target_os = "macos")]
1125const VK_Z: u16 = 0x06;
1126#[cfg(target_os = "macos")]
1127const VK_TAB: u16 = 0x30;
1128
1129pub const SYNTHETIC_EVENT_USER_DATA: i64 = 0x4F4C_4749;
1136
1137#[cfg(target_os = "macos")]
1139mod macos {
1140 use core_graphics::event::{
1141 CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, EventField,
1142 ScrollEventUnit,
1143 };
1144 use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
1145 use core_graphics::geometry::CGPoint;
1146
1147 use crate::binding::Action;
1148
1149 pub(super) fn post_click(button: CGMouseButton) {
1157 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1158 tracing::warn!("CGEventSource::new failed for click");
1159 return;
1160 };
1161 let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
1164 let (down, up) = match button {
1165 CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp),
1166 CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp),
1167 CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp),
1168 };
1169 for (kind, phase) in [(down, "down"), (up, "up")] {
1170 if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) {
1171 ev.set_integer_value_field(
1175 EventField::EVENT_SOURCE_USER_DATA,
1176 super::SYNTHETIC_EVENT_USER_DATA,
1177 );
1178 ev.post(CGEventTapLocation::HID);
1179 } else {
1180 tracing::warn!(phase, "CGEvent::new_mouse_event failed");
1181 }
1182 }
1183 }
1184
1185 pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
1187 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1188 tracing::warn!("CGEventSource::new failed");
1189 return;
1190 };
1191 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1192 tracing::warn!("CGEvent::new_keyboard_event(down) failed");
1193 return;
1194 };
1195 down.set_flags(flags);
1196 down.post(CGEventTapLocation::HID);
1197 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1198 tracing::warn!("CGEvent::new_keyboard_event(up) failed");
1199 return;
1200 };
1201 up.set_flags(flags);
1202 up.post(CGEventTapLocation::HID);
1203 }
1204
1205 pub(super) fn post_media_key(kind: i32) {
1214 let nx_key: i64 = match kind {
1216 0 => 16,
1217 1 => 17,
1218 _ => 18,
1219 };
1220 tracing::debug!(
1221 nx_key,
1222 "media key event: NSSystemDefined stub — full AppKit impl tracked in P1.x"
1223 );
1224 }
1225
1226 pub(super) fn post_scroll(action: &Action) {
1228 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1229 tracing::warn!("CGEventSource::new failed for scroll");
1230 return;
1231 };
1232 let (v, h): (i32, i32) = match action {
1233 Action::ScrollUp => (3, 0),
1234 Action::ScrollDown => (-3, 0),
1235 Action::HorizontalScrollLeft => (0, -3),
1236 Action::HorizontalScrollRight => (0, 3),
1237 _ => return,
1238 };
1239 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
1240 tracing::warn!("CGEvent::new_scroll_event failed");
1241 return;
1242 };
1243 ev.post(CGEventTapLocation::HID);
1244 }
1245
1246 pub(super) fn post_horizontal_scroll(delta: i32) {
1249 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1250 tracing::warn!("CGEventSource::new failed for thumbwheel scroll");
1251 return;
1252 };
1253 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else {
1254 tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel");
1255 return;
1256 };
1257 ev.post(CGEventTapLocation::HID);
1258 }
1259
1260 pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop};
1261 pub(super) use symbolic_hotkey::{next_desktop, previous_desktop};
1262
1263 use app_services::symbol as app_services_symbol;
1264
1265 #[allow(
1268 unsafe_code,
1269 reason = "private ApplicationServices SPI symbols are resolved via dlopen/dlsym FFI"
1270 )]
1271 mod app_services {
1272 use std::ffi::{CStr, c_char, c_int, c_void};
1273 use std::sync::OnceLock;
1274
1275 pub(super) fn symbol(symbol: &CStr) -> Option<*mut c_void> {
1279 const RTLD_LAZY: c_int = 0x1;
1280 const APP_SERVICES: &CStr =
1281 c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices";
1282 static HANDLE: OnceLock<usize> = OnceLock::new();
1283
1284 let sym = unsafe {
1288 let handle =
1289 *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize);
1290 if handle == 0 {
1291 return None;
1292 }
1293 dlsym(handle as *mut c_void, symbol.as_ptr())
1294 };
1295 (!sym.is_null()).then_some(sym)
1296 }
1297
1298 unsafe extern "C" {
1299 fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
1300 fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
1301 }
1302 }
1303
1304 #[allow(
1317 unsafe_code,
1318 reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI"
1319 )]
1320 mod dock {
1321 use std::ffi::{c_int, c_void};
1322
1323 use core_foundation::base::TCFType;
1324 use core_foundation::string::CFString;
1325
1326 use super::app_services_symbol;
1327
1328 pub(crate) fn mission_control() {
1330 send("com.apple.expose.awake");
1331 }
1332
1333 pub(crate) fn app_expose() {
1335 send("com.apple.expose.front.awake");
1336 }
1337
1338 pub(crate) fn show_desktop() {
1340 send("com.apple.showdesktop.awake");
1341 }
1342
1343 pub(crate) fn launchpad() {
1345 send("com.apple.launchpad.toggle");
1346 }
1347
1348 fn send(notification: &str) {
1350 let Some(core_dock_send) = core_dock_send_notification() else {
1351 tracing::warn!(notification, "CoreDockSendNotification unavailable");
1352 return;
1353 };
1354 let name = CFString::new(notification);
1355 let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) };
1358 if err != 0 {
1359 tracing::warn!(notification, err, "CoreDockSendNotification failed");
1360 }
1361 }
1362
1363 type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int;
1364
1365 fn core_dock_send_notification() -> Option<CoreDockSendNotificationFn> {
1368 let sym = app_services_symbol(c"CoreDockSendNotification")?;
1369 Some(unsafe { std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym) })
1371 }
1372 }
1373
1374 #[allow(
1381 unsafe_code,
1382 reason = "CGS symbolic hotkey SPI is only reachable via dlopen/dlsym FFI"
1383 )]
1384 mod symbolic_hotkey {
1385 use std::ffi::{c_int, c_uint, c_ushort, c_void};
1386
1387 use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation};
1388 use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
1389
1390 use super::app_services_symbol;
1391
1392 const SPACE_LEFT: u32 = 79;
1393 const SPACE_RIGHT: u32 = 81;
1394
1395 pub(crate) fn previous_desktop() {
1397 post_symbolic_hotkey(SPACE_LEFT);
1398 }
1399
1400 pub(crate) fn next_desktop() {
1402 post_symbolic_hotkey(SPACE_RIGHT);
1403 }
1404
1405 fn post_symbolic_hotkey(hotkey: u32) {
1406 let Some(cgs) = cgs_hotkey_api() else {
1407 tracing::warn!(hotkey, "CGS symbolic hotkey API unavailable");
1408 return;
1409 };
1410
1411 let mut key_equivalent = 0_u16;
1412 let mut virtual_key = 0_u16;
1413 let mut modifiers = 0_u32;
1414
1415 let err = unsafe {
1418 (cgs.get_value)(
1419 hotkey,
1420 &raw mut key_equivalent,
1421 &raw mut virtual_key,
1422 &raw mut modifiers,
1423 )
1424 };
1425 if err != 0 {
1426 tracing::warn!(hotkey, err, "CGSGetSymbolicHotKeyValue failed");
1427 return;
1428 }
1429
1430 let was_enabled = unsafe { (cgs.is_enabled)(hotkey) };
1433 if !was_enabled {
1434 let err = unsafe { (cgs.set_enabled)(hotkey, true) };
1437 if err != 0 {
1438 tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(true) failed");
1439 }
1440 }
1441
1442 post_key(virtual_key, modifiers);
1443
1444 if !was_enabled {
1445 let err = unsafe { (cgs.set_enabled)(hotkey, false) };
1448 if err != 0 {
1449 tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(false) failed");
1450 }
1451 }
1452 }
1453
1454 fn post_key(vk: u16, modifiers: u32) {
1455 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1456 tracing::warn!("CGEventSource::new failed for symbolic hotkey");
1457 return;
1458 };
1459 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1460 tracing::warn!(vk, "CGEvent::new_keyboard_event(down) failed");
1461 return;
1462 };
1463 let flags = CGEventFlags::from_bits_truncate(u64::from(modifiers));
1464 down.set_flags(flags);
1465 down.post(CGEventTapLocation::Session);
1466
1467 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1468 tracing::warn!(vk, "CGEvent::new_keyboard_event(up) failed");
1469 return;
1470 };
1471 up.set_flags(flags);
1472 up.post(CGEventTapLocation::Session);
1473 }
1474
1475 #[derive(Clone, Copy)]
1476 struct CgsHotkeyApi {
1477 get_value: CgsGetSymbolicHotKeyValueFn,
1478 is_enabled: CgsIsSymbolicHotKeyEnabledFn,
1479 set_enabled: CgsSetSymbolicHotKeyEnabledFn,
1480 }
1481
1482 type CgsGetSymbolicHotKeyValueFn =
1483 unsafe extern "C" fn(c_uint, *mut c_ushort, *mut c_ushort, *mut c_uint) -> c_int;
1484 type CgsIsSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint) -> bool;
1485 type CgsSetSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint, bool) -> c_int;
1486
1487 fn cgs_hotkey_api() -> Option<CgsHotkeyApi> {
1488 let get_value = app_services_symbol(c"CGSGetSymbolicHotKeyValue")?;
1489 let is_enabled = app_services_symbol(c"CGSIsSymbolicHotKeyEnabled")?;
1490 let set_enabled = app_services_symbol(c"CGSSetSymbolicHotKeyEnabled")?;
1491
1492 Some(unsafe {
1495 CgsHotkeyApi {
1496 get_value: std::mem::transmute::<*mut c_void, CgsGetSymbolicHotKeyValueFn>(
1497 get_value,
1498 ),
1499 is_enabled: std::mem::transmute::<*mut c_void, CgsIsSymbolicHotKeyEnabledFn>(
1500 is_enabled,
1501 ),
1502 set_enabled: std::mem::transmute::<*mut c_void, CgsSetSymbolicHotKeyEnabledFn>(
1503 set_enabled,
1504 ),
1505 }
1506 })
1507 }
1508 }
1509}
1510
1511#[must_use]
1525pub fn default_binding(button: ButtonId) -> Action {
1526 match button {
1527 ButtonId::LeftClick => Action::LeftClick,
1528 ButtonId::RightClick => Action::RightClick,
1529 ButtonId::MiddleClick => Action::MiddleClick,
1530 ButtonId::Back => Action::BrowserBack,
1531 ButtonId::Forward => Action::BrowserForward,
1532 ButtonId::DpiToggle => Action::CycleDpiPresets,
1533 ButtonId::Thumbwheel => Action::AppExpose,
1534 ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight,
1540 ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft,
1541 ButtonId::GestureButton => Action::MissionControl,
1542 }
1543}
1544
1545#[must_use]
1549pub fn default_gesture_binding(direction: GestureDirection) -> Action {
1550 match direction {
1551 GestureDirection::Up => Action::MissionControl,
1552 GestureDirection::Down => Action::ShowDesktop,
1553 GestureDirection::Left => Action::PrevTab,
1554 GestureDirection::Right => Action::NextTab,
1555 GestureDirection::Click => Action::AppExpose,
1556 }
1557}
1558
1559#[must_use]
1573pub fn default_binding_for(button: ButtonId) -> Binding {
1574 match button {
1575 ButtonId::GestureButton => Binding::Gesture(
1576 GestureDirection::ALL
1577 .into_iter()
1578 .map(|d| (d, default_gesture_binding(d)))
1579 .collect(),
1580 ),
1581 other => Binding::Single(default_binding(other)),
1582 }
1583}
1584
1585#[cfg(target_os = "linux")]
1592mod linux {
1593 use std::io;
1594 use std::sync::{LazyLock, Mutex};
1595
1596 use evdev::uinput::VirtualDevice;
1597 use evdev::{AttributeSet, EventType, InputEvent, KeyCode, RelativeAxisCode};
1598 use zbus::blocking::Connection as DbusConn;
1599
1600 const DEVICE_NAME: &str = "OpenLogi action injector";
1601
1602 static VIRTUAL_INPUT: LazyLock<Option<Mutex<VirtualDevice>>> = LazyLock::new(|| {
1603 build()
1604 .map(Mutex::new)
1605 .map_err(|e| tracing::warn!("failed to create uinput action device: {e}"))
1606 .ok()
1607 });
1608
1609 #[rustfmt::skip]
1610 const KEY_CAPABILITIES: &[KeyCode] = &[
1611 KeyCode::KEY_A, KeyCode::KEY_B, KeyCode::KEY_C, KeyCode::KEY_D,
1613 KeyCode::KEY_E, KeyCode::KEY_F, KeyCode::KEY_G, KeyCode::KEY_H,
1614 KeyCode::KEY_I, KeyCode::KEY_J, KeyCode::KEY_K, KeyCode::KEY_L,
1615 KeyCode::KEY_M, KeyCode::KEY_N, KeyCode::KEY_O, KeyCode::KEY_P,
1616 KeyCode::KEY_Q, KeyCode::KEY_R, KeyCode::KEY_S, KeyCode::KEY_T,
1617 KeyCode::KEY_U, KeyCode::KEY_V, KeyCode::KEY_W, KeyCode::KEY_X,
1618 KeyCode::KEY_Y, KeyCode::KEY_Z,
1619 KeyCode::KEY_0, KeyCode::KEY_1, KeyCode::KEY_2, KeyCode::KEY_3,
1621 KeyCode::KEY_4, KeyCode::KEY_5, KeyCode::KEY_6, KeyCode::KEY_7,
1622 KeyCode::KEY_8, KeyCode::KEY_9,
1623 KeyCode::KEY_MINUS, KeyCode::KEY_EQUAL, KeyCode::KEY_LEFTBRACE,
1625 KeyCode::KEY_RIGHTBRACE, KeyCode::KEY_BACKSLASH, KeyCode::KEY_SEMICOLON,
1626 KeyCode::KEY_APOSTROPHE, KeyCode::KEY_GRAVE, KeyCode::KEY_COMMA,
1627 KeyCode::KEY_DOT, KeyCode::KEY_SLASH,
1628 KeyCode::KEY_LEFT, KeyCode::KEY_RIGHT, KeyCode::KEY_UP, KeyCode::KEY_DOWN,
1630 KeyCode::KEY_HOME, KeyCode::KEY_END, KeyCode::KEY_PAGEUP, KeyCode::KEY_PAGEDOWN,
1631 KeyCode::KEY_TAB, KeyCode::KEY_ENTER, KeyCode::KEY_BACKSPACE, KeyCode::KEY_DELETE,
1632 KeyCode::KEY_ESC, KeyCode::KEY_SPACE,
1633 KeyCode::KEY_LEFTCTRL, KeyCode::KEY_LEFTSHIFT, KeyCode::KEY_LEFTALT, KeyCode::KEY_LEFTMETA,
1635 KeyCode::KEY_F1, KeyCode::KEY_F2, KeyCode::KEY_F3, KeyCode::KEY_F4,
1637 KeyCode::KEY_F5, KeyCode::KEY_F6, KeyCode::KEY_F7, KeyCode::KEY_F8,
1638 KeyCode::KEY_F9, KeyCode::KEY_F10, KeyCode::KEY_F11, KeyCode::KEY_F12,
1639 KeyCode::KEY_SYSRQ,
1641 KeyCode::KEY_PLAYPAUSE, KeyCode::KEY_NEXTSONG, KeyCode::KEY_PREVIOUSSONG,
1643 KeyCode::KEY_VOLUMEUP, KeyCode::KEY_VOLUMEDOWN, KeyCode::KEY_MUTE,
1644 KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE,
1646 ];
1647
1648 fn build() -> io::Result<VirtualDevice> {
1649 let mut keys = AttributeSet::<KeyCode>::default();
1650 for &k in KEY_CAPABILITIES {
1651 keys.insert(k);
1652 }
1653
1654 let mut axes = AttributeSet::<RelativeAxisCode>::default();
1659 for a in [RelativeAxisCode::REL_WHEEL, RelativeAxisCode::REL_HWHEEL] {
1660 axes.insert(a);
1661 }
1662
1663 VirtualDevice::builder()?
1664 .name(DEVICE_NAME)
1665 .with_keys(&keys)?
1666 .with_relative_axes(&axes)?
1667 .build()
1668 }
1669
1670 fn emit(events: &[InputEvent]) {
1671 if let Some(m) = &*VIRTUAL_INPUT {
1672 if let Ok(mut guard) = m.lock() {
1673 if let Err(e) = guard.emit(events) {
1674 tracing::warn!("uinput action emit failed: {e}");
1675 }
1676 } else {
1677 tracing::warn!("uinput action device mutex poisoned");
1678 }
1679 } else {
1680 tracing::debug!("uinput action device unavailable — action skipped");
1682 }
1683 }
1684
1685 fn syn() -> InputEvent {
1686 InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0)
1687 }
1688
1689 fn key_ev(code: KeyCode, value: i32) -> InputEvent {
1690 InputEvent::new(EventType::KEY.0, code.0, value)
1691 }
1692
1693 fn rel_ev(axis: RelativeAxisCode, value: i32) -> InputEvent {
1694 InputEvent::new(EventType::RELATIVE.0, axis.0, value)
1695 }
1696
1697 pub(super) fn press_key(mods: &[KeyCode], key: KeyCode) {
1704 let mut down: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1706 for &m in mods {
1707 down.push(key_ev(m, 1));
1708 }
1709 down.push(key_ev(key, 1));
1710 down.push(syn());
1711 emit(&down);
1712
1713 let mut up: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1715 up.push(key_ev(key, 0));
1716 for &m in mods.iter().rev() {
1717 up.push(key_ev(m, 0));
1718 }
1719 up.push(syn());
1720 emit(&up);
1721 }
1722
1723 pub(super) fn click(button: KeyCode) {
1725 emit(&[key_ev(button, 1), syn()]);
1726 emit(&[key_ev(button, 0), syn()]);
1727 }
1728
1729 pub(super) fn scroll(axis: RelativeAxisCode, value: i32) {
1731 emit(&[rel_ev(axis, value), syn()]);
1732 }
1733
1734 pub(super) fn device_node() -> Option<std::path::PathBuf> {
1742 let _ = &*VIRTUAL_INPUT;
1744 std::thread::sleep(std::time::Duration::from_millis(150));
1746 if let Some(m) = &*VIRTUAL_INPUT {
1747 if let Ok(mut guard) = m.lock() {
1748 return guard.enumerate_dev_nodes_blocking().ok()?.flatten().next();
1749 }
1750 }
1751 None
1752 }
1753
1754 pub(super) fn modifiers_to_keycodes(modifiers: u8) -> Vec<KeyCode> {
1760 use crate::binding::KeyCombo;
1761 let mut mods = Vec::new();
1762 if modifiers & (KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL) != 0 {
1763 mods.push(KeyCode::KEY_LEFTCTRL);
1764 }
1765 if modifiers & KeyCombo::MOD_SHIFT != 0 {
1766 mods.push(KeyCode::KEY_LEFTSHIFT);
1767 }
1768 if modifiers & KeyCombo::MOD_OPTION != 0 {
1769 mods.push(KeyCode::KEY_LEFTALT);
1770 }
1771 mods
1772 }
1773
1774 pub(super) fn macos_vk_to_linux(vk: u16) -> Option<KeyCode> {
1780 Some(match vk {
1781 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,
1855 })
1856 }
1857
1858 static SESSION_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
1861 DbusConn::session()
1862 .map_err(|e| tracing::warn!("D-Bus session bus unavailable: {e}"))
1863 .ok()
1864 });
1865
1866 static SYSTEM_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
1867 DbusConn::system()
1868 .map_err(|e| tracing::warn!("D-Bus system bus unavailable: {e}"))
1869 .ok()
1870 });
1871
1872 pub(super) fn lock_screen() {
1880 if let (Some(conn), Ok(id)) = (SYSTEM_BUS.as_ref(), std::env::var("XDG_SESSION_ID")) {
1881 match conn.call_method(
1882 Some("org.freedesktop.login1"),
1883 "/org/freedesktop/login1",
1884 Some("org.freedesktop.login1.Manager"),
1885 "LockSession",
1886 &(id.as_str(),),
1887 ) {
1888 Ok(_) => {
1889 tracing::debug!("LockScreen via logind");
1890 return;
1891 }
1892 Err(e) => tracing::warn!("logind LockSession failed: {e}"),
1893 }
1894 }
1895 tracing::debug!("LockScreen via Super+L key combo");
1897 press_key(&[KeyCode::KEY_LEFTMETA], KeyCode::KEY_L);
1898 }
1899
1900 pub(super) fn mpris_command(command: &str) {
1906 if try_mpris_command(command).is_none() {
1907 let fallback = match command {
1908 "PlayPause" => KeyCode::KEY_PLAYPAUSE,
1909 "Next" => KeyCode::KEY_NEXTSONG,
1910 "Previous" => KeyCode::KEY_PREVIOUSSONG,
1911 _ => return,
1912 };
1913 press_key(&[], fallback);
1914 }
1915 }
1916
1917 fn try_mpris_command(command: &str) -> Option<()> {
1918 let conn = SESSION_BUS.as_ref()?;
1919 let reply = conn
1920 .call_method(
1921 Some("org.freedesktop.DBus"),
1922 "/org/freedesktop/DBus",
1923 Some("org.freedesktop.DBus"),
1924 "ListNames",
1925 &(),
1926 )
1927 .ok()?;
1928 let names = reply.body().deserialize::<Vec<String>>().ok()?;
1929 let Some(player) = names
1930 .iter()
1931 .find(|n| n.starts_with("org.mpris.MediaPlayer2."))
1932 else {
1933 tracing::debug!("no MPRIS player found — {command} via XF86 key fallback");
1934 return None;
1935 };
1936 match conn.call_method(
1937 Some(player.as_str()),
1938 "/org/mpris/MediaPlayer2",
1939 Some("org.mpris.MediaPlayer2.Player"),
1940 command,
1941 &(),
1942 ) {
1943 Ok(_) => {
1944 tracing::debug!("MPRIS {command} via {player}");
1945 Some(())
1946 }
1947 Err(e) => {
1948 tracing::warn!("MPRIS {command} on {player} failed: {e}");
1951 Some(())
1952 }
1953 }
1954 }
1955}
1956
1957#[cfg(test)]
1958#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
1959mod tests {
1960 use std::collections::BTreeMap;
1961
1962 use serde::{Deserialize, Serialize};
1963
1964 use super::*;
1965
1966 #[derive(Serialize, Deserialize)]
1971 struct RoundtripWrapper {
1972 binding: BTreeMap<ButtonId, Action>,
1973 }
1974
1975 #[test]
1978 fn catalog_has_at_least_29_entries() {
1979 let catalog = Action::catalog();
1980 assert!(
1981 catalog.len() >= 29,
1982 "catalog has {} entries, need ≥ 29",
1983 catalog.len()
1984 );
1985 }
1986
1987 #[test]
1988 fn catalog_excludes_custom_shortcut() {
1989 let catalog = Action::catalog();
1990 for action in &catalog {
1991 assert!(
1992 !matches!(action, Action::CustomShortcut(_)),
1993 "catalog must not contain CustomShortcut"
1994 );
1995 }
1996 }
1997
1998 #[derive(Serialize, Deserialize)]
2003 struct BindingWrapper {
2004 bindings: BTreeMap<ButtonId, Binding>,
2005 }
2006
2007 fn binding_roundtrip(bindings: BTreeMap<ButtonId, Binding>) -> BTreeMap<ButtonId, Binding> {
2008 let toml = toml::to_string_pretty(&BindingWrapper { bindings }).expect("serialize");
2009 toml::from_str::<BindingWrapper>(&toml)
2010 .expect("deserialize")
2011 .bindings
2012 }
2013
2014 #[test]
2015 fn binding_single_roundtrips_including_payload_variants() {
2016 let mut bindings = BTreeMap::new();
2017 bindings.insert(ButtonId::Back, Binding::Single(Action::BrowserBack));
2018 bindings.insert(
2019 ButtonId::DpiToggle,
2020 Binding::Single(Action::SetDpiPreset(2)),
2021 );
2022 bindings.insert(
2023 ButtonId::Forward,
2024 Binding::Single(Action::CustomShortcut(KeyCombo {
2025 modifiers: KeyCombo::MOD_CMD,
2026 key_code: 0x23,
2027 display: "⌘P".into(),
2028 })),
2029 );
2030 let back = binding_roundtrip(bindings);
2031 assert_eq!(back[&ButtonId::Back], Binding::Single(Action::BrowserBack));
2032 assert_eq!(
2033 back[&ButtonId::DpiToggle],
2034 Binding::Single(Action::SetDpiPreset(2))
2035 );
2036 assert!(matches!(
2037 back[&ButtonId::Forward],
2038 Binding::Single(Action::CustomShortcut(_))
2039 ));
2040 }
2041
2042 #[test]
2043 fn binding_gesture_roundtrips() {
2044 let mut map = BTreeMap::new();
2045 map.insert(GestureDirection::Up, Action::Copy);
2046 map.insert(GestureDirection::Click, Action::Paste);
2047 let mut bindings = BTreeMap::new();
2048 bindings.insert(ButtonId::GestureButton, Binding::Gesture(map.clone()));
2049 let back = binding_roundtrip(bindings);
2050 assert_eq!(back[&ButtonId::GestureButton], Binding::Gesture(map));
2051 }
2052
2053 #[test]
2059 fn binding_direction_keyed_table_routes_to_gesture() {
2060 for dir in GestureDirection::ALL {
2061 let toml = format!("bindings.GestureButton.{dir} = \"None\"");
2063 let parsed = toml::from_str::<BindingWrapper>(&toml).expect("deserialize");
2064 assert!(
2065 matches!(
2066 parsed.bindings[&ButtonId::GestureButton],
2067 Binding::Gesture(_)
2068 ),
2069 "a {dir}-keyed table must route to Gesture, not Single"
2070 );
2071 }
2072 }
2073
2074 #[test]
2078 fn binding_payload_action_stays_single() {
2079 let toml = "bindings.DpiToggle.SetDpiPreset = 2";
2080 let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
2081 assert_eq!(
2082 parsed.bindings[&ButtonId::DpiToggle],
2083 Binding::Single(Action::SetDpiPreset(2))
2084 );
2085 }
2086
2087 #[test]
2090 fn detect_swipe_below_threshold_keeps_accumulating() {
2091 assert_eq!(detect_swipe(40, 5), None);
2093 assert_eq!(detect_swipe(0, 0), None);
2094 }
2095
2096 #[test]
2097 fn detect_swipe_commits_clean_direction() {
2098 assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
2099 assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
2100 assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
2101 assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
2102 }
2103
2104 #[test]
2105 fn detect_swipe_rejects_diagonal() {
2106 assert_eq!(detect_swipe(60, 60), None);
2108 assert_eq!(detect_swipe(-60, -60), None);
2109 }
2110
2111 #[test]
2112 fn detect_swipe_threshold_and_cross_band_boundaries() {
2113 assert_eq!(
2116 detect_swipe(GESTURE_SWIPE_THRESHOLD, 0),
2117 Some(GestureDirection::Right)
2118 );
2119 assert_eq!(detect_swipe(GESTURE_SWIPE_THRESHOLD - 1, 0), None);
2120
2121 assert_eq!(detect_swipe(200, 69), Some(GestureDirection::Right));
2124 assert_eq!(detect_swipe(200, 71), None);
2125 assert_eq!(detect_swipe(100, 39), Some(GestureDirection::Right));
2127 assert_eq!(detect_swipe(100, 41), None);
2128 }
2129
2130 #[test]
2131 fn detect_swipe_does_not_panic_on_extreme_values() {
2132 assert_eq!(detect_swipe(i32::MAX, 0), Some(GestureDirection::Right));
2135 assert_eq!(detect_swipe(i32::MIN, 0), Some(GestureDirection::Left));
2136 assert_eq!(detect_swipe(0, i32::MAX), Some(GestureDirection::Down));
2137 assert_eq!(detect_swipe(0, i32::MIN), Some(GestureDirection::Up));
2138 assert_eq!(detect_swipe(i32::MIN, i32::MIN), None);
2140 }
2141
2142 #[test]
2145 fn accumulator_commits_a_direction_once_after_the_hold_gate() {
2146 let mut acc = SwipeAccumulator::default();
2147 acc.begin();
2148 acc.backdate_hold_for_test();
2149 assert_eq!(
2151 acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
2152 Some(GestureDirection::Right)
2153 );
2154 assert_eq!(acc.accumulate(50, 0), None);
2156 }
2157
2158 #[test]
2159 fn accumulator_does_not_commit_before_the_hold_gate() {
2160 let mut acc = SwipeAccumulator::default();
2161 acc.begin(); assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
2165 acc.backdate_hold_for_test();
2167 assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0).is_some());
2168 }
2169
2170 #[test]
2171 fn accumulator_end_reports_click_only_when_no_swipe_fired() {
2172 let mut acc = SwipeAccumulator::default();
2174 acc.begin();
2175 acc.backdate_hold_for_test();
2176 assert_eq!(acc.accumulate(2, -1), None);
2177 assert!(acc.end(), "a hold that never swiped is a click");
2178
2179 acc.begin();
2181 acc.backdate_hold_for_test();
2182 assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0).is_some());
2183 assert!(!acc.end(), "a committed swipe must not also click");
2184 }
2185
2186 #[test]
2187 fn accumulator_ignores_motion_when_not_holding() {
2188 let mut acc = SwipeAccumulator::default();
2189 assert!(!acc.is_holding());
2190 assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
2192 }
2193
2194 #[test]
2195 fn accumulator_sums_sub_threshold_deltas_until_they_commit() {
2196 let mut acc = SwipeAccumulator::default();
2200 acc.begin();
2201 acc.backdate_hold_for_test();
2202 let step = GESTURE_SWIPE_THRESHOLD / 2 - 1;
2204 assert_eq!(acc.accumulate(step, 0), None, "one step is sub-threshold");
2205 assert_eq!(acc.accumulate(step, 0), None, "two steps still under");
2206 assert_eq!(
2207 acc.accumulate(step, 0),
2208 Some(GestureDirection::Right),
2209 "the running sum finally crosses the threshold"
2210 );
2211 }
2212
2213 #[test]
2214 fn accumulator_saturates_instead_of_overflowing() {
2215 let mut acc = SwipeAccumulator::default();
2220 acc.begin();
2221 acc.backdate_hold_for_test();
2222 assert_eq!(
2223 acc.accumulate(i32::MAX, i32::MAX),
2224 None,
2225 "a diagonal never commits"
2226 );
2227 assert_eq!(
2228 acc.accumulate(i32::MAX, i32::MAX),
2229 None,
2230 "the saturating sum must not panic"
2231 );
2232 acc.begin();
2234 acc.backdate_hold_for_test();
2235 assert_eq!(acc.accumulate(i32::MAX, 0), Some(GestureDirection::Right));
2236 }
2237
2238 #[test]
2239 fn accumulator_begin_recovers_a_stale_hold() {
2240 let mut acc = SwipeAccumulator::default();
2245 acc.begin();
2246 acc.backdate_hold_for_test();
2247 assert_eq!(
2249 acc.accumulate(-(GESTURE_SWIPE_THRESHOLD + 10), 0),
2250 Some(GestureDirection::Left)
2251 );
2252 acc.begin();
2254 acc.backdate_hold_for_test();
2255 assert_eq!(
2258 acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
2259 Some(GestureDirection::Right)
2260 );
2261 }
2262
2263 #[test]
2264 fn accumulator_end_without_a_hold_is_not_a_click() {
2265 let mut acc = SwipeAccumulator::default();
2268 assert!(!acc.end(), "a release with no hold is not a click");
2269 acc.begin();
2271 assert!(acc.end(), "the held release is a click");
2272 assert!(!acc.end(), "the redundant second release is not a click");
2273 }
2274
2275 fn roundtrip(action: &Action) -> Action {
2280 let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
2281 map.insert(ButtonId::Back, action.clone());
2282 let w = RoundtripWrapper { binding: map };
2283 let s = toml::to_string(&w).expect("serialize");
2284 let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
2285 back.binding
2286 .into_values()
2287 .next()
2288 .expect("binding present after roundtrip")
2289 }
2290
2291 #[test]
2292 fn all_catalog_variants_roundtrip_toml() {
2293 for action in Action::catalog() {
2294 let back = roundtrip(&action);
2295 assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
2296 }
2297 }
2298
2299 #[test]
2300 fn custom_shortcut_roundtrips_toml() {
2301 let action = Action::CustomShortcut(KeyCombo {
2302 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
2303 key_code: 0x23, display: "⌘⇧P".into(),
2305 });
2306 assert_eq!(roundtrip(&action), action);
2307 }
2308
2309 #[test]
2310 fn key_combo_rendered_label_uses_display_when_set() {
2311 let combo = KeyCombo {
2312 modifiers: 0,
2313 key_code: 0,
2314 display: "preset".into(),
2315 };
2316 assert_eq!(combo.rendered_label(), "preset");
2317 }
2318
2319 #[test]
2320 fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
2321 let combo = KeyCombo {
2322 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
2323 key_code: 0x23, display: String::new(),
2325 };
2326 assert_eq!(combo.rendered_label(), "⇧⌘P");
2327 }
2328
2329 #[test]
2332 fn category_editing_variants() {
2333 assert_eq!(Action::Copy.category(), Category::Editing);
2334 assert_eq!(Action::Undo.category(), Category::Editing);
2335 assert_eq!(Action::SelectAll.category(), Category::Editing);
2336 assert_eq!(Action::Find.category(), Category::Editing);
2337 assert_eq!(Action::Save.category(), Category::Editing);
2338 assert_eq!(Action::Cut.category(), Category::Editing);
2339 assert_eq!(Action::Redo.category(), Category::Editing);
2340 assert_eq!(Action::Paste.category(), Category::Editing);
2341 }
2342
2343 #[test]
2344 fn category_browser_variants() {
2345 assert_eq!(Action::BrowserBack.category(), Category::Browser);
2346 assert_eq!(Action::BrowserForward.category(), Category::Browser);
2347 assert_eq!(Action::NewTab.category(), Category::Browser);
2348 assert_eq!(Action::CloseTab.category(), Category::Browser);
2349 assert_eq!(Action::ReopenTab.category(), Category::Browser);
2350 assert_eq!(Action::NextTab.category(), Category::Browser);
2351 assert_eq!(Action::PrevTab.category(), Category::Browser);
2352 assert_eq!(Action::ReloadPage.category(), Category::Browser);
2353 }
2354
2355 #[test]
2356 fn category_media_variants() {
2357 assert_eq!(Action::PlayPause.category(), Category::Media);
2358 assert_eq!(Action::NextTrack.category(), Category::Media);
2359 assert_eq!(Action::PrevTrack.category(), Category::Media);
2360 assert_eq!(Action::VolumeUp.category(), Category::Media);
2361 assert_eq!(Action::VolumeDown.category(), Category::Media);
2362 assert_eq!(Action::MuteVolume.category(), Category::Media);
2363 }
2364
2365 #[test]
2366 fn category_mouse_variants() {
2367 assert_eq!(Action::LeftClick.category(), Category::Mouse);
2368 assert_eq!(Action::RightClick.category(), Category::Mouse);
2369 assert_eq!(Action::MiddleClick.category(), Category::Mouse);
2370 }
2371
2372 #[test]
2373 fn category_dpi_variants() {
2374 assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
2375 assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
2376 }
2377
2378 #[test]
2379 fn category_scroll_variants() {
2380 assert_eq!(Action::ScrollUp.category(), Category::Scroll);
2381 assert_eq!(Action::ScrollDown.category(), Category::Scroll);
2382 assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
2383 assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
2384 }
2385
2386 #[test]
2387 fn category_navigation_variants() {
2388 assert_eq!(Action::MissionControl.category(), Category::Navigation);
2389 assert_eq!(Action::AppExpose.category(), Category::Navigation);
2390 assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
2391 assert_eq!(Action::NextDesktop.category(), Category::Navigation);
2392 assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
2393 assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
2394 }
2395
2396 #[test]
2397 fn category_system_variants() {
2398 assert_eq!(Action::LockScreen.category(), Category::System);
2399 assert_eq!(Action::Screenshot.category(), Category::System);
2400 }
2401
2402 #[test]
2405 fn category_labels_are_nonempty() {
2406 let categories = [
2407 Category::Editing,
2408 Category::Browser,
2409 Category::Media,
2410 Category::Mouse,
2411 Category::Dpi,
2412 Category::Scroll,
2413 Category::Navigation,
2414 Category::System,
2415 ];
2416 for cat in categories {
2417 assert!(!cat.label().is_empty(), "label empty for {cat:?}");
2418 }
2419 }
2420
2421 #[test]
2424 fn dpi_toggle_default_is_cycle_dpi_presets() {
2425 assert_eq!(
2426 default_binding(ButtonId::DpiToggle),
2427 Action::CycleDpiPresets
2428 );
2429 }
2430
2431 #[cfg(target_os = "linux")]
2434 mod modifier_mapping {
2435 use evdev::KeyCode;
2436
2437 use crate::binding::{KeyCombo, linux::modifiers_to_keycodes};
2438
2439 #[test]
2440 fn mod_cmd_alone_maps_to_ctrl() {
2441 assert_eq!(
2442 modifiers_to_keycodes(KeyCombo::MOD_CMD),
2443 vec![KeyCode::KEY_LEFTCTRL]
2444 );
2445 }
2446
2447 #[test]
2448 fn mod_ctrl_alone_maps_to_ctrl() {
2449 assert_eq!(
2450 modifiers_to_keycodes(KeyCombo::MOD_CTRL),
2451 vec![KeyCode::KEY_LEFTCTRL]
2452 );
2453 }
2454
2455 #[test]
2456 fn mod_cmd_and_ctrl_together_produce_single_ctrl() {
2457 assert_eq!(
2459 modifiers_to_keycodes(KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL),
2460 vec![KeyCode::KEY_LEFTCTRL]
2461 );
2462 }
2463
2464 #[test]
2465 fn all_modifiers_produce_canonical_order() {
2466 let mods = modifiers_to_keycodes(
2467 KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT | KeyCombo::MOD_OPTION,
2468 );
2469 assert_eq!(
2470 mods,
2471 vec![
2472 KeyCode::KEY_LEFTCTRL,
2473 KeyCode::KEY_LEFTSHIFT,
2474 KeyCode::KEY_LEFTALT
2475 ]
2476 );
2477 }
2478
2479 #[test]
2480 fn no_modifiers_produces_empty_vec() {
2481 assert!(modifiers_to_keycodes(0).is_empty());
2482 }
2483 }
2484
2485 #[cfg(target_os = "linux")]
2488 mod vk_mapping {
2489 use evdev::KeyCode;
2490
2491 use crate::binding::linux::macos_vk_to_linux;
2492
2493 #[test]
2494 fn common_letters_map_correctly() {
2495 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)); }
2502
2503 #[test]
2504 fn digits_map_correctly() {
2505 assert_eq!(macos_vk_to_linux(0x12), Some(KeyCode::KEY_1)); assert_eq!(macos_vk_to_linux(0x1D), Some(KeyCode::KEY_0)); }
2508
2509 #[test]
2510 fn arrow_keys_map_correctly() {
2511 assert_eq!(macos_vk_to_linux(0x7B), Some(KeyCode::KEY_LEFT));
2512 assert_eq!(macos_vk_to_linux(0x7C), Some(KeyCode::KEY_RIGHT));
2513 assert_eq!(macos_vk_to_linux(0x7D), Some(KeyCode::KEY_DOWN));
2514 assert_eq!(macos_vk_to_linux(0x7E), Some(KeyCode::KEY_UP));
2515 }
2516
2517 #[test]
2518 fn function_keys_map_correctly() {
2519 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)); }
2525
2526 #[test]
2527 fn nav_keys_map_correctly() {
2528 assert_eq!(macos_vk_to_linux(0x73), Some(KeyCode::KEY_HOME));
2529 assert_eq!(macos_vk_to_linux(0x77), Some(KeyCode::KEY_END));
2530 assert_eq!(macos_vk_to_linux(0x74), Some(KeyCode::KEY_PAGEUP));
2531 assert_eq!(macos_vk_to_linux(0x79), Some(KeyCode::KEY_PAGEDOWN));
2532 assert_eq!(macos_vk_to_linux(0x75), Some(KeyCode::KEY_DELETE));
2533 }
2534
2535 #[test]
2536 fn brackets_follow_ansi_layout() {
2537 assert_eq!(macos_vk_to_linux(0x21), Some(KeyCode::KEY_LEFTBRACE));
2539 assert_eq!(macos_vk_to_linux(0x1E), Some(KeyCode::KEY_RIGHTBRACE));
2540 }
2541
2542 #[test]
2543 fn unmapped_code_returns_none() {
2544 assert_eq!(macos_vk_to_linux(0xFF), None);
2545 assert_eq!(macos_vk_to_linux(0x34), None); }
2547 }
2548}