pub mod action;
pub mod binding;
pub mod chord;
pub mod condition;
pub mod conflict;
mod error;
mod hotkey;
mod manager;
pub mod mode;
pub mod macos;
pub mod remap;
pub mod repeat_gate;
pub use action::Action;
pub use binding::Binding;
pub use chord::{ChordState, KeyChord};
pub use condition::{Condition, MatchContext};
pub use conflict::{detect_conflicts, ConflictEntry, ConflictReport};
pub use error::AwaseError;
pub use hotkey::{atlas_hotkey, Hotkey, Key, Modifiers};
pub use manager::{HotkeyManager, NoopManager};
pub use mode::{BindingMap, KeyMode, MatchResult};
pub use remap::KeyRemap;
pub use repeat_gate::{KeyRepeatGate, DEFAULT_MIN_INTERVAL};
#[cfg(test)]
mod integration_tests {
use super::*;
#[test]
fn full_wm_scenario() {
let mut map = BindingMap::new();
let default = map.mode_mut("default").unwrap();
default.add_binding(
Binding::new(
Hotkey::parse("cmd+h").unwrap(),
Action::command("focus_west"),
)
.with_condition(Condition {
app_exclude: Some("Terminal|ghostty".to_string()),
..Default::default()
}),
);
default.add_binding(Binding::new(
Hotkey::parse("cmd+j").unwrap(),
Action::command("focus_south"),
));
default.add_binding(Binding::new(
Hotkey::parse("ctrl+alt+r").unwrap(),
Action::mode_switch("resize"),
));
let mut resize = KeyMode::new("resize", false);
resize.add_binding(Binding::new(
Hotkey::parse("h").unwrap(),
Action::command("shrink_west"),
));
resize.add_binding(Binding::new(
Hotkey::parse("l").unwrap(),
Action::command("grow_east"),
));
resize.add_binding(Binding::new(
Hotkey::parse("escape").unwrap(),
Action::mode_switch("default"),
));
map.add_mode(resize);
map.add_chord(KeyChord {
leader: Hotkey::parse("ctrl+a").unwrap(),
follower: Hotkey::parse("c").unwrap(),
timeout_ms: 1000,
action: Action::exec("open -a Terminal"),
});
map.add_remap(KeyRemap::new(
Hotkey::parse("capslock").unwrap(),
Hotkey::parse("escape").unwrap(),
));
let safari_ctx = MatchContext {
focused_app_bundle_id: Some("com.apple.Safari".to_string()),
..Default::default()
};
let terminal_ctx = MatchContext {
focused_app_bundle_id: Some("com.apple.Terminal".to_string()),
..Default::default()
};
let result = map.match_key(Hotkey::parse("cmd+h").unwrap(), &safari_ctx);
assert_eq!(
result,
MatchResult::Matched {
action: Action::command("focus_west"),
consume: true,
}
);
let result = map.match_key(Hotkey::parse("cmd+h").unwrap(), &terminal_ctx);
assert_eq!(result, MatchResult::NoMatch);
let result = map.match_key(Hotkey::parse("capslock").unwrap(), &safari_ctx);
assert_eq!(
result,
MatchResult::Remapped {
to: Hotkey::parse("escape").unwrap(),
}
);
let result = map.match_key(Hotkey::parse("ctrl+alt+r").unwrap(), &safari_ctx);
assert_eq!(
result,
MatchResult::Matched {
action: Action::mode_switch("resize"),
consume: true,
}
);
map.set_mode("resize").unwrap();
assert_eq!(map.current_mode(), "resize");
assert!(!map.current_mode_passthrough());
let result = map.match_key(Hotkey::parse("h").unwrap(), &safari_ctx);
assert_eq!(
result,
MatchResult::Matched {
action: Action::command("shrink_west"),
consume: true,
}
);
let result = map.match_key(Hotkey::parse("escape").unwrap(), &safari_ctx);
assert_eq!(
result,
MatchResult::Matched {
action: Action::mode_switch("default"),
consume: true,
}
);
map.set_mode("default").unwrap();
assert_eq!(map.current_mode(), "default");
let result = map.match_key(Hotkey::parse("ctrl+a").unwrap(), &safari_ctx);
assert!(matches!(result, MatchResult::ChordPending { .. }));
let result = map.match_key(Hotkey::parse("c").unwrap(), &safari_ctx);
assert_eq!(
result,
MatchResult::Matched {
action: Action::exec("open -a Terminal"),
consume: true,
}
);
}
#[test]
fn conflict_detection_for_wm_scenario() {
let mut default = KeyMode::new("default", true);
default.add_binding(Binding::new(
Hotkey::parse("ctrl+a").unwrap(),
Action::command("select_all"),
));
let chord = KeyChord {
leader: Hotkey::parse("ctrl+a").unwrap(),
follower: Hotkey::parse("c").unwrap(),
timeout_ms: 1000,
action: Action::exec("open -a Terminal"),
};
let report = detect_conflicts(&[&default], &[chord]);
assert!(!report.is_clean());
assert_eq!(report.conflicts.len(), 1);
}
#[test]
fn noop_manager_via_trait_object() {
let mut manager: Box<dyn HotkeyManager> = Box::new(NoopManager::new());
let hk = Hotkey::parse("cmd+space").unwrap();
manager.register(1, hk).unwrap();
assert!(manager.register(1, hk).is_err());
manager.unregister(1).unwrap();
manager.register(1, hk).unwrap();
}
#[test]
fn parsed_hotkeys_as_hashmap_keys() {
use std::collections::HashMap;
let mut bindings: HashMap<Hotkey, &str> = HashMap::new();
let plus_format = Hotkey::parse("cmd+alt+h").unwrap();
let skhd_format = Hotkey::parse("cmd + alt - h").unwrap();
bindings.insert(plus_format, "focus_west");
assert_eq!(bindings.get(&skhd_format), Some(&"focus_west"));
}
#[test]
fn display_always_parseable() {
let hotkeys = [
Hotkey::new(Modifiers::NONE, Key::Escape),
Hotkey::new(Modifiers::CMD, Key::Space),
Hotkey::new(Modifiers::CMD | Modifiers::SHIFT, Key::A),
Hotkey::new(Modifiers::HYPER, Key::F12),
Hotkey::new(Modifiers::FN | Modifiers::CAPS_LOCK, Key::H),
Hotkey::new(Modifiers::NONE, Key::MouseLeft),
Hotkey::new(Modifiers::NONE, Key::NumpadEnter),
Hotkey::new(Modifiers::NONE, Key::BrightnessUp),
];
for hk in &hotkeys {
let displayed = hk.display();
let reparsed = Hotkey::parse(&displayed).unwrap_or_else(|e| {
panic!("failed to reparse \"{displayed}\" (from {hk:?}): {e}");
});
assert_eq!(hk, &reparsed, "roundtrip failed for {hk:?}");
}
}
#[test]
fn macos_keycode_roundtrip_via_public_api() {
let key = Key::A;
let code = macos::key_to_keycode(key).unwrap();
let back = macos::keycode_to_key(code).unwrap();
assert_eq!(key, back);
}
#[test]
fn macos_flags_roundtrip_via_public_api() {
let mods = Modifiers::CMD | Modifiers::SHIFT;
let flags = macos::modifiers_to_cg_flags(mods);
let back = macos::cg_flags_to_modifiers(flags);
assert_eq!(mods, back);
}
#[test]
fn error_display_all_variants() {
let errors = [
AwaseError::InvalidHotkey("bad combo".to_string()),
AwaseError::AlreadyRegistered(42),
AwaseError::ModeNotFound("nonexistent".to_string()),
AwaseError::DuplicateBinding {
mode: "default".to_string(),
hotkey: "cmd+a".to_string(),
},
AwaseError::PermissionDenied("accessibility not granted".to_string()),
AwaseError::Platform("CGEventTap failed".to_string()),
];
for err in &errors {
let msg = format!("{err}");
assert!(!msg.is_empty(), "Display for {err:?} should not be empty");
}
assert!(format!("{}", errors[0]).contains("bad combo"));
assert!(format!("{}", errors[1]).contains("42"));
assert!(format!("{}", errors[2]).contains("nonexistent"));
assert!(format!("{}", errors[3]).contains("cmd+a"));
assert!(format!("{}", errors[3]).contains("default"));
assert!(format!("{}", errors[4]).contains("accessibility"));
assert!(format!("{}", errors[5]).contains("CGEventTap"));
}
#[test]
fn hotkey_serde_roundtrip() {
let hotkeys = [
Hotkey::new(Modifiers::NONE, Key::A),
Hotkey::new(Modifiers::CMD | Modifiers::SHIFT, Key::F12),
Hotkey::new(Modifiers::HYPER, Key::Space),
Hotkey::new(Modifiers::FN | Modifiers::CAPS_LOCK, Key::NumpadEnter),
];
for hk in &hotkeys {
let json = serde_json::to_string(hk).unwrap();
let deserialized: Hotkey = serde_json::from_str(&json).unwrap();
assert_eq!(hk, &deserialized, "serde roundtrip failed for {hk:?}");
}
}
#[test]
fn modifiers_bitor_assign_accumulates() {
let mut mods = Modifiers::NONE;
assert!(mods.is_empty());
mods |= Modifiers::CMD;
assert!(mods.contains(Modifiers::CMD));
assert!(!mods.contains(Modifiers::ALT));
mods |= Modifiers::ALT;
assert!(mods.contains(Modifiers::CMD));
assert!(mods.contains(Modifiers::ALT));
mods |= Modifiers::CMD;
assert_eq!(mods, Modifiers::CMD | Modifiers::ALT);
}
#[test]
fn modifiers_display_all_six() {
let all = Modifiers::CMD
| Modifiers::CTRL
| Modifiers::ALT
| Modifiers::SHIFT
| Modifiers::FN
| Modifiers::CAPS_LOCK;
let s = format!("{all}");
assert_eq!(s, "cmd+ctrl+alt+shift+fn+caps_lock");
}
#[test]
fn binding_map_mode_immutable_accessor() {
let mut map = BindingMap::new();
let default = map.mode("default");
assert!(default.is_some());
assert_eq!(default.unwrap().name, "default");
assert!(map.mode("nonexistent").is_none());
map.add_mode(mode::KeyMode::new("resize", false));
let resize = map.mode("resize");
assert!(resize.is_some());
assert!(!resize.unwrap().passthrough);
}
#[test]
fn key_mode_serde_roundtrip_empty() {
let mode = mode::KeyMode::new("resize", false);
let json = serde_json::to_string(&mode).unwrap();
let deserialized: mode::KeyMode = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "resize");
assert!(!deserialized.passthrough);
assert!(deserialized.bindings.is_empty());
}
#[test]
fn key_mode_debug_includes_fields() {
let mut mode = mode::KeyMode::new("resize", false);
mode.add_binding(binding::Binding::new(
Hotkey::parse("h").unwrap(),
action::Action::command("shrink"),
));
let debug = format!("{mode:?}");
assert!(debug.contains("resize"));
assert!(debug.contains("shrink"));
assert!(debug.contains("passthrough"));
let found = mode.find_binding(
&Hotkey::parse("h").unwrap(),
&condition::MatchContext::default(),
);
assert!(found.is_some());
assert_eq!(found.unwrap().action, action::Action::command("shrink"));
}
#[test]
fn match_result_clone_and_debug() {
let variants = [
mode::MatchResult::NoMatch,
mode::MatchResult::Matched {
action: action::Action::command("test"),
consume: true,
},
mode::MatchResult::ChordPending {
leader: Hotkey::new(Modifiers::CTRL, Key::A),
timeout_ms: 1000,
},
mode::MatchResult::Remapped {
to: Hotkey::new(Modifiers::NONE, Key::Escape),
},
];
for variant in &variants {
let cloned = variant.clone();
assert_eq!(variant, &cloned);
let debug = format!("{variant:?}");
assert!(!debug.is_empty());
}
}
#[test]
fn multiple_remaps_with_mixed_conditions() {
let mut map = BindingMap::new();
map.add_remap(remap::KeyRemap::new(
Hotkey::new(Modifiers::NONE, Key::CapsLock),
Hotkey::new(Modifiers::NONE, Key::Escape),
).with_condition(Condition {
app: Some("Terminal".to_string()),
..Default::default()
}));
map.add_remap(remap::KeyRemap::new(
Hotkey::new(Modifiers::NONE, Key::CapsLock),
Hotkey::new(Modifiers::NONE, Key::Tab),
));
let caps = Hotkey::new(Modifiers::NONE, Key::CapsLock);
let term_ctx = condition::MatchContext {
focused_app_bundle_id: Some("com.apple.Terminal".to_string()),
..Default::default()
};
let result = map.match_key(caps, &term_ctx);
assert_eq!(
result,
mode::MatchResult::Remapped {
to: Hotkey::new(Modifiers::NONE, Key::Escape),
}
);
let safari_ctx = condition::MatchContext {
focused_app_bundle_id: Some("com.apple.Safari".to_string()),
..Default::default()
};
let result = map.match_key(caps, &safari_ctx);
assert_eq!(
result,
mode::MatchResult::Remapped {
to: Hotkey::new(Modifiers::NONE, Key::Tab),
}
);
}
#[test]
fn conflict_report_debug_and_entry_clone() {
let entry = ConflictEntry {
mode: "default".to_string(),
hotkey: Hotkey::new(Modifiers::CTRL, Key::A),
existing: "binding: select_all".to_string(),
new: "chord leader".to_string(),
};
let cloned = entry.clone();
assert_eq!(entry, cloned);
let report = ConflictReport {
conflicts: vec![entry],
};
let debug = format!("{report:?}");
assert!(debug.contains("ConflictReport"));
assert!(debug.contains("default"));
}
#[test]
fn skhd_leading_space_dash_not_skhd() {
let result = Hotkey::parse(" - a");
assert!(result.is_err());
}
#[test]
fn skhd_valid_no_modifier_text() {
let hk = Hotkey::parse("fn - a").unwrap();
assert!(hk.modifiers.contains(Modifiers::FN));
assert_eq!(hk.key, Key::A);
}
#[test]
fn parse_unicode_returns_error() {
let result = Hotkey::parse("cmd+\u{00e9}"); assert!(result.is_err());
match result.unwrap_err() {
AwaseError::InvalidHotkey(msg) => assert!(msg.contains("unknown key")),
other => panic!("expected InvalidHotkey, got {other:?}"),
}
}
#[test]
fn remap_complex_modifier_serde() {
let remap = remap::KeyRemap::new(
Hotkey::new(Modifiers::HYPER, Key::Space),
Hotkey::new(Modifiers::CMD | Modifiers::SHIFT, Key::F12),
).with_condition(Condition {
app: Some("Safari|Chrome".to_string()),
app_exclude: Some("Firefox".to_string()),
title: Some("Dashboard".to_string()),
display: Some(2),
});
let json = serde_json::to_string_pretty(&remap).unwrap();
let deserialized: remap::KeyRemap = serde_json::from_str(&json).unwrap();
assert_eq!(remap, deserialized);
}
#[test]
fn conflict_detection_same_leader_multiple_chords() {
let mut mode = mode::KeyMode::new("default", true);
mode.add_binding(binding::Binding::new(
Hotkey::new(Modifiers::CTRL, Key::A),
action::Action::command("select_all"),
));
let chord1 = chord::KeyChord {
leader: Hotkey::new(Modifiers::CTRL, Key::A),
follower: Hotkey::new(Modifiers::NONE, Key::C),
timeout_ms: 1000,
action: action::Action::command("new_window"),
};
let chord2 = chord::KeyChord {
leader: Hotkey::new(Modifiers::CTRL, Key::A),
follower: Hotkey::new(Modifiers::NONE, Key::N),
timeout_ms: 1000,
action: action::Action::command("next_window"),
};
let report = detect_conflicts(&[&mode], &[chord1, chord2]);
assert_eq!(report.conflicts.len(), 2);
assert!(report.conflicts.iter().all(|c| c.hotkey == Hotkey::new(Modifiers::CTRL, Key::A)));
}
#[test]
fn list_bindings_respects_current_mode() {
let mut map = BindingMap::new();
map.mode_mut("default").unwrap().add_binding(
binding::Binding::new(
Hotkey::parse("cmd+a").unwrap(),
action::Action::command("default_action"),
),
);
let mut other = mode::KeyMode::new("other", true);
other.add_binding(binding::Binding::new(
Hotkey::parse("cmd+b").unwrap(),
action::Action::command("other_b"),
));
other.add_binding(binding::Binding::new(
Hotkey::parse("cmd+c").unwrap(),
action::Action::command("other_c"),
));
map.add_mode(other);
assert_eq!(map.list_bindings().len(), 1);
map.set_mode("other").unwrap();
assert_eq!(map.list_bindings().len(), 2);
}
}