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
123#[must_use]
135pub fn classify_gesture(dx: i32, dy: i32, min_travel: u32) -> GestureDirection {
136 let dxl = i64::from(dx);
137 let dyl = i64::from(dy);
138 let min = i64::from(min_travel);
139 if dxl * dxl + dyl * dyl < min * min {
140 return GestureDirection::Click;
141 }
142 if dx.unsigned_abs() >= dy.unsigned_abs() {
143 if dx >= 0 {
144 GestureDirection::Right
145 } else {
146 GestureDirection::Left
147 }
148 } else if dy >= 0 {
149 GestureDirection::Down
150 } else {
151 GestureDirection::Up
152 }
153}
154
155#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
160pub enum Category {
161 Editing,
163 Browser,
165 Media,
167 Mouse,
169 Dpi,
171 Scroll,
173 Navigation,
175 System,
177}
178
179impl Category {
180 #[must_use]
183 pub fn label(self) -> &'static str {
184 match self {
185 Category::Editing => "EDITING",
186 Category::Browser => "BROWSER",
187 Category::Media => "MEDIA",
188 Category::Mouse => "MOUSE",
189 Category::Dpi => "DPI",
190 Category::Scroll => "SCROLL",
191 Category::Navigation => "NAVIGATION",
192 Category::System => "SYSTEM",
193 }
194 }
195}
196
197#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
219pub enum Action {
220 LeftClick,
223 RightClick,
225 MiddleClick,
227
228 Copy,
231 Paste,
233 Cut,
235 Undo,
237 Redo,
239 SelectAll,
241 Find,
243 Save,
245
246 BrowserBack,
249 BrowserForward,
251 NewTab,
253 CloseTab,
255 ReopenTab,
257 NextTab,
259 PrevTab,
261 ReloadPage,
263
264 MissionControl,
267 AppExpose,
269 ShowDesktop,
271 LaunchpadShow,
273
274 LockScreen,
277 Screenshot,
279
280 PlayPause,
283 NextTrack,
285 PrevTrack,
287 VolumeUp,
289 VolumeDown,
291 MuteVolume,
293
294 CycleDpiPresets,
297 SetDpiPreset(u8),
300 ToggleSmartShift,
302
303 ScrollUp,
306 ScrollDown,
308 HorizontalScrollLeft,
310 HorizontalScrollRight,
312
313 CustomShortcut(KeyCombo),
321}
322
323#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
335pub struct KeyCombo {
336 pub modifiers: u8,
338 pub key_code: u16,
341 #[serde(default)]
344 pub display: String,
345}
346
347impl KeyCombo {
348 pub const MOD_CMD: u8 = 1 << 0;
349 pub const MOD_SHIFT: u8 = 1 << 1;
350 pub const MOD_CTRL: u8 = 1 << 2;
351 pub const MOD_OPTION: u8 = 1 << 3;
352
353 #[must_use]
358 pub fn rendered_label(&self) -> String {
359 if !self.display.is_empty() {
360 return self.display.clone();
361 }
362 let mut out = String::new();
363 if self.modifiers & Self::MOD_CTRL != 0 {
364 out.push('⌃');
365 }
366 if self.modifiers & Self::MOD_OPTION != 0 {
367 out.push('⌥');
368 }
369 if self.modifiers & Self::MOD_SHIFT != 0 {
370 out.push('⇧');
371 }
372 if self.modifiers & Self::MOD_CMD != 0 {
373 out.push('⌘');
374 }
375 match self.key_code {
376 0x00 => out.push('A'),
377 0x01 => out.push('S'),
378 0x02 => out.push('D'),
379 0x03 => out.push('F'),
380 0x06 => out.push('Z'),
381 0x07 => out.push('X'),
382 0x08 => out.push('C'),
383 0x09 => out.push('V'),
384 0x0B => out.push('B'),
385 0x0C => out.push('Q'),
386 0x0D => out.push('W'),
387 0x0E => out.push('E'),
388 0x0F => out.push('R'),
389 0x10 => out.push('Y'),
390 0x11 => out.push('T'),
391 0x20 => out.push('U'),
392 0x22 => out.push('I'),
393 0x1F => out.push('O'),
394 0x23 => out.push('P'),
395 _ => {
396 use std::fmt::Write as _;
397 let _ = write!(out, "key 0x{:02X}", self.key_code);
398 }
399 }
400 out
401 }
402}
403
404impl Action {
405 #[must_use]
411 pub fn label(&self) -> String {
412 match self {
413 Action::LeftClick => "Left Click".into(),
414 Action::RightClick => "Right Click".into(),
415 Action::MiddleClick => "Middle Click".into(),
416 Action::Copy => "Copy".into(),
417 Action::Paste => "Paste".into(),
418 Action::Cut => "Cut".into(),
419 Action::Undo => "Undo".into(),
420 Action::Redo => "Redo".into(),
421 Action::SelectAll => "Select All".into(),
422 Action::Find => "Find".into(),
423 Action::Save => "Save".into(),
424 Action::BrowserBack => "Browser Back".into(),
425 Action::BrowserForward => "Browser Forward".into(),
426 Action::NewTab => "New Tab".into(),
427 Action::CloseTab => "Close Tab".into(),
428 Action::ReopenTab => "Reopen Tab".into(),
429 Action::NextTab => "Next Tab".into(),
430 Action::PrevTab => "Previous Tab".into(),
431 Action::ReloadPage => "Reload Page".into(),
432 Action::MissionControl => "Mission Control".into(),
433 Action::AppExpose => "App Exposé".into(),
434 Action::ShowDesktop => "Show Desktop".into(),
435 Action::LaunchpadShow => "Launchpad".into(),
436 Action::LockScreen => "Lock Screen".into(),
437 Action::Screenshot => "Screenshot".into(),
438 Action::PlayPause => "Play / Pause".into(),
439 Action::NextTrack => "Next Track".into(),
440 Action::PrevTrack => "Previous Track".into(),
441 Action::VolumeUp => "Volume Up".into(),
442 Action::VolumeDown => "Volume Down".into(),
443 Action::MuteVolume => "Mute".into(),
444 Action::CycleDpiPresets => "Cycle DPI Presets".into(),
445 Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
446 Action::ToggleSmartShift => "Toggle SmartShift".into(),
447 Action::ScrollUp => "Scroll Up".into(),
448 Action::ScrollDown => "Scroll Down".into(),
449 Action::HorizontalScrollLeft => "Scroll Left".into(),
450 Action::HorizontalScrollRight => "Scroll Right".into(),
451 Action::CustomShortcut(combo) => combo.rendered_label(),
452 }
453 }
454
455 #[must_use]
457 pub fn category(&self) -> Category {
458 match self {
459 Action::LeftClick | Action::RightClick | Action::MiddleClick => Category::Mouse,
460 Action::Copy
463 | Action::Paste
464 | Action::Cut
465 | Action::Undo
466 | Action::Redo
467 | Action::SelectAll
468 | Action::Find
469 | Action::Save
470 | Action::CustomShortcut(_) => Category::Editing,
471 Action::BrowserBack
472 | Action::BrowserForward
473 | Action::NewTab
474 | Action::CloseTab
475 | Action::ReopenTab
476 | Action::NextTab
477 | Action::PrevTab
478 | Action::ReloadPage => Category::Browser,
479 Action::MissionControl
480 | Action::AppExpose
481 | Action::ShowDesktop
482 | Action::LaunchpadShow => Category::Navigation,
483 Action::LockScreen | Action::Screenshot => Category::System,
484 Action::PlayPause
485 | Action::NextTrack
486 | Action::PrevTrack
487 | Action::VolumeUp
488 | Action::VolumeDown
489 | Action::MuteVolume => Category::Media,
490 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
491 Category::Dpi
492 }
493 Action::ScrollUp
494 | Action::ScrollDown
495 | Action::HorizontalScrollLeft
496 | Action::HorizontalScrollRight => Category::Scroll,
497 }
498 }
499
500 #[must_use]
505 pub fn catalog() -> Vec<Action> {
506 vec![
507 Action::LeftClick,
509 Action::RightClick,
510 Action::MiddleClick,
511 Action::Copy,
513 Action::Paste,
514 Action::Cut,
515 Action::Undo,
516 Action::Redo,
517 Action::SelectAll,
518 Action::Find,
519 Action::Save,
520 Action::BrowserBack,
522 Action::BrowserForward,
523 Action::NewTab,
524 Action::CloseTab,
525 Action::ReopenTab,
526 Action::NextTab,
527 Action::PrevTab,
528 Action::ReloadPage,
529 Action::MissionControl,
531 Action::AppExpose,
532 Action::ShowDesktop,
533 Action::LaunchpadShow,
534 Action::LockScreen,
536 Action::Screenshot,
537 Action::PlayPause,
539 Action::NextTrack,
540 Action::PrevTrack,
541 Action::VolumeUp,
542 Action::VolumeDown,
543 Action::MuteVolume,
544 Action::CycleDpiPresets,
546 Action::ToggleSmartShift,
547 Action::ScrollUp,
549 Action::ScrollDown,
550 Action::HorizontalScrollLeft,
551 Action::HorizontalScrollRight,
552 ]
553 }
554
555 pub fn execute(&self) {
566 #[cfg(target_os = "macos")]
567 self.execute_macos();
568
569 #[cfg(not(target_os = "macos"))]
570 {
571 tracing::warn!(
572 action = self.label(),
573 "Action::execute unsupported on this platform"
574 );
575 }
576 }
577
578 #[cfg(target_os = "macos")]
580 fn execute_macos(&self) {
581 use core_graphics::event::CGEventFlags;
582
583 let cmd = CGEventFlags::CGEventFlagCommand;
585 let shift = CGEventFlags::CGEventFlagShift;
586 let ctrl = CGEventFlags::CGEventFlagControl;
587 let none = CGEventFlags::CGEventFlagNull;
588
589 match self {
590 Action::LeftClick | Action::RightClick | Action::MiddleClick => {
592 tracing::debug!(
593 action = self.label(),
594 "mouse-click execute delegated to hook layer"
595 );
596 }
597 Action::Copy => macos::post_key(VK_C, cmd),
599 Action::Paste => macos::post_key(VK_V, cmd),
600 Action::Cut => macos::post_key(VK_X, cmd),
601 Action::Undo => macos::post_key(VK_Z, cmd),
602 Action::Redo => macos::post_key(VK_Z, cmd | shift),
603 Action::SelectAll => macos::post_key(VK_A, cmd),
604 Action::Find => macos::post_key(VK_F, cmd),
605 Action::Save => macos::post_key(VK_S, cmd),
606 Action::BrowserBack => macos::post_key(0x21, cmd),
611 Action::BrowserForward => macos::post_key(0x1E, cmd),
612 Action::NewTab => macos::post_key(VK_T, cmd),
613 Action::CloseTab => macos::post_key(VK_W, cmd),
614 Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
615 Action::NextTab => macos::post_key(VK_TAB, ctrl),
616 Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
617 Action::ReloadPage => macos::post_key(VK_R, cmd),
618 Action::MissionControl => macos::post_key(0x7E, ctrl),
621 Action::AppExpose => macos::post_key(0x7D, ctrl),
623 Action::ShowDesktop => macos::post_key(0x63, cmd),
625 Action::LaunchpadShow => macos::post_key(0x76, none),
627 Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
630 Action::Screenshot => macos::post_key(0x14, cmd | shift),
632 Action::PlayPause => macos::post_media_key(0),
635 Action::NextTrack => macos::post_media_key(1),
636 Action::PrevTrack => macos::post_media_key(2),
637 Action::VolumeUp => macos::post_key(0x48, none),
639 Action::VolumeDown => macos::post_key(0x49, none),
640 Action::MuteVolume => macos::post_key(0x4A, none),
641 Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
643 tracing::debug!(
644 action = self.label(),
645 "device action handled by hook/HID layer"
646 );
647 }
648 Action::ScrollUp
650 | Action::ScrollDown
651 | Action::HorizontalScrollLeft
652 | Action::HorizontalScrollRight => macos::post_scroll(self),
653 Action::CustomShortcut(combo) => {
655 if combo.key_code == 0 {
660 tracing::warn!(
661 chord = %combo.rendered_label(),
662 "CustomShortcut with no key code — press ignored"
663 );
664 return;
665 }
666 let mut flags = CGEventFlags::CGEventFlagNull;
667 if combo.modifiers & KeyCombo::MOD_CMD != 0 {
668 flags |= CGEventFlags::CGEventFlagCommand;
669 }
670 if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
671 flags |= CGEventFlags::CGEventFlagShift;
672 }
673 if combo.modifiers & KeyCombo::MOD_CTRL != 0 {
674 flags |= CGEventFlags::CGEventFlagControl;
675 }
676 if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
677 flags |= CGEventFlags::CGEventFlagAlternate;
678 }
679 macos::post_key(combo.key_code, flags);
680 }
681 }
682 }
683}
684
685#[cfg(target_os = "macos")]
689const VK_A: u16 = 0x00;
690#[cfg(target_os = "macos")]
691const VK_C: u16 = 0x08;
692#[cfg(target_os = "macos")]
693const VK_F: u16 = 0x03;
694#[cfg(target_os = "macos")]
695const VK_R: u16 = 0x0F;
696#[cfg(target_os = "macos")]
697const VK_S: u16 = 0x01;
698#[cfg(target_os = "macos")]
699const VK_T: u16 = 0x11;
700#[cfg(target_os = "macos")]
701const VK_V: u16 = 0x09;
702#[cfg(target_os = "macos")]
703const VK_W: u16 = 0x0D;
704#[cfg(target_os = "macos")]
705const VK_X: u16 = 0x07;
706#[cfg(target_os = "macos")]
707const VK_Z: u16 = 0x06;
708#[cfg(target_os = "macos")]
709const VK_TAB: u16 = 0x30;
710
711#[cfg(target_os = "macos")]
713mod macos {
714 use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation, ScrollEventUnit};
715 use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
716
717 use crate::binding::Action;
718
719 pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
721 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
722 tracing::warn!("CGEventSource::new failed");
723 return;
724 };
725 let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
726 tracing::warn!("CGEvent::new_keyboard_event(down) failed");
727 return;
728 };
729 down.set_flags(flags);
730 down.post(CGEventTapLocation::HID);
731 let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
732 tracing::warn!("CGEvent::new_keyboard_event(up) failed");
733 return;
734 };
735 up.set_flags(flags);
736 up.post(CGEventTapLocation::HID);
737 }
738
739 pub(super) fn post_media_key(kind: i32) {
748 let nx_key: i64 = match kind {
750 0 => 16,
751 1 => 17,
752 _ => 18,
753 };
754 tracing::debug!(
755 nx_key,
756 "media key event: NSSystemDefined stub — full AppKit impl tracked in P1.x"
757 );
758 }
759
760 pub(super) fn post_scroll(action: &Action) {
762 let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
763 tracing::warn!("CGEventSource::new failed for scroll");
764 return;
765 };
766 let (v, h): (i32, i32) = match action {
767 Action::ScrollUp => (3, 0),
768 Action::ScrollDown => (-3, 0),
769 Action::HorizontalScrollLeft => (0, -3),
770 Action::HorizontalScrollRight => (0, 3),
771 _ => return,
772 };
773 let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
774 tracing::warn!("CGEvent::new_scroll_event failed");
775 return;
776 };
777 ev.post(CGEventTapLocation::HID);
778 }
779}
780
781#[must_use]
793pub fn default_binding(button: ButtonId) -> Action {
794 match button {
795 ButtonId::LeftClick => Action::LeftClick,
796 ButtonId::RightClick => Action::RightClick,
797 ButtonId::MiddleClick => Action::MiddleClick,
798 ButtonId::Back => Action::BrowserBack,
799 ButtonId::Forward => Action::BrowserForward,
800 ButtonId::DpiToggle => Action::CycleDpiPresets,
801 ButtonId::Thumbwheel => Action::AppExpose,
802 ButtonId::GestureButton => Action::MissionControl,
803 }
804}
805
806#[must_use]
810pub fn default_gesture_binding(direction: GestureDirection) -> Action {
811 match direction {
812 GestureDirection::Up => Action::MissionControl,
813 GestureDirection::Down => Action::ShowDesktop,
814 GestureDirection::Left => Action::PrevTab,
815 GestureDirection::Right => Action::NextTab,
816 GestureDirection::Click => Action::AppExpose,
817 }
818}
819
820#[cfg(test)]
821#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
822mod tests {
823 use std::collections::BTreeMap;
824
825 use serde::{Deserialize, Serialize};
826
827 use super::*;
828
829 #[derive(Serialize, Deserialize)]
834 struct RoundtripWrapper {
835 binding: BTreeMap<ButtonId, Action>,
836 }
837
838 #[test]
841 fn catalog_has_at_least_29_entries() {
842 let catalog = Action::catalog();
843 assert!(
844 catalog.len() >= 29,
845 "catalog has {} entries, need ≥ 29",
846 catalog.len()
847 );
848 }
849
850 #[test]
851 fn catalog_excludes_custom_shortcut() {
852 let catalog = Action::catalog();
853 for action in &catalog {
854 assert!(
855 !matches!(action, Action::CustomShortcut(_)),
856 "catalog must not contain CustomShortcut"
857 );
858 }
859 }
860
861 #[test]
864 fn classify_gesture_short_travel_is_click() {
865 assert_eq!(classify_gesture(3, -2, 32), GestureDirection::Click);
866 assert_eq!(classify_gesture(0, 0, 32), GestureDirection::Click);
867 }
868
869 #[test]
870 fn classify_gesture_dominant_axis_wins() {
871 assert_eq!(classify_gesture(120, 5, 32), GestureDirection::Right);
872 assert_eq!(classify_gesture(-120, 5, 32), GestureDirection::Left);
873 assert_eq!(classify_gesture(5, 120, 32), GestureDirection::Down);
874 assert_eq!(classify_gesture(5, -120, 32), GestureDirection::Up);
875 }
876
877 #[test]
878 fn classify_gesture_ties_favor_horizontal() {
879 assert_eq!(classify_gesture(50, 50, 32), GestureDirection::Right);
880 assert_eq!(classify_gesture(-50, -50, 32), GestureDirection::Left);
881 }
882
883 fn roundtrip(action: &Action) -> Action {
888 let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
889 map.insert(ButtonId::Back, action.clone());
890 let w = RoundtripWrapper { binding: map };
891 let s = toml::to_string(&w).expect("serialize");
892 let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
893 back.binding
894 .into_values()
895 .next()
896 .expect("binding present after roundtrip")
897 }
898
899 #[test]
900 fn all_catalog_variants_roundtrip_toml() {
901 for action in Action::catalog() {
902 let back = roundtrip(&action);
903 assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
904 }
905 }
906
907 #[test]
908 fn custom_shortcut_roundtrips_toml() {
909 let action = Action::CustomShortcut(KeyCombo {
910 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
911 key_code: 0x23, display: "⌘⇧P".into(),
913 });
914 assert_eq!(roundtrip(&action), action);
915 }
916
917 #[test]
918 fn key_combo_rendered_label_uses_display_when_set() {
919 let combo = KeyCombo {
920 modifiers: 0,
921 key_code: 0,
922 display: "preset".into(),
923 };
924 assert_eq!(combo.rendered_label(), "preset");
925 }
926
927 #[test]
928 fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
929 let combo = KeyCombo {
930 modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
931 key_code: 0x23, display: String::new(),
933 };
934 assert_eq!(combo.rendered_label(), "⇧⌘P");
935 }
936
937 #[test]
940 fn category_editing_variants() {
941 assert_eq!(Action::Copy.category(), Category::Editing);
942 assert_eq!(Action::Undo.category(), Category::Editing);
943 assert_eq!(Action::SelectAll.category(), Category::Editing);
944 assert_eq!(Action::Find.category(), Category::Editing);
945 assert_eq!(Action::Save.category(), Category::Editing);
946 assert_eq!(Action::Cut.category(), Category::Editing);
947 assert_eq!(Action::Redo.category(), Category::Editing);
948 assert_eq!(Action::Paste.category(), Category::Editing);
949 }
950
951 #[test]
952 fn category_browser_variants() {
953 assert_eq!(Action::BrowserBack.category(), Category::Browser);
954 assert_eq!(Action::BrowserForward.category(), Category::Browser);
955 assert_eq!(Action::NewTab.category(), Category::Browser);
956 assert_eq!(Action::CloseTab.category(), Category::Browser);
957 assert_eq!(Action::ReopenTab.category(), Category::Browser);
958 assert_eq!(Action::NextTab.category(), Category::Browser);
959 assert_eq!(Action::PrevTab.category(), Category::Browser);
960 assert_eq!(Action::ReloadPage.category(), Category::Browser);
961 }
962
963 #[test]
964 fn category_media_variants() {
965 assert_eq!(Action::PlayPause.category(), Category::Media);
966 assert_eq!(Action::NextTrack.category(), Category::Media);
967 assert_eq!(Action::PrevTrack.category(), Category::Media);
968 assert_eq!(Action::VolumeUp.category(), Category::Media);
969 assert_eq!(Action::VolumeDown.category(), Category::Media);
970 assert_eq!(Action::MuteVolume.category(), Category::Media);
971 }
972
973 #[test]
974 fn category_mouse_variants() {
975 assert_eq!(Action::LeftClick.category(), Category::Mouse);
976 assert_eq!(Action::RightClick.category(), Category::Mouse);
977 assert_eq!(Action::MiddleClick.category(), Category::Mouse);
978 }
979
980 #[test]
981 fn category_dpi_variants() {
982 assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
983 assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
984 }
985
986 #[test]
987 fn category_scroll_variants() {
988 assert_eq!(Action::ScrollUp.category(), Category::Scroll);
989 assert_eq!(Action::ScrollDown.category(), Category::Scroll);
990 assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
991 assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
992 }
993
994 #[test]
995 fn category_navigation_variants() {
996 assert_eq!(Action::MissionControl.category(), Category::Navigation);
997 assert_eq!(Action::AppExpose.category(), Category::Navigation);
998 assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
999 assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
1000 }
1001
1002 #[test]
1003 fn category_system_variants() {
1004 assert_eq!(Action::LockScreen.category(), Category::System);
1005 assert_eq!(Action::Screenshot.category(), Category::System);
1006 }
1007
1008 #[test]
1011 fn category_labels_are_nonempty() {
1012 let categories = [
1013 Category::Editing,
1014 Category::Browser,
1015 Category::Media,
1016 Category::Mouse,
1017 Category::Dpi,
1018 Category::Scroll,
1019 Category::Navigation,
1020 Category::System,
1021 ];
1022 for cat in categories {
1023 assert!(!cat.label().is_empty(), "label empty for {cat:?}");
1024 }
1025 }
1026
1027 #[test]
1030 fn dpi_toggle_default_is_cycle_dpi_presets() {
1031 assert_eq!(
1032 default_binding(ButtonId::DpiToggle),
1033 Action::CycleDpiPresets
1034 );
1035 }
1036}