use std::collections::BTreeMap;
use std::fmt;
use std::time::Instant;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ButtonId {
LeftClick,
RightClick,
MiddleClick,
Back,
Forward,
DpiToggle,
Thumbwheel,
ThumbwheelScrollUp,
ThumbwheelScrollDown,
GestureButton,
}
impl ButtonId {
pub const ALL: [ButtonId; 10] = [
ButtonId::LeftClick,
ButtonId::RightClick,
ButtonId::MiddleClick,
ButtonId::Back,
ButtonId::Forward,
ButtonId::DpiToggle,
ButtonId::Thumbwheel,
ButtonId::ThumbwheelScrollUp,
ButtonId::ThumbwheelScrollDown,
ButtonId::GestureButton,
];
#[must_use]
pub fn is_os_hook_button(self) -> bool {
matches!(
self,
ButtonId::MiddleClick | ButtonId::Back | ButtonId::Forward
)
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
ButtonId::LeftClick => "Left Click",
ButtonId::RightClick => "Right Click",
ButtonId::MiddleClick => "Middle Click",
ButtonId::Back => "Back",
ButtonId::Forward => "Forward",
ButtonId::DpiToggle => "DPI Toggle",
ButtonId::Thumbwheel => "Thumb Wheel",
ButtonId::ThumbwheelScrollUp => "Thumb Wheel Up",
ButtonId::ThumbwheelScrollDown => "Thumb Wheel Down",
ButtonId::GestureButton => "Gesture Button",
}
}
}
impl fmt::Display for ButtonId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum GestureDirection {
Up,
Down,
Left,
Right,
Click,
}
impl GestureDirection {
pub const ALL: [GestureDirection; 5] = [
GestureDirection::Up,
GestureDirection::Down,
GestureDirection::Left,
GestureDirection::Right,
GestureDirection::Click,
];
#[must_use]
pub fn label(self) -> &'static str {
match self {
GestureDirection::Up => "Up",
GestureDirection::Down => "Down",
GestureDirection::Left => "Left",
GestureDirection::Right => "Right",
GestureDirection::Click => "Click",
}
}
#[must_use]
pub fn glyph(self) -> &'static str {
match self {
GestureDirection::Up => "↑",
GestureDirection::Down => "↓",
GestureDirection::Left => "←",
GestureDirection::Right => "→",
GestureDirection::Click => "·",
}
}
}
impl fmt::Display for GestureDirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
pub const GESTURE_SWIPE_THRESHOLD: i32 = 50;
pub const GESTURE_SWIPE_DEADZONE: i32 = 40;
pub const GESTURE_HOLD_FOR_SWIPE: std::time::Duration = std::time::Duration::from_millis(160);
#[must_use]
pub fn detect_swipe(dx: i32, dy: i32) -> Option<GestureDirection> {
let (abs_x, abs_y) = (dx.saturating_abs(), dy.saturating_abs());
let dominant = abs_x.max(abs_y);
if dominant < GESTURE_SWIPE_THRESHOLD {
return None;
}
let cross_limit = GESTURE_SWIPE_DEADZONE.max(dominant.saturating_mul(35) / 100);
if abs_x > abs_y {
if abs_y > cross_limit {
return None;
}
Some(if dx > 0 {
GestureDirection::Right
} else {
GestureDirection::Left
})
} else {
if abs_x > cross_limit {
return None;
}
Some(if dy > 0 {
GestureDirection::Down
} else {
GestureDirection::Up
})
}
}
#[derive(Debug, Default)]
pub struct SwipeAccumulator {
held_since: Option<Instant>,
dx: i32,
dy: i32,
fired: bool,
}
impl SwipeAccumulator {
pub fn begin(&mut self) {
self.held_since = Some(Instant::now());
self.dx = 0;
self.dy = 0;
self.fired = false;
}
#[must_use]
pub fn is_holding(&self) -> bool {
self.held_since.is_some()
}
pub fn accumulate(&mut self, dx: i32, dy: i32) -> Option<GestureDirection> {
if self.fired || self.held_since.is_none() {
return None;
}
self.dx = self.dx.saturating_add(dx);
self.dy = self.dy.saturating_add(dy);
let held_long_enough = self
.held_since
.is_some_and(|t| t.elapsed() >= GESTURE_HOLD_FOR_SWIPE);
if held_long_enough && let Some(dir) = detect_swipe(self.dx, self.dy) {
self.fired = true;
return Some(dir);
}
None
}
pub fn end(&mut self) -> bool {
let was_click = self.held_since.is_some() && !self.fired;
self.held_since = None;
was_click
}
#[doc(hidden)]
pub fn backdate_hold_for_test(&mut self) {
if self.held_since.is_some() {
self.held_since = Instant::now().checked_sub(GESTURE_HOLD_FOR_SWIPE * 2);
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Category {
Editing,
Browser,
Media,
Mouse,
Dpi,
Scroll,
Navigation,
System,
}
impl Category {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Category::Editing => "EDITING",
Category::Browser => "BROWSER",
Category::Media => "MEDIA",
Category::Mouse => "MOUSE",
Category::Dpi => "DPI",
Category::Scroll => "SCROLL",
Category::Navigation => "NAVIGATION",
Category::System => "SYSTEM",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Action {
None,
LeftClick,
RightClick,
MiddleClick,
MouseBack,
MouseForward,
Copy,
Paste,
Cut,
Undo,
Redo,
SelectAll,
Find,
Save,
BrowserBack,
BrowserForward,
NewTab,
CloseTab,
ReopenTab,
NextTab,
PrevTab,
ReloadPage,
MissionControl,
AppExpose,
PreviousDesktop,
NextDesktop,
ShowDesktop,
LaunchpadShow,
LockScreen,
Screenshot,
CaptureRegion,
PlayPause,
NextTrack,
PrevTrack,
VolumeUp,
VolumeDown,
MuteVolume,
CycleDpiPresets,
SetDpiPreset(u8),
ToggleSmartShift,
ScrollUp,
ScrollDown,
HorizontalScrollLeft,
HorizontalScrollRight,
CustomShortcut(KeyCombo),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct KeyCombo {
pub modifiers: u8,
pub key_code: u16,
#[serde(default)]
pub display: String,
}
impl KeyCombo {
pub const MOD_CMD: u8 = 1 << 0;
pub const MOD_SHIFT: u8 = 1 << 1;
pub const MOD_CTRL: u8 = 1 << 2;
pub const MOD_OPTION: u8 = 1 << 3;
#[must_use]
pub fn rendered_label(&self) -> String {
if !self.display.is_empty() {
return self.display.clone();
}
let mut out = String::new();
if self.modifiers & Self::MOD_CTRL != 0 {
out.push('⌃');
}
if self.modifiers & Self::MOD_OPTION != 0 {
out.push('⌥');
}
if self.modifiers & Self::MOD_SHIFT != 0 {
out.push('⇧');
}
if self.modifiers & Self::MOD_CMD != 0 {
out.push('⌘');
}
match self.key_code {
0x00 => out.push('A'),
0x01 => out.push('S'),
0x02 => out.push('D'),
0x03 => out.push('F'),
0x06 => out.push('Z'),
0x07 => out.push('X'),
0x08 => out.push('C'),
0x09 => out.push('V'),
0x0B => out.push('B'),
0x0C => out.push('Q'),
0x0D => out.push('W'),
0x0E => out.push('E'),
0x0F => out.push('R'),
0x10 => out.push('Y'),
0x11 => out.push('T'),
0x20 => out.push('U'),
0x22 => out.push('I'),
0x1F => out.push('O'),
0x23 => out.push('P'),
_ => {
use std::fmt::Write as _;
let _ = write!(out, "key 0x{:02X}", self.key_code);
}
}
out
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Binding {
Single(Action),
Gesture(BTreeMap<GestureDirection, Action>),
}
impl Binding {
#[must_use]
pub fn click_action(&self) -> Action {
match self {
Binding::Single(action) => action.clone(),
Binding::Gesture(map) => map
.get(&GestureDirection::Click)
.cloned()
.unwrap_or(Action::None),
}
}
#[must_use]
pub fn direction_action(&self, direction: GestureDirection) -> Option<&Action> {
match self {
Binding::Single(_) => None,
Binding::Gesture(map) => map.get(&direction),
}
}
#[must_use]
pub fn is_gesture(&self) -> bool {
matches!(self, Binding::Gesture(_))
}
pub fn upgrade_to_gesture(&mut self) {
if let Binding::Single(action) = self {
let mut map = BTreeMap::new();
map.insert(GestureDirection::Click, action.clone());
*self = Binding::Gesture(map);
}
}
pub fn fill_gesture_defaults(&mut self) {
if let Binding::Gesture(map) = self {
for dir in GestureDirection::ALL {
map.entry(dir)
.or_insert_with(|| default_gesture_binding(dir));
}
}
}
}
impl From<Action> for Binding {
fn from(action: Action) -> Self {
Binding::Single(action)
}
}
impl Action {
#[must_use]
pub fn label(&self) -> String {
match self {
Action::None => "Do Nothing".into(),
Action::LeftClick => "Left Click".into(),
Action::RightClick => "Right Click".into(),
Action::MiddleClick => "Middle Click".into(),
Action::MouseBack => "Back (Button 4)".into(),
Action::MouseForward => "Forward (Button 5)".into(),
Action::Copy => "Copy".into(),
Action::Paste => "Paste".into(),
Action::Cut => "Cut".into(),
Action::Undo => "Undo".into(),
Action::Redo => "Redo".into(),
Action::SelectAll => "Select All".into(),
Action::Find => "Find".into(),
Action::Save => "Save".into(),
Action::BrowserBack => "Browser Back".into(),
Action::BrowserForward => "Browser Forward".into(),
Action::NewTab => "New Tab".into(),
Action::CloseTab => "Close Tab".into(),
Action::ReopenTab => "Reopen Tab".into(),
Action::NextTab => "Next Tab".into(),
Action::PrevTab => "Previous Tab".into(),
Action::ReloadPage => "Reload Page".into(),
Action::MissionControl => "Mission Control".into(),
Action::AppExpose => "App Exposé".into(),
Action::PreviousDesktop => "Previous Desktop".into(),
Action::NextDesktop => "Next Desktop".into(),
Action::ShowDesktop => "Show Desktop".into(),
Action::LaunchpadShow => "Launchpad".into(),
Action::LockScreen => "Lock Screen".into(),
Action::Screenshot => "Screenshot".into(),
Action::CaptureRegion => "Capture Region".into(),
Action::PlayPause => "Play / Pause".into(),
Action::NextTrack => "Next Track".into(),
Action::PrevTrack => "Previous Track".into(),
Action::VolumeUp => "Volume Up".into(),
Action::VolumeDown => "Volume Down".into(),
Action::MuteVolume => "Mute".into(),
Action::CycleDpiPresets => "Cycle DPI Presets".into(),
Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
Action::ToggleSmartShift => "Toggle SmartShift".into(),
Action::ScrollUp => "Scroll Up".into(),
Action::ScrollDown => "Scroll Down".into(),
Action::HorizontalScrollLeft => "Scroll Left".into(),
Action::HorizontalScrollRight => "Scroll Right".into(),
Action::CustomShortcut(combo) => combo.rendered_label(),
}
}
#[must_use]
pub fn category(&self) -> Category {
match self {
Action::LeftClick
| Action::RightClick
| Action::MiddleClick
| Action::MouseBack
| Action::MouseForward => Category::Mouse,
Action::Copy
| Action::Paste
| Action::Cut
| Action::Undo
| Action::Redo
| Action::SelectAll
| Action::Find
| Action::Save
| Action::CustomShortcut(_) => Category::Editing,
Action::BrowserBack
| Action::BrowserForward
| Action::NewTab
| Action::CloseTab
| Action::ReopenTab
| Action::NextTab
| Action::PrevTab
| Action::ReloadPage => Category::Browser,
Action::MissionControl
| Action::AppExpose
| Action::PreviousDesktop
| Action::NextDesktop
| Action::ShowDesktop
| Action::LaunchpadShow => Category::Navigation,
Action::None | Action::LockScreen | Action::Screenshot | Action::CaptureRegion => {
Category::System
}
Action::PlayPause
| Action::NextTrack
| Action::PrevTrack
| Action::VolumeUp
| Action::VolumeDown
| Action::MuteVolume => Category::Media,
Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
Category::Dpi
}
Action::ScrollUp
| Action::ScrollDown
| Action::HorizontalScrollLeft
| Action::HorizontalScrollRight => Category::Scroll,
}
}
#[must_use]
pub fn catalog() -> Vec<Action> {
vec![
Action::LeftClick,
Action::RightClick,
Action::MiddleClick,
Action::MouseBack,
Action::MouseForward,
Action::Copy,
Action::Paste,
Action::Cut,
Action::Undo,
Action::Redo,
Action::SelectAll,
Action::Find,
Action::Save,
Action::BrowserBack,
Action::BrowserForward,
Action::NewTab,
Action::CloseTab,
Action::ReopenTab,
Action::NextTab,
Action::PrevTab,
Action::ReloadPage,
Action::MissionControl,
Action::AppExpose,
Action::PreviousDesktop,
Action::NextDesktop,
Action::ShowDesktop,
Action::LaunchpadShow,
Action::None,
Action::LockScreen,
Action::Screenshot,
Action::CaptureRegion,
Action::PlayPause,
Action::NextTrack,
Action::PrevTrack,
Action::VolumeUp,
Action::VolumeDown,
Action::MuteVolume,
Action::CycleDpiPresets,
Action::ToggleSmartShift,
Action::ScrollUp,
Action::ScrollDown,
Action::HorizontalScrollLeft,
Action::HorizontalScrollRight,
]
}
}
#[must_use]
pub fn default_binding(button: ButtonId) -> Action {
match button {
ButtonId::LeftClick => Action::LeftClick,
ButtonId::RightClick => Action::RightClick,
ButtonId::MiddleClick => Action::MiddleClick,
ButtonId::Back => Action::BrowserBack,
ButtonId::Forward => Action::BrowserForward,
ButtonId::DpiToggle => Action::CycleDpiPresets,
ButtonId::Thumbwheel => Action::AppExpose,
ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight,
ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft,
ButtonId::GestureButton => Action::MissionControl,
}
}
#[must_use]
pub fn default_gesture_binding(direction: GestureDirection) -> Action {
match direction {
GestureDirection::Up => Action::MissionControl,
GestureDirection::Down => Action::ShowDesktop,
GestureDirection::Left => Action::PrevTab,
GestureDirection::Right => Action::NextTab,
GestureDirection::Click => Action::AppExpose,
}
}
#[must_use]
pub fn default_binding_for(button: ButtonId) -> Binding {
match button {
ButtonId::GestureButton => Binding::Gesture(
GestureDirection::ALL
.into_iter()
.map(|d| (d, default_gesture_binding(d)))
.collect(),
),
other => Binding::Single(default_binding(other)),
}
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
mod tests {
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::*;
#[derive(Serialize, Deserialize)]
struct RoundtripWrapper {
binding: BTreeMap<ButtonId, Action>,
}
#[test]
fn catalog_has_at_least_29_entries() {
let catalog = Action::catalog();
assert!(
catalog.len() >= 29,
"catalog has {} entries, need ≥ 29",
catalog.len()
);
}
#[test]
fn catalog_excludes_custom_shortcut() {
let catalog = Action::catalog();
for action in &catalog {
assert!(
!matches!(action, Action::CustomShortcut(_)),
"catalog must not contain CustomShortcut"
);
}
}
#[derive(Serialize, Deserialize)]
struct BindingWrapper {
bindings: BTreeMap<ButtonId, Binding>,
}
fn binding_roundtrip(bindings: BTreeMap<ButtonId, Binding>) -> BTreeMap<ButtonId, Binding> {
let toml = toml::to_string_pretty(&BindingWrapper { bindings }).expect("serialize");
toml::from_str::<BindingWrapper>(&toml)
.expect("deserialize")
.bindings
}
#[test]
fn binding_single_roundtrips_including_payload_variants() {
let mut bindings = BTreeMap::new();
bindings.insert(ButtonId::Back, Binding::Single(Action::BrowserBack));
bindings.insert(
ButtonId::DpiToggle,
Binding::Single(Action::SetDpiPreset(2)),
);
bindings.insert(
ButtonId::Forward,
Binding::Single(Action::CustomShortcut(KeyCombo {
modifiers: KeyCombo::MOD_CMD,
key_code: 0x23,
display: "⌘P".into(),
})),
);
let back = binding_roundtrip(bindings);
assert_eq!(back[&ButtonId::Back], Binding::Single(Action::BrowserBack));
assert_eq!(
back[&ButtonId::DpiToggle],
Binding::Single(Action::SetDpiPreset(2))
);
assert!(matches!(
back[&ButtonId::Forward],
Binding::Single(Action::CustomShortcut(_))
));
}
#[test]
fn binding_gesture_roundtrips() {
let mut map = BTreeMap::new();
map.insert(GestureDirection::Up, Action::Copy);
map.insert(GestureDirection::Click, Action::Paste);
let mut bindings = BTreeMap::new();
bindings.insert(ButtonId::GestureButton, Binding::Gesture(map.clone()));
let back = binding_roundtrip(bindings);
assert_eq!(back[&ButtonId::GestureButton], Binding::Gesture(map));
}
#[test]
fn binding_direction_keyed_table_routes_to_gesture() {
for dir in GestureDirection::ALL {
let toml = format!("bindings.GestureButton.{dir} = \"None\"");
let parsed = toml::from_str::<BindingWrapper>(&toml).expect("deserialize");
assert!(
matches!(
parsed.bindings[&ButtonId::GestureButton],
Binding::Gesture(_)
),
"a {dir}-keyed table must route to Gesture, not Single"
);
}
}
#[test]
fn binding_payload_action_stays_single() {
let toml = "bindings.DpiToggle.SetDpiPreset = 2";
let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
assert_eq!(
parsed.bindings[&ButtonId::DpiToggle],
Binding::Single(Action::SetDpiPreset(2))
);
}
#[test]
fn binding_capture_region_roundtrips_as_single_string() {
let toml = "bindings.Back = \"CaptureRegion\"";
let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
assert_eq!(
parsed.bindings[&ButtonId::Back],
Binding::Single(Action::CaptureRegion)
);
let back = binding_roundtrip(parsed.bindings);
assert_eq!(
back[&ButtonId::Back],
Binding::Single(Action::CaptureRegion)
);
assert_eq!(Action::CaptureRegion.label(), "Capture Region");
assert_eq!(Action::CaptureRegion.category(), Category::System);
assert!(Action::catalog().contains(&Action::CaptureRegion));
}
#[test]
fn detect_swipe_below_threshold_keeps_accumulating() {
assert_eq!(detect_swipe(40, 5), None);
assert_eq!(detect_swipe(0, 0), None);
}
#[test]
fn detect_swipe_commits_clean_direction() {
assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
}
#[test]
fn detect_swipe_rejects_diagonal() {
assert_eq!(detect_swipe(60, 60), None);
assert_eq!(detect_swipe(-60, -60), None);
}
#[test]
fn detect_swipe_threshold_and_cross_band_boundaries() {
assert_eq!(
detect_swipe(GESTURE_SWIPE_THRESHOLD, 0),
Some(GestureDirection::Right)
);
assert_eq!(detect_swipe(GESTURE_SWIPE_THRESHOLD - 1, 0), None);
assert_eq!(detect_swipe(200, 69), Some(GestureDirection::Right));
assert_eq!(detect_swipe(200, 71), None);
assert_eq!(detect_swipe(100, 39), Some(GestureDirection::Right));
assert_eq!(detect_swipe(100, 41), None);
}
#[test]
fn detect_swipe_does_not_panic_on_extreme_values() {
assert_eq!(detect_swipe(i32::MAX, 0), Some(GestureDirection::Right));
assert_eq!(detect_swipe(i32::MIN, 0), Some(GestureDirection::Left));
assert_eq!(detect_swipe(0, i32::MAX), Some(GestureDirection::Down));
assert_eq!(detect_swipe(0, i32::MIN), Some(GestureDirection::Up));
assert_eq!(detect_swipe(i32::MIN, i32::MIN), None);
}
#[test]
fn accumulator_commits_a_direction_once_after_the_hold_gate() {
let mut acc = SwipeAccumulator::default();
acc.begin();
acc.backdate_hold_for_test();
assert_eq!(
acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
Some(GestureDirection::Right)
);
assert_eq!(acc.accumulate(50, 0), None);
}
#[test]
fn accumulator_does_not_commit_before_the_hold_gate() {
let mut acc = SwipeAccumulator::default();
acc.begin(); assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
acc.backdate_hold_for_test();
assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0).is_some());
}
#[test]
fn accumulator_end_reports_click_only_when_no_swipe_fired() {
let mut acc = SwipeAccumulator::default();
acc.begin();
acc.backdate_hold_for_test();
assert_eq!(acc.accumulate(2, -1), None);
assert!(acc.end(), "a hold that never swiped is a click");
acc.begin();
acc.backdate_hold_for_test();
assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0).is_some());
assert!(!acc.end(), "a committed swipe must not also click");
}
#[test]
fn accumulator_ignores_motion_when_not_holding() {
let mut acc = SwipeAccumulator::default();
assert!(!acc.is_holding());
assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
}
#[test]
fn accumulator_sums_sub_threshold_deltas_until_they_commit() {
let mut acc = SwipeAccumulator::default();
acc.begin();
acc.backdate_hold_for_test();
let step = GESTURE_SWIPE_THRESHOLD / 2 - 1;
assert_eq!(acc.accumulate(step, 0), None, "one step is sub-threshold");
assert_eq!(acc.accumulate(step, 0), None, "two steps still under");
assert_eq!(
acc.accumulate(step, 0),
Some(GestureDirection::Right),
"the running sum finally crosses the threshold"
);
}
#[test]
fn accumulator_saturates_instead_of_overflowing() {
let mut acc = SwipeAccumulator::default();
acc.begin();
acc.backdate_hold_for_test();
assert_eq!(
acc.accumulate(i32::MAX, i32::MAX),
None,
"a diagonal never commits"
);
assert_eq!(
acc.accumulate(i32::MAX, i32::MAX),
None,
"the saturating sum must not panic"
);
acc.begin();
acc.backdate_hold_for_test();
assert_eq!(acc.accumulate(i32::MAX, 0), Some(GestureDirection::Right));
}
#[test]
fn accumulator_begin_recovers_a_stale_hold() {
let mut acc = SwipeAccumulator::default();
acc.begin();
acc.backdate_hold_for_test();
assert_eq!(
acc.accumulate(-(GESTURE_SWIPE_THRESHOLD + 10), 0),
Some(GestureDirection::Left)
);
acc.begin();
acc.backdate_hold_for_test();
assert_eq!(
acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
Some(GestureDirection::Right)
);
}
#[test]
fn accumulator_end_without_a_hold_is_not_a_click() {
let mut acc = SwipeAccumulator::default();
assert!(!acc.end(), "a release with no hold is not a click");
acc.begin();
assert!(acc.end(), "the held release is a click");
assert!(!acc.end(), "the redundant second release is not a click");
}
fn roundtrip(action: &Action) -> Action {
let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
map.insert(ButtonId::Back, action.clone());
let w = RoundtripWrapper { binding: map };
let s = toml::to_string(&w).expect("serialize");
let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
back.binding
.into_values()
.next()
.expect("binding present after roundtrip")
}
#[test]
fn all_catalog_variants_roundtrip_toml() {
for action in Action::catalog() {
let back = roundtrip(&action);
assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
}
}
#[test]
fn custom_shortcut_roundtrips_toml() {
let action = Action::CustomShortcut(KeyCombo {
modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
key_code: 0x23, display: "⌘⇧P".into(),
});
assert_eq!(roundtrip(&action), action);
}
#[test]
fn key_combo_rendered_label_uses_display_when_set() {
let combo = KeyCombo {
modifiers: 0,
key_code: 0,
display: "preset".into(),
};
assert_eq!(combo.rendered_label(), "preset");
}
#[test]
fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
let combo = KeyCombo {
modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
key_code: 0x23, display: String::new(),
};
assert_eq!(combo.rendered_label(), "⇧⌘P");
}
#[test]
fn category_editing_variants() {
assert_eq!(Action::Copy.category(), Category::Editing);
assert_eq!(Action::Undo.category(), Category::Editing);
assert_eq!(Action::SelectAll.category(), Category::Editing);
assert_eq!(Action::Find.category(), Category::Editing);
assert_eq!(Action::Save.category(), Category::Editing);
assert_eq!(Action::Cut.category(), Category::Editing);
assert_eq!(Action::Redo.category(), Category::Editing);
assert_eq!(Action::Paste.category(), Category::Editing);
}
#[test]
fn category_browser_variants() {
assert_eq!(Action::BrowserBack.category(), Category::Browser);
assert_eq!(Action::BrowserForward.category(), Category::Browser);
assert_eq!(Action::NewTab.category(), Category::Browser);
assert_eq!(Action::CloseTab.category(), Category::Browser);
assert_eq!(Action::ReopenTab.category(), Category::Browser);
assert_eq!(Action::NextTab.category(), Category::Browser);
assert_eq!(Action::PrevTab.category(), Category::Browser);
assert_eq!(Action::ReloadPage.category(), Category::Browser);
}
#[test]
fn category_media_variants() {
assert_eq!(Action::PlayPause.category(), Category::Media);
assert_eq!(Action::NextTrack.category(), Category::Media);
assert_eq!(Action::PrevTrack.category(), Category::Media);
assert_eq!(Action::VolumeUp.category(), Category::Media);
assert_eq!(Action::VolumeDown.category(), Category::Media);
assert_eq!(Action::MuteVolume.category(), Category::Media);
}
#[test]
fn category_mouse_variants() {
assert_eq!(Action::LeftClick.category(), Category::Mouse);
assert_eq!(Action::RightClick.category(), Category::Mouse);
assert_eq!(Action::MiddleClick.category(), Category::Mouse);
}
#[test]
fn category_dpi_variants() {
assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
}
#[test]
fn category_scroll_variants() {
assert_eq!(Action::ScrollUp.category(), Category::Scroll);
assert_eq!(Action::ScrollDown.category(), Category::Scroll);
assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
}
#[test]
fn category_navigation_variants() {
assert_eq!(Action::MissionControl.category(), Category::Navigation);
assert_eq!(Action::AppExpose.category(), Category::Navigation);
assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
assert_eq!(Action::NextDesktop.category(), Category::Navigation);
assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
}
#[test]
fn category_system_variants() {
assert_eq!(Action::LockScreen.category(), Category::System);
assert_eq!(Action::Screenshot.category(), Category::System);
}
#[test]
fn category_labels_are_nonempty() {
let categories = [
Category::Editing,
Category::Browser,
Category::Media,
Category::Mouse,
Category::Dpi,
Category::Scroll,
Category::Navigation,
Category::System,
];
for cat in categories {
assert!(!cat.label().is_empty(), "label empty for {cat:?}");
}
}
#[test]
fn dpi_toggle_default_is_cycle_dpi_presets() {
assert_eq!(
default_binding(ButtonId::DpiToggle),
Action::CycleDpiPresets
);
}
}