Skip to content

feat: hold-to-talk shortcut action (push-to-talk dictation) - #433

Open
theoephraim wants to merge 2 commits into
AprilNEA:masterfrom
theoephraim:feat/hold-shortcut-action
Open

feat: hold-to-talk shortcut action (push-to-talk dictation)#433
theoephraim wants to merge 2 commits into
AprilNEA:masterfrom
theoephraim:feat/hold-shortcut-action

Conversation

@theoephraim

@theoephraim theoephraim commented Jul 20, 2026

Copy link
Copy Markdown

feel free to toss this implementation... But was a first go at making it possible to hold a button to do something.


Summary

Adds a HoldShortcut action so a mouse button can hold a key combo down for
as long as the button is physically held, instead of firing a one-shot tap.

The motivating use case is push-to-talk dictation: bind a thumb button to
the dictation hotkey and hold it to talk, exactly like a walkie-talkie —
press-and-hold to dictate, release to stop. Every existing action fired on
button-down only, so there was no way to bind a key that stays held for the
duration of the press.

Changes

  • openlogi-core: new Action::HoldShortcut(KeyCombo), carrying the same
    recorded chord as CustomShortcut but with press/release semantics.
    Appended at the end of the enum so serde wire positions and goldens are
    unchanged. PROTOCOL_VERSION 10 → 11 (Action crosses the IPC boundary).
  • openlogi-inject: phase-aware press_hold / release_hold primitives
    built from the existing per-platform helpers. Down order is
    modifiers-then-key, up order is the reverse, so a chord never surfaces as a
    bare keypress while its modifiers are mid-transition.
  • openlogi-agent-core: wire the release edge through to HoldShortcut
    press synthesises key-down, release the matching key-up. Held chords live in
    a thread-local keyed by button (press/release for a device arrive on the
    same thread, so the pair always balances). Unbalanced paths are handled so a
    stuck key-down can't jam a key system-wide: the release resolves from stored
    state before binding maps are read (survives a mid-hold rebind/unbind), a
    repeated button-down won't stack a second key-down, and
    CaptureInterrupted force-releases every held chord. Button arm extracted
    into on_button to stay under the pedantic line limit.
  • openlogi-gui: label + render the new action; locale strings across all
    languages.

Like CustomShortcut, this is hand-authored TOML for now — the recorder UI
that would offer a Hold toggle doesn't exist yet, so the GUI only labels and
renders it. A HoldShortcut reaching a dispatch path with no release edge
(the HID++ gesture button, which reports an instantaneous click) degrades to
a tap rather than jamming the chord down.

Testing

  • devenv tasks run openlogi:check (fmt + clippy -D warnings + workspace tests)
  • Wire-format goldens unchanged; PROTOCOL_VERSION bump covered by
    wire_format test
  • Not runtime-tested on hardware — no Logitech device available in this
    environment. Needs a real press/release verification on macOS/Linux/Windows.

@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a HoldShortcut(KeyCombo) action to enable push-to-talk–style dictation: the synthesised chord is held down for the physical duration of the button press and released on button-up, instead of firing a one-shot tap. The protocol version is bumped to 11 to reflect the new Action variant, and all locale files get a "Hold %{chord}" string.

  • openlogi-core: New Action::HoldShortcut enum variant appended (preserving existing serde wire positions), plus held_combo() helper; protocol version 10 → 11.
  • openlogi-inject: New press_hold / release_hold public API backed by platform-specific key_phase helpers that emit exactly one keyboard edge (modifier-then-key for down, key-then-modifier for up) per call; HoldShortcut in the no-release-edge execute path (gesture button) degrades to a tap on all three platforms.
  • openlogi-agent-core: HeldChords thread-local bookkeeping in hook_runtime.rs stores the chord on press and resolves it on release from stored state (surviving mid-hold rebinds); CaptureInterrupted drains and force-releases all held chords to prevent stuck keys.

Confidence Score: 4/5

Safe to merge for non-production testing; a chord held at the moment the app quits normally can stay stuck system-wide until that key is manually pressed and released.

The implementation is carefully structured — rebind-safe, auto-repeat-safe, and CaptureInterrupted force-releases every held chord. One gap remains from the previous review: the normal shutdown path (Hook::drop / stop()) does not drain HELD_CHORDS before the callback thread exits, so a chord held at quit time stays physically down on the OS until the user manually clears it. That path is not addressed in this PR.

crates/openlogi-agent-core/src/hook_runtime.rs — the shutdown drain is the one path still missing cleanup for held chords.

Important Files Changed

Filename Overview
crates/openlogi-agent-core/src/hook_runtime.rs Adds HeldChords thread-local and on_button refactor. Core press/release logic is correct and well-tested; CaptureInterrupted drains held chords. Known gap: clean Hook::stop() path does not drain HELD_CHORDS before the callback thread exits, which can leave a chord stuck system-wide (flagged in a previous review round).
crates/openlogi-inject/src/inject/windows.rs Adds key_phase for Windows SendInput: down=mods-then-key, up=key-then-mods. key_input second param (key_up: bool) semantics are correct — false=down, true=up — matching post_key. combo_modifiers extracted for reuse, deduplication of VK_CONTROL preserved.
crates/openlogi-inject/src/inject/linux.rs Adds key_phase emitting a single SYN frame per edge; ordering matches press_key. HoldShortcut in execute degrades to press_key tap correctly. key_code==0 guard in execute is redundant (hold_phase already guards it) but harmless.
crates/openlogi-inject/src/inject/macos.rs post_key refactored into post_key_phase (single-edge) + post_key (tap). combo_flags extracted correctly. HoldShortcut in execute degrades to tap for the no-release-edge path. Clean extraction with no logic changes to the existing tap path.
crates/openlogi-core/src/binding.rs HoldShortcut appended last in the Action enum (serde positions preserved), held_combo() helper added, Category::Editing assignment and catalog exclusion match CustomShortcut treatment. TOML roundtrip test confirms correct serde routing.
crates/openlogi-agent-core/src/ipc.rs PROTOCOL_VERSION correctly bumped 10 to 11 with matching comment; wire_format golden test updated to 11.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant HW as Physical Button
    participant Hook as OS Hook Thread
    participant HC as HeldChords (thread-local)
    participant Inject as openlogi-inject

    Note over HW,Inject: Happy path - button held and released normally
    HW->>Hook: "Button DOWN (pressed=true)"
    Hook->>HC: press(button, combo)
    HC-->>Hook: Some(combo) first press only
    Hook->>Inject: "press_hold(&combo)"
    Inject-->>HW: key-down + modifiers posted to OS

    HW->>Hook: "Button UP (pressed=false)"
    Hook->>HC: release(button)
    HC-->>Hook: Some(combo) stored chord
    Hook->>Inject: "release_hold(&combo)"
    Inject-->>HW: key-up + modifiers posted to OS

    Note over HW,Inject: Auto-repeat guard
    HW->>Hook: Button DOWN again (auto-repeat)
    Hook->>HC: press(button, combo)
    HC-->>Hook: None already held no double press

    Note over HW,Inject: CaptureInterrupted (OS kills tap)
    HW->>Hook: CaptureInterrupted
    Hook->>HC: drain()
    HC-->>Hook: all held combos
    Hook->>Inject: release_hold x N
    Inject-->>HW: force key-up for every stranded chord
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant HW as Physical Button
    participant Hook as OS Hook Thread
    participant HC as HeldChords (thread-local)
    participant Inject as openlogi-inject

    Note over HW,Inject: Happy path - button held and released normally
    HW->>Hook: "Button DOWN (pressed=true)"
    Hook->>HC: press(button, combo)
    HC-->>Hook: Some(combo) first press only
    Hook->>Inject: "press_hold(&combo)"
    Inject-->>HW: key-down + modifiers posted to OS

    HW->>Hook: "Button UP (pressed=false)"
    Hook->>HC: release(button)
    HC-->>Hook: Some(combo) stored chord
    Hook->>Inject: "release_hold(&combo)"
    Inject-->>HW: key-up + modifiers posted to OS

    Note over HW,Inject: Auto-repeat guard
    HW->>Hook: Button DOWN again (auto-repeat)
    Hook->>HC: press(button, combo)
    HC-->>Hook: None already held no double press

    Note over HW,Inject: CaptureInterrupted (OS kills tap)
    HW->>Hook: CaptureInterrupted
    Hook->>HC: drain()
    HC-->>Hook: all held combos
    Hook->>Inject: release_hold x N
    Inject-->>HW: force key-up for every stranded chord
Loading

Reviews (2): Last reviewed commit: "feat(agent): hold push-to-talk chords fo..." | Re-trigger Greptile

Comment thread crates/openlogi-agent-core/src/hook_runtime.rs
Every action was a one-shot tap fired on button-down, so there was no way
to bind a key that stays held — the shape push-to-talk needs.

Append Action::HoldShortcut(KeyCombo), carrying the same recorded chord
as CustomShortcut but with press/release semantics. Appended at the end:
serde encodes the declaration index, so existing variants keep their wire
positions and the goldens are unchanged. PROTOCOL_VERSION 8 -> 9 since
Action crosses the IPC boundary.

openlogi-inject gains phase-aware primitives (press_hold/release_hold)
built from the existing per-platform helpers. Down order is
modifiers-then-key and up order is the reverse: a chord whose modifiers
lifted before its key would surface as a bare keypress.

A HoldShortcut reaching execute() came from a dispatch path with no
release edge (the HID++ gesture button, which reports an instantaneous
click), so it degrades to a tap rather than jamming the chord down.

Like CustomShortcut, this is hand-authored TOML for now — the recorder UI
that would offer a Hold toggle does not exist yet, so the GUI only labels
and renders it.
The hook already delivered both edges on all three platforms; the
single-action path dropped the release with an `if pressed` guard. Wire
the release edge up to Action::HoldShortcut: press synthesises the
key-down, release the matching key-up.

Held chords live in a thread-local keyed by button, matching HOLD's
thread-per-device reasoning — press and release for a device arrive on
the same thread, so the pair always balances.

A stuck key-down jams that key for every app on the system, so the
unbalanced paths get the care:

- The release resolves from stored state *before* the binding maps are
  read. A rebind or unbind mid-hold would otherwise resolve to a
  different action (or none, returning PassThrough early) and strand the
  chord that actually went down.
- A repeated button-down does not stack a second key-down the single
  release could never balance.
- CaptureInterrupted force-releases every held chord: the button-ups are
  never coming.

Extract the button arm into on_button. It was already at the pedantic
line limit and the hold paths pushed it over; the callback was doing too
much to read.

Not runtime-tested on hardware — no Logitech device available here.
@theoephraim
theoephraim force-pushed the feat/hold-shortcut-action branch from e46a128 to c8e1cb0 Compare July 20, 2026 19:46
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