diff --git a/docs/config_options.md b/docs/config_options.md index 95cc89eaa7ee..43b25953324a 100644 --- a/docs/config_options.md +++ b/docs/config_options.md @@ -190,6 +190,10 @@ If you define these options you will enable the associated feature, which may in * Sets the key repeat interval for [key overrides](features/key_overrides). * `#define LEGACY_MAGIC_HANDLING` * Enables magic configuration handling for advanced keycodes (such as Mod Tap and Layer Tap) +* `#define PROGRESSIVE_KEYBOARD_REPORTS` + * Splits a single logical report change into ordered sub-reports so that modifier and key ordering is preserved on the host: keys that are no longer held are released first, then the new modifier byte is applied against the surviving keys, then the full report is sent. This guarantees that e.g. `Shift` is registered before the key it modifies (and that keys release before their modifiers), avoiding mis-shifted characters on fast or chorded input. When the define is absent, report emission is unchanged. Has no effect on `PROTOCOL_VUSB` boards, which always send a single report. +* `#define PROGRESSIVE_REPORT_DELAY 1` + * Optional. When `PROGRESSIVE_KEYBOARD_REPORTS` is enabled, sets the delay in milliseconds inserted between each sub-report. If not defined, no delay is added. ## RGB Light Configuration diff --git a/quantum/action_util.c b/quantum/action_util.c index 00cec24e3f0f..a0004ed45983 100644 --- a/quantum/action_util.c +++ b/quantum/action_util.c @@ -24,6 +24,10 @@ along with this program. If not, see . #include "keycode_config.h" #include +#if defined(PROGRESSIVE_KEYBOARD_REPORTS) && defined(PROGRESSIVE_REPORT_DELAY) +# include "wait.h" +#endif // defined(PROGRESSIVE_KEYBOARD_REPORTS) && defined(PROGRESSIVE_REPORT_DELAY) + extern keymap_config_t keymap_config; static uint8_t real_mods = 0; @@ -306,6 +310,42 @@ void send_6kro_report(void) { #else static report_keyboard_t last_report; +# ifdef PROGRESSIVE_KEYBOARD_REPORTS +# ifdef KEYBOARD_SHARED_EP + last_report.report_id = keyboard_report->report_id; +# endif // KEYBOARD_SHARED_EP + + /* Release any keys that are no longer held before applying the new mods. This only splits out + key releases (a slot cleared to 0); a slot that changes from one key to another in a single + scan is not split, as 6KRO reports keys positionally rather than as a bitmap. */ + if (memcmp(keyboard_report->keys, last_report.keys, sizeof(keyboard_report->keys)) != 0) { + bool changed = false; + for (uint8_t i = 0; i < KEYBOARD_REPORT_KEYS; ++i) { + if (keyboard_report->keys[i] == 0) { + if (last_report.keys[i] != 0) { + last_report.keys[i] = 0; + changed = true; + } + } + } + if (changed) { + host_keyboard_send(&last_report); +# ifdef PROGRESSIVE_REPORT_DELAY + wait_ms(PROGRESSIVE_REPORT_DELAY); +# endif // PROGRESSIVE_REPORT_DELAY + } + } + + /* Send the new mods alongside the keys that are still held. */ + if (keyboard_report->mods != last_report.mods) { + last_report.mods = keyboard_report->mods; + host_keyboard_send(&last_report); +# ifdef PROGRESSIVE_REPORT_DELAY + wait_ms(PROGRESSIVE_REPORT_DELAY); +# endif // PROGRESSIVE_REPORT_DELAY + } +# endif // PROGRESSIVE_KEYBOARD_REPORTS + /* Only send the report if there are changes to propagate to the host. */ if (memcmp(keyboard_report, &last_report, sizeof(report_keyboard_t)) != 0) { memcpy(&last_report, keyboard_report, sizeof(report_keyboard_t)); @@ -320,6 +360,37 @@ void send_nkro_report(void) { static report_nkro_t last_report; +# ifdef PROGRESSIVE_KEYBOARD_REPORTS + last_report.report_id = nkro_report->report_id; + + /* Remove existing keys that aren't in the intended report. */ + if (memcmp(nkro_report->bits, last_report.bits, sizeof(nkro_report->bits)) != 0) { + bool changed = false; + for (uint8_t i = 0; i < NKRO_REPORT_BITS; ++i) { + uint8_t orig = last_report.bits[i]; + last_report.bits[i] &= nkro_report->bits[i]; + if (last_report.bits[i] != orig) { + changed = true; + } + } + if (changed) { + host_nkro_send(&last_report); +# ifdef PROGRESSIVE_REPORT_DELAY + wait_ms(PROGRESSIVE_REPORT_DELAY); +# endif // PROGRESSIVE_REPORT_DELAY + } + } + + /* Send the new mods with the intersecting set of keys */ + if (nkro_report->mods != last_report.mods) { + last_report.mods = nkro_report->mods; + host_nkro_send(&last_report); +# ifdef PROGRESSIVE_REPORT_DELAY + wait_ms(PROGRESSIVE_REPORT_DELAY); +# endif // PROGRESSIVE_REPORT_DELAY + } +# endif // PROGRESSIVE_KEYBOARD_REPORTS + /* Only send the report if there are changes to propagate to the host. */ if (memcmp(nkro_report, &last_report, sizeof(report_nkro_t)) != 0) { memcpy(&last_report, nkro_report, sizeof(report_nkro_t)); diff --git a/tests/progressive_keyboard_reports/config.h b/tests/progressive_keyboard_reports/config.h new file mode 100644 index 000000000000..0c9bfb646fea --- /dev/null +++ b/tests/progressive_keyboard_reports/config.h @@ -0,0 +1,21 @@ +/* Copyright 2026 QMK + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "test_common.h" + +#define PROGRESSIVE_KEYBOARD_REPORTS diff --git a/tests/progressive_keyboard_reports/test.mk b/tests/progressive_keyboard_reports/test.mk new file mode 100644 index 000000000000..85ba61359009 --- /dev/null +++ b/tests/progressive_keyboard_reports/test.mk @@ -0,0 +1,18 @@ +# Copyright 2026 QMK +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -------------------------------------------------------------------------------- +# Keep this file, even if it is empty, as a marker that this folder contains tests +# -------------------------------------------------------------------------------- diff --git a/tests/progressive_keyboard_reports/test_progressive_keyboard_reports.cpp b/tests/progressive_keyboard_reports/test_progressive_keyboard_reports.cpp new file mode 100644 index 000000000000..9f5b17ed987d --- /dev/null +++ b/tests/progressive_keyboard_reports/test_progressive_keyboard_reports.cpp @@ -0,0 +1,84 @@ +/* Copyright 2026 QMK + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "action_util.h" +#include "keyboard_report_util.hpp" +#include "test_common.hpp" + +using testing::_; +using testing::AnyNumber; +using testing::InSequence; + +// These tests drive the QMK core report builders directly (add_mods/add_key/... then a single +// send_keyboard_report()) so that a modifier and a key change land in one report transition, +// which is what exercises the progressive reporting. The calls are qualified with `::` because the +// test body is a TestFixture member, and TestFixture::add_key(KeymapKey) would otherwise shadow +// the global core function ::add_key(uint8_t). The mod helpers have no such collision, but are +// qualified too for symmetry and to make clear these are core functions, not fixture helpers. + +class ProgressiveKeyboardReports : public TestFixture {}; + +// On press, a single logical change that adds both a modifier and a key must be +// split so the modifier reaches the host first, guaranteeing the key is shifted. +TEST_F(ProgressiveKeyboardReports, ModifierIsReportedBeforeKeyOnPress) { + TestDriver driver; + InSequence s; + + EXPECT_REPORT(driver, (KC_LEFT_SHIFT)); + EXPECT_REPORT(driver, (KC_LEFT_SHIFT, KC_A)); + + ::add_mods(MOD_BIT(KC_LEFT_SHIFT)); + ::add_key(KC_A); + send_keyboard_report(); + + VERIFY_AND_CLEAR(driver); +} + +// On release, the same change in reverse must clear the key while the modifier +// is still held, then clear the modifier, so the key never registers unshifted. +TEST_F(ProgressiveKeyboardReports, KeyIsReleasedBeforeModifierOnRelease) { + TestDriver driver; + + /* Establish the held state: Shift + A. */ + EXPECT_ANY_REPORT(driver).Times(AnyNumber()); + ::add_mods(MOD_BIT(KC_LEFT_SHIFT)); + ::add_key(KC_A); + send_keyboard_report(); + VERIFY_AND_CLEAR(driver); + + InSequence s; + + EXPECT_REPORT(driver, (KC_LEFT_SHIFT)); + EXPECT_EMPTY_REPORT(driver); + + ::del_mods(MOD_BIT(KC_LEFT_SHIFT)); + ::del_key(KC_A); + send_keyboard_report(); + + VERIFY_AND_CLEAR(driver); +} + +// With no modifier change, a plain key press still collapses to a single report. +TEST_F(ProgressiveKeyboardReports, PlainKeyPressSendsSingleReport) { + TestDriver driver; + + EXPECT_REPORT(driver, (KC_A)).Times(1); + + ::add_key(KC_A); + send_keyboard_report(); + + VERIFY_AND_CLEAR(driver); +}