1use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
17pub enum ButtonId {
18 LeftClick,
19 RightClick,
20 MiddleClick,
21 Back,
22 Forward,
23 DpiToggle,
26 Thumbwheel,
29 GestureButton,
32}
33
34impl ButtonId {
35 pub const ALL: [ButtonId; 8] = [
36 ButtonId::LeftClick,
37 ButtonId::RightClick,
38 ButtonId::MiddleClick,
39 ButtonId::Back,
40 ButtonId::Forward,
41 ButtonId::DpiToggle,
42 ButtonId::Thumbwheel,
43 ButtonId::GestureButton,
44 ];
45
46 #[must_use]
48 pub fn label(self) -> &'static str {
49 match self {
50 ButtonId::LeftClick => "Left Click",
51 ButtonId::RightClick => "Right Click",
52 ButtonId::MiddleClick => "Middle Click",
53 ButtonId::Back => "Back",
54 ButtonId::Forward => "Forward",
55 ButtonId::DpiToggle => "DPI Toggle",
56 ButtonId::Thumbwheel => "Thumb Wheel",
57 ButtonId::GestureButton => "Gesture Button",
58 }
59 }
60}
61
62impl fmt::Display for ButtonId {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 f.write_str(self.label())
65 }
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
76pub enum GestureDirection {
77 Up,
78 Down,
79 Left,
80 Right,
81 Click,
82}
83
84impl GestureDirection {
85 pub const ALL: [GestureDirection; 5] = [
86 GestureDirection::Up,
87 GestureDirection::Down,
88 GestureDirection::Left,
89 GestureDirection::Right,
90 GestureDirection::Click,
91 ];
92
93 #[must_use]
94 pub fn label(self) -> &'static str {
95 match self {
96 GestureDirection::Up => "Up",
97 GestureDirection::Down => "Down",
98 GestureDirection::Left => "Left",
99 GestureDirection::Right => "Right",
100 GestureDirection::Click => "Click",
101 }
102 }
103
104 #[must_use]
106 pub fn glyph(self) -> &'static str {
107 match self {
108 GestureDirection::Up => "↑",
109 GestureDirection::Down => "↓",
110 GestureDirection::Left => "←",
111 GestureDirection::Right => "→",
112 GestureDirection::Click => "·",
113 }
114 }
115}
116
117impl fmt::Display for GestureDirection {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 f.write_str(self.label())
120 }
121}
122
123pub const GESTURE_SWIPE_THRESHOLD: i32 = 50;
126pub const GESTURE_SWIPE_DEADZONE: i32 = 40;
129
130#[must_use]
143pub fn detect_swipe(dx: i32, dy: i32) -> Option<GestureDirection> {
144 let (abs_x, abs_y) = (dx.abs(), dy.abs());
145 let dominant = abs_x.max(abs_y);
146 if dominant < GESTURE_SWIPE_THRESHOLD {
147 return None;
148 }
149 let cross_limit = GESTURE_SWIPE_DEADZONE.max(dominant * 35 / 100);
150 if abs_x > abs_y {
151 if abs_y > cross_limit {
152 return None;
153 }
154 Some(if dx > 0 {
155 GestureDirection::Right
156 } else {
157 GestureDirection::Left
158 })
159 } else {
160 if abs_x > cross_limit {
161 return None;
162 }
163 Some(if dy > 0 {
164 GestureDirection::Down
165 } else {
166 GestureDirection::Up
167 })
168 }
169}
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
176pub enum Category {
177 Editing,
179 Browser,
181 Media,
183 Mouse,
185 Dpi,
187 Scroll,
189 Navigation,
191 System,
193}
194
195impl Category {
196 #[must_use]
199 pub fn label(self) -> &'static str {
200 match self {
201 Category::Editing => "EDITING",
202 Category::Browser => "BROWSER",
203 Category::Media => "MEDIA",
204 Category::Mouse => "MOUSE",
205 Category::Dpi => "DPI",
206 Category::Scroll => "SCROLL",
207 Category::Navigation => "NAVIGATION",
208 Category::System => "SYSTEM",
209 }
210 }
211}
212
213#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
235pub enum Action {
236 LeftClick,
239 RightClick,
241 MiddleClick,
243
244 Copy,
247 Paste,
249 Cut,
251 Undo,
253 Redo,
255 SelectAll,
257 Find,
259 Save,
261
262 BrowserBack,
265 BrowserForward,
267 NewTab,
269 CloseTab,
271 ReopenTab,
273 NextTab,
275 PrevTab,
277 ReloadPage,
279
280 MissionControl,
283 AppExpose,
285 ShowDesktop,
287 LaunchpadShow,
289
290 LockScreen,
293 Screenshot,
295
296 PlayPause,
299 NextTrack,
301 PrevTrack,
303 VolumeUp,
305 VolumeDown,
307 MuteVolume,
309
310 CycleDpiPresets,
313 SetDpiPreset(u8),
316 ToggleSmartShift,
318
319 ScrollUp,
322 ScrollDown,
324 HorizontalScrollLeft,
326 HorizontalScrollRight,
328
329 CustomShortcut(KeyCombo),
337}
338
339#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
351pub struct KeyCombo {
352 pub modifiers: u8,
354 pub key_code: u16,
357 #[serde(default)]
360 pub display: String,
361}
362
363impl KeyCombo {
364 pub const MOD_CMD: u8 = 1 << 0;
365 pub const MOD_SHIFT: u8 = 1 << 1;
366 pub const MOD_CTRL: u8 = 1 << 2;
367 pub const MOD_OPTION: u8 = 1 << 3;
368
369 #[must_use]
374 pub fn rendered_label(&self) -> String {
375 if !self.display.is_empty() {
376 return self.display.clone();
377 }
378 let mut out = String::new();
379 if self.modifiers & Self::MOD_CTRL != 0 {
380 out.push('⌃');
381 }
382 if self.modifiers & Self::MOD_OPTION != 0 {
383 out.push('⌥');
384 }
385 if self.modifiers & Self::MOD_SHIFT != 0 {
386 out.push('⇧');
387 }
388 if self.modifiers & Self::MOD_CMD != 0 {
389 out.push('⌘');
390 }
391 match self.key_code {
392 0x00 => out.push('A'),
393 0x01 => out.push('S'),
394 0x02 => out.push('D'),
395 0x03 => out.push('F'),
396 0x06 => out.push('Z'),
397 0x07 => out.push('X'),
398 0x08 => out.push('C'),
399 0x09 => out.push('V'),
400 0x0B => out.push('B'),
401 0x0C => out.push('Q'),
402 0x0D => out.push('W'),
403 0x0E => out.push('E'),
404 0x0F => out.push('R'),
405 0x10 => out.push('Y'),
406 0x11 => out.push('T'),
407 0x20 => out.push('U'),
408 0x22 => out.push('I'),
409 0x1F => out.push('O'),
410 0x23 => out.push('P'),
411 _ => {
412 use std::fmt::Write as _;
413 let _ = write!(out, "key 0x{:02X}", self.key_code);
414 }
415 }
416 out
417 }
418}
419
420impl Action {
421 #[must_use]
427 pub fn label(&self) -> String {
428 match self {
429 Action::LeftClick => "Left Click".into(),
430 Action::RightClick => "Right Click".into(),
431 Action::MiddleClick => "Middle Click".into(),
432 Action::Copy => "Copy".into(),
433 Action::Paste => "Paste".into(),
434 Action::Cut => "Cut".into(),
435 Action::Undo => "Undo".into(),
436 Action::Redo => "Redo".into(),
437 Action::SelectAll => "Select All".into(),
438 Action::Find => "Find".into(),
439 Action::Save => "Save".into(),
440 Action::BrowserBack => "Browser Back".into(),
441 Action::BrowserForward => "Browser Forward".into(),
442 Action::NewTab => "New Tab".into(),
443 Action::CloseTab => "Close Tab".into(),
444 Action::ReopenTab => "Reopen Tab".into(),
445 Action::NextTab => "Next Tab".into(),
446 Action::PrevTab => "Previous Tab".into(),
447 Action::ReloadPage => "Reload Page".into(),
448 Action::MissionControl => "Mission Control".into(),
449 Action::AppExpose => "App Exposé".into(),
450 Action::ShowDesktop => "Show Desktop".into(),
451 Action::LaunchpadShow => "Launchpad".into(),
452 Action::LockScreen => "Lock Screen".into(),
453 Action::Screenshot => "Screenshot".into(),
454 Action::PlayPause => "Play / Pause".into(),
455 Action::NextTrack => "Next Track".into(),
456 Action::PrevTrack => "Previous Track".into(),
457 Action::VolumeUp => "Volume Up".into(),
458 Action::VolumeDown => "Volume Down".into(),
459 Action::MuteVolume => "Mute".into(),
460 Action::CycleDpiPresets => "Cycle DPI Presets".into(),
461 Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
462 Action::ToggleSmartShift => "Toggle SmartShift".into(),
463 Action::ScrollUp => "Scroll Up".into(),
464 Action::ScrollDown => "Scroll Down".into(),
465 Action::HorizontalScrollLeft => "Scroll Left".into(),
466 Action::HorizontalScrollRight => "Scroll Right".into(),
467 Action::CustomShortcut(combo) => combo.rendered_label(),
468 }
469 }
470
471 #[must_use]
473 pub fn category(&self) -> Category {
474 match self {
475 Action::LeftClick | Action::RightClick | Action::MiddleClick => Category::Mouse,
476 Action::Copy
479 | Action::Paste
480 | Action::Cut
481 | Action::Undo
482 | Action::Redo
483 | Action::SelectAll
484 | Action::Find
485 | Action::Save
486 | Action::CustomShortcut(_) => Category::Editing,
487 Action::BrowserBack
488 | Action::BrowserForward
489 | Action::NewTab
490 | Action::CloseTab
491 | Action::ReopenTab
492 | Action::NextTab
493 | Action::PrevTab
494 | Action::ReloadPage => Category::Browser,
495 Action::MissionControl
496 | Action::AppExpose
497 | Action::ShowDesktop
498 | Action::LaunchpadShow => Category::Navigation,
499 Action::LockScreen | Action::Screenshot => Category::System,
500 Action::PlayPause
501 | Action::NextTrack
502 | Action::PrevTrack
503 | Action::VolumeUp
504 | Action::VolumeDown
505 | Action::MuteVolume => Category::Media,
506 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
507 Category::Dpi
508 }
509 Action::ScrollUp
510 | Action::ScrollDown
511 | Action::HorizontalScrollLeft
512 | Action::HorizontalScrollRight => Category::Scroll,
513 }
514 }
515
516 #[must_use]
521 pub fn catalog() -> Vec<Action> {
522 vec![
523 Action::LeftClick,
525 Action::RightClick,
526 Action::MiddleClick,
527 Action::Copy,
529 Action::Paste,
530 Action::Cut,
531 Action::Undo,
532 Action::Redo,
533 Action::SelectAll,
534 Action::Find,
535 Action::Save,
536 Action::BrowserBack,
538 Action::BrowserForward,
539 Action::NewTab,
540 Action::CloseTab,
541 Action::ReopenTab,
542 Action::NextTab,
543 Action::PrevTab,
544 Action::ReloadPage,
545 Action::MissionControl,
547 Action::AppExpose,
548 Action::ShowDesktop,
549 Action::LaunchpadShow,
550 Action::LockScreen,
552 Action::Screenshot,
553 Action::PlayPause,
555 Action::NextTrack,
556 Action::PrevTrack,
557 Action::VolumeUp,
558 Action::VolumeDown,
559 Action::MuteVolume,
560 Action::CycleDpiPresets,
562 Action::ToggleSmartShift,
563 Action::ScrollUp,
565 Action::ScrollDown,
566 Action::HorizontalScrollLeft,
567 Action::HorizontalScrollRight,
568 ]
569 }
570
571 pub fn execute(&self) {
585 #[cfg(target_os = "macos")]
586 self.execute_macos();
587
588 #[cfg(not(target_os = "macos"))]
589 {
590 tracing::warn!(
591 action = self.label(),
592 "Action::execute unsupported on this platform"
593 );
594 }
595 }
596
597 #[cfg(target_os = "macos")]
599 fn execute_macos(&self) {
600 use core_graphics::event::{CGEventFlags, CGMouseButton};
601
602 let cmd = CGEventFlags::CGEventFlagCommand;
604 let shift = CGEventFlags::CGEventFlagShift;
605 let ctrl = CGEventFlags::CGEventFlagControl;
606 let none = CGEventFlags::CGEventFlagNull;
607
608 match self {
609 Action::LeftClick => macos::post_click(CGMouseButton::Left),
614 Action::RightClick => macos::post_click(CGMouseButton::Right),
615 Action::MiddleClick => macos::post_click(CGMouseButton::Center),
616 Action::Copy => macos::post_key(VK_C, cmd),
618 Action::Paste => macos::post_key(VK_V, cmd),
619 Action::Cut => macos::post_key(VK_X, cmd),
620 Action::Undo => macos::post_key(VK_Z, cmd),
621 Action::Redo => macos::post_key(VK_Z, cmd | shift),
622 Action::SelectAll => macos::post_key(VK_A, cmd),
623 Action::Find => macos::post_key(VK_F, cmd),
624 Action::Save => macos::post_key(VK_S, cmd),
625 Action::BrowserBack => macos::post_key(0x21, cmd),
630 Action::BrowserForward => macos::post_key(0x1E, cmd),
631 Action::NewTab => macos::post_key(VK_T, cmd),
632 Action::CloseTab => macos::post_key(VK_W, cmd),
633 Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
634 Action::NextTab => macos::post_key(VK_TAB, ctrl),
635 Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
636 Action::ReloadPage => macos::post_key(VK_R, cmd),
637 Action::MissionControl => macos::mission_control(),
644 Action::AppExpose => macos::app_expose(),
645 Action::ShowDesktop => macos::show_desktop(),
646 Action::LaunchpadShow => macos::launchpad(),
647 Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
650 Action::Screenshot => macos::post_key(0x14, cmd | shift),
652 Action::PlayPause => macos::post_media_key(0),
655 Action::NextTrack => macos::post_media_key(1),
656 Action::PrevTrack => macos::post_media_key(2),
657 Action::VolumeUp => macos::post_key(0x48, none),
659 Action::VolumeDown => macos::post_key(0x49, none),
660 Action::MuteVolume => macos::post_key(0x4A, none),
661 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
663 tracing::debug!(
664 action = self.label(),
665 "device action handled by hook/HID layer"
666 );
667 }
668 Action::ScrollUp
670 | Action::ScrollDown
671 | Action::HorizontalScrollLeft
672 | Action::HorizontalScrollRight => macos::post_scroll(self),
673 Action::CustomShortcut(combo) => {
675 if combo.key_code == 0 {
680 tracing::warn!(
681 chord = %combo.rendered_label(),
682 "CustomShortcut with no key code — press ignored"
683 );
684 return;
685 }
686 let mut flags = CGEventFlags::CGEventFlagNull;
687 if combo.modifiers & KeyCombo::MOD_CMD != 0 {
688 flags |= CGEventFlags::CGEventFlagCommand;
689 }
690 if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
691 flags |= CGEventFlags::CGEventFlagShift;
692 }
693 if combo.modifiers & KeyCombo::MOD_CTRL != 0 {
694 flags |= CGEventFlags::CGEventFlagControl;
695 }
696 if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
697 flags |= CGEventFlags::CGEventFlagAlternate;
698 }
699 macos::post_key(combo.key_code, flags);
700 }
701 }
702 }
703}
704
705pub fn post_horizontal_scroll(delta: i32) {
715 #[cfg(target_os = "macos")]
716 macos::post_horizontal_scroll(delta);
717
718 #[cfg(not(target_os = "macos"))]
719 let _ = delta;
720}
721
722#[cfg(target_os = "macos")]
726const VK_A: u16 = 0x00;
727#[cfg(target_os = "macos")]
728const VK_C: u16 = 0x08;
729#[cfg(target_os = "macos")]
730const VK_F: u16 = 0x03;
731#[cfg(target_os = "macos")]
732const VK_R: u16 = 0x0F;
733#[cfg(target_os = "macos")]
734const VK_S: u16 = 0x01;
735#[cfg(target_os = "macos")]
736const VK_T: u16 = 0x11;
737#[cfg(target_os = "macos")]
738const VK_V: u16 = 0x09;
739#[cfg(target_os = "macos")]
740const VK_W: u16 = 0x0D;
741#[cfg(target_os = "macos")]
742const VK_X: u16 = 0x07;
743#[cfg(target_os = "macos")]
744const VK_Z: u16 = 0x06;
745#[cfg(target_os = "macos")]
746const VK_TAB: u16 = 0x30;
747
748#[cfg(target_os = "macos")]
750mod macos {
751 use core_graphics::event::{
752 CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, ScrollEventUnit,
753 };
754 use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
755 use core_graphics::geometry::CGPoint;
756
757 use crate::binding::Action;
758
759 pub(super) fn post_click(button: CGMouseButton) {
767 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
768 tracing::warn!("CGEventSource::new failed for click");
769 return;
770 };
771 let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
774 let (down, up) = match button {
775 CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp),
776 CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp),
777 CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp),
778 };
779 for (kind, phase) in [(down, "down"), (up, "up")] {
780 if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) {
781 ev.post(CGEventTapLocation::HID);
782 } else {
783 tracing::warn!(phase, "CGEvent::new_mouse_event failed");
784 }
785 }
786 }
787
788 pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
790 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
791 tracing::warn!("CGEventSource::new failed");
792 return;
793 };
794 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
795 tracing::warn!("CGEvent::new_keyboard_event(down) failed");
796 return;
797 };
798 down.set_flags(flags);
799 down.post(CGEventTapLocation::HID);
800 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
801 tracing::warn!("CGEvent::new_keyboard_event(up) failed");
802 return;
803 };
804 up.set_flags(flags);
805 up.post(CGEventTapLocation::HID);
806 }
807
808 pub(super) fn post_media_key(kind: i32) {
817 let nx_key: i64 = match kind {
819 0 => 16,
820 1 => 17,
821 _ => 18,
822 };
823 tracing::debug!(
824 nx_key,
825 "media key event: NSSystemDefined stub — full AppKit impl tracked in P1.x"
826 );
827 }
828
829 pub(super) fn post_scroll(action: &Action) {
831 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
832 tracing::warn!("CGEventSource::new failed for scroll");
833 return;
834 };
835 let (v, h): (i32, i32) = match action {
836 Action::ScrollUp => (3, 0),
837 Action::ScrollDown => (-3, 0),
838 Action::HorizontalScrollLeft => (0, -3),
839 Action::HorizontalScrollRight => (0, 3),
840 _ => return,
841 };
842 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
843 tracing::warn!("CGEvent::new_scroll_event failed");
844 return;
845 };
846 ev.post(CGEventTapLocation::HID);
847 }
848
849 pub(super) fn post_horizontal_scroll(delta: i32) {
852 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
853 tracing::warn!("CGEventSource::new failed for thumbwheel scroll");
854 return;
855 };
856 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else {
857 tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel");
858 return;
859 };
860 ev.post(CGEventTapLocation::HID);
861 }
862
863 pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop};
864
865 #[allow(
878 unsafe_code,
879 reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI"
880 )]
881 mod dock {
882 use std::ffi::{CStr, c_char, c_int, c_void};
883 use std::sync::OnceLock;
884
885 use core_foundation::base::TCFType;
886 use core_foundation::string::CFString;
887
888 pub(crate) fn mission_control() {
890 send("com.apple.expose.awake");
891 }
892
893 pub(crate) fn app_expose() {
895 send("com.apple.expose.front.awake");
896 }
897
898 pub(crate) fn show_desktop() {
900 send("com.apple.showdesktop.awake");
901 }
902
903 pub(crate) fn launchpad() {
905 send("com.apple.launchpad.toggle");
906 }
907
908 fn send(notification: &str) {
910 let Some(core_dock_send) = core_dock_send_notification() else {
911 tracing::warn!(notification, "CoreDockSendNotification unavailable");
912 return;
913 };
914 let name = CFString::new(notification);
915 let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) };
918 if err != 0 {
919 tracing::warn!(notification, err, "CoreDockSendNotification failed");
920 }
921 }
922
923 type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int;
924
925 fn core_dock_send_notification() -> Option<CoreDockSendNotificationFn> {
928 const RTLD_LAZY: c_int = 0x1;
929 const APP_SERVICES: &CStr =
930 c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices";
931 static HANDLE: OnceLock<usize> = OnceLock::new();
932
933 let sym = unsafe {
937 let handle =
938 *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize);
939 if handle == 0 {
940 return None;
941 }
942 dlsym(handle as *mut c_void, c"CoreDockSendNotification".as_ptr())
943 };
944 (!sym.is_null()).then(|| unsafe {
946 std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym)
947 })
948 }
949
950 unsafe extern "C" {
951 fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
952 fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
953 }
954 }
955}
956
957#[must_use]
969pub fn default_binding(button: ButtonId) -> Action {
970 match button {
971 ButtonId::LeftClick => Action::LeftClick,
972 ButtonId::RightClick => Action::RightClick,
973 ButtonId::MiddleClick => Action::MiddleClick,
974 ButtonId::Back => Action::BrowserBack,
975 ButtonId::Forward => Action::BrowserForward,
976 ButtonId::DpiToggle => Action::CycleDpiPresets,
977 ButtonId::Thumbwheel => Action::AppExpose,
978 ButtonId::GestureButton => Action::MissionControl,
979 }
980}
981
982#[must_use]
986pub fn default_gesture_binding(direction: GestureDirection) -> Action {
987 match direction {
988 GestureDirection::Up => Action::MissionControl,
989 GestureDirection::Down => Action::ShowDesktop,
990 GestureDirection::Left => Action::PrevTab,
991 GestureDirection::Right => Action::NextTab,
992 GestureDirection::Click => Action::AppExpose,
993 }
994}
995
996#[cfg(test)]
997#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
998mod tests {
999 use std::collections::BTreeMap;
1000
1001 use serde::{Deserialize, Serialize};
1002
1003 use super::*;
1004
1005 #[derive(Serialize, Deserialize)]
1010 struct RoundtripWrapper {
1011 binding: BTreeMap<ButtonId, Action>,
1012 }
1013
1014 #[test]
1017 fn catalog_has_at_least_29_entries() {
1018 let catalog = Action::catalog();
1019 assert!(
1020 catalog.len() >= 29,
1021 "catalog has {} entries, need ≥ 29",
1022 catalog.len()
1023 );
1024 }
1025
1026 #[test]
1027 fn catalog_excludes_custom_shortcut() {
1028 let catalog = Action::catalog();
1029 for action in &catalog {
1030 assert!(
1031 !matches!(action, Action::CustomShortcut(_)),
1032 "catalog must not contain CustomShortcut"
1033 );
1034 }
1035 }
1036
1037 #[test]
1040 fn detect_swipe_below_threshold_keeps_accumulating() {
1041 assert_eq!(detect_swipe(40, 5), None);
1043 assert_eq!(detect_swipe(0, 0), None);
1044 }
1045
1046 #[test]
1047 fn detect_swipe_commits_clean_direction() {
1048 assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
1049 assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
1050 assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
1051 assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
1052 }
1053
1054 #[test]
1055 fn detect_swipe_rejects_diagonal() {
1056 assert_eq!(detect_swipe(60, 60), None);
1058 assert_eq!(detect_swipe(-60, -60), None);
1059 }
1060
1061 fn roundtrip(action: &Action) -> Action {
1066 let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
1067 map.insert(ButtonId::Back, action.clone());
1068 let w = RoundtripWrapper { binding: map };
1069 let s = toml::to_string(&w).expect("serialize");
1070 let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
1071 back.binding
1072 .into_values()
1073 .next()
1074 .expect("binding present after roundtrip")
1075 }
1076
1077 #[test]
1078 fn all_catalog_variants_roundtrip_toml() {
1079 for action in Action::catalog() {
1080 let back = roundtrip(&action);
1081 assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
1082 }
1083 }
1084
1085 #[test]
1086 fn custom_shortcut_roundtrips_toml() {
1087 let action = Action::CustomShortcut(KeyCombo {
1088 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1089 key_code: 0x23, display: "⌘⇧P".into(),
1091 });
1092 assert_eq!(roundtrip(&action), action);
1093 }
1094
1095 #[test]
1096 fn key_combo_rendered_label_uses_display_when_set() {
1097 let combo = KeyCombo {
1098 modifiers: 0,
1099 key_code: 0,
1100 display: "preset".into(),
1101 };
1102 assert_eq!(combo.rendered_label(), "preset");
1103 }
1104
1105 #[test]
1106 fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
1107 let combo = KeyCombo {
1108 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1109 key_code: 0x23, display: String::new(),
1111 };
1112 assert_eq!(combo.rendered_label(), "⇧⌘P");
1113 }
1114
1115 #[test]
1118 fn category_editing_variants() {
1119 assert_eq!(Action::Copy.category(), Category::Editing);
1120 assert_eq!(Action::Undo.category(), Category::Editing);
1121 assert_eq!(Action::SelectAll.category(), Category::Editing);
1122 assert_eq!(Action::Find.category(), Category::Editing);
1123 assert_eq!(Action::Save.category(), Category::Editing);
1124 assert_eq!(Action::Cut.category(), Category::Editing);
1125 assert_eq!(Action::Redo.category(), Category::Editing);
1126 assert_eq!(Action::Paste.category(), Category::Editing);
1127 }
1128
1129 #[test]
1130 fn category_browser_variants() {
1131 assert_eq!(Action::BrowserBack.category(), Category::Browser);
1132 assert_eq!(Action::BrowserForward.category(), Category::Browser);
1133 assert_eq!(Action::NewTab.category(), Category::Browser);
1134 assert_eq!(Action::CloseTab.category(), Category::Browser);
1135 assert_eq!(Action::ReopenTab.category(), Category::Browser);
1136 assert_eq!(Action::NextTab.category(), Category::Browser);
1137 assert_eq!(Action::PrevTab.category(), Category::Browser);
1138 assert_eq!(Action::ReloadPage.category(), Category::Browser);
1139 }
1140
1141 #[test]
1142 fn category_media_variants() {
1143 assert_eq!(Action::PlayPause.category(), Category::Media);
1144 assert_eq!(Action::NextTrack.category(), Category::Media);
1145 assert_eq!(Action::PrevTrack.category(), Category::Media);
1146 assert_eq!(Action::VolumeUp.category(), Category::Media);
1147 assert_eq!(Action::VolumeDown.category(), Category::Media);
1148 assert_eq!(Action::MuteVolume.category(), Category::Media);
1149 }
1150
1151 #[test]
1152 fn category_mouse_variants() {
1153 assert_eq!(Action::LeftClick.category(), Category::Mouse);
1154 assert_eq!(Action::RightClick.category(), Category::Mouse);
1155 assert_eq!(Action::MiddleClick.category(), Category::Mouse);
1156 }
1157
1158 #[test]
1159 fn category_dpi_variants() {
1160 assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
1161 assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
1162 }
1163
1164 #[test]
1165 fn category_scroll_variants() {
1166 assert_eq!(Action::ScrollUp.category(), Category::Scroll);
1167 assert_eq!(Action::ScrollDown.category(), Category::Scroll);
1168 assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
1169 assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
1170 }
1171
1172 #[test]
1173 fn category_navigation_variants() {
1174 assert_eq!(Action::MissionControl.category(), Category::Navigation);
1175 assert_eq!(Action::AppExpose.category(), Category::Navigation);
1176 assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
1177 assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
1178 }
1179
1180 #[test]
1181 fn category_system_variants() {
1182 assert_eq!(Action::LockScreen.category(), Category::System);
1183 assert_eq!(Action::Screenshot.category(), Category::System);
1184 }
1185
1186 #[test]
1189 fn category_labels_are_nonempty() {
1190 let categories = [
1191 Category::Editing,
1192 Category::Browser,
1193 Category::Media,
1194 Category::Mouse,
1195 Category::Dpi,
1196 Category::Scroll,
1197 Category::Navigation,
1198 Category::System,
1199 ];
1200 for cat in categories {
1201 assert!(!cat.label().is_empty(), "label empty for {cat:?}");
1202 }
1203 }
1204
1205 #[test]
1208 fn dpi_toggle_default_is_cycle_dpi_presets() {
1209 assert_eq!(
1210 default_binding(ButtonId::DpiToggle),
1211 Action::CycleDpiPresets
1212 );
1213 }
1214}