Repeat held key bindings at the system typematic rate

Holding a paddle bound to a letter produced exactly one character. The
emission loop only acts on edges, so a held paddle sent one key-down and
nothing further until release.

That was the correct consequence of the design rather than a fault in the
send path: auto-repeat is not something Windows synthesises for a key it
believes is down. A physical keyboard repeats because its own firmware
keeps sending make codes while the key is held, and Windows forwards each
one. Injected input has no firmware behind it, so the repeat has to be
generated here.

Re-send the press while the paddle is held, after the user's configured
delay and at their configured rate — SPI_GETKEYBOARDDELAY and
SPI_GETKEYBOARDSPEED — so a paddle behaves like the key it is bound to
rather than at a rate invented here. Both are captured at press time, so
the rate cannot shift mid-hold. The read loop already ticks at report
rate, so this is a per-frame elapsed check with no new thread or timer.

Keys only. Mice do not repeat when held, so mouse-button bindings keep
their one-shot behaviour, and gamepad bindings are held state in the pad
report, where a repeat would mean nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dylan DeverillandClaude Opus 5 committed 2026-08-05 18:55:24 -07:00
1 parent b5b7e28cf1
commit 7b2391dd95
3 files changed
+58 -1

No files matched your search

+28 -1
View File
@@ -42,6 +42,12 @@ struct ControllerManager::Slot {
// again on release, so rebinding a paddle mid-hold still releases cleanly.
BackButtonBinding paddleHeld[4];
// Auto-repeat for held key bindings. When the next repeat is due, and the
// gap to use after that — both captured at press time from the user's
// keyboard settings, so the rate cannot shift mid-hold.
std::chrono::steady_clock::time_point paddleRepeatAt[4]{};
std::chrono::milliseconds paddleRepeatGap[4]{};
// Haptic edge-detection state for touch (movement ticks).
bool hapticWasRightTouching = false;
bool hapticWasLeftTouching = false;
@@ -823,7 +829,12 @@ void ControllerManager::ReadLoop(Slot* slot) {
const auto& e = edges[i];
if (e.cur == e.prev) continue;
if (e.cur) {
if (SendPaddleInput(e.binding, true)) slot->paddleHeld[i] = e.binding;
if (SendPaddleInput(e.binding, true)) {
slot->paddleHeld[i] = e.binding;
slot->paddleRepeatGap[i] = KeyRepeatInterval();
slot->paddleRepeatAt[i] =
std::chrono::steady_clock::now() + KeyRepeatDelay();
}
} else {
// Release what the press sent, not what the binding says
// now — the two differ if the user rebound mid-hold.
@@ -833,6 +844,22 @@ void ControllerManager::ReadLoop(Slot* slot) {
}
}
// Auto-repeat held keys. A physical keyboard repeats because its own
// firmware keeps sending the key, not because Windows tracks the key as
// down, so injected input has to reproduce that itself. Keys only —
// mice do not repeat when held, and gamepad bindings are held state in
// the pad report, where a repeat would be meaningless.
for (size_t i = 0; i < std::size(slot->paddleHeld); ++i) {
const auto& held = slot->paddleHeld[i];
if (held.kind != BackButtonBinding::Kind::Key) continue;
const auto now = std::chrono::steady_clock::now();
if (now < slot->paddleRepeatAt[i]) continue;
SendKeyInput(held.code, true);
slot->paddleRepeatAt[i] = now + slot->paddleRepeatGap[i];
}
// Button capture for the remap window (press-to-bind).
// Only fires on a rising edge to avoid repeat-triggering on hold.
if (m_capturing.load() && hasPrev) {