feat(gesture): native pan and pinch zoom, real trackpad gestures on any button - #1175
feat(gesture): native pan and pinch zoom, real trackpad gestures on any button#1175akincan-kilic wants to merge 1 commit into
Conversation
Hold a bound button and HID++ feature 0x1b04 diverts the control with the raw_xy flag, so the firmware streams sensor deltas over HID++ instead of moving the pointer. Pan turns that travel into a pixel-precise two-axis scroll with real gesture phases; Zoom turns vertical travel into a continuous pinch magnify. A Zoom-bound button clicked without dragging fires the discrete native smart zoom instead, so both zoom gestures live on one button and neither can fire on button-down. Deltas are normalised against a live 0x2201 getSensorDpi read, so the feel does not change when the sensor DPI is cycled. Two settings scale it: Zoom Sensitivity on the same 1..=100 scale as the existing sliders, and Invert pan direction. Both are snapshotted when a hold opens, so saving settings mid-gesture cannot change the scale under the user's hand. Three things the hardware forced, each measured on an MX Master 3S at 950 DPI over Bluetooth: - The first raw-XY report of a hold is the sensor's pre-press bank, not travel, because the firmware only streams while the control is down. The worst case measured 1694 x 1619 counts 10 ms after button-down, which is 63 mm, against a 1306 mm/s ceiling for real motion in the same session. It is dropped at the device layer, which also keeps it out of the click/drag deadzone that smart zoom depends on. - A pinch is CGEventType 29 (NSEventTypeGesture) with field 110 = 8 at the HID layer, not CGEventType 30. AppKit is what promotes it into the NSEventTypeMagnify applications see, so posting 30 is inert. - CoreGraphics recomputes sibling scroll-delta fields on every write, and a line delta of 0, which is any pixel delta under 10, zeroes pointDelta. The line field has to be written before the pixel field. Hold-mode arming also needed a real sensor read: live_sensor_dpi() was reading the configured DPI-preset cycle, which is empty for anyone who never set one, so hold mode failed closed and logged nothing. Injection availability is the only hard gate now, since arming a raw-XY divert without an injector freezes the cursor with nothing to deliver the motion. The smart-zoom event synthesis, the ZoomToggle subtype and the two private payload markers, is Kyle Foley's work from PR AprilNEA#1119, reused with thanks and credited at the call site. Closes AprilNEA#360
Greptile SummaryThis PR adds native hold-to-pan and hold-to-pinch gestures, including DPI-normalized device capture, platform-specific injection, lifecycle-safe teardown, configurable sensitivity and direction, UI integration, IPC versioning, and localization.
Confidence Score: 4/5The PR appears safe to merge, with only non-blocking cleanup needed around duplicate DPI-read coalescing and shutdown documentation. Gesture retirement and process-exit sealing cover the critical injected-input lifecycle, while the remaining issues cause redundant background device traffic and misleading maintenance guidance rather than a current correctness failure. Files Needing Attention: crates/openlogi-agent-core/src/orchestrator.rs, crates/openlogi-agent-core/src/runtime.rs
|
| Filename | Overview |
|---|---|
| crates/openlogi-agent-core/src/orchestrator.rs | Adds host capability and live-DPI planning, but sensor refreshes can enqueue duplicate reads while an earlier request remains in flight. |
| crates/openlogi-device/src/session/gesture.rs | Adds raw-XY hold lifecycle, first-report suppression, deadzone tracking, interruption handling, and stale-session cleanup with substantial focused coverage. |
| crates/openlogi-agent-core/src/watchers/gesture/dispatch/hold.rs | Converts hold events into DPI-normalized pan and zoom commands while preventing retired sessions and interruptions from reopening or clicking. |
| crates/openlogi-inject/src/inject/gesture.rs | Centralizes serialized gesture sessions, terminal sealing, quantization, and platform-independent lifecycle behavior. |
| crates/openlogi-inject/src/inject/macos.rs | Implements native Core Graphics pan, pinch, and smart-zoom event synthesis with explicit phase and timestamp handling. |
| crates/openlogi-agent/src/shutdown.rs | Funnels explicit process exits and relaunches through a terminal gesture-session seal. |
| crates/openlogi-agent-core/src/runtime.rs | Flushes gesture state during runtime shutdown, but its documentation incorrectly describes that reopenable flush as the terminal emit. |
| crates/openlogi-core/src/binding/action.rs | Appends Pan and Zoom to the persisted action vocabulary without shifting existing serialized variant indexes. |
| crates/openlogi-ipc/src/ipc.rs | Correctly bumps the strict IPC protocol version for the newly serialized action variants. |
| crates/openlogi-core/src/config/settings.rs | Adds validated zoom-sensitivity and pan-direction settings used consistently by agent and desktop state. |
| crates/openlogi-desktop/src/windows/settings/general.rs | Exposes the new zoom sensitivity and pan inversion controls in general settings. |
Sequence Diagram
sequenceDiagram
participant M as Mouse firmware
participant D as Device hold session
participant A as Agent dispatcher
participant I as Injection session
participant O as Operating system
M->>D: Button down + raw XY
D->>D: Drop first sample and apply deadzone
D->>A: HoldBegin / HoldMotion
A->>A: Normalize counts using sensor DPI
A->>I: Pan or pinch frame
I->>O: Native gesture event
M->>D: Button release
D->>A: HoldEnd(released or interrupted)
A->>I: End gesture or smart zoom
I->>O: Terminal gesture event
Reviews (1): Last reviewed commit: "feat: add hold-mode Pan and Zoom actions..." | Re-trigger Greptile
| if cached_sensor_dpi(&route).is_some() { | ||
| continue; | ||
| } | ||
| self.shared | ||
| .device(&route) | ||
| .detach("sensor DPI read", |channel| async move { | ||
| get_dpi_on(&channel).await.map(|_| ()) | ||
| }); |
There was a problem hiding this comment.
If device runtime state is republished before an earlier getSensorDpi request completes, the completed-value cache remains empty and another request is detached for the same route. These redundant HID++ transactions and worker threads queue behind the receiver lease and can unnecessarily delay later receiver operations on slow devices; track an in-flight request per route or otherwise coalesce refreshes.
Knowledge Base Used: Background agent service
| /// Reject new button input, emit terminal cancellation, and join the worker. | ||
| /// | ||
| /// Also flushes hold-mode inject sessions. The agent calls `process::exit` | ||
| /// after this, which skips `Drop`; this is the guaranteed terminal emit. | ||
| pub fn shutdown(&mut self) { | ||
| openlogi_inject::flush_gesture_sessions(); |
There was a problem hiding this comment.
This documentation calls flush_gesture_sessions the guaranteed terminal emit, but flushing permits later watcher input to reopen a gesture; the actual terminal operation is the subsequent seal_gesture_sessions call in the process-exit funnel. Describing that distinction here avoids encouraging a future shutdown path to rely on the flush alone and strand a pinch or held Ctrl state.
| /// Reject new button input, emit terminal cancellation, and join the worker. | |
| /// | |
| /// Also flushes hold-mode inject sessions. The agent calls `process::exit` | |
| /// after this, which skips `Drop`; this is the guaranteed terminal emit. | |
| pub fn shutdown(&mut self) { | |
| openlogi_inject::flush_gesture_sessions(); | |
| /// Reject new button input, emit cancellation, and join the worker. | |
| /// | |
| /// Also flushes hold-mode inject sessions. This flush is reopenable; the | |
| /// process-exit funnel performs the guaranteed terminal seal afterward. | |
| pub fn shutdown(&mut self) { | |
| openlogi_inject::flush_gesture_sessions(); |
Knowledge Base Used:
|
Thanks for the credit! This looks great! Nice to see the Smart Zoom work carry over into the broader pan/zoom implementation. Credit to @AprilNEA big time. Love working on this. |
|
I'm so stoked for this! |
Hey! I love this project, it is the reason my MX Master is usable on macOS at all, and pan and zoom were the two things I kept missing from Options+. So I spent a few days building them. This is 22 commits squashed into one.
The short version: hold a bound button and your mouse becomes a trackpad. Not a scroll emulation, an actual macOS trackpad gesture, so apps treat it exactly like two fingers on the pad.
What I built
getSensorDpiread, so cycling your DPI does not change how far a gesture travels.Demo
pan-zoom-demo.mp4
Panning a spreadsheet, then zooming in and back out, on the Forward and Back buttons of an MX Master 3S over Bluetooth. The recording does not show the button presses: pan is Forward held and dragged, zoom is Back held and dragged, and the jump in magnification early on is a single click firing smart zoom.
Issues
Closes #360 (Zoom gesture).
Related:
Smart zoom's event synthesis, the
ZoomTogglesubtype and the two private payload markers, is Kyle Foley's work from #1119. Reused with thanks and credited at the call site.How it works
HID++ feature
0x1b04diverts the button with theraw_xyflag, so the firmware streams sensor deltas over HID++ instead of moving the pointer. Those counts convert to screen pixels for pan, or to a magnification increment for zoom, scaled by a live0x2201getSensorDpiread. Then they go out as real OS gesture events.Three crates are involved.
openlogi-devicetracks the hold and decides whether it cleared the click/drag deadzone.openlogi-agent-coredoes the counts-to-output conversion.openlogi-injectholds the OS gesture session and is the only place that knows a pan or pinch is currently open.What broke along the way, and how I fixed it (long, but if you are ever implementing this, it will save you a week 😄)
Everything below was measured on an MX Master 3S over Bluetooth, MacBookPro18,1, macOS 26, sensor at 950 DPI.
Hold mode never armed and never said why
Binding Pan did nothing. No error, no log line.
live_sensor_dpi()inorchestrator.rswas not a sensor read. It read the configured DPI-preset cycle, which is empty for anyone who never set one, sosensor_dpiwasNoneand the arming gate required it. Hold mode failed closed silently.Fixed with a real
0x2201read and a fallback chain: live read, then cycle preset, then configured DPI, then a named constant with a warning. Injection availability is the only hard gate now, because arming a raw-XY divert with no injector freezes the cursor with nothing to deliver the motion. The capture log line carries the resolved DPI and hold-button count so this is visible next time.Zoom did nothing at all
I was posting
NSEventTypeMagnify, CGEventType 30, which is what the AppKit docs point you at.I wrote a throwaway CGEventTap probe and captured 339 events from real trackpad pinches. Every real pinch is CGEventType 29 (
NSEventTypeGesture), never 30, carrying field 110 = 8 (kIOHIDEventTypeZoom), field 113 = magnitude, field 132 = phase. AppKit is what promotes 29 into theNSEventTypeMagnifythat apps see. Posting 30 at the HID layer does nothing.It also needs a Mach timestamp. Without one Safari accepts a zoom-in and then refuses the gesture that toggles back out. #1119 documents this and it saved me an afternoon.
Small pans disappeared
Anything under a few pixels produced nothing.
CoreGraphics recomputes the sibling scroll-delta fields every time you write one, and a line delta of 0, which is any pixel delta under 10, zeroes
pointDeltabehind you. Write the line field first, pixel field second.Found by capturing 1304 real two-finger trackpad scroll events and diffing field by field. Of 546 scroll events, 541 carry a non-zero
pointDelta, 209 also carryfixedPtand a line delta, and zero carryfixedPtwithoutpointDelta. #1156 hit the same behaviour on the scroll path.The pan jump
This was the hard one. Activating pan made the view jump, sometimes most of a screen, while the cursor stayed put. Intermittent, different distance every time.
Wrong guesses, in case they look tempting to you too:
CGEventGetLocationon an unposted scroll event already returns live cursor coordinates, and tapping our own posted pans showed the right location on every frame including Began.What found it was instrumentation plus a scripted repro. Every raw-XY report logged with a per-hold sequence number, the pixel delta it became, and the frames posted. Then: sit on a known cell, activate without moving, move a long way with the gesture off, activate again, repeat.
Every jump was sequence number 1. Never anywhere else in the stream.
F23 to F69 is 46 rows, and at Sheets' 21 px row height that is 966 pixels against the 952 we posted. The fastest real motion in five minutes of panning was 1306 mm/s, so 6264 mm/s is not a hand.
The evidence that settled it was an absence: zero raw-XY log lines between app start at 20:07:46 and the first press at 20:13:14. The firmware does not stream diverted raw XY until the divert engages, and the first packet after it engages flushes whatever the sensor banked beforehand. That is also why later presses were usually clean.
So: drop the first raw-XY report of every hold-mode stream. Not a threshold or a speed clamp, because the report is pre-press travel by construction. The cost is one HID report, about 8 ms, from a hand that is not moving at button-down.
I first put this in the agent's hold dispatcher. It stopped the jump but it belonged one layer up. The device layer already had a
skip_first_raw_xyflag for the haptic panel's contact jump, andbegin_holdjust never set it for hold-mode streams. Moving it there made it smaller and kept the backlog out of the click/drag deadzone, which matters because smart zoom depends on that deadzone being clean. Downstream, the deadzone still saw 1694 counts and would have read every first press as a drag.This is the same root cause as #752 on the swipe path.
Five more found by review
I had this working on hardware and then ran two adversarial review passes over it. Five real bugs came out, all fixed here, each with a test that fails without its fix.
HoldEndonly carriedtraveled: bool, so a teardown looked identical to the user letting go. A Zoom button still held through a Bluetooth reconnect fired a smart zoom into whatever was frontmost. It carriesHoldRelease::{Released { traveled }, Interrupted}now.openlogi-injectcomputed each gesture frame under theGESTURE_SESSIONSmutex and posted it after releasing the guard, so a lateBegancould outrace a shutdownEnded. The post happens under the guard now.zoom_continuousopens a pinch from closed by contract, which is how each new hold starts one, and the gesture watcher is not joined before exit. A thread still streaming reopened a pinch after the final flush, andprocess::exitskipsDrop. On Linux and Windows that is a held Ctrl with nothing to release it. The exit path seals now instead of flushing.sensor_dpiwas onCaptureSpec, which is part ofCaptureTarget's identity, so the firstgetSensorDpicompleting a second after connect changed the target and retired the whole capture session, killing any hold in progress. The armed spec carries only the fallback now. The live reading still reaches the millimetre conversion through the dispatch plan, and the deadzone reads the sensor cache at press time.Testing
cargo fmt --checkclean,cargo clippy --workspace --all-targets -D warningsclean, 37 suites, 0 failures.Exercised by hand on the device, not just in tests: pan in Sheets with no jump across repeated activations, pan held well past 10 seconds, zoom in and out, zoom sensitivity at 1, 14 and 100, inverted pan, smart zoom toggling in and back out, and continuous pinch on the same button.
One flake turned up that is not mine:
tray::tests::startup_stays_suspended_when_the_display_is_already_asleepposts a real NSWorkspace notification while sibling tests hold observers, and fails roughly one run in five. I ran the same suite eight times on a clean worktree at the base commit with none of my code in it and it failed there too, same assertion. #1168 looks like it is aimed at this.Notes for review
AppSettingskeeps#[serde(deny_unknown_fields)], so the two new fields downgrade the same waythumbwheel_sensitivityalready does. I did not want to change that policy inside a feature PR, but say the word.Happy to split this up or change anything.