Skip to content

feat: dial navigation modes, and shortcut/off bindings for every spare key - #11

Closed
thefotes wants to merge 4 commits into
schacon:mainfrom
thefotes:dial-nav-and-key-shortcuts
Closed

thefotes wants to merge 4 commits into
schacon:mainfrom
thefotes:dial-nav-and-key-shortcuts

Conversation

@thefotes

@thefotes thefotes commented Sep 4, 2026

Copy link
Copy Markdown

What

Two additions to config.json:

  1. A top-level "dial" string repurposes the knob from a fixed effort ladder into a Herdr navigator — "agent" steps focus through agents, "tab" cycles tabs in the focused workspace, "space" steps through workspaces. "effort" stays the default.
  2. Every spare key — stack (6), tabs (7), land (8), the macro keys (9, 12), and the wide voice key (10+11) — can now be bound to a system-wide keyboard shortcut ({"shortcut": "cmd+shift+5"}), synthesised as a real CGEvent the same way the wide key's own right-command tap already works. Any of these can also be explicitly turned off (false) — distinct from being left unmentioned, which keeps the key's built-in job.

Why

Wanted the dial to step through Herdr workspaces instead of effort, and wanted the stack/tabs/land keys free to bind to system-wide hotkeys (screenshot, app shortcuts) instead of GitButler actions I don't use.

Note on #8

This overlaps in spirit with #8's chord binding, but is a different mechanism: #8 sends keystrokes into the focused Herdr pane's pty (pane.send_keys); this sends real system-wide CGEvents to whatever's frontmost, independent of Herdr. #8's own write-up flags the system-wide case as future work — this is that. Happy to reconcile config vocabulary ("chords" vs "keys": {"shortcut": ...}) if a unified scheme is preferred.

A note on shortcut fidelity

A configured shortcut's modifiers are synthesised as their own keydown/keyup events (not just a flag riding on the base key), matching how real hardware reports a chord — needed for apps whose hotkey engine tracks each modifier's own press independently (some support "tap a bare modifier alone" as its own trigger type, which requires this). Verified against a real third-party global-hotkey app, not just a synthetic listener.

Testing

Developed test-first. New: ShortcutSpecTests (parsing — modifiers, aliases, case, named keys, malformed input), HerdrDialCycleTests (agent/workspace stepping, direction, wrap), plus new HerdrTabCycleTests direction cases and KeyBindingsTests coverage for shortcuts, .off, and the now-overridable stack/tabs/land keys.

Full suite: 133 tests, 3 skipped (need physical hardware), 0 failures (includes the 7 tests #9 brought in — this branch is rebased on top of it).

Verified end to end on a Creator Micro 2, both USB and Bluetooth LE:

  • "dial": "space" correctly cycled Herdr workspaces
  • 10+11 bound to "ctrl+t" and 12 bound to "return" both fired as real system keystrokes

🤖 Generated with Claude Code

mmorrison and others added 4 commits September 1, 2026 09:32
keyboard.badge.exclamationmark is not an SF Symbol. It is absent from
CoreGlyphs' name_availability.plist entirely, so NSImage(systemSymbolName:)
returns nil for it on every macOS, and the compose path falls back to an
empty NSImage - leaving the menu bar showing the status dot on its own
with no keyboard behind it.

It only affects .permissionDenied and .deviceMissing, which are the two
states where the icon most needs to be findable, so the app appears to
vanish from the menu bar at exactly the moment something is wrong. The
healthy states use keyboard (10.15) and keyboard.fill (12.0), which is
why this stays hidden in normal use.

Move the symbol names to WLKit so a test can assert they all resolve on
the running system, use keyboard.badge.ellipsis for the problem states
(macOS 11.0, under the package's 13.0 floor), and fall back to the plain
keyboard rather than to a blank image so an unresolvable name can never
blank the icon again.
IOHIDDeviceOpen answers kIOReturnNotPermitted (0xE00002E2) both when the
Input Monitoring grant is missing and when the pad is already held or has
wedged, so the error code cannot be what decides between them. The check
here only recognised the older 0xE00002C1, so on current systems a
permissions failure fell through to the generic branch and the menu said
"Looking for the pad" - which sends you hunting for a cable when the
answer is a checkbox.

The 0xE00002C1 reading is also a leftover from before d2e7c86 ("open the
HID device shared instead of seized"), where that code meant a refused
seize rather than a refused grant.

Consult IOHIDCheckAccess, which is the only caller that actually knows
the state of the grant, and keep believing an error that names a
permission outright in case the check is stale. Each case now names what
to do next rather than printing a bare error code.
… to a shortcut or off

Two related additions to config.json:

- A top-level "dial" string ("effort" | "agent" | "tab" | "space") turns
  the knob from a fixed effort-ladder control into a Herdr navigator:
  step the focused agent, cycle tabs in the focused workspace, or step
  through workspaces, all direction-aware and wrapping. Effort stays the
  default, unchanged. New HerdrClient wrappers for workspace.list and
  workspace.focus, and adjacentAgent/adjacentWorkspace/adjacentTab as the
  pure, tested seams (adjacentTab generalizes the existing nextTab to take
  a direction; nextTab itself becomes a one-line wrapper so its callers
  and tests are untouched).

- Every spare key — stack (6), tabs (7), land (8), the macro keys (9, 12),
  and the wide voice key (10+11) — can now be bound in "keys" to a
  keyboard shortcut instead of only a text macro:

      "6": { "shortcut": "cmd+shift+5" }

  ShortcutSpec parses the "+"-joined spec (modifiers in any order, one
  base key) into a keycode and CGEventFlags with no AppKit dependency, so
  it's unit-testable; ShortcutController (app layer) posts it as a real
  CGEvent, the same technique VoiceController already uses for the wide
  key's right-command tap. A bad spec doesn't get silently dropped — it
  surfaces as a panel error the next time that key is pressed.

  Binding any of these previously-fixed keys means a config value now
  needs to be able to say "no, really, nothing" distinctly from "left
  unmentioned, keep the default" — an unmentioned stack key keeps
  floating the GitButler window, but a key explicitly turned off should
  go dark. KeyAction.off (reached via `false`, or an empty string/
  shortcut) is that explicit, present-but-inert binding, named to avoid
  colliding with Optional<KeyAction>.none in a switch.

  BridgeController.handleKeyPress and refresh()'s per-key lighting both
  route through KeyBindings.action(for:) first now, falling back to each
  key's built-in job only when nothing is bound. MenuPanelView's key
  buttons now call bridge.handleKeyPress(index) directly instead of
  duplicating the dispatch logic, so a click and a physical press can
  never disagree about what a key does.

Developed test-first throughout. New: ShortcutSpecTests (parsing),
HerdrDialCycleTests (agent/workspace direction and wrap), tab-cycle
direction cases, and KeyBindings coverage for shortcuts, off, and the
now-overridable stack/tabs/land keys. Full suite: 126 tests, 3 skipped
(need physical hardware), 0 failures. Verified end to end on a Creator
Micro 2 over both USB and Bluetooth LE: dial set to "space" correctly
cycled Herdr workspaces, and 10+11 bound to "ctrl+t" and 12 bound to
"return" both fired as real system keystrokes.

Config docs (README, KeyBindings' header comment) updated for both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqHC39HxzGPUxiG1xtXip3
…t just a flag

Real hardware reports a modifier key's own press as a distinct HID event —
a keyboard never sends "T with the control flag set" without also sending
control's own keydown. Stamping the flag on the base key alone (what this
did until now) satisfies a listener that only reads event.flags on the
base key, but not one that also tracks each modifier's own press
independently, which is exactly what lets an app offer "tap a bare
modifier alone" as its own trigger type — as OpenSuperWhisper's hotkey
settings do. That mismatch is the likely cause of the pad's ctrl+t binding
working inconsistently against it.

ShortcutController now presses each configured modifier in a fixed order,
building the CGEventFlags up one key at a time as a real chord's reports
would arrive, fires the base key with the full set held, then releases the
modifiers in reverse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqHC39HxzGPUxiG1xtXip3
@thefotes thefotes closed this Sep 22, 2026
@thefotes
thefotes deleted the dial-nav-and-key-shortcuts branch September 22, 2026 16:36
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.

2 participants