From 95a509d4034f78072f270f9c22d0b039516dfaaa Mon Sep 17 00:00:00 2001 From: Myron Date: Mon, 20 Jul 2026 17:47:47 +0800 Subject: [PATCH] fix(agent): apply per-app gesture button clicks --- .../openlogi-agent-core/src/capture_plan.rs | 2 +- .../src/orchestrator/tests.rs | 22 ++++++- crates/openlogi-core/src/bindings.rs | 61 +++++++++++++++++-- crates/openlogi-desktop/src/state/bindings.rs | 2 +- 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/crates/openlogi-agent-core/src/capture_plan.rs b/crates/openlogi-agent-core/src/capture_plan.rs index 34c040ef..1789ce01 100644 --- a/crates/openlogi-agent-core/src/capture_plan.rs +++ b/crates/openlogi-agent-core/src/capture_plan.rs @@ -67,7 +67,7 @@ pub fn plan_for_device( // One direction map per HID++ source in gesture mode — several may // gesture at once, each armed with its own raw-XY divert (the watcher // derives the CIDs to divert from this map's keys). - let gesture_bindings = hidpp_gesture_maps_for(config, Some(config_key)); + let gesture_bindings = hidpp_gesture_maps_for(config, Some(config_key), app); // The HID++ gesture sources never reach the OS hook, so a non-default // single binding on one is deliverable only via a plain HID++ divert — but // only while the source is NOT in gesture mode (the raw-XY gesture divert diff --git a/crates/openlogi-agent-core/src/orchestrator/tests.rs b/crates/openlogi-agent-core/src/orchestrator/tests.rs index 4b176844..5e0fb26c 100644 --- a/crates/openlogi-agent-core/src/orchestrator/tests.rs +++ b/crates/openlogi-agent-core/src/orchestrator/tests.rs @@ -5,7 +5,7 @@ use super::{ any_device_needs_capture_rearm, build_devices, configured_wheel_mode, host_switch_links, pick_current, plan_reapply, reapply_targets, }; -use openlogi_core::binding::{Action, ButtonId}; +use openlogi_core::binding::{Action, ButtonId, GestureDirection}; use openlogi_core::config::{Config, LightSettings, ScrollResolution}; use openlogi_core::device::{ Capabilities, DeviceInventory, DeviceKind, DeviceModelInfo, DeviceTransports, @@ -745,6 +745,18 @@ fn published_back_binding(orch: &Orchestrator) -> Option { }) } +/// The published dedicated HID++ gesture button's plain-click action. +fn published_gesture_click(orch: &Orchestrator) -> Option { + orch.shared.capture_plans.read().ok().and_then(|plans| { + plans.first().and_then(|plan| { + plan.gesture_bindings + .get(&ButtonId::GestureButton) + .and_then(|map| map.get(&GestureDirection::Click)) + .cloned() + }) + }) +} + #[test] fn app_switch_republishes_capture_plans() { // HID++ dispatch reads `plan.bindings` at event time, so a @@ -758,6 +770,12 @@ fn app_switch_republishes_capture_plans() { ButtonId::Back, Some(Action::Undo), ); + config.set_per_app_binding( + "a", + "com.example.editor", + ButtonId::GestureButton, + Some(Action::BrowserBack), + ); let mut orch = orchestrator(config); orch.devices = vec![dev("a", 1, true)]; orch.rebuild(); @@ -766,6 +784,8 @@ fn app_switch_republishes_capture_plans() { Some(Action::Undo), "no per-app overlay while no app is in front" ); + assert_ne!(published_gesture_click(&orch), Some(Action::BrowserBack)); orch.set_current_app(Some("com.example.editor".into())); assert_eq!(published_back_binding(&orch), Some(Action::Undo)); + assert_eq!(published_gesture_click(&orch), Some(Action::BrowserBack)); } diff --git a/crates/openlogi-core/src/bindings.rs b/crates/openlogi-core/src/bindings.rs index 9852f5f7..68784fe2 100644 --- a/crates/openlogi-core/src/bindings.rs +++ b/crates/openlogi-core/src/bindings.rs @@ -52,16 +52,20 @@ pub fn bindings_for( /// keyed by the button its captured swipes dispatch as. Each map is seeded /// via [`Binding::fill_gesture_defaults`] — the one canonical seeding rule — /// so the watcher always dispatches the full five-direction set the GUI -/// shows. Empty when no HID++ source gestures (or `config_key` is `None`). +/// shows. A per-app single-action override replaces only that source's Click +/// arm; its global swipe arms and gesture mode remain intact. Empty when no +/// HID++ source gestures (or `config_key` is `None`). #[must_use] pub fn hidpp_gesture_maps_for( config: &Config, config_key: Option<&str>, + app_bundle: Option<&str>, ) -> BTreeMap> { let Some(key) = config_key else { return BTreeMap::new(); }; let stored = config.bindings_for(key); + let effective = app_bundle.map(|app| config.effective_bindings(key, Some(app))); ButtonId::ALL .iter() .copied() @@ -75,7 +79,17 @@ pub fn hidpp_gesture_maps_for( .unwrap_or_else(|| default_binding_for(button)); binding.fill_gesture_defaults(); match binding { - Binding::Gesture(map) => Some((button, map)), + Binding::Gesture(mut map) => { + // HID++ gesture sources never reach the OS hook. Keep the + // global gesture shape armed and apply an app's single + // override to the plain press only, preserving swipes. + if let Some(Binding::Single(action)) = + effective.as_ref().and_then(|map| map.get(&button)) + { + map.insert(GestureDirection::Click, action.clone()); + } + Some((button, map)) + } Binding::Single(_) => None, } }) @@ -220,7 +234,7 @@ mod tests { let mut cfg = Config::default(); cfg.set_gesture_mode("2b042", ButtonId::HapticPanel, true); - let maps = hidpp_gesture_maps_for(&cfg, Some("2b042")); + let maps = hidpp_gesture_maps_for(&cfg, Some("2b042"), None); // The dedicated button gestures by default... let dedicated = maps .get(&ButtonId::GestureButton) @@ -273,7 +287,7 @@ mod tests { // Default device: the dedicated HID++ gesture button gestures, with its // defaults seeded. let mut cfg = Config::default(); - let maps = hidpp_gesture_maps_for(&cfg, Some("2b042")); + let maps = hidpp_gesture_maps_for(&cfg, Some("2b042"), None); assert_eq!( maps.get(&ButtonId::GestureButton) .and_then(|m| m.get(&GestureDirection::Up)), @@ -286,8 +300,45 @@ mod tests { 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(), + hidpp_gesture_maps_for(&cfg, Some("2b042"), None).is_empty(), "a demoted dedicated button must dispatch nothing over HID++" ); } + + #[test] + fn hidpp_source_applies_per_app_click_and_preserves_swipes() { + let mut cfg = Config::default(); + cfg.set_per_app_binding( + "2b042", + "com.apple.Safari", + ButtonId::GestureButton, + Some(Action::BrowserBack), + ); + + let global = hidpp_gesture_maps_for(&cfg, Some("2b042"), None); + let safari = hidpp_gesture_maps_for(&cfg, Some("2b042"), Some("com.apple.Safari")); + let global_map = global + .get(&ButtonId::GestureButton) + .expect("default HID++ gesture map"); + let safari_map = safari + .get(&ButtonId::GestureButton) + .expect("app override must keep gesture mode armed"); + + assert_eq!( + safari_map.get(&GestureDirection::Click), + Some(&Action::BrowserBack) + ); + for direction in [ + GestureDirection::Up, + GestureDirection::Down, + GestureDirection::Left, + GestureDirection::Right, + ] { + assert_eq!( + safari_map.get(&direction), + global_map.get(&direction), + "per-app click override changed {direction:?}" + ); + } + } } diff --git a/crates/openlogi-desktop/src/state/bindings.rs b/crates/openlogi-desktop/src/state/bindings.rs index 77b59372..64546589 100644 --- a/crates/openlogi-desktop/src/state/bindings.rs +++ b/crates/openlogi-desktop/src/state/bindings.rs @@ -124,7 +124,7 @@ impl AppState { // so the menus can never drift from what the agent actually does: // HID++ sources seeded like the gesture watcher, OS-hook buttons raw // like the hook (global view — no per-app overlay here). - let mut maps = hidpp_gesture_maps_for(&self.config, Some(key)); + let mut maps = hidpp_gesture_maps_for(&self.config, Some(key), None); maps.extend(oshook_gestures_for(&self.config, Some(key), None)); maps }