use std::collections::BTreeMap;
use crate::binding::{
Action, Binding, ButtonId, GestureDirection, default_binding, default_binding_for,
};
use crate::config::Config;
#[must_use]
pub fn bindings_for(
config: &Config,
config_key: Option<&str>,
app_bundle: Option<&str>,
) -> BTreeMap<ButtonId, Action> {
button_bindings_for(config, config_key, app_bundle)
.into_iter()
.map(|(button, binding)| (button, binding.click_action()))
.collect()
}
#[must_use]
pub fn button_bindings_for(
config: &Config,
config_key: Option<&str>,
app_bundle: Option<&str>,
) -> BTreeMap<ButtonId, Binding> {
let stored = config_key
.map(|key| config.effective_bindings(key, app_bundle))
.unwrap_or_default();
let mut bindings: BTreeMap<ButtonId, Binding> = ButtonId::ALL
.iter()
.copied()
.map(|button| (button, Binding::Single(default_binding(button))))
.collect();
for (button, mut binding) in stored {
if let Binding::Gesture(map) = &mut binding {
map.entry(GestureDirection::Click)
.or_insert_with(|| default_binding(button));
}
bindings.insert(button, binding);
}
bindings
}
#[must_use]
pub fn hidpp_gesture_maps_for(
config: &Config,
config_key: Option<&str>,
) -> BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>> {
let Some(key) = config_key else {
return BTreeMap::new();
};
let stored = config.bindings_for(key);
ButtonId::ALL
.iter()
.copied()
.filter(|button| button.is_hidpp_gesture_source())
.filter_map(|button| {
let mut binding = stored
.get(&button)
.cloned()
.unwrap_or_else(|| default_binding_for(button));
binding.fill_gesture_defaults();
match binding {
Binding::Gesture(map) => Some((button, map)),
Binding::Single(_) | Binding::LongPress(_) => None,
}
})
.collect()
}
#[must_use]
pub fn oshook_gestures_for(
config: &Config,
config_key: Option<&str>,
app_bundle: Option<&str>,
) -> BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>> {
let Some(key) = config_key else {
return BTreeMap::new();
};
config
.effective_bindings(key, app_bundle)
.into_iter()
.filter(|(id, _)| id.is_os_hook_button())
.filter_map(|(id, binding)| match binding {
Binding::Gesture(map) => Some((id, map)),
Binding::Single(_) | Binding::LongPress(_) => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use crate::binding::{LongPressBinding, default_gesture_binding};
use super::*;
#[test]
fn click_less_gesture_keeps_default_click_in_projection() {
let mut cfg = Config::default();
let mut map = BTreeMap::new();
map.insert(GestureDirection::Up, Action::Copy);
cfg.set_binding("2b042", ButtonId::GestureButton, Binding::Gesture(map));
let projected = bindings_for(&cfg, Some("2b042"), None);
assert_eq!(
projected.get(&ButtonId::GestureButton),
Some(&default_binding(ButtonId::GestureButton)),
"a Click-less gesture must keep the default click, not None"
);
}
#[test]
fn lifecycle_projection_preserves_long_press_while_action_projection_uses_short() {
let mut cfg = Config::default();
let binding = Binding::LongPress(LongPressBinding::new(Action::Copy, Action::Paste));
cfg.set_binding("2b042", ButtonId::Back, binding.clone());
let lifecycle = button_bindings_for(&cfg, Some("2b042"), None);
assert_eq!(lifecycle.get(&ButtonId::Back), Some(&binding));
let actions = bindings_for(&cfg, Some("2b042"), None);
assert_eq!(actions.get(&ButtonId::Back), Some(&Action::Copy));
}
#[test]
fn a_rotation_rebind_leaves_the_wheels_tap_inert() {
let mut cfg = Config::default();
cfg.set_binding(
"2b034",
ButtonId::ThumbwheelScrollUp,
Action::VolumeUp.into(),
);
let projected = bindings_for(&cfg, Some("2b034"), None);
assert_eq!(projected.get(&ButtonId::Thumbwheel), Some(&Action::None));
}
#[test]
fn an_explicitly_bound_tap_survives_the_inert_default() {
let mut cfg = Config::default();
cfg.set_binding("2b034", ButtonId::Thumbwheel, Action::AppExpose.into());
let projected = bindings_for(&cfg, Some("2b034"), None);
assert_eq!(
projected.get(&ButtonId::Thumbwheel),
Some(&Action::AppExpose)
);
}
#[test]
fn explicit_gesture_click_overrides_default_in_projection() {
let mut cfg = Config::default();
let mut map = BTreeMap::new();
map.insert(GestureDirection::Click, Action::Paste);
cfg.set_binding("2b042", ButtonId::GestureButton, Binding::Gesture(map));
let projected = bindings_for(&cfg, Some("2b042"), None);
assert_eq!(
projected.get(&ButtonId::GestureButton),
Some(&Action::Paste)
);
}
#[test]
fn oshook_gestures_collects_only_os_hook_gesture_buttons() {
let mut cfg = Config::default();
cfg.set_binding(
"2b042",
ButtonId::Back,
Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
);
cfg.set_binding("2b042", ButtonId::MiddleClick, Action::MiddleClick.into());
cfg.set_binding(
"2b042",
ButtonId::GestureButton,
Binding::Gesture(BTreeMap::from([(
GestureDirection::Up,
Action::MissionControl,
)])),
);
let oshook = oshook_gestures_for(&cfg, Some("2b042"), None);
assert_eq!(oshook.len(), 1, "only the gesture-mode Back belongs here");
assert_eq!(
oshook.get(&ButtonId::Back),
Some(&BTreeMap::from([(GestureDirection::Up, Action::Copy)]))
);
assert!(!oshook.contains_key(&ButtonId::MiddleClick));
assert!(!oshook.contains_key(&ButtonId::GestureButton));
}
#[test]
fn oshook_gestures_includes_every_gesture_mode_button() {
let mut cfg = Config::default();
cfg.set_gesture_mode("2b042", ButtonId::Back, true);
cfg.set_gesture_mode("2b042", ButtonId::MiddleClick, true);
let oshook = oshook_gestures_for(&cfg, Some("2b042"), None);
assert!(oshook.contains_key(&ButtonId::Back), "got: {oshook:?}");
assert!(
oshook.contains_key(&ButtonId::MiddleClick),
"got: {oshook:?}"
);
}
#[test]
fn hidpp_gesture_maps_includes_every_gesture_mode_source() {
let mut cfg = Config::default();
cfg.set_gesture_mode("2b042", ButtonId::HapticPanel, true);
let maps = hidpp_gesture_maps_for(&cfg, Some("2b042"));
let dedicated = maps
.get(&ButtonId::GestureButton)
.expect("the dedicated button's default gesture mode must survive");
assert_eq!(
dedicated.get(&GestureDirection::Up),
Some(&default_gesture_binding(GestureDirection::Up))
);
let panel = maps
.get(&ButtonId::HapticPanel)
.expect("a gesture-mode panel must dispatch");
for dir in GestureDirection::ALL {
assert!(panel.contains_key(&dir), "unseeded panel arm {dir:?}");
}
}
#[test]
fn per_app_override_drops_the_owner_from_the_oshook_gesture_set() {
let mut cfg = Config::default();
cfg.set_gesture_mode("2b042", ButtonId::Back, true);
assert!(
oshook_gestures_for(&cfg, Some("2b042"), None).contains_key(&ButtonId::Back),
"Back gestures globally"
);
cfg.set_per_app_binding(
"2b042",
"com.apple.Safari",
ButtonId::Back,
Some(Action::NextTab),
);
assert!(
oshook_gestures_for(&cfg, Some("2b042"), Some("com.apple.Safari")).is_empty(),
"a per-app override of the owner removes it from the gesture set"
);
assert!(
oshook_gestures_for(&cfg, Some("2b042"), Some("com.other.App"))
.contains_key(&ButtonId::Back)
);
}
#[test]
fn hidpp_maps_silent_for_a_demoted_dedicated_button() {
let mut cfg = Config::default();
let maps = hidpp_gesture_maps_for(&cfg, Some("2b042"));
assert_eq!(
maps.get(&ButtonId::GestureButton)
.and_then(|m| m.get(&GestureDirection::Up)),
Some(&default_gesture_binding(GestureDirection::Up)),
"the dedicated button gestures by default, seeded"
);
cfg.set_gesture_mode("2b042", ButtonId::GestureButton, false);
cfg.set_gesture_mode("2b042", ButtonId::Back, true);
assert!(
hidpp_gesture_maps_for(&cfg, Some("2b042")).is_empty(),
"a demoted dedicated button must dispatch nothing over HID++"
);
}
}