diff --git a/README.md b/README.md index 6edb10e5..510eb0ea 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,9 @@ Things OpenLogi does that Options+ won't: user unit, and `.deb` / `.rpm` / `.pkg.tar.zst` packages. - **Move the Gesture Button.** Pick which physical button owns the gesture role — the dedicated Gesture Button, middle, back, or forward — with per-direction swipe - bindings, or turn gestures off entirely. Options+ pins the gesture role to - the dedicated Gesture Button. + bindings. Turn OpenLogi gestures off while preserving the control's native + action, or disable the dedicated control entirely. Options+ pins the gesture + role to the dedicated Gesture Button. - **Keep config in plain text.** Everything is one TOML file you can read, diff, version-control, and copy between machines. - **Script it.** A real CLI: device inventory, asset prefetch, and on-device diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index eff033c2..e70e3101 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -11,7 +11,7 @@ //! (still valid) values — exactly the GUI's "window never opened" behaviour. use std::collections::{BTreeMap, HashSet}; -use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::sync::{Arc, RwLock}; use openlogi_core::config::{Config, ScrollResolution}; @@ -55,6 +55,9 @@ pub struct SharedRuntime { /// gesture watcher for the thumb-wheel/DPI-button single actions. pub hook_maps: SharedHookMaps, pub gesture_bindings: GestureBindings, + /// Whether the dedicated gesture control should be diverted and discarded + /// without enabling raw-XY motion. + pub gesture_button_disabled: Arc, pub dpi_cycle: Arc>, pub thumbwheel_sensitivity: Arc, pub capture_channel: CaptureChannel, @@ -106,6 +109,7 @@ impl Orchestrator { let shared = SharedRuntime { hook_maps: Arc::new(RwLock::new(HookMaps::default())), gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())), + gesture_button_disabled: Arc::new(AtomicBool::new(false)), dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())), thumbwheel_sensitivity: Arc::new(AtomicI32::new( config.app_settings.thumbwheel_sensitivity, @@ -164,6 +168,10 @@ impl Orchestrator { self.hook_maps_for(key, self.current_app.as_deref()), "hook_maps", ); + self.shared.gesture_button_disabled.store( + key.is_some_and(|key| self.config.gesture_button_disabled(key)), + Ordering::Relaxed, + ); write_value( &self.shared.gesture_bindings, gesture_bindings_for(&self.config, key), diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index 80659682..3b1751be 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture.rs @@ -19,14 +19,16 @@ //! way regardless. use std::collections::BTreeMap; -use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::sync::{Arc, RwLock}; use std::thread; use std::time::{Duration, Instant}; use openlogi_core::binding::{Action, ButtonId, GestureDirection, default_binding}; use openlogi_core::config::DEFAULT_THUMBWHEEL_SENSITIVITY; -use openlogi_hid::{CaptureChannel, CapturedInput, DeviceRoute, run_capture_session}; +use openlogi_hid::{ + CaptureChannel, CapturedInput, DeviceRoute, GestureButtonMode, run_capture_session, +}; use tokio::sync::{mpsc, oneshot}; use tracing::{debug, warn}; @@ -77,6 +79,7 @@ fn action_threshold(sensitivity: i32) -> i32 { pub fn spawn( hook_maps: SharedHookMaps, gesture_bindings: GestureBindings, + gesture_button_disabled: Arc, dpi_cycle: Arc>, capture_channel: CaptureChannel, thumbwheel_sensitivity: ThumbwheelSensitivity, @@ -96,6 +99,7 @@ pub fn spawn( runtime.block_on(manage( hook_maps, gesture_bindings, + gesture_button_disabled, dpi_cycle, capture_channel, thumbwheel_sensitivity, @@ -147,14 +151,15 @@ fn should_rearm(done_epoch: u64, live_epoch: u64, has_target: bool) -> bool { async fn manage( hook_maps: SharedHookMaps, gesture_bindings: GestureBindings, + gesture_button_disabled: Arc, dpi_cycle: Arc>, capture_channel: CaptureChannel, thumbwheel_sensitivity: ThumbwheelSensitivity, receiver_access: ReceiverAccess, ) { let (tx, mut rx) = mpsc::unbounded_channel::(); - // (route, capture_thumbwheel, divert_gesture_button) - let mut current: Option<(DeviceRoute, bool, bool)> = None; + // (route, capture_thumbwheel, gesture-button handling) + let mut current: Option<(DeviceRoute, bool, GestureButtonMode)> = None; let mut stop: Option> = None; let mut ticker = tokio::time::interval(TARGET_POLL); let mut accumulators = WheelAccumulators::default(); @@ -191,17 +196,22 @@ async fn manage( } else { let target = dpi_cycle.read().ok().and_then(|guard| guard.target.clone()); let sensitivity = thumbwheel_sensitivity.load(Ordering::Relaxed); - // Divert the dedicated HID++ gesture button only while it owns the gesture role. The - // shared gesture map is non-empty exactly then (gesture_bindings_for - // gates on the owner), so it doubles as that signal — no need to - // thread the full config in. Re-evaluated each tick, so a - // ReloadConfig owner change restarts the session accordingly. - let divert_gesture = gesture_bindings.read().is_ok_and(|g| !g.is_empty()); + // A non-empty gesture map requests raw-XY gesture capture. + // The explicit Disabled flag instead requests plain diversion + // so firmware cannot fire its native action. Re-evaluated each + // tick, so ReloadConfig restarts the session accordingly. + let gesture_mode = if gesture_bindings.read().is_ok_and(|g| !g.is_empty()) { + GestureButtonMode::Gestures + } else if gesture_button_disabled.load(Ordering::Relaxed) { + GestureButtonMode::Disabled + } else { + GestureButtonMode::Native + }; target.map(|t| { ( t, thumbwheel_armed(&hook_maps, sensitivity), - divert_gesture, + gesture_mode, ) }) }; @@ -218,12 +228,12 @@ async fn manage( current = None; continue; } - if let Some((route, capture_thumbwheel, divert_gesture_button)) = want { + if let Some((route, capture_thumbwheel, gesture_button_mode)) = want { let Some(receiver_lease) = receiver_access.try_acquire_for_capture() else { current = None; continue; }; - current = Some((route.clone(), capture_thumbwheel, divert_gesture_button)); + current = Some((route.clone(), capture_thumbwheel, gesture_button_mode)); let (stop_tx, stop_rx) = oneshot::channel(); let sink = tx.clone(); let slot = Arc::clone(&capture_channel); @@ -235,7 +245,7 @@ async fn manage( if let Err(e) = run_capture_session( route, capture_thumbwheel, - divert_gesture_button, + gesture_button_mode, sink, stop_rx, slot, diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs index 6ce40c45..38b85d3f 100644 --- a/crates/openlogi-agent/src/main.rs +++ b/crates/openlogi-agent/src/main.rs @@ -165,6 +165,7 @@ async fn run(config: Config) { watchers::gesture::spawn( shared.hook_maps.clone(), shared.gesture_bindings.clone(), + shared.gesture_button_disabled.clone(), shared.dpi_cycle.clone(), shared.capture_channel.clone(), shared.thumbwheel_sensitivity.clone(), diff --git a/crates/openlogi-agent/src/pairing.rs b/crates/openlogi-agent/src/pairing.rs index ef365590..a80962bf 100644 --- a/crates/openlogi-agent/src/pairing.rs +++ b/crates/openlogi-agent/src/pairing.rs @@ -267,6 +267,7 @@ mod tests { SharedRuntime { hook_maps: Arc::new(RwLock::new(HookMaps::default())), gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())), + gesture_button_disabled: Arc::new(false.into()), dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())), thumbwheel_sensitivity: Arc::new(0.into()), capture_channel: Arc::new(RwLock::new(None)), diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index dead22c3..0daaa856 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -291,7 +291,7 @@ impl Config { return Some(ButtonId::GestureButton); }; match device.gesture_owner { - Some(GestureOwner::Off) => None, + Some(GestureOwner::Off | GestureOwner::Disabled) => None, Some(GestureOwner::Button(id)) => Some(id), None => Self::infer_gesture_owner(&device.bindings), } @@ -351,6 +351,25 @@ impl Config { .gesture_owner = Some(GestureOwner::Off); } + /// Disable the dedicated gesture control completely. Unlike + /// [`Self::disable_gestures`], this asks the runtime to divert and discard + /// the control's button reports so its native firmware action cannot fire. + pub fn disable_gesture_button(&mut self, device_key: &str) { + self.devices + .entry(device_key.to_string()) + .or_default() + .gesture_owner = Some(GestureOwner::Disabled); + } + + /// Whether the dedicated gesture control is explicitly disabled and should + /// be diverted without raw-XY reporting. + #[must_use] + pub fn gesture_button_disabled(&self, device_key: &str) -> bool { + self.devices + .get(device_key) + .is_some_and(|device| device.gesture_owner == Some(GestureOwner::Disabled)) + } + /// Resolve the effective binding map for `device_key`, overlaying the /// per-app entry for `bundle_id` (if any) on top of the global per-device /// `bindings`. A per-app override replaces the whole button with a @@ -1391,6 +1410,30 @@ Back = \"BrowserBack\" } } + #[test] + fn disabled_gesture_button_is_distinct_from_native_off_and_preserves_maps() { + let mut cfg = Config::default(); + cfg.set_gesture_direction( + "2b042", + ButtonId::GestureButton, + GestureDirection::Up, + Action::Copy, + ); + cfg.disable_gesture_button("2b042"); + + assert_eq!(cfg.gesture_owner("2b042"), None); + assert!(cfg.gesture_button_disabled("2b042")); + assert!(matches!( + cfg.bindings_for("2b042").get(&ButtonId::GestureButton), + Some(Binding::Gesture(map)) if map.get(&GestureDirection::Up) == Some(&Action::Copy) + )); + + let parsed = write_and_read(&cfg); + assert!(parsed.gesture_button_disabled("2b042")); + let body = toml::to_string_pretty(&cfg).expect("serialize"); + assert!(body.contains("gesture_owner = \"Disabled\""), "got: {body}"); + } + #[test] fn gesture_owner_field_roundtrips_as_a_scalar() { let mut cfg = Config::default(); diff --git a/crates/openlogi-core/src/config/settings.rs b/crates/openlogi-core/src/config/settings.rs index cc50d019..22e5f296 100644 --- a/crates/openlogi-core/src/config/settings.rs +++ b/crates/openlogi-core/src/config/settings.rs @@ -363,12 +363,15 @@ pub struct SmartShift { /// [`Binding::Gesture`](crate::binding::Binding::Gesture) — so switching the /// gesture button never has to collapse a button's gesture map to encode the /// choice: every gesture-capable button keeps its full direction map, and only -/// the owner is dispatched. Serialized as a bare string (`"Off"` or a +/// the owner is dispatched. Serialized as a bare string (`"Off"`, `"Disabled"`, or a /// [`ButtonId`] name) so it stays a TOML scalar. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GestureOwner { /// Gestures are explicitly turned off for this device. Off, + /// The dedicated gesture control is diverted without raw motion and its + /// button reports are discarded. + Disabled, /// The named button owns the gesture role. Button(ButtonId), } @@ -379,6 +382,7 @@ impl Serialize for GestureOwner { // "Off" can't collide with a ButtonId variant name (all CamelCase // control names), so the string space is unambiguous. GestureOwner::Off => serializer.serialize_str("Off"), + GestureOwner::Disabled => serializer.serialize_str("Disabled"), GestureOwner::Button(id) => id.serialize(serializer), } } @@ -401,6 +405,9 @@ where if s == "Off" { return Ok(Some(GestureOwner::Off)); } + if s == "Disabled" { + return Ok(Some(GestureOwner::Disabled)); + } // Parse the button name with a throwaway error type so an unknown token maps // to `None` (infer) rather than propagating an error. let button = ButtonId::deserialize( diff --git a/crates/openlogi-gui/src/mouse_model/view.rs b/crates/openlogi-gui/src/mouse_model/view.rs index 0ef86518..3c02981a 100644 --- a/crates/openlogi-gui/src/mouse_model/view.rs +++ b/crates/openlogi-gui/src/mouse_model/view.rs @@ -108,7 +108,7 @@ enum BindingPopover { impl Render for MouseModelView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let (device_key, asset, active, bindings, gesture_owner, glow) = cx + let (device_key, asset, active, bindings, gesture_owner, gesture_disabled, glow) = cx .try_global::() .map(|s| { ( @@ -117,6 +117,7 @@ impl Render for MouseModelView { s.active_button, s.button_bindings.clone(), s.current_gesture_owner(), + s.current_gesture_button_disabled(), s.current_record().and_then(|r| keyboard_glow(s, r)), ) }) @@ -229,7 +230,13 @@ impl Render for MouseModelView { .w(px(canvas_w)) .gap_4() .when(!capable.is_empty(), |col| { - col.child(gesture_owner_selector(&capable, gesture_owner, &view, pal)) + col.child(gesture_owner_selector( + &capable, + gesture_owner, + gesture_disabled, + &view, + pal, + )) }) .child(canvas) } @@ -309,6 +316,7 @@ fn gesture_owner_label(btn: ButtonId) -> &'static str { fn gesture_owner_selector( capable: &[ButtonId], owner: Option, + disabled: bool, view: &Entity, pal: Palette, ) -> impl IntoElement { @@ -323,27 +331,57 @@ fn gesture_owner_selector( .child(tr!("Gesture Button")), ) .children( - capable - .iter() - .map(|&btn| owner_chip(Some(btn), owner, view, pal)), + capable.iter().map(|&btn| { + owner_chip(GestureOwnerChoice::Button(btn), owner, disabled, view, pal) + }), ) - .child(owner_chip(None, owner, view, pal)) + .child(owner_chip( + GestureOwnerChoice::Off, + owner, + disabled, + view, + pal, + )) + .child(owner_chip( + GestureOwnerChoice::Disabled, + owner, + disabled, + view, + pal, + )) +} + +#[derive(Clone, Copy)] +enum GestureOwnerChoice { + Button(ButtonId), + Off, + Disabled, } /// One selectable chip in [`gesture_owner_selector`]. Clicking commits the new /// gesture owner via [`AppState::commit_gesture_owner`]. fn owner_chip( - btn: Option, + choice: GestureOwnerChoice, owner: Option, + disabled: bool, view: &Entity, pal: Palette, ) -> AnyElement { - let selected = btn == owner; - let text = match btn { - Some(b) => tr!(gesture_owner_label(b)), - None => tr!("Off"), + let selected = match choice { + GestureOwnerChoice::Button(button) => !disabled && owner == Some(button), + GestureOwnerChoice::Off => !disabled && owner.is_none(), + GestureOwnerChoice::Disabled => disabled, + }; + let text = match choice { + GestureOwnerChoice::Button(button) => tr!(gesture_owner_label(button)), + GestureOwnerChoice::Off => tr!("Off"), + GestureOwnerChoice::Disabled => tr!("Disabled"), + }; + let id_part = match choice { + GestureOwnerChoice::Off => 0, + GestureOwnerChoice::Disabled => 1, + GestureOwnerChoice::Button(button) => button as usize + 2, }; - let id_part = btn.map_or(0usize, |b| b as usize + 1); let view = view.clone(); div() .id(("gesture-owner", id_part)) @@ -369,7 +407,13 @@ fn owner_chip( .cursor_pointer() .child(text) .on_click(move |_event, _window, cx| { - cx.update_global::(|state, _| state.commit_gesture_owner(btn)); + cx.update_global::(|state, _| match choice { + GestureOwnerChoice::Button(button) => { + state.commit_gesture_owner(Some(button)); + } + GestureOwnerChoice::Off => state.commit_gesture_owner(None), + GestureOwnerChoice::Disabled => state.commit_gesture_button_disabled(), + }); view.update(cx, |_, vcx| vcx.notify()); }) .into_any_element() diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs index bd0536e3..c6b6ee00 100644 --- a/crates/openlogi-gui/src/state.rs +++ b/crates/openlogi-gui/src/state.rs @@ -1381,6 +1381,15 @@ impl AppState { self.config.gesture_owner(key) } + /// Whether the current device's dedicated gesture control is explicitly + /// disabled (diverted without raw motion and discarded). + #[must_use] + pub fn current_gesture_button_disabled(&self) -> bool { + self.current_record() + .and_then(DeviceRecord::persistent_config_key) + .is_some_and(|key| self.config.gesture_button_disabled(key)) + } + /// Make `button` the current device's gesture button (or clear it with /// `None`), enforcing the one-gesture-button-per-device lock. Persists, tells /// the agent to rebuild, and refreshes the projected maps the UI reads. @@ -1406,6 +1415,22 @@ impl AppState { self.persist_and_reload("gesture-button change"); } + /// Disable the dedicated gesture control without enabling raw-XY capture. + /// Existing gesture maps remain intact for later restoration. + pub fn commit_gesture_button_disabled(&mut self) { + let Some(key) = self + .current_record() + .and_then(DeviceRecord::persistent_config_key) + .map(str::to_string) + else { + return; + }; + self.config.disable_gesture_button(&key); + self.button_bindings = self.bindings_for_current(); + self.gesture_bindings = self.gesture_bindings_for_current(); + self.persist_and_reload("gesture-button disabled"); + } + /// Update a single gesture-button sub-binding in memory, on disk, and in the /// shared gesture map the watcher thread reads. pub fn commit_gesture_binding(&mut self, direction: GestureDirection, action: Action) { diff --git a/crates/openlogi-hid/src/gesture.rs b/crates/openlogi-hid/src/gesture.rs index 1f0da4e1..827aabfe 100644 --- a/crates/openlogi-hid/src/gesture.rs +++ b/crates/openlogi-hid/src/gesture.rs @@ -50,6 +50,18 @@ pub enum CapturedInput { Scroll(i16), } +/// How OpenLogi should handle the device's dedicated gesture control. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GestureButtonMode { + /// Leave the control owned by device firmware, preserving its native action. + Native, + /// Divert and discard button reports without requesting raw XY motion. + /// This disables the control while leaving normal pointer motion untouched. + Disabled, + /// Divert the control with raw XY motion and decode click/swipe gestures. + Gestures, +} + /// Why a capture session could not start (or had to stop). #[derive(Debug, Error)] pub enum GestureError { @@ -82,12 +94,9 @@ struct CaptureAccum { /// `capture_thumbwheel`) the thumb wheel on `route` until `shutdown` resolves, /// forwarding each event to `sink`. /// -/// The dedicated gesture button (raw-XY) is diverted only when `divert_gesture_button` — -/// i.e. it is the device's gesture owner. When the user moves the gesture role -/// to an OS-hook button or turns gestures off, the HID++ gesture control is -/// left undiverted so it keeps its native behavior instead of being -/// captured-and-swallowed. The DPI/ModeShift capture and the channel-reuse slot -/// are independent of this. +/// `gesture_button_mode` selects native firmware behavior, disabled plain-button +/// diversion, or full raw-XY gesture capture. The DPI/ModeShift capture and the +/// channel-reuse slot are independent of this. /// /// Opens and holds one HID++ channel, diverts whichever of those controls the /// device exposes, and listens. Returns once `shutdown` fires (or its sender is @@ -96,7 +105,7 @@ struct CaptureAccum { pub async fn run_capture_session( route: DeviceRoute, capture_thumbwheel: bool, - divert_gesture_button: bool, + gesture_button_mode: GestureButtonMode, sink: mpsc::UnboundedSender, shutdown: oneshot::Receiver<()>, channel_slot: CaptureChannel, @@ -105,13 +114,7 @@ pub async fn run_capture_session( .await? .ok_or(GestureError::DeviceNotFound)?; let device_index = route.device_index(); - let armed = arm_controls( - &chan, - device_index, - capture_thumbwheel, - divert_gesture_button, - ) - .await?; + let armed = arm_controls(&chan, device_index, capture_thumbwheel, gesture_button_mode).await?; // Publish this device's open channel so DPI/SmartShift writes reuse it // instead of opening their own. Cleared on the way out. @@ -122,6 +125,8 @@ pub async fn run_capture_session( let accum = Arc::new(Mutex::new(CaptureAccum::default())); let reprog_index = armed.reprog.as_ref().map(|(_, idx)| *idx); let thumb_index = armed.thumb.as_ref().map(|(_, idx)| *idx); + let gesture_cid = armed.gesture_cid; + let gesture_button_mode = armed.gesture_button_mode; let dpi_set = armed.dpi_cids.clone(); let listener = chan.add_msg_listener_guarded({ let accum = Arc::clone(&accum); @@ -137,7 +142,14 @@ pub async fn run_capture_session( // Recover the guard even if a prior holder panicked — the // critical section is panic-free, so the data is consistent. let mut acc = accum.lock().unwrap_or_else(PoisonError::into_inner); - handle_reprog(&mut acc, event, &dpi_set, &sink); + handle_reprog( + &mut acc, + event, + gesture_cid, + gesture_button_mode, + &dpi_set, + &sink, + ); return; } if let Some(idx) = thumb_index @@ -155,7 +167,8 @@ pub async fn run_capture_session( info!( index = device_index, - gesture = armed.gesture_diverted, + gesture_cid = ?armed.gesture_cid, + gesture_mode = ?armed.gesture_button_mode, dpi_buttons = armed.dpi_cids.len(), thumbwheel = armed.thumb.is_some(), "control capture active" @@ -176,8 +189,10 @@ pub async fn run_capture_session( struct ArmedControls { /// `0x1b04` accessor + feature index, present when the device exposes it. reprog: Option<(ReprogControlsV4, u8)>, - /// Whether the gesture button is diverted with raw-XY reporting. - gesture_diverted: bool, + /// Gesture-button CID diverted for either disabled or gesture mode. + gesture_cid: Option, + /// Effective handling mode; native when no compatible control was found. + gesture_button_mode: GestureButtonMode, /// DPI/ModeShift CIDs diverted as plain buttons. dpi_cids: Vec, /// `0x2150` accessor + feature index, present when the thumb wheel is @@ -189,10 +204,8 @@ impl ArmedControls { /// Restore every diverted control. Failures are logged, not propagated. async fn disarm(&self) { if let Some((rc, _)) = self.reprog.as_ref() { - if self.gesture_diverted { - let r = rc - .set_cid_reporting(reprog_controls::GESTURE_BUTTON_CID, false, false) - .await; + if let Some(cid) = self.gesture_cid { + let r = rc.set_cid_reporting(cid, false, false).await; restore(r, "gesture button"); } for &cid in &self.dpi_cids { @@ -214,14 +227,14 @@ async fn arm_controls( chan: &Arc, slot: u8, capture_thumbwheel: bool, - divert_gesture_button: bool, + gesture_button_mode: GestureButtonMode, ) -> Result { let device = Device::new(Arc::clone(chan), slot) .await .map_err(|_| GestureError::DeviceUnreachable(slot))?; let mut reprog: Option<(ReprogControlsV4, u8)> = None; - let mut gesture_diverted = false; + let mut gesture_cid = None; let mut dpi_cids: Vec = Vec::new(); if let Some(info) = device .root() @@ -232,17 +245,14 @@ async fn arm_controls( let rc = ReprogControlsV4::new(Arc::clone(chan), slot, info.index); let controls = enumerate_controls(&rc).await?; - // Only divert the gesture button when it owns the gesture role; otherwise - // leave it native (a non-owner HID++ control must not be captured-and-dropped). - if divert_gesture_button - && controls - .iter() - .any(|c| c.cid == reprog_controls::GESTURE_BUTTON_CID && c.supports_raw_xy()) + if gesture_button_mode != GestureButtonMode::Native + && let Some(cid) = find_gesture_cid(&controls, gesture_button_mode) { - rc.set_cid_reporting(reprog_controls::GESTURE_BUTTON_CID, true, true) + let raw_xy = gesture_button_mode == GestureButtonMode::Gestures; + rc.set_cid_reporting(cid, true, raw_xy) .await .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?; - gesture_diverted = true; + gesture_cid = Some(cid); } for &cid in &reprog_controls::DPI_MODE_SHIFT_CIDS { if controls.iter().any(|c| c.cid == cid && c.is_divertable()) { @@ -287,12 +297,17 @@ async fn arm_controls( thumb = Some((tw, info.index)); } - if !gesture_diverted && dpi_cids.is_empty() && thumb.is_none() { + if gesture_cid.is_none() && dpi_cids.is_empty() && thumb.is_none() { debug!(slot, "no capturable controls — idle session"); } Ok(ArmedControls { reprog, - gesture_diverted, + gesture_cid, + gesture_button_mode: if gesture_cid.is_some() { + gesture_button_mode + } else { + GestureButtonMode::Native + }, dpi_cids, thumb, }) @@ -325,6 +340,28 @@ async fn enumerate_controls( Ok(controls) } +/// Pick the device's dedicated thumb control for the requested mode. Disabled +/// mode only needs safe diversion; gesture capture additionally requires +/// raw-XY reporting so swipe motion can be decoded. +fn find_gesture_cid( + controls: &[reprog_controls::CtrlIdInfo], + mode: GestureButtonMode, +) -> Option { + if mode == GestureButtonMode::Native { + return None; + } + reprog_controls::GESTURE_BUTTON_CIDS + .iter() + .copied() + .find(|candidate| { + controls.iter().any(|control| { + control.cid == *candidate + && control.is_divertable() + && (mode == GestureButtonMode::Disabled || control.supports_raw_xy()) + }) + }) +} + /// Update `acc` and emit on a decoded `0x1b04` event: commit a gesture swipe the /// instant it crosses the threshold (mid-swipe, like Options+) rather than on /// release, and emit a [`ButtonId::DpiToggle`] press on the rising edge of any @@ -332,12 +369,15 @@ async fn enumerate_controls( fn handle_reprog( acc: &mut CaptureAccum, event: RawControlEvent, + gesture_cid: Option, + gesture_button_mode: GestureButtonMode, dpi_cids: &[u16], sink: &mpsc::UnboundedSender, ) { match event { RawControlEvent::DivertedButtons(cids) => { - let gesture_held = cids.contains(&reprog_controls::GESTURE_BUTTON_CID); + let gesture_held = gesture_button_mode == GestureButtonMode::Gestures + && gesture_cid.is_some_and(|cid| cids.contains(&cid)); if gesture_held && !acc.swipe.is_holding() { acc.swipe.begin(); } else if !gesture_held && acc.swipe.is_holding() { @@ -355,6 +395,9 @@ fn handle_reprog( acc.dpi_down = dpi_down; } RawControlEvent::RawXy { dx, dy } => { + if gesture_button_mode != GestureButtonMode::Gestures { + return; + } // Commit the instant a clean direction emerges (mid-swipe, once per // hold); the accumulator gates on hold duration internally and drops // travel that arrives outside a hold. diff --git a/crates/openlogi-hid/src/gesture/tests.rs b/crates/openlogi-hid/src/gesture/tests.rs index 035f2cda..b72c7a73 100644 --- a/crates/openlogi-hid/src/gesture/tests.rs +++ b/crates/openlogi-hid/src/gesture/tests.rs @@ -1,7 +1,7 @@ use super::*; -fn press() -> RawControlEvent { - RawControlEvent::DivertedButtons([reprog_controls::GESTURE_BUTTON_CID, 0, 0, 0]) +fn press(cid: u16) -> RawControlEvent { + RawControlEvent::DivertedButtons([cid, 0, 0, 0]) } fn release() -> RawControlEvent { @@ -13,14 +13,30 @@ fn quick_tap_is_a_click_even_while_the_cursor_moves() { let (tx, mut rx) = mpsc::unbounded_channel(); let mut acc = CaptureAccum::default(); - handle_reprog(&mut acc, press(), &[], &tx); + handle_reprog( + &mut acc, + press(reprog_controls::GESTURE_BUTTON_CID), + Some(reprog_controls::GESTURE_BUTTON_CID), + GestureButtonMode::Gestures, + &[], + &tx, + ); handle_reprog( &mut acc, RawControlEvent::RawXy { dx: 120, dy: 5 }, + Some(reprog_controls::GESTURE_BUTTON_CID), + GestureButtonMode::Gestures, + &[], + &tx, + ); + handle_reprog( + &mut acc, + release(), + Some(reprog_controls::GESTURE_BUTTON_CID), + GestureButtonMode::Gestures, &[], &tx, ); - handle_reprog(&mut acc, release(), &[], &tx); assert_eq!( rx.try_recv(), @@ -37,12 +53,21 @@ fn a_held_gesture_commits_a_swipe_and_does_not_also_click() { let (tx, mut rx) = mpsc::unbounded_channel(); let mut acc = CaptureAccum::default(); - handle_reprog(&mut acc, press(), &[], &tx); + handle_reprog( + &mut acc, + press(reprog_controls::GESTURE_BUTTON_CID), + Some(reprog_controls::GESTURE_BUTTON_CID), + GestureButtonMode::Gestures, + &[], + &tx, + ); // Pretend the button has been held well past the swipe gate. acc.swipe.backdate_hold_for_test(); handle_reprog( &mut acc, RawControlEvent::RawXy { dx: 120, dy: 5 }, + Some(reprog_controls::GESTURE_BUTTON_CID), + GestureButtonMode::Gestures, &[], &tx, ); @@ -52,7 +77,14 @@ fn a_held_gesture_commits_a_swipe_and_does_not_also_click() { Ok(CapturedInput::Gesture(GestureDirection::Right)) ); - handle_reprog(&mut acc, release(), &[], &tx); + handle_reprog( + &mut acc, + release(), + Some(reprog_controls::GESTURE_BUTTON_CID), + GestureButtonMode::Gestures, + &[], + &tx, + ); assert!( rx.try_recv().is_err(), "a committed swipe must not also click on release" @@ -66,8 +98,8 @@ fn a_held_dpi_button_presses_once_on_the_rising_edge() { let dpi = reprog_controls::DPI_MODE_SHIFT_CIDS[0]; let down = RawControlEvent::DivertedButtons([dpi, 0, 0, 0]); - handle_reprog(&mut acc, down, &[dpi], &tx); - handle_reprog(&mut acc, down, &[dpi], &tx); + handle_reprog(&mut acc, down, None, GestureButtonMode::Native, &[dpi], &tx); + handle_reprog(&mut acc, down, None, GestureButtonMode::Native, &[dpi], &tx); assert_eq!( rx.try_recv(), @@ -87,9 +119,9 @@ fn a_dpi_button_re_presses_after_a_release() { let down = RawControlEvent::DivertedButtons([dpi, 0, 0, 0]); let up = RawControlEvent::DivertedButtons([0, 0, 0, 0]); - handle_reprog(&mut acc, down, &[dpi], &tx); - handle_reprog(&mut acc, up, &[dpi], &tx); - handle_reprog(&mut acc, down, &[dpi], &tx); + handle_reprog(&mut acc, down, None, GestureButtonMode::Native, &[dpi], &tx); + handle_reprog(&mut acc, up, None, GestureButtonMode::Native, &[dpi], &tx); + handle_reprog(&mut acc, down, None, GestureButtonMode::Native, &[dpi], &tx); assert_eq!( rx.try_recv(), @@ -102,3 +134,142 @@ fn a_dpi_button_re_presses_after_a_release() { ); assert!(rx.try_recv().is_err()); } + +#[test] +fn m720_switch_apps_control_is_selected_as_the_gesture_button() { + let controls = [reprog_controls::CtrlIdInfo { + cid: reprog_controls::M720_GESTURE_BUTTON_CID, + task_id: 0x00ad, + flags: 0x0171, + }]; + + assert_eq!( + find_gesture_cid(&controls, GestureButtonMode::Gestures), + Some(reprog_controls::M720_GESTURE_BUTTON_CID) + ); +} + +#[test] +fn m720_switch_apps_tap_emits_a_gesture_click() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + let cid = reprog_controls::M720_GESTURE_BUTTON_CID; + + handle_reprog( + &mut acc, + press(cid), + Some(cid), + GestureButtonMode::Gestures, + &[], + &tx, + ); + handle_reprog( + &mut acc, + release(), + Some(cid), + GestureButtonMode::Gestures, + &[], + &tx, + ); + + assert_eq!( + rx.try_recv(), + Ok(CapturedInput::Gesture(GestureDirection::Click)) + ); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn dedicated_gesture_cid_is_preferred_when_both_are_present() { + let controls = reprog_controls::GESTURE_BUTTON_CIDS.map(|cid| reprog_controls::CtrlIdInfo { + cid, + task_id: 0, + flags: 0x0171, + }); + + assert_eq!( + find_gesture_cid(&controls, GestureButtonMode::Gestures), + Some(reprog_controls::GESTURE_BUTTON_CID) + ); +} + +#[test] +fn switch_apps_without_raw_xy_is_not_treated_as_a_gesture_button() { + let controls = [reprog_controls::CtrlIdInfo { + cid: reprog_controls::M720_GESTURE_BUTTON_CID, + task_id: 0x00ad, + flags: 0x0011, + }]; + + assert_eq!( + find_gesture_cid(&controls, GestureButtonMode::Gestures), + None + ); +} + +#[test] +fn raw_xy_control_without_diversion_is_not_treated_as_a_gesture_button() { + let controls = [reprog_controls::CtrlIdInfo { + cid: reprog_controls::M720_GESTURE_BUTTON_CID, + task_id: 0x00ad, + flags: 0x0101, + }]; + + assert_eq!( + find_gesture_cid(&controls, GestureButtonMode::Gestures), + None + ); +} + +#[test] +fn disabled_mode_accepts_a_divertable_control_without_raw_xy() { + let controls = [reprog_controls::CtrlIdInfo { + cid: reprog_controls::M720_GESTURE_BUTTON_CID, + task_id: 0x00ad, + flags: 0x0031, + }]; + + assert_eq!( + find_gesture_cid(&controls, GestureButtonMode::Disabled), + Some(reprog_controls::M720_GESTURE_BUTTON_CID) + ); + assert_eq!( + find_gesture_cid(&controls, GestureButtonMode::Gestures), + None + ); +} + +#[test] +fn disabled_gesture_button_emits_nothing_and_ignores_raw_motion() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + let cid = reprog_controls::M720_GESTURE_BUTTON_CID; + + handle_reprog( + &mut acc, + press(cid), + Some(cid), + GestureButtonMode::Disabled, + &[], + &tx, + ); + handle_reprog( + &mut acc, + RawControlEvent::RawXy { dx: 120, dy: 5 }, + Some(cid), + GestureButtonMode::Disabled, + &[], + &tx, + ); + handle_reprog( + &mut acc, + release(), + Some(cid), + GestureButtonMode::Disabled, + &[], + &tx, + ); + + assert!(rx.try_recv().is_err()); + assert!(!acc.swipe.is_holding()); +} diff --git a/crates/openlogi-hid/src/lib.rs b/crates/openlogi-hid/src/lib.rs index b6cf1547..2b0ff4ac 100644 --- a/crates/openlogi-hid/src/lib.rs +++ b/crates/openlogi-hid/src/lib.rs @@ -29,7 +29,9 @@ pub mod smartshift; pub mod thumbwheel; pub mod write; -pub use gesture::{CaptureChannel, CapturedInput, GestureError, run_capture_session}; +pub use gesture::{ + CaptureChannel, CapturedInput, GestureButtonMode, GestureError, run_capture_session, +}; pub use hires_wheel::{ ScrollReportingTarget, ScrollResolution, ScrollWheelMode, get_scroll_wheel_mode, get_scroll_wheel_mode_on, set_scroll_inversion, set_scroll_inversion_on, set_scroll_resolution, diff --git a/crates/openlogi-hid/src/reprog_controls.rs b/crates/openlogi-hid/src/reprog_controls.rs index c25d4e3b..c77ea12d 100644 --- a/crates/openlogi-hid/src/reprog_controls.rs +++ b/crates/openlogi-hid/src/reprog_controls.rs @@ -43,6 +43,16 @@ pub const FEATURE_ID: u16 = 0x1b04; /// bindable/capturable input. pub const GESTURE_BUTTON_CID: u16 = 0x00c3; +/// Control ID used by the M720 Triathlon's thumb-rest "Switch Apps" button. +/// The control advertises raw-XY reporting and is the M720 equivalent of the +/// dedicated MX gesture button. +pub const M720_GESTURE_BUTTON_CID: u16 = 0x00d0; + +/// Gesture-capable thumb controls, in preference order. Devices which expose +/// the dedicated MX gesture CID use it; the M720 falls back to its raw-XY +/// Switch Apps control. +pub const GESTURE_BUTTON_CIDS: [u16; 2] = [GESTURE_BUTTON_CID, M720_GESTURE_BUTTON_CID]; + /// Control IDs of the "DPI / ModeShift" button family. Whichever a device /// exposes (and can divert) is captured and mapped to /// [`ButtonId::DpiToggle`](openlogi_core::binding::ButtonId::DpiToggle): the MX diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 6afa4162..f5a1935c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -31,7 +31,10 @@ MX Master 4): - `lighting` — static RGB colour, brightness (0–100), and on/off for wired RGB keyboards. - `gesture_owner` — which button owns the gesture role, when chosen - explicitly (otherwise inferred). + explicitly (otherwise inferred). Set it to `"Off"` to stop OpenLogi gesture + handling while leaving the dedicated control's native firmware action + intact, or `"Disabled"` to divert and discard that control so pressing it + has no effect. The app-wide `[app_settings]` block holds `launch_at_login`, `check_for_updates`, and `auto_install_updates` (all off by default); @@ -65,6 +68,8 @@ appearance = "system" [devices.2b042] dpi_presets = [800, 1600, 3200] +# Keep the dedicated control inactive instead of restoring its native action. +gesture_owner = "Disabled" [devices.2b042.bindings] Back = "BrowserBack"