Skip to content

feat: MX Master 4 Action Ring support on Windows (capture, bindings, on-screen ring) - #436

Open
ultradaoto wants to merge 41 commits into
AprilNEA:masterfrom
ultradaoto:feat/haptic-sense-panel
Open

feat: MX Master 4 Action Ring support on Windows (capture, bindings, on-screen ring)#436
ultradaoto wants to merge 41 commits into
AprilNEA:masterfrom
ultradaoto:feat/haptic-sense-panel

Conversation

@ultradaoto

Copy link
Copy Markdown

Summary

On Windows, the MX Master 4's Action Ring pad — and its side Back/Forward buttons — do nothing without Logi Options+: those controls only speak HID++ and emit no native HID events at all. This PR reverse-engineers the pad's activation recipe (wiretap captures included under docs/mx-master-4-panel-captures/) and implements the feature end to end: the pad is captured, bindable (Binding::Ring, eight compass-keyed slots in the v3 schema), opens an on-screen radial ring (tap → move toward a sector → tap to fire; centre ✕ / Esc / outside click cancel), and is fully customizable from the mouse panel. Two pre-existing Windows-port issues surfaced as prerequisites on hardware and are fixed here too.

Changes

  • hidpp: 0x19c0 ForceSensingButton + 0x1e00 EnableHiddenFeatures wrappers (typed threshold setter; raw probe surface, reverse-engineering annotations kept honest) and a raw_feature_call diag helper.
  • hid: decode 0x1b04 analyticsKeyEvents into the gesture pipeline (they were discarded — the line that made the pad look dead); arm the pad when the control table advertises 0x01a0 (0x19c0 force threshold 0x15a3 + analytics reporting, re-applied on a 3 s cadence because the config does not survive device sleep, each pass time-bounded so a cold link can't wedge shutdown); divert the side Back/Forward buttons (0x53/0x56) like the DPI button and dispatch their bindings; give first-contact Bolt slot walks a 3 s budget (a first feature walk on the Windows BLE stack takes 1–3 s; the 1 s cap starved it forever, so devices could take minutes to attach). Important correction to the discovery notes: 0x0050/0x0051 are the left/right click-telemetry CIDs, not pad companions — arming them makes every click open the ring; production arms 0x01a0 alone.
  • core: ButtonId::ActionRing + a third untagged Binding::Ring(BTreeMap<RingSlot, Action>) arm (compass names, deliberately disjoint from Action and GestureDirection namespaces; routing guard tests extended), defaults, Config::set_ring_slot with complete-map seeding.
  • ipc: v11 — next_ring_press long-poll (a press reaches the GUI in one IPC round trip, ~11 ms measured) + execute_action; goldens regenerated.
  • agent: routes ring-shaped presses to the GUI; a pad bound to a single action keeps dispatching in the agent; per-app single-action overrides disarm the ring for that app.
  • gui: the popup (gpui WindowKind::PopUp; raw-HWND no-activate + topmost; the window is clipped to a region of exactly its nine circles, so there is no rectangle to show a backdrop and clicks between circles fall through; work-area clamped at screen edges; global-cursor sector aiming at 33 ms) and the customization menu (compass-grid navigator + per-slot action-catalog flyout, mirroring the gesture button's two-level menu; seven new UI strings across all twenty locales, parity test green).
  • cli: the reverse-engineering diag subcommands used for discovery (panel, hidden, fsb, call, hidsniff, rawinput).
  • docs: CONFIGURATION.md refreshed to the live v3 route-keyed schema and the ring table.

Testing

  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace — green on Windows (stable-gnu).
  • Runtime-verified on real hardware (MX Master 4 wpid b042 on a Bolt receiver, Windows 11, no Logitech software installed or running): pad taps open the ring at the cursor; sector select by second tap and by click; ✕ / Esc / outside-click cancel; left/right clicks confirmed not to trigger the pad; Back/Forward navigate in Chrome via the new divert path; slots edited from the mouse panel persist and apply on the next open; ~11 ms press→GUI latency, ~5 ms reopen (window reused).
  • Not runtime-tested: macOS/Linux (the overlay is Windows-first with platform stubs; capture, bindings and the editor compile everywhere), other MX-family devices.
  • Operational note: the pad's analytics reporting can wedge in device RAM after repeated arm/hard-kill cycles during development — a mouse power cycle clears it.

Happy to split this into smaller PRs if you'd prefer — the commits are already sliced that way. Also fine if you'd rather take the capture/binding layers first and hold the overlay.

0x19c0 ForceSensingButton is the MX Master 4 Action Ring pad; its function
map is undocumented, so the wrapper exposes a raw probe surface plus the one
call confirmed on hardware: setForceThreshold (fn 3), the write that arms
the dormant pad. 0x1e00 EnableHiddenFeatures gates hidden-feature access.
Device::raw_feature_call sends one short-form call to any feature by ID for
reverse-engineering diagnostics.
windows_hid.rs calls WriteFile, whose signature references OVERLAPPED from
Win32_System_IO; the build only worked through feature unification with
sibling crates. Declare what the crate itself uses.
hidden_features.rs carries the route-level diag plumbing proven on real
hardware: watch_panel arms the MX Master 4 Action Ring with the Options+
recipe (0x19c0 force threshold + 0x1b04 analyticsKeyEvents reporting on the
panel CIDs, re-applied on a cadence for cold BTLE links) and decodes each
press/release; plus raw probe helpers for 0x1e00/0x19c0 and arbitrary
features. Errors stay in a local type so the IPC wire format is untouched.
diag panel arms the MX Master 4 Action Ring and prints each press/release —
the hardware exerciser behind the production ring capture. diag hidden/fsb
probe 0x1e00 and 0x19c0, diag call sends one raw call to any feature by ID,
diag hidsniff hex-dumps Logitech HID interfaces, and diag rawinput taps
RawInput to observe OS-owned collections (Windows only).
The empirical record behind the panel protocol: the Options+ cold-start
wiretap the arming recipe came from, the analytics event stream under
Options+, and the confirming clean-state capture (48/48 press/release
events from a power-cycled mouse with no Logitech software running).
Raw-pointer borrows use the `&raw` forms, the RAWINPUT read buffer is
u64-backed so the struct alignment is guaranteed rather than assumed from
allocator behaviour, the two const-size u32 casts carry expect reasons,
and the four hand-rolled hex dumps share one fold-based helper.
Wire the MX Master 4 Action Ring pad into the capture pipeline using the
hardware-confirmed analytics recipe:

- decode 0x1b04 analyticsKeyEvents into RawControlEvent::AnalyticsKeys
  instead of discarding them — the discard is what made the pad look dead
- arm the pad in arm_controls when the control table advertises 0x01a0
  with analytics-events: 0x19c0 force threshold 0x15a3, then analytics
  reporting on 0x01a0/0x0050/0x0051 (never diversion, never 0x00d7)
- re-apply the recipe every 3 s from the session loop — a single arm is
  lost while the BTLE link sleeps — with each pass time-bounded so a cold
  link cannot wedge the shutdown path; the first pass runs after the
  message listener is registered so no event is missed
- track taps with a rising edge across the pad CIDs in handle_reprog
  (hardware reports each tap on one of 0x01a0/0x0050, varying); log-only
  for now — emitting a CapturedInput needs the ButtonId append, which is
  a wire change and lands separately
- retire the 0x00d7 divert experiment and document the dead end on
  HAPTIC_PANEL_CID; share the panel CID list from reprog_controls

Non-MX4 devices skip the arming entirely (no 0x01a0 in their table).
ButtonId gains ActionRing (appended; ButtonId::ALL is now 11), and Binding
gains a third untagged arm: Ring(BTreeMap<RingSlot, Action>) — eight
compass-named sectors, clockwise from the top. The names are deliberately
disjoint from every Action and GestureDirection variant, which is what
keeps untagged TOML routing unambiguous; guard tests cover ring-keyed and
sparse tables. ActionRing defaults to a full Ring map (opposing copy/paste
+ undo/redo pairs on the cardinals); its single-action projection is
Action::None, so a ring-bound tap never double-fires an action in the
agent. A user can instead bind the pad to a Single action, which
dispatches through the existing HID++ button path.

The capture session now emits ButtonPressed(ActionRing) on the pad's
rising edge (verified on hardware: every tap = exactly one press event,
including 210 ms double-taps). No IPC change: CapturedInput is consumed
in-process by the agent-core gesture watcher and never crosses the tarpc
wire, and config crosses via the file, not the socket.

The GUI maps the MX Master 4 metadata slot ASSIGNMENT_NAME_SHOW_RADIAL_MENU
to the ActionRing hotspot — Logi's own marker for the pad (its slotId
suffix _c416 is CID 0x01a0, the control the session arms). "Action Ring"
is inserted at the same position in all 20 locale files (parity test
green). docs/CONFIGURATION.md is refreshed for the live v3 route-keyed
schema and documents the ring table.
BOLT_SLOT_PROBE (1 s) assumed "a healthy walk is well under a second" —
true on a hot link, false for an MX Master 4 on the Windows BLE stack,
where a first walk takes 1-3 s even in active use. With an empty cache
every tick timed out, model_info stayed None, the device never entered
the agent device list, and nothing that keys off it (capture, DPI, the
Action Ring) ever saw the mouse; attach became a minutes-long lottery
that only resolved when one walk happened to land under 1 s.

A slot with no cached walk now gets a 3 s first-contact budget; once
cached, the tight 1 s cap returns, keeping the AprilNEA#218 protection (a hung
device falls back to its cache and cannot starve the receiver). Slots
probe concurrently, so the worst case against PROBE_BUDGET is max, not
sum: 1.5 s arrival drain + 3 s = 4.5 s still fits.

Hardware-verified: attach went from ~8 minutes (or never) to within two
ticks of agent start.
The discovery notes called 0x0050/0x0051 "companions of the Action Ring
pad". They are the LEFT and RIGHT mouse buttons: with analyticsKeyEvents
enabled they report every physical click as a press/release pair — which
is why Options+ arms them (click telemetry). With them armed for the
ring, every click anywhere on the desktop opened the overlay
(hardware-confirmed; the earlier captures'"taps on 0x0050" were stray
real clicks misread as pad events).

Production arming and the press edge now use ACTION_RING_CID (0x01a0)
alone; analytics from any other CID are ignored with a debug note. The
click CIDs stay as documented constants, and the diag panel keeps
arming all three deliberately so the full analytics stream stays
visible to diagnostics.
…(v11)

The overlay lives in the GUI, so a ring-shaped pad press must cross the
IPC boundary fast. Two methods appended (order is wire format):
next_ring_press long-polls the new agent-core RingChannel — held ~20 s,
answered the instant a press queues, so open latency is one IPC round
trip (~11 ms measured) — and execute_action fires the selected sector
through the agent dispatch path, unbound from GUI window focus.

The gesture watcher routes ButtonPressed(ActionRing) into the channel
only while the effective binding is ring-shaped (ring_armed_for,
published by the orchestrator on every rebuild; a per-app single-action
override disarms the ring for that app). A Single-bound pad keeps
dispatching in the agent as before. Presses carry a monotonic seq and a
small drop-oldest queue so a backlog never replays ghost opens.

PROTOCOL_VERSION 10 -> 11; goldens regenerated with the two request
variants and RingPress varint encodings pinned.
A pad tap opens a ring of eight sector buttons centred on the cursor;
the pointer moves freely (never captured); a second tap fires the sector
the cursor points toward. Cancel: the centre X, a second tap in the dead
zone, Esc, or a click outside the ring.

The window is gpui WindowKind::PopUp (borderless toolwindow) made
transparent, and the bits the pinned backend does not expose go through
the raw HWND (HasWindowHandle): WS_EX_NOACTIVATE so focus never leaves
the app being acted on, HWND_TOPMOST + SW_SHOWNA / SW_HIDE for
show/hide, and physical-pixel positioning at the cursor. The window is
created once and reused — reopen latency measured at ~5 ms (first open
~200 ms including surface creation).

Sector selection is pure geometry against the global cursor (the pointer
may leave the little window entirely), polled at 33 ms while open by a
watcher that also closes on Esc / outside-click — signals a no-activate
window never receives as messages. Slots come from the device config
(Binding::Ring, defaults filled); the selected action executes in the
agent via execute_action. Non-Windows builds compile with stub platform
plumbing (the pad stays captured and bindable; the overlay is
Windows-first).

Latency choice per the measured numbers: GUI draws (option A); the
long-poll push path makes the IPC hop ~11 ms, far under a poll interval.
Three rounds of on-hardware feedback:

- The gpui Transparent background also enables the DWM accent
  (ACCENT_ENABLE_TRANSPARENTGRADIENT), which painted a faint veil over
  the whole window rect — the visible "box" behind the floating ring.
  DirectComposition already composites per-pixel alpha, so the accent is
  disabled via SetWindowCompositionAttribute (resolved from user32 at
  runtime like gpui does; not in the import library).
- The window clamps into the cursor monitor''s work area, so a ring
  opened at the bottom of the screen slides up instead of losing its
  lower buttons; show_at returns the actual centre so sector aiming
  tracks the clamped position.
- Options+-inspired look, inverted for a dark-mode desktop: bright
  circular icon buttons, dark label pills floating outside the button
  circle (above the top sectors, beside the side ones), small centre
  cancel button, in a wider-than-tall compact window.
Hardware-confirmed: with no Logitech software running, the MX Master 4
side buttons emit no native HID button events at all — they are dead
until something drives them over HID++, which Options+ silently does.
Scrolling and clicks are ordinary HID; only these buttons (and the
gesture button and Action Ring) are software-defined.

The capture session now diverts BACK_CID/FORWARD_CID (0x53/0x56 —
decimal `_c83`/`_c86` in Logi''s own asset metadata) exactly like the
DPI button, and dispatches ButtonPressed(Back/Forward) on the rising
edge; the watcher routes them through the user''s bindings, whose
defaults are BrowserBack/BrowserForward. Devices whose table lacks the
CIDs are untouched. Verified in Chrome on hardware: navigation works
again with only OpenLogi running.
Two rounds of on-hardware feedback:

- The visible "box" behind the ring had two causes. The app''s theme
  plumbing re-stamps window background appearance across every window
  when a theme applies, flipping the overlay back to Opaque (whose clear
  colour is solid white) — now re-asserted Transparent on every open.
  And regardless of what the compositor does with surface alpha, the
  window is now clipped to a region of exactly its nine circles
  (SetWindowRgn), so no rectangle exists to show a backdrop and clicks
  between the circles fall through to the app underneath.
- The popup shows only the circles and the centre cancel button — the
  floating labels belong to the customization menu, not the popup — and
  the palette inverts to bright-on-dark (the user''s desktop lives in
  dark mode). The window shrinks to a tight square.
Clicking the pad''s hotspot (or its side label, now summarized as
"8 actions") opens a two-level menu mirroring the gesture button''s: a
compass-grid navigator showing all eight slots in their on-screen
arrangement, and a per-slot action-catalog flyout. Picking persists via
Config::set_ring_slot — which seeds a complete default ring around the
first edit and promotes a single-action pad back to a ring (guarded by
set_ring_slot_seeds_a_complete_map) — and the agent reloads, so the
next popup already uses the new layout.

RingSlot gains compass glyphs; the seven new UI strings are inserted at
the same position in all twenty locale files (parity test green).
@ultradaoto

Copy link
Copy Markdown
Author

Just so it's clear, it could have been done already, I'm not sure, but I got the ring to work in Windows. So that's what this is about. And there's some customization, and it seems to work okay if you want to maintain the actions ring button.
Screenshot 2026-07-20 093658

@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds end-to-end Action Ring support for the MX Master 4 on Windows: HID++ analytics-event capture (reversing the device's dormant-by-default pad), a bindable ButtonId::ActionRing, an on-screen radial ring overlay (GPUI PopUp, no-activate, clipped to nine circles), and side Back/Forward button diversion. Two pre-existing Windows-port issues are also fixed — the 1 s Bolt slot-probe budget that starved devices on the Windows BLE stack, and an instantaneous SendInput chord that polling-based hotkey listeners would miss.

  • HID capture layer (gesture.rs, hidden_features.rs, reprog_controls.rs): decodes analyticsKeyEvents from 0x01a0 only, re-arms the force threshold + analytics reporting on a 3 s cadence bounded by a 2 s per-pass timeout so shutdown is never wedged; HAPTIC_PANEL_CID (0x00d7) and the click-telemetry CIDs are explicitly not armed, with the hardware-confirmed failure mode documented.
  • Core / config layer (binding.rs, config.rs): adds RingSlot (eight compass slots), Action::Named / Run / PasteText / Folder variants, Binding::Ring, schema v4, and a defaults_for_unreadable() guard that prevents an older binary from silently overwriting a newer config with defaults.
  • Agent / IPC / GUI: RingChannel long-poll (press → GUI in one IPC round trip), execute_action IPC endpoint, GPUI ring overlay with global-cursor sector aiming, folder sub-menus, per-device slot resolution, and a new ChordRecorder for the shortcut editor.

Confidence Score: 4/5

Largely safe to merge with one targeted fix: dispatch_action should resolve action.inner() before the DPI/SmartShift match arms so that Named-wrapped device actions work correctly across all binding paths.

dispatch_action in hook_runtime.rs matches on the raw action before stripping Named wrappers introduced by this PR. The design contract ("every consumer dispatches Action::inner()") is upheld by the inject layer and the ring GUI, but not by dispatch_action itself: a Named(CycleDpiPresets) binding on any button routes to inject::execute, whose DPI arms only log a debug message rather than cycling DPI, so the action silently does nothing. The rest of the PR — the HID++ capture recipe, the ring overlay, the schema migration guard, and the protocol versioning — is well-constructed and thoroughly tested.

Files Needing Attention: crates/openlogi-agent-core/src/hook_runtime.rsdispatch_action needs to call .inner() before the DPI action match arms. crates/openlogi-agent-core/src/orchestrator.rsset_current_app() doesn't update ring.set_armed() (previously flagged).

Important Files Changed

Filename Overview
crates/openlogi-agent-core/src/hook_runtime.rs dispatch_action matches on the raw action before resolving Named wrappers; CycleDpiPresets/SetDpiPreset/ToggleSmartShift nested inside Named silently fail because inject's handler for those variants only logs a debug message.
crates/openlogi-agent-core/src/orchestrator.rs Adds RingChannel to SharedRuntime and calls set_armed in rebuild(), but set_current_app() does not update the armed flag — flagged previously as an outside-diff comment.
crates/openlogi-agent-core/src/ring.rs New press channel between the gesture watcher and the IPC long-poll; well-structured with correct tokio Notify pattern and overflow handling.
crates/openlogi-hid/src/gesture.rs Adds Action Ring analytics-event decoding, per-cadence re-arming, and Back/Forward divert path; the 3 s budget with MissedTickBehavior::Delay correctly bounds cold-link hangs without racing shutdown.
crates/openlogi-core/src/binding.rs Adds RingSlot, Named, Run, PasteText, Folder Action variants. inner() correctly unwraps Named chains via a loop. KeyCombo::parse stores macOS kVK codes, consistent with the existing mac_virtual_key_to_windows mapping.
crates/openlogi-gui/src/ring.rs On-screen ring overlay; sector math and folder navigation are correct. The outside-click cancel check compares cursor against the window RECT rather than the clipped region, leaving the ring open when a click lands between circles — flagged separately.
crates/openlogi-inject/src/inject/windows.rs Adds Run (ShellExecuteW), PasteText (KEYEVENTF_UNICODE), and Named handling; switches to post_held_chord for CustomShortcut to fix polling-listener races. Correctly calls action.inner() first.
crates/openlogi-core/src/config.rs Bumps schema to v4, adds set_ring_slot/set_ring_folder_slot helpers, and introduces defaults_for_unreadable() to prevent a newer-binary config from being overwritten by defaults on parse failure.

Sequence Diagram

sequenceDiagram
    participant Pad as MX Master 4 Pad
    participant Watcher as gesture watcher
    participant Ring as RingChannel
    participant Agent as AgentServer (IPC)
    participant GUI as GUI ring_poll
    participant Overlay as RingOverlay
    participant Inject as openlogi-inject

    Note over Watcher,Pad: Every 3 s cadence: write force-threshold + arm analyticsKeyEvents
    Pad->>Watcher: analyticsKeyEvent (0x01a0 press)
    Watcher->>Ring: push_press() if is_armed()
    Ring-->>Agent: next_ring_press() unblocks
    Agent-->>GUI: "Ok(Some(RingPress { seq, device_key }))"
    GUI->>Overlay: on_pad_press(device_key, cx)

    alt Ring closed
        Overlay->>Overlay: open() at cursor, bump epoch
        Overlay->>Overlay: spawn watcher (33 ms tick: aim + Esc/outside-click)
    else Ring open
        Overlay->>Overlay: confirm() → activate(slot)
        Overlay->>Agent: execute_action(action)
        Agent->>Inject: dispatch_action → inject::execute
        Inject->>Inject: SendInput / ShellExecuteW / …
    end
Loading

Reviews (9): Last reviewed commit: "feat(gui): allow shortcuts, commands and..." | Re-trigger Greptile

Comment thread crates/openlogi-agent-core/src/ring.rs Outdated
Comment thread crates/openlogi-gui/src/ring.rs
Options+ parity for its "Run" and "Paste Text" assignments: `Run` opens a
URL, file, or program (the payload carries an optional `||`-separated
argument string, split by the new `split_run_target`), and `PasteText` types
a fixed snippet. Both are appended after `CustomShortcut`, excluded from the
picker catalog like it, and derive short readable labels (URL host / file
stem / first snippet line). The untagged-routing guard gains cases proving
their single-key tables still land on `Binding::Single`.
Windows: `Run` goes through `ShellExecuteW` (URLs launch the browser,
documents their association, programs run with the `||` argument string) with
cmd.exe-style `%VAR%` expansion, injected via a testable lookup; `PasteText`
types the snippet as `KEYEVENTF_UNICODE` events — no clipboard round-trip —
with line breaks as Enter and tabs as Tab. macOS/Linux run targets through
`open` / `xdg-open` (children reaped off-thread); their PasteText is an
honest logged stub pending platform Unicode typing.
The appended enum variants ride inside execute_action and config snapshots,
so the strict-equality handshake must fence old/new pairs. Goldens pin the
new variant encodings (varint index + length-prefixed payload).
The ring flyout grows a CUSTOM section with "Run Command…" and "Paste Text…"
rows. Each opens a small payload-editor dialog — the Options+ Run editor
shape: one input (seeded with the slot''s current payload for in-place
edits), a hint line, Cancel / Save — committing through the same
commit_ring_binding path as the plain rows. Editor strings land in all
twenty locales at one position.
Measured against the real Options+ popup: sector circles grow to 48 at
radius 86 so neighbours nearly touch, the centre ✕ becomes the 36px crimson
cancel button, and the window shrinks to 240 logical px. The dead-zone test
is now const-relative so a retune can''t silently rot it.
`--background` (for login autostart) starts the GUI resident without opening
the main window; the tray''s Show deeplink opens it on demand. The ring
overlay window is now created eagerly and hidden at init — first-tap latency
drops, and since it is hidden rather than closed, quit-on-last-window-close
can no longer tear down the process that hosts the on-screen ring.
KeyCombo carried only the four macOS modifiers, so Windows-first chords like
Win+Ctrl+Space were unrepresentable. MOD_WIN is appended as bit 4 and
renders as a leading ⊞ in derived labels.
Windows posts VK_LWIN, Linux KEY_LEFTMETA; macOS folds it into ⌘, the
closest key it has.
The compass-grid popover read as a menu, not an editor. Clicking the ring
hotspot or its label card now swaps the mouse diagram for the Options+
customization-page shape: a back arrow, the ring drawn large with every
circle labelled by its bound action (selected slot accented), and a
persistent action panel on the right hosting the catalog plus the Run /
Paste Text editor rows. The popover path and its cards are removed — one
editing surface, same commit_ring_binding flow.
`Action::Folder(BTreeMap<RingSlot, Action>)` nests a sub-ring in a ring
slot, one level deep by convention. Labels derive from the filled count
(None entries are hidden positions), the catalog excludes it like the other
payload actions, and the untagged-routing guard covers its table shape.
Config grows `convert_ring_slot_to_folder` (a plain action survives as the
folder''s North entry — conversion never silently discards a binding) and
`set_ring_folder_slot` (implicit conversion; guarded by tests).
The overlay resolves folders; one reaching the injector is a routing bug
surfaced as a warning, not a dispatch.
On-screen ring: firing a folder slot swaps the ring to its contents in
place (sparse positions simply don''t render), the centre becomes a ← that
steps back to the main ring, and Esc / outside-click still close outright.
Editor: clicking an already-selected folder circle — or the panel''s "Open
Folder" row — drills into the folder on the same canvas, empty positions
render as dashed "+" add targets, a "Folder…" row converts a plain slot
(keeping its action as North), and the payload dialog carries an EditTarget
so Run / Paste Text commit to top-level slots and folder sub-slots alike.
Editor polish in the same pass: the canvas is sized so label pills stay out
of the action panel, pills are clickable selectors, and building the page
no longer reads the view entity during its own render (a gpui panic that
aborted the GUI the moment the editor opened).
`KeyCombo::parse("Win+Ctrl+Space")`: case-insensitive modifier names joined
with +, one trailing key (letters, digits, F1-F12, named keys), canonical
re-rendered display. Rejects bare modifiers, double keys, unknown names.
…editor

The CUSTOM section gains "Keyboard Shortcut…": the payload dialog accepts a
typed chord, validates it through KeyCombo::parse (invalid input keeps the
dialog open with an inline error instead of silently closing), and seeds
from the slot''s current combo — so recovered bindings like Alt+Shift+Z are
finally editable in place. Layout: the canvas becomes a wide, short
rectangle (a square at pill width was taller than the content area and
clipped), the header sits above the canvas+panel pair which centres as one
block, and pills ellipsize instead of hard-clipping.
The shortcut dialog gains a "Press Keys" button: hold a chord, release, and
it commits — recording is the save. A live readout shows the chord as it is
held; Escape alone backs out; listening self-cancels after 15 s idle.

Capture reads physical key state (GetAsyncKeyState on a 30 ms timer, the
same technique as the ring overlay''s Esc watcher) rather than key events,
so chords another app already owns as a global hotkey — Win+Ctrl+Space, for
one — still record instead of being swallowed by their owner. The recorder
emits canonical chord TEXT, so it and the typed field share one parser and
one rendering; the field remains as the editable, non-Windows path.
A chord or URL is what the machine does; `Named { name, action }` is what
the user calls it ("Wispr Voice", not Win+Ctrl+Space). Purely
presentational: `Action::inner()` strips the wrapper and every dispatcher
(all three injectors, the overlay, icon lookup, categories) resolves through
it, so naming can never change behaviour. `with_name("")` unwraps rather
than storing a blank label, and re-naming replaces the wrapper instead of
stacking one. IPC v14 with the appended-variant golden.
The payload dialog gains a Name field beside the payload, with captions on
both so it reads as "this is the command, this is what I call it". The name
is stored via Action::with_name, shown on the ring canvas and label pills in
place of the raw payload, seeded when reopening a slot, and carried through
the chord recorder''s auto-save path too.
A chord sent as one SendInput batch is physically down for microseconds.
Any hotkey listener that polls key state (GetAsyncKeyState) rather than
handling key messages therefore never sees it — the press falls between two
samples. Polling is common for chords like Ctrl+Space, where RegisterHotKey
modifier-order rules are awkward, so "the shortcut fires but nothing
happens" was the norm for those apps.

CustomShortcut now presses, holds 120 ms (a real keypress is ~100 ms), then
releases in reverse — on its own thread, so the hook callback never stalls.
Injected keys also carry their hardware scan code (MapVirtualKeyW), which
low-level hooks read as KBDLLHOOKSTRUCT::scanCode and some listeners reject
when zero. An ignored test asserts a 15 ms poller observes the chord.

Also declares Win32_UI_WindowsAndMessaging, which windows-sys requires for
ShellExecuteW: the workspace build only linked because another crate
enabled it, so `cargo test -p openlogi-inject` alone did not compile.
The recorder state machine was tested through the real keyboard, so a test
run failed whenever a key happened to be held. The sample is now injected;
the tests cover the whole gesture — bare modifier, growing chord, release,
Escape-cancel — instead of one hardware-dependent case.
@ultradaoto

Copy link
Copy Markdown
Author

Summary

On Windows, the MX Master 4's Action Ring pad — and its side Back/Forward buttons — do nothing without Logi Options+: those controls only speak HID++ and emit no native HID events at all. This PR reverse-engineers the pad's activation recipe (wiretap captures included under docs/mx-master-4-panel-captures/) and implements the feature end to end: the pad is captured, bindable (Binding::Ring, eight compass-keyed slots in the v3 schema), opens an on-screen radial ring (tap → move toward a sector → tap to fire; centre ✕ / Esc / outside click cancel), and is fully customizable from the mouse panel. Two pre-existing Windows-port issues surfaced as prerequisites on hardware and are fixed here too.

Changes

  • hidpp: 0x19c0 ForceSensingButton + 0x1e00 EnableHiddenFeatures wrappers (typed threshold setter; raw probe surface, reverse-engineering annotations kept honest) and a raw_feature_call diag helper.
  • hid: decode 0x1b04 analyticsKeyEvents into the gesture pipeline (they were discarded — the line that made the pad look dead); arm the pad when the control table advertises 0x01a0 (0x19c0 force threshold 0x15a3 + analytics reporting, re-applied on a 3 s cadence because the config does not survive device sleep, each pass time-bounded so a cold link can't wedge shutdown); divert the side Back/Forward buttons (0x53/0x56) like the DPI button and dispatch their bindings; give first-contact Bolt slot walks a 3 s budget (a first feature walk on the Windows BLE stack takes 1–3 s; the 1 s cap starved it forever, so devices could take minutes to attach). Important correction to the discovery notes: 0x0050/0x0051 are the left/right click-telemetry CIDs, not pad companions — arming them makes every click open the ring; production arms 0x01a0 alone.
  • core: ButtonId::ActionRing + a third untagged Binding::Ring(BTreeMap<RingSlot, Action>) arm (compass names, deliberately disjoint from Action and GestureDirection namespaces; routing guard tests extended), defaults, Config::set_ring_slot with complete-map seeding. Three appended payload actions for Options+ parity: Run(String) (URL / file / program, ||-separated argument string, short derived labels), PasteText(String), and Folder(BTreeMap<RingSlot, Action>) — a one-level sub-ring with conversion helpers that never silently discard the replaced binding. KeyCombo gains a MOD_WIN bit (⊞) for Windows-first chords like Win+Ctrl+Space.
  • inject: Run dispatch — Windows ShellExecuteW with cmd.exe-style %VAR% expansion (testable lookup injection), macOS/Linux via open/xdg-open with off-thread child reaping; PasteText on Windows as KEYEVENTF_UNICODE typing (no clipboard clobber, Enter/Tab preserved) — macOS/Linux PasteText is an honest logged stub pending platform Unicode typing.
  • ipc: v11 — next_ring_press long-poll (a press reaches the GUI in one IPC round trip, ~11 ms measured) + execute_action; v12 — the appended Run/PasteText Action variants; v13 — the appended Folder variant; goldens regenerated for each.
  • agent: routes ring-shaped presses to the GUI; a pad bound to a single action keeps dispatching in the agent; per-app single-action overrides disarm the ring for that app.
  • gui: the popup (gpui WindowKind::PopUp; raw-HWND no-activate + topmost; the window is clipped to a region of exactly its nine circles, so there is no rectangle to show a backdrop and clicks between circles fall through; work-area clamped at screen edges; global-cursor sector aiming at 33 ms; geometry and the crimson centre ✕ measured against the real Options+ popup). Folders open in place: the ring swaps to the folder's contents, the centre becomes a ← back, sparse positions don't render. Customization is a full editor page in the Options+ shape (back arrow, the ring drawn large with clickable label pills, a persistent action panel with the catalog plus "Run Command…" / "Paste Text…" dialog rows and "Folder…" conversion; folder drill-in with dashed "+" add targets on empty positions; sixteen new UI strings across all twenty locales, parity test green). A --background flag starts the GUI resident without the main window for login autostart, and the overlay window is created eagerly/hidden so quit-on-last-window-close can't tear down the process hosting the ring.
  • cli: the reverse-engineering diag subcommands used for discovery (panel, hidden, fsb, call, hidsniff, rawinput).
  • docs: CONFIGURATION.md refreshed to the live v3 route-keyed schema and the ring table.

Testing

  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace — green on Windows (stable-gnu).
  • Runtime-verified on real hardware (MX Master 4 wpid b042 on a Bolt receiver, Windows 11, no Logitech software installed or running): pad taps open the ring at the cursor; sector select by second tap and by click; ✕ / Esc / outside-click cancel; left/right clicks confirmed not to trigger the pad; Back/Forward navigate in Chrome via the new divert path; slots edited from the mouse panel persist and apply on the next open; ~11 ms press→GUI latency, ~5 ms reopen (window reused).
  • Not runtime-tested: macOS/Linux (the overlay is Windows-first with platform stubs; capture, bindings and the editor compile everywhere), other MX-family devices.
  • Operational note: the pad's analytics reporting can wedge in device RAM after repeated arm/hard-kill cycles during development — a mouse power cycle clears it.

Happy to split this into smaller PRs if you'd prefer — the commits are already sliced that way. Also fine if you'd rather take the capture/binding layers first and hold the overlay.

…-panel

# Conflicts:
#	crates/openlogi-gui/src/mouse_model/picker.rs
#	crates/openlogi-gui/src/mouse_model/view.rs
#	crates/openlogi-hid/src/inventory/probe.rs
Two holes let a downgrade silently destroy user settings, as happened on a
machine running both an installed 0.6.21 and a newer build:

Schema. The ring bindings (ActionRing/Binding::Ring, Run, PasteText, Folder,
Named) were added without bumping SCHEMA_VERSION, so a v3 reader accepted
the file, failed to parse the unknown actions, and fell back to defaults —
defeating the version gate that exists precisely to stop that. SCHEMA_VERSION
is now 4.

Persistence. Both binaries fell back to `Config::default()` on ANY load
error and then saved it, replacing the file they could not read. Loading
failures now yield `Config::defaults_for_unreadable()`, which refuses to
save, so an unreadable config is left intact for a build that understands
it. Tests pin both paths.
The overlay resolved slots via the carousel selection. With another device
selected — trivially common, since the GUI starts before enumeration and
lands on whichever device sorts first — it asked a keyboard for ring slots,
got the canonical defaults, and drew a ring the user never configured: a
phantom "old version" that appeared and vanished as selection changed.

RingPress now carries the armed device''s config key (IPC v15) and the
overlay resolves against it via ring_slots_for_device.

The selection itself was also lost on every launch: the first inventory
arrives after startup, so `with_runtime` has an empty list to match
`selected_device` against, and the later refresh only preserved a live
selection. It now falls back to the persisted key before "first device".
That is not cosmetic — the agent aims control capture at the selected
device, so landing on the keyboard left the mouse entirely uncaptured (no
Action Ring, no diverted buttons).
…rface

The CUSTOM section (Run Command / Paste Text / Keyboard Shortcut) existed
only in the ring editor, so a gesture direction or a plain button could be
bound to the built-in catalog and nothing else. EditTarget gains Gesture and
Button variants, and one shared `payload_rows` appends the section to the
gesture flyout and the button picker as well — a hold-and-swipe or the DPI
toggle can now trigger another app''s global hotkey.
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