fix(wayland): the first key repeat lands at the delay, not as a burst of the periods since the press

keyboard_key set lastFireTime to the press and TickKeyRepeats caught up on
every period since it, so the first tick past the compositor's delay fired
floor(delay / period) repeats at once: with rate 25 and delay 600 a held
Backspace deleted sixteen characters at the delay mark and a held letter
put fifteen copies in the field, from a hardware keyboard and from an
on-screen keyboard that sends Backspace as key events. A rate above 1000
made the period zero and the loop endless.

The first repeat now lands at the delay, as wl_keyboard.repeat_info
promises (one after `delay`, then `rate` a second): the first tick past
the delay sets lastFireTime to pressTime + delay - period. The period is
at least a millisecond, and the catch-up after a stalled frame is capped
(kKeyRepeatCatchUp, three) with the rest of the stall dropped, so a held
key is never a burst of edits. TickKeyRepeats takes the clock as a
parameter for the tests; the frame path's call is unchanged.

tests/KeyRepeat drives TickKeyRepeats with an injected clock on a
default-constructed Window: nothing before the delay, one at it, one per
period after, a capped catch-up, a key without text, a rate above 1000, a
released key, rate 0.
This commit is contained in:
Jorijn van der Graaf 2026-11-01 00:11:46 +01:00
commit 3f646ec42b
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
4 changed files with 145 additions and 11 deletions

View file

@ -634,6 +634,7 @@ void Device::keyboard_key(void *data, wl_keyboard *keyboard, uint32_t serial, ui
keyRepeat.utf8 = std::move(utf8);
keyRepeat.pressTime = std::chrono::steady_clock::now();
keyRepeat.lastFireTime = keyRepeat.pressTime;
keyRepeat.fired = false;
} else {
keyboardWindow->heldKeys.erase(code);
keyboardWindow->onRawKeyUp.Invoke(code);
@ -659,28 +660,44 @@ void Device::keyboard_repeat_info(void *data, wl_keyboard *keyboard, int32_t rat
}
void Device::TickKeyRepeats() {
TickKeyRepeats(std::chrono::steady_clock::now());
}
void Device::TickKeyRepeats(std::chrono::steady_clock::time_point now) {
if (!keyRepeat.active || !keyboardWindow) return;
if (keyRepeat.rate <= 0) return;
auto now = std::chrono::steady_clock::now();
using ms = std::chrono::milliseconds;
auto sincePress = std::chrono::duration_cast<ms>(now - keyRepeat.pressTime).count();
if (sincePress < keyRepeat.delay) return;
const auto delay = ms(keyRepeat.delay);
if (now - keyRepeat.pressTime < delay) return;
auto period = std::chrono::milliseconds(1000 / keyRepeat.rate);
auto sinceLastFire = std::chrono::duration_cast<ms>(now - keyRepeat.lastFireTime).count();
if (sinceLastFire < period.count()) return;
// At least a millisecond: a compositor's rate above 1000 would make the
// period zero and the loop below endless.
const auto period = ms(std::max(1, 1000 / keyRepeat.rate));
// Catch up — emit one event per missed period so a paused frame doesn't
// make the repeat permanently lag behind.
while (now - keyRepeat.lastFireTime >= period) {
// The first repeat lands at the delay — wl_keyboard.repeat_info's
// contract: one after `delay`, then `rate` a second — so the delay is not
// counted as periods missed.
if (!keyRepeat.fired) {
keyRepeat.lastFireTime = keyRepeat.pressTime + delay - period;
keyRepeat.fired = true;
}
if (now - keyRepeat.lastFireTime < period) return;
// Catch up — one event per missed period, so a paused frame doesn't make
// the repeat permanently lag behind — but a few at most: a long stall is
// not a burst of edits, the rest of it is dropped.
int fires = 0;
while (now - keyRepeat.lastFireTime >= period && fires < kKeyRepeatCatchUp) {
keyboardWindow->onRawKeyDown.Invoke(keyRepeat.key);
keyboardWindow->onRawKeyHold.Invoke(keyRepeat.key);
if (!keyRepeat.utf8.empty()) {
keyboardWindow->onTextInput.Invoke(keyRepeat.utf8);
}
keyRepeat.lastFireTime += period;
++fires;
}
if (now - keyRepeat.lastFireTime >= period) keyRepeat.lastFireTime = now;
}
void Device::ForgetTouches() {