feat: MX Master 4 Action Ring support on Windows (capture, bindings, on-screen ring) - #436
feat: MX Master 4 Action Ring support on Windows (capture, bindings, on-screen ring)#436ultradaoto wants to merge 41 commits into
Conversation
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).
Greptile SummaryThis 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
Confidence Score: 4/5Largely safe to merge with one targeted fix:
Files Needing Attention:
|
| 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
Reviews (9): Last reviewed commit: "feat(gui): allow shortcuts, commands and..." | Re-trigger Greptile
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.
SummaryOn 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 Changes
Testing
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.

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
0x19c0 ForceSensingButton+0x1e00 EnableHiddenFeatureswrappers (typed threshold setter; raw probe surface, reverse-engineering annotations kept honest) and araw_feature_calldiag helper.0x1b04 analyticsKeyEventsinto the gesture pipeline (they were discarded — the line that made the pad look dead); arm the pad when the control table advertises0x01a0(0x19c0force threshold0x15a3+ 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/0x0051are the left/right click-telemetry CIDs, not pad companions — arming them makes every click open the ring; production arms0x01a0alone.ButtonId::ActionRing+ a third untaggedBinding::Ring(BTreeMap<RingSlot, Action>)arm (compass names, deliberately disjoint fromActionandGestureDirectionnamespaces; routing guard tests extended), defaults,Config::set_ring_slotwith complete-map seeding.next_ring_presslong-poll (a press reaches the GUI in one IPC round trip, ~11 ms measured) +execute_action; goldens regenerated.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).panel,hidden,fsb,call,hidsniff,rawinput).CONFIGURATION.mdrefreshed 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).wpid b042on 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).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.