Skip to main content

openlogi_core/
bindings.rs

1//! Binding-map construction: overlay the stored per-device (and per-app)
2//! bindings on top of the built-in defaults.
3//!
4//! Keyed by `config_key` (`Option<&str>`) rather than any UI device record so
5//! both the agent and the GUI can build the effective map from a [`Config`].
6
7use std::collections::BTreeMap;
8
9use crate::binding::{
10    Action, Binding, ButtonId, GestureDirection, default_binding, default_binding_for,
11};
12use crate::config::Config;
13
14/// Effective per-button single-action map for the device `config_key`, with
15/// `app_bundle`'s per-app overlay applied. Unset buttons fall back to
16/// [`default_binding`].
17///
18/// This is the map the OS hook and the HID++ button-press path consume, so a
19/// `Binding::Gesture` is projected to its `click_action()` — a gesture-mode
20/// button's per-direction swipes are dispatched via the separate
21/// [`hidpp_gesture_maps_for`] / [`oshook_gestures_for`] maps, not here.
22#[must_use]
23pub fn bindings_for(
24    config: &Config,
25    config_key: Option<&str>,
26    app_bundle: Option<&str>,
27) -> BTreeMap<ButtonId, Action> {
28    let stored = config_key
29        .map(|key| config.effective_bindings(key, app_bundle))
30        .unwrap_or_default();
31    let mut bindings: BTreeMap<ButtonId, Action> = ButtonId::ALL
32        .iter()
33        .copied()
34        .map(|b| (b, default_binding(b)))
35        .collect();
36    for (k, binding) in stored {
37        // A gesture binding with no explicit `Click` has no opinion on the
38        // plain-press action, so leave the button's default seed in place rather
39        // than clobbering it with the `Action::None` that `click_action()` would
40        // project. (An explicit `Single(Action::None)` — a user-disabled button —
41        // still overrides, as it should.)
42        if binding.is_gesture() && binding.direction_action(GestureDirection::Click).is_none() {
43            continue;
44        }
45        bindings.insert(k, binding.click_action());
46    }
47    bindings
48}
49
50/// Per-direction maps for every HID++ gesture source (the dedicated gesture
51/// button, the MX Master 4 haptic panel) in gesture mode on `config_key`,
52/// keyed by the button its captured swipes dispatch as. Each map is seeded
53/// via [`Binding::fill_gesture_defaults`] — the one canonical seeding rule —
54/// so the watcher always dispatches the full five-direction set the GUI
55/// shows. Empty when no HID++ source gestures (or `config_key` is `None`).
56#[must_use]
57pub fn hidpp_gesture_maps_for(
58    config: &Config,
59    config_key: Option<&str>,
60) -> BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>> {
61    let Some(key) = config_key else {
62        return BTreeMap::new();
63    };
64    let stored = config.bindings_for(key);
65    ButtonId::ALL
66        .iter()
67        .copied()
68        .filter(|button| button.is_hidpp_gesture_source())
69        .filter_map(|button| {
70            // The stored shape (or the button's canonical default) IS gesture
71            // mode — a Single-shaped source simply drops out.
72            let mut binding = stored
73                .get(&button)
74                .cloned()
75                .unwrap_or_else(|| default_binding_for(button));
76            binding.fill_gesture_defaults();
77            match binding {
78                Binding::Gesture(map) => Some((button, map)),
79                Binding::Single(_) => None,
80            }
81        })
82        .collect()
83}
84
85/// Per-direction maps for every OS-hook button (Middle/Back/Forward) in
86/// gesture mode on `config_key`, with `app_bundle`'s per-app overlay applied,
87/// for the OS hook to resolve a hold+swipe. Gesture mode is per-button (see
88/// [`Config::is_gesture_mode`]), so any number of entries may be live at once —
89/// concurrency between them is the hook's first-hold-wins policy, not a config
90/// concern.
91///
92/// Unlike [`hidpp_gesture_maps_for`] (whose maps seed every direction at
93/// projection time), this returns each button's raw stored map. In practice
94/// those maps are
95/// already fully populated — [`Config::set_gesture_mode`] seeds all five
96/// directions via [`Binding::fill_gesture_defaults`] when a button is
97/// promoted — so only a hand-edited sparse map leaves a direction unbound, in
98/// which case that swipe simply does nothing. The dedicated gesture button is
99/// intentionally excluded: it never reaches the OS hook (it's captured over
100/// HID++), so it has no entry here.
101///
102/// A per-app override of a gesture button turns it into a [`Binding::Single`]
103/// for that app, so it stops being a gesture button there and falls through to
104/// the single-action path (which applies the override) — mirroring how a single
105/// binding is overridden per app.
106#[must_use]
107pub fn oshook_gestures_for(
108    config: &Config,
109    config_key: Option<&str>,
110    app_bundle: Option<&str>,
111) -> BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>> {
112    let Some(key) = config_key else {
113        return BTreeMap::new();
114    };
115    // Read the per-app *effective* map: a per-app override replaces a gesture
116    // button with a `Single`, dropping it from the gesture set for that app.
117    config
118        .effective_bindings(key, app_bundle)
119        .into_iter()
120        .filter(|(id, _)| id.is_os_hook_button())
121        .filter_map(|(id, binding)| match binding {
122            Binding::Gesture(map) => Some((id, map)),
123            Binding::Single(_) => None,
124        })
125        .collect()
126}
127
128#[cfg(test)]
129#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
130mod tests {
131    use crate::binding::default_gesture_binding;
132
133    use super::*;
134
135    #[test]
136    fn click_less_gesture_keeps_default_click_in_projection() {
137        // A gesture binding with no explicit `Click` (a migrated sparse v1 map or
138        // a hand-edited config) must not project to `Action::None` and silently
139        // disable the button — the button's default click survives.
140        let mut cfg = Config::default();
141        let mut map = BTreeMap::new();
142        map.insert(GestureDirection::Up, Action::Copy);
143        cfg.set_binding("2b042", ButtonId::GestureButton, Binding::Gesture(map));
144
145        let projected = bindings_for(&cfg, Some("2b042"), None);
146        assert_eq!(
147            projected.get(&ButtonId::GestureButton),
148            Some(&default_binding(ButtonId::GestureButton)),
149            "a Click-less gesture must keep the default click, not None"
150        );
151    }
152
153    #[test]
154    fn explicit_gesture_click_overrides_default_in_projection() {
155        // A gesture binding that DOES define `Click` projects that action.
156        let mut cfg = Config::default();
157        let mut map = BTreeMap::new();
158        map.insert(GestureDirection::Click, Action::Paste);
159        cfg.set_binding("2b042", ButtonId::GestureButton, Binding::Gesture(map));
160
161        let projected = bindings_for(&cfg, Some("2b042"), None);
162        assert_eq!(
163            projected.get(&ButtonId::GestureButton),
164            Some(&Action::Paste)
165        );
166    }
167
168    #[test]
169    fn oshook_gestures_collects_only_os_hook_gesture_buttons() {
170        let mut cfg = Config::default();
171        // A gesture-mode Back (an OS-hook button) — included, raw map preserved.
172        cfg.set_binding(
173            "2b042",
174            ButtonId::Back,
175            Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
176        );
177        // A single-mode Middle — excluded (not a gesture button).
178        cfg.set_binding("2b042", ButtonId::MiddleClick, Action::MiddleClick.into());
179        // The dedicated HID++ gesture button — excluded (it never reaches the
180        // OS hook, so it must not appear in the hook's gesture map).
181        cfg.set_binding(
182            "2b042",
183            ButtonId::GestureButton,
184            Binding::Gesture(BTreeMap::from([(
185                GestureDirection::Up,
186                Action::MissionControl,
187            )])),
188        );
189
190        let oshook = oshook_gestures_for(&cfg, Some("2b042"), None);
191        assert_eq!(oshook.len(), 1, "only the gesture-mode Back belongs here");
192        assert_eq!(
193            oshook.get(&ButtonId::Back),
194            Some(&BTreeMap::from([(GestureDirection::Up, Action::Copy)]))
195        );
196        assert!(!oshook.contains_key(&ButtonId::MiddleClick));
197        assert!(!oshook.contains_key(&ButtonId::GestureButton));
198    }
199
200    #[test]
201    fn oshook_gestures_includes_every_gesture_mode_button() {
202        // The owner lock is gone: every OS-hook button in gesture mode
203        // dispatches, each through its own direction map.
204        let mut cfg = Config::default();
205        cfg.set_gesture_mode("2b042", ButtonId::Back, true);
206        cfg.set_gesture_mode("2b042", ButtonId::MiddleClick, true);
207
208        let oshook = oshook_gestures_for(&cfg, Some("2b042"), None);
209        assert!(oshook.contains_key(&ButtonId::Back), "got: {oshook:?}");
210        assert!(
211            oshook.contains_key(&ButtonId::MiddleClick),
212            "got: {oshook:?}"
213        );
214    }
215
216    #[test]
217    fn hidpp_gesture_maps_includes_every_gesture_mode_source() {
218        // Both HID++ sources in gesture mode dispatch simultaneously, each
219        // through its own seeded direction map.
220        let mut cfg = Config::default();
221        cfg.set_gesture_mode("2b042", ButtonId::HapticPanel, true);
222
223        let maps = hidpp_gesture_maps_for(&cfg, Some("2b042"));
224        // The dedicated button gestures by default...
225        let dedicated = maps
226            .get(&ButtonId::GestureButton)
227            .expect("the dedicated button's default gesture mode must survive");
228        assert_eq!(
229            dedicated.get(&GestureDirection::Up),
230            Some(&default_gesture_binding(GestureDirection::Up))
231        );
232        // ...and the panel's promotion adds a second, fully-seeded map.
233        let panel = maps
234            .get(&ButtonId::HapticPanel)
235            .expect("a gesture-mode panel must dispatch");
236        for dir in GestureDirection::ALL {
237            assert!(panel.contains_key(&dir), "unseeded panel arm {dir:?}");
238        }
239    }
240
241    #[test]
242    fn per_app_override_drops_the_owner_from_the_oshook_gesture_set() {
243        // Back is the gesture owner globally...
244        let mut cfg = Config::default();
245        cfg.set_gesture_mode("2b042", ButtonId::Back, true);
246        assert!(
247            oshook_gestures_for(&cfg, Some("2b042"), None).contains_key(&ButtonId::Back),
248            "Back gestures globally"
249        );
250
251        // ...but a per-app override makes it a single action in that app, so it
252        // must drop out of the gesture set there (and fall through to the
253        // single-action path, which applies the override).
254        cfg.set_per_app_binding(
255            "2b042",
256            "com.apple.Safari",
257            ButtonId::Back,
258            Some(Action::NextTab),
259        );
260        assert!(
261            oshook_gestures_for(&cfg, Some("2b042"), Some("com.apple.Safari")).is_empty(),
262            "a per-app override of the owner removes it from the gesture set"
263        );
264        // Other apps are unaffected — Back still gestures.
265        assert!(
266            oshook_gestures_for(&cfg, Some("2b042"), Some("com.other.App"))
267                .contains_key(&ButtonId::Back)
268        );
269    }
270
271    #[test]
272    fn hidpp_maps_silent_for_a_demoted_dedicated_button() {
273        // Default device: the dedicated HID++ gesture button gestures, with its
274        // defaults seeded.
275        let mut cfg = Config::default();
276        let maps = hidpp_gesture_maps_for(&cfg, Some("2b042"));
277        assert_eq!(
278            maps.get(&ButtonId::GestureButton)
279                .and_then(|m| m.get(&GestureDirection::Up)),
280            Some(&default_gesture_binding(GestureDirection::Up)),
281            "the dedicated button gestures by default, seeded"
282        );
283
284        // Demoting it silences the watcher for 0x00c3 — and promoting an
285        // OS-hook button never resurrects it.
286        cfg.set_gesture_mode("2b042", ButtonId::GestureButton, false);
287        cfg.set_gesture_mode("2b042", ButtonId::Back, true);
288        assert!(
289            hidpp_gesture_maps_for(&cfg, Some("2b042")).is_empty(),
290            "a demoted dedicated button must dispatch nothing over HID++"
291        );
292    }
293}