How OpenLogi Captures and Remaps Gestures with Per-Direction Bindings and Live Capture

OpenLogi captures gestures through a single HID++ device session that diverts controls, accumulates raw-XY motion into directional swipes, and maps each GestureDirection to user-configured actions via per-button binding tables.

OpenLogi provides live gesture capture and remapping for Logitech devices by implementing a unified device-session architecture. The system intercepts raw input at the HID++ protocol layer, transforms motion data into directional gestures, and routes the results to a configurable binding system. This article walks through the complete pipeline, from channel establishment to per-direction action execution, using the actual source implementation in AprilNEA/OpenLogi.

The Capture Session Architecture

OpenLogi centralizes gesture handling in run_capture_session within [crates/openlogi-device/src/session/gesture.rs](https://github.com/AprilNEA/OpenLogi/blob/master/crates/openlogi-device/src/session/gesture.rs). This function opens a single HID++ channel per device rather than splitting input across multiple streams, preventing race conditions and simplifying state management.

The session begins with a CaptureSpec that declares which controls to divert:

pub struct CaptureSpec {
    pub capture_thumbwheel: bool,           // Thumb-wheel rotation and tap
    pub divert_gesture_sources: Vec<Cid>,   // Dedicated gesture button or Haptic panel (raw-XY)
    pub divert_gesture_buttons: Vec<Cid>,   // Standard buttons with raw-XY support
    pub divert_buttons: Vec<Cid>,           // Plain-press diversion for non-default bindings
}

Each flag corresponds to a specific hardware capability. The capture_thumbwheel field handles devices like the MX Master series with horizontal scroll wheels, while divert_gesture_sources targets the dedicated gesture button or Haptic touch panel found on premium Logitech mice.

Arming Controls for Diverted Reporting

Before receiving input, the session must arm the selected controls. The arm_controls function (via arm_controls_into) writes to the device's ReprogControlsV4 table to enable diverted reporting mode.

Diverted reporting instructs the device to send raw HID++ messages instead of standard HID reports, allowing OpenLogi to intercept and reinterpret the data. For controls supporting raw-XY, the arming process also requests absolute motion deltas.

The original reporting state is preserved in an ArmedReporting structure:

// From reprog_controls.rs - stores pre-diversion state for restoration
pub struct ArmedReporting {
    pub cid: Cid,
    pub original_flags: ControlFlags,
    pub supports_raw_xy: bool,
}

This enables clean shutdown: when the session ends (normally or via error), finish_capture → restore_after_stop reverts every control to its original state using the logic in [capture_restore.rs](https://github.com/AprilNEA/OpenLogi/blob/master/crates/openlogi-device/src/session/capture_restore.rs).

Parsing Incoming HID++ Reports

Once armed, the session installs a message listener that classifies every v20::Message into one of three processing paths:

Path 1: Reprogrammable Control Events

decoded by reprog_controls::decode_event. The handle_reprog_with_gesture_buttons function updates a shared CaptureAccum and emits CapturedInput variants:

  • CapturedInput::ButtonDown / CapturedInput::ButtonUp — discrete press/release
  • CapturedInput::DpiSwitch — DPI toggle button activation
  • CapturedInput::Gesture(button, direction) — completed swipe with directional classification

Path 2: Thumb-Wheel Events

decoded by thumbwheel::decode_event in [thumbwheel.rs](https://github.com/AprilNEA/OpenLogi/blob/master/crates/openlogi-device/src/thumbwheel.rs). The thumbwheel_input function produces:

  • CapturedInput::Scroll { increments, resolution } — rotational movement
  • CapturedInput::ButtonPulse — wheel tap/click

Path 3: Raw-XY Motion

fed into handle_raw_xy when a gesture button is held. Motion deltas accumulate until they cross the gesture threshold.

Swipe Accumulation and Direction Detection

The SwipeAccumulator transforms raw motion into semantic directions. Located in crates/openlogi-core/src/swipe.rs, this component:

  1. Accumulates (dx, dy) deltas while HoldState::Holding is active
  2. Compares cumulative displacement against GESTURE_SWIPE_THRESHOLD
  3. Once exceeded, determines the dominant axis and sign
  4. Yields a GestureDirection: Up, Down, Left, Right, or Click (for taps without motion)

The accumulator implements minimum distance gating to prevent accidental triggers from micro-adjustments, while still maintaining responsive feel for intentional swipes.

Per-Direction Binding Resolution

CapturedInput events traverse an unbounded Tokio channel (mpsc::unbounded_channel) to the GUI process in openlogi-desktop. The binding resolution occurs in [crates/openlogi-desktop/src/state/bindings.rs](https://github.com/AprilNEA/OpenLogi/blob/master/crates/openlogi-desktop/src/state/bindings.rs):

// bindings.rs - per-button, per-direction action mapping
pub struct GestureBindings {
    // ButtonId → {GestureDirection → Action}
    inner: BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>>,
}

Each ButtonId (defined in [binding.rs](https://github.com/AprilNEA/OpenLogi/blob/master/crates/openlogi-core/src/binding.rs)) maintains its own BTreeMap from direction to action. This structure enables complex chorded configurations:

  • GestureButton + Up → Copy
  • GestureButton + Down → Paste
  • GestureButton + Left → Undo
  • GestureButton + Right → Redo
  • GestureButton + Click (tap, no direction) → Middle-click

The GestureDirection enum in binding.rs provides exhaustive pattern matching for all cases:

pub enum GestureDirection {
    Up,
    Down,
    Left,
    Right,
    Click,  // No motion detected—treated as tap
}

Live Capture Implementation Example

The following demonstrates establishing a complete capture session with per-direction handling:

use openlogi_device::session::{run_capture_session, CaptureSpec};
use openlogi_core::binding::{ButtonId, GestureDirection};
use openlogi_core::action::Action;
use tokio::sync::mpsc;

// Configure which hardware features to divert
let spec = CaptureSpec {
    capture_thumbwheel: true,
    divert_gesture_sources: vec![reprog_controls::GESTURE_BUTTON_CID],
    divert_gesture_buttons: vec![],
    divert_buttons: vec![],
    ..Default::default()
};

// Channel for live capture streaming
let (tx, mut rx) = mpsc::unbounded_channel::<CapturedInput>();

// Spawn the capture session
let session_handle = tokio::spawn(async move {
    run_capture_session(
        backend,      // HidBackend implementation
        device_route, // DeviceRoute identifying the mouse
        spec,
        tx,           // Event sink
        shutdown_rx,  // Graceful termination signal
        Default::default(),
        Default::default(),
    ).await
});

// Process captured inputs with per-direction binding map
while let Some(input) = rx.recv().await {
    match input {
        CapturedInput::Gesture(btn, dir) => {
            let action = resolve_gesture_binding(btn, dir);
            inject_action(action).await;
        }
        CapturedInput::Scroll { increments, resolution } => {
            synthesize_scroll(increments, resolution).await;
        }
        CapturedInput::ButtonPulse => {
            // Thumb-wheel tap—handle as configured
        }
        _ => {}
    }
}

fn resolve_gesture_binding(btn: ButtonId, dir: GestureDirection) -> Action {
    // Lookup in BTreeMap<GestureDirection, Action>
    GESTURE_BINDINGS.get(&btn)
        .and_then(|m| m.get(&dir))
        .copied()
        .unwrap_or(Action::NoOp)
}

Session Liveness and Auto-Recovery

The CaptureLiveness watchdog ensures uninterrupted capture. It periodically emits root.ping(0x5a) heartbeat messages; if the channel falls silent, the watchdog triggers automatic session restart. This prevents "stuck" states from device sleep/wake cycles or USB transient errors.

On any termination path, the session guarantees state restoration:

// gesture.rs - cleanup sequence
async fn finish_capture(&mut self) {
    self.restore_after_stop().await;  // Return controls to original mode
    self.clear_shared_channel_slot(); // Release for next session
}

Component Reference

File Purpose
[gesture.rs](https://github.com/AprilNEA/OpenLogi/blob/master/crates/openlogi-device/src/session/gesture.rs) Core session: channel management, arming, event routing
[swipe.rs](https://github.com/AprilNEA/OpenLogi/blob/master/crates/openlogi-core/src/swipe.rs) SwipeAccumulator for delta-to-direction conversion
[bindings.rs](https://github.com/AprilNEA/OpenLogi/blob/master/crates/openlogi-desktop/src/state/bindings.rs) Per-button/per-direction action maps
[binding.rs](https://github.com/AprilNEA/OpenLogi/blob/master/crates/openlogi-core/src/binding.rs) ButtonId, GestureDirection type definitions
[reprog_controls.rs](https://github.com/AprilNEA/OpenLogi/blob/master/crates/openlogi-device/src/reprog_controls.rs) ReprogControlsV4 table operations
[thumbwheel.rs](https://github.com/AprilNEA/OpenLogi/blob/master/crates/openlogi-device/src/thumbwheel.rs) Wheel-specific event decoding
[capture_restore.rs](https://github.com/AprilNEA/OpenLogi/blob/master/crates/openlogi-device/src/session/capture_restore.rs) State preservation and restoration

Summary

  • Single HID++ channel prevents input stream fragmentation and race conditions
  • CaptureSpec declaratively configures which hardware features to divert
  • Arming process writes ReprogControlsV4 flags and preserves original state in ArmedReporting
  • Three report paths handle reprogrammable controls, thumb-wheel events, and raw-XY motion
  • SwipeAccumulator applies threshold-based direction detection from accumulated deltas
  • Per-direction bindings use BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>> for flexible configuration
  • Liveness watchdog ensures automatic recovery from channel failures
  • Guaranteed restoration returns devices to original state on session end

Frequently Asked Questions

What hardware does OpenLogi gesture capture support?

OpenLogi targets Logitech devices implementing the HID++ 2.0 protocol, particularly those with reprogrammable controls and raw-XY capable buttons. This includes the MX Master series (gesture button, thumb-wheel), MX Anywhere, and devices with Haptic touch panels. The divert_gesture_sources and divert_gesture_buttons fields in CaptureSpec allow selective enablement based on detected hardware capabilities.

How does OpenLogi distinguish between a tap and a directional swipe?

The SwipeAccumulator maintains a HoldState tracking whether motion has exceeded GESTURE_SWIPE_THRESHOLD. If the button releases before threshold crossing, the session emits GestureDirection::Click. If threshold is exceeded, the dominant axis determines Up/Down/Left/Right. This occurs in handle_raw_xy within the gesture session before CapturedInput reaches the binding layer.

Can the same physical button have different actions for different swipe directions?

Yes—this is the core design of the binding system. The GestureBindings structure in bindings.rs stores a BTreeMap<GestureDirection, Action> for each ButtonId, enabling independent configuration per direction. A single gesture button can trigger Copy (up), Paste (down), Undo (left), Redo (right), and Middle-click (tap) simultaneously.

What happens if the USB connection drops during active capture?

The CaptureLiveness watchdog detects silence via missed ping responses and initiates automatic session restart. The restoration logic in capture_restore.rs first attempts to return the device to original reporting state; if this fails (device unplugged), the error is logged and the shared channel slot is cleared for re-acquisition when the device reconnects.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →