Skip to content

feat: gestures and software scroll inversion for MX Vertical - #783

Open
fuloskop wants to merge 4 commits into
AprilNEA:masterfrom
fuloskop:feat/mx-vertical-gestures-and-scroll-inversion
Open

feat: gestures and software scroll inversion for MX Vertical#783
fuloskop wants to merge 4 commits into
AprilNEA:masterfrom
fuloskop:feat/mx-vertical-gestures-and-scroll-inversion

Conversation

@fuloskop

Copy link
Copy Markdown

What this fixes

MX Vertical can't use gestures or scroll inversion today, for two unrelated reasons.

Gestures. The device ships no dedicated gesture button, and the capture layer knows only two gesture sources (0x00c3, plus the MX Master 4 haptic panel). diag controls on real hardware:

device: MX Vertical Advanced Ergonomic Mouse (direct 046d:b020)
     cid    task   flags  capabilities
  0x0050  0x0038  0x0411  analytics-events
  0x0051  0x0039  0x0411  analytics-events
  0x0052  0x003a  0x0571  divertable, raw-xy, analytics-events
  0x0053  0x003c  0x0571  divertable, raw-xy, analytics-events
  0x0056  0x003e  0x0571  divertable, raw-xy, analytics-events
  0x00fd  0x00d2  0x0571  divertable, raw-xy, analytics-events
  0x00d7  0x00b4  0x03a0  divertable, raw-xy, force-raw-xy

There is no 0x00c3. 0x00d7 (virtual gesture button) is listed but never emits — armed with raw-XY it produced no events across repeated presses. 0x00fd, the DPI switch, is the control that actually fires, and it is raw-XY capable.

Logitech's own asset metadata agrees: for this model it ships SLOT_NAME_GESTURE_{UP,DOWN,LEFT,RIGHT,CLICK}_BUTTON next to SLOT_NAME_DPI_BUTTON, i.e. it also treats the DPI button as the control that gestures here.

The GUI had a second, independent problem: map_slot_name knew only SLOT_NAME_MODESHIFT_BUTTON, so this device's DPI button produced no hotspot and could not be bound at all.

Scroll inversion. Capabilities::scroll_inversion is derived from 0x2121 (HiResWheel). MX Vertical reports 30 features and none is 0x2121, so the toggle read "Unavailable" permanently with no way to reach the setting.

What changed

  • feat(hid) — the DPI/ModeShift family joins GESTURE_SOURCE_BUTTONS and ButtonId::is_hidpp_gesture_source. DPI cycling stays the out-of-the-box behavior: that button's canonical binding is Single, which drops out of the gesture-map lookup. Also guards the capture session's DPI pass against re-arming a CID already armed with raw-XY — that write carries none and would strip the reporting the hold depends on, reachable exactly on a device whose DPI button is its gesture source.
  • fix(gui)SLOT_NAME_DPI_BUTTON maps to ButtonId::DpiToggle. The five per-direction gesture markers stay unmapped: they mark the same control, and the hotspot builder does not dedupe, so mapping them would stack duplicate hotspots on one button.
  • feat(hook)EventDisposition::InvertScroll. macOS negates the CGEvent delta fields in place, so the event keeps its phase, momentum and pixel precision and no synthetic replacement re-enters the tap. Only fields the event actually carries are negated, since writing an axis the device left empty would introduce a delta an app reading that field would honour. Only devices in HookMaps::invert_scroll are rewritten — natively-capable ones are excluded, because their setting goes to the firmware and rewriting on top would invert twice and cancel out. Trackpad scroll is never touched, since macOS already applies its own natural-scrolling preference there. evdev and WH_MOUSE_LL have no rewrite path yet and pass the disposition through rather than swallowing the scroll.

Verification

Real hardware, MX Vertical over Bluetooth-direct:

before after
ten presses of the gesture button 19 × button=DpiToggle action=Cycle DPI Presets, zero gestures gesture_sources=1 dpi_buttons=0; Left/Right/Up/Down and click all dispatch their bound actions
Pointer tab "Invert scroll direction — Unavailable" toggle live; wheel inverts, trackpad unaffected

Full local gate clean on the final tree: cargo fmt --all -- --check, cargo clippy --all-targets -- -D warnings, cargo test, and RUSTDOCFLAGS="-D warnings" cargo doc. 635 tests pass, six of them new — covering the DPI-toggle Single default, both DPI slot names, the deliberately unmapped direction markers, exclusion of natively-capable devices, and the per-device property of inversion (disabling the selected device must not stop inverting another's wheel).

Only macOS was exercised on hardware. The Linux and Windows hook arms are compile-only changes that pass the new disposition through.

MX Vertical ships no dedicated gesture button. Its `0x1b04` list carries
no `0x00c3` at all, its `0x00d7` (virtual gesture button) is listed but
never emits an event, and `0x00fd` — the DPI/ModeShift switch — is the
only control there flagged `raw-xy`. That is also the control Logitech's
own software gestures from on this model, corroborated by the device's
asset metadata, which ships `SLOT_NAME_GESTURE_*_BUTTON` markers next to
`SLOT_NAME_DPI_BUTTON`.

Add the DPI/ModeShift family to `GESTURE_SOURCE_BUTTONS` and to
`ButtonId::is_hidpp_gesture_source`, so a gesture map bound to
`ButtonId::DpiToggle` arms a raw-XY divert and dispatches swipes. DPI
cycling remains the out-of-the-box behavior: `default_binding_for` returns
`Single` for that button and a `Single` shape drops out of the gesture-map
lookup, pinned by a test so it cannot regress silently.

Also guard the capture session's DPI pass against re-arming a CID already
armed as a raw-XY gesture source. That write carries no raw-XY and would
strip the reporting the hold depends on — reachable exactly on a device
whose DPI button *is* its gesture source.

Verified on MX Vertical over Bluetooth-direct. Before: ten presses gave 19
`button=DpiToggle action=Cycle DPI Presets` events and no gestures. After:
`gesture_sources=1 dpi_buttons=0`, and all four directions plus click
dispatch their bound actions.
`map_slot_name` knew only `SLOT_NAME_MODESHIFT_BUTTON`, the MX Master
line's name for that control. MX Vertical calls the same button
`SLOT_NAME_DPI_BUTTON`, so it produced no hotspot: the device rendered
with no editable control for its only extra button and could not be bound
from the GUI at all.

The five `SLOT_NAME_GESTURE_*_BUTTON` markers that model also ships stay
unmapped on purpose — they mark the control the DPI marker already covers,
this builder does not dedupe, and the picker renders swipe directions from
the binding's gesture map. Both halves are pinned by tests.
Scroll inversion was gated on HID++ `0x2121` (HiResWheel) reporting an
invert capability, so a device without that feature showed a permanently
"Unavailable" toggle. MX Vertical reports 30 features and none is `0x2121`,
putting the setting out of reach on that hardware.

Add `EventDisposition::InvertScroll`. macOS negates the `CGEvent`'s delta
fields in place, so the event keeps its phase, momentum and pixel
precision and no synthetic replacement re-enters the tap. Only fields the
event actually carries are negated: writing an axis the device left empty
would introduce a delta an app reading that field would honour.

The hook rewrites only devices in `HookMaps::invert_scroll`, which the
orchestrator builds from the config while excluding natively-capable ones —
for those the setting goes to the firmware, and rewriting on top would
invert twice and cancel out. Trackpad scroll is never touched, since macOS
already applies its own natural-scrolling preference there.

Inversion is keyed per device rather than scoped to the selected one, so
disabling the selected device empties the button maps without stopping
another device's inversion. Both properties are covered by tests.

evdev and `WH_MOUSE_LL` have no rewrite path yet; both pass the new
disposition through rather than swallowing the scroll.
@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds MX Vertical gesture support and macOS software scroll inversion while preserving native firmware inversion where available.

  • Treats DPI/ModeShift controls as HID++ gesture sources and prevents raw-XY reporting from being overwritten.
  • Maps the MX Vertical DPI asset slot to the configurable DPI button hotspot.
  • Adds an in-place macOS scroll-inversion disposition, scoped to eligible direct devices.
  • Resolves identical-device VID/PID conflicts by declining software inversion when their settings disagree.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
crates/openlogi-agent-core/src/orchestrator.rs Builds software-inversion identities from enabled direct devices, excludes native-capable devices, and handles identical-device setting conflicts explicitly.
crates/openlogi-agent-core/src/hook_runtime.rs Selects software inversion for non-trackpad scroll events whose device identity is present in the published inversion set.
crates/openlogi-hook/src/macos.rs Implements in-place negation of populated vertical and horizontal Core Graphics scroll fields.
crates/openlogi-hid/src/session/gesture.rs Adds DPI-family controls as gesture sources while preserving their raw-XY reporting during capture setup.
crates/openlogi-desktop/src/state/scroll.rs Exposes software inversion for pointer-capable devices on macOS while retaining native-capability gating elsewhere.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[Physical mouse event] --> B{Event type}
    B -->|DPI gesture source| C[HID++ raw-XY capture]
    C --> D[Resolve gesture binding]
    D --> E[Action dispatcher]
    B -->|Wheel event| F[OS input hook]
    F --> G{Trackpad or native inversion?}
    G -->|Yes| H[Pass through]
    G -->|No| I{VID/PID enabled for software inversion?}
    I -->|No| H
    I -->|Yes| J[Negate macOS scroll deltas]
    J --> H
Loading

Reviews (2): Last reviewed commit: "fix(agent): don't invert scroll for iden..." | Re-trigger Greptile

Comment thread crates/openlogi-agent-core/src/orchestrator.rs Outdated
The hook matches a scroll event by vendor/product id, which two identical
directly-attached mice share, so one device's inversion setting cannot be
applied without also applying it to its twin. Collapsing them inverted a
wheel the user never configured, untraceable from the GUI.

Group candidates by identity and drop any identity whose devices disagree,
logging a warning instead; identities that agree still yield one entry.
Reported by Greptile on AprilNEA#783.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant