From 9d47119457fac9a8c3efec32aef703be94280aca Mon Sep 17 00:00:00 2001
From: Pascal Getreuer <50221757+getreuer@users.noreply.github.com>
Date: Fri, 22 May 2026 14:50:52 -0700
Subject: [PATCH] [Core] Options to constrain Speculative Hold:
`SPECULATIVE_HOLD_ONE_KEY` and `SPECULATIVE_HOLD_FLOW_TERM`. (#26099)
---
data/mappings/info_config.hjson | 2 +
data/schemas/keyboard.jsonschema | 4 +
docs/tap_hold.md | 25 ++-
quantum/action.c | 4 +-
quantum/action_tapping.c | 27 ++-
quantum/action_tapping.h | 8 +-
.../speculative_hold_flow_term/config.h | 23 +++
.../speculative_hold_flow_term/test.mk | 16 ++
.../test_tap_hold.cpp | 195 ++++++++++++++++++
.../speculative_hold_one_key/config.h | 24 +++
.../speculative_hold_one_key/test.mk | 16 ++
.../test_tap_hold.cpp | 189 +++++++++++++++++
tests/test_common/keyboard_report_util.hpp | 4 +
13 files changed, 525 insertions(+), 12 deletions(-)
create mode 100644 tests/tap_hold_configurations/speculative_hold/speculative_hold_flow_term/config.h
create mode 100644 tests/tap_hold_configurations/speculative_hold/speculative_hold_flow_term/test.mk
create mode 100644 tests/tap_hold_configurations/speculative_hold/speculative_hold_flow_term/test_tap_hold.cpp
create mode 100644 tests/tap_hold_configurations/speculative_hold/speculative_hold_one_key/config.h
create mode 100644 tests/tap_hold_configurations/speculative_hold/speculative_hold_one_key/test.mk
create mode 100644 tests/tap_hold_configurations/speculative_hold/speculative_hold_one_key/test_tap_hold.cpp
diff --git a/data/mappings/info_config.hjson b/data/mappings/info_config.hjson
index 1816124df07..8b08f2e45d3 100644
--- a/data/mappings/info_config.hjson
+++ b/data/mappings/info_config.hjson
@@ -225,6 +225,8 @@
"RETRO_TAPPING": {"info_key": "tapping.retro", "value_type": "flag"},
"RETRO_TAPPING_PER_KEY": {"info_key": "tapping.retro_per_key", "value_type": "flag"},
"SPECULATIVE_HOLD": {"info_key": "tapping.speculative_hold", "value_type": "flag"},
+ "SPECULATIVE_HOLD_FLOW_TERM": {"info_key": "tapping.speculative_hold_flow_term", "value_type": "int"},
+ "SPECULATIVE_HOLD_ONE_KEY": {"info_key": "tapping.speculative_hold_one_key", "value_type": "flag"},
"TAP_CODE_DELAY": {"info_key": "qmk.tap_keycode_delay", "value_type": "int"},
"TAP_HOLD_CAPS_DELAY": {"info_key": "qmk.tap_capslock_delay", "value_type": "int"},
"TAPPING_TERM": {"info_key": "tapping.term", "value_type": "int"},
diff --git a/data/schemas/keyboard.jsonschema b/data/schemas/keyboard.jsonschema
index c0647494ac0..ef2b4802a40 100644
--- a/data/schemas/keyboard.jsonschema
+++ b/data/schemas/keyboard.jsonschema
@@ -993,6 +993,7 @@
"type": "object",
"properties": {
"chordal_hold": {"type": "boolean"},
+ "flow_tap_term": {"$ref": "./definitions.jsonschema#/unsigned_int"},
"force_hold": {"type": "boolean"},
"force_hold_per_key": {"type": "boolean"},
"ignore_mod_tap_interrupt": {"type": "boolean"},
@@ -1002,6 +1003,9 @@
"permissive_hold_per_key": {"type": "boolean"},
"retro": {"type": "boolean"},
"retro_per_key": {"type": "boolean"},
+ "speculative_hold": {"type": "boolean"},
+ "speculative_hold_flow_term": {"$ref": "./definitions.jsonschema#/unsigned_int"},
+ "speculative_hold_one_key": {"type": "boolean"},
"term": {"$ref": "./definitions.jsonschema#/unsigned_int"},
"term_per_key": {"type": "boolean"},
"toggle": {"$ref": "./definitions.jsonschema#/unsigned_int"}
diff --git a/docs/tap_hold.md b/docs/tap_hold.md
index 3e88a7a5c23..8fe81cfe1d4 100644
--- a/docs/tap_hold.md
+++ b/docs/tap_hold.md
@@ -824,7 +824,30 @@ bool get_speculative_hold(uint16_t keycode, keyrecord_t* record) {
}
```
-Some operating systems or applications assign actions to tapping a modifier key by itself, e.g., tapping GUI to open a start menu. Because Speculative Hold sends a lone modifier key press in some cases, it can falsely trigger these actions. To prevent this, set `DUMMY_MOD_NEUTRALIZER_KEYCODE` (and optionally `MODS_TO_NEUTRALIZE`) in your `config.h` in the same way as described above for [Retro Tapping](#retro-tapping).
+Some operating systems or applications assign actions to tapping a modifier key by itself, e.g., tapping GUI to open a start menu. Because Speculative Hold sometimes sends a lone modifier key press, it can falsely trigger these actions (known as the "flashing mods" problem). How such an input is handled depends on the OS and application, so unfortunately, there is no universal solution.
+
+To mitigate this issue, you can set `DUMMY_MOD_NEUTRALIZER_KEYCODE` (and optionally `MODS_TO_NEUTRALIZE`) in your `config.h`, as described above for [Retro Tapping](#retro-tapping).
+
+You can further prevent flashing mods by restricting when Speculative Hold is allowed to trigger. There are several options for this:
+
+* Constrain Speculative Hold to one key at a time. Add to your config.h:
+
+ ```c
+ #define SPECULATIVE_HOLD_ONE_KEY
+ ```
+
+ With this option, Speculative Hold does not apply when any mods are already active. Mod combinations across multiple keys can still be made after the mod-tap keys settle.
+
+* Disable Speculative Hold during the flow of fast typing. Add to your config.h:
+
+ ```c
+ #define SPECULATIVE_HOLD_FLOW_TERM 200
+ ```
+
+ This value specifies a duration in milliseconds. Speculative Hold does not apply if a key is pressed within this threshold of the previous key. The effect is similar to [Flow Tap](#flow-tap); however, `SPECULATIVE_HOLD_FLOW_TERM` only restricts when speculation is allowed, without affecting how the key settles.
+
+* Use the Flow Tap option. In the fast flow of typing, the mod-tap key is immediately settled, and therefore no speculation occurs. See [Flow Tap](#flow-tap) for details.
+
## Why do we include the key record for the per key functions?
diff --git a/quantum/action.c b/quantum/action.c
index aacafbe2ffb..827defcb45c 100644
--- a/quantum/action.c
+++ b/quantum/action.c
@@ -286,9 +286,9 @@ void process_record(keyrecord_t *record) {
speculative_key_settled(record);
}
#endif // SPECULATIVE_HOLD
-#ifdef FLOW_TAP_TERM
+#if defined(FLOW_TAP_TERM) || defined(SPECULATIVE_HOLD_FLOW_TERM)
flow_tap_update_last_event(record);
-#endif // FLOW_TAP_TERM
+#endif // defined(FLOW_TAP_TERM) || defined(SPECULATIVE_HOLD_FLOW_TERM)
if (!process_record_quantum(record)) {
#ifndef NO_ACTION_ONESHOT
diff --git a/quantum/action_tapping.c b/quantum/action_tapping.c
index 6baf7721ad5..d8069917b44 100644
--- a/quantum/action_tapping.c
+++ b/quantum/action_tapping.c
@@ -119,11 +119,13 @@ __attribute__((weak)) bool get_hold_on_other_key_press(uint16_t keycode, keyreco
# include "process_auto_shift.h"
# endif
-# if defined(FLOW_TAP_TERM)
+# if defined(FLOW_TAP_TERM) || defined(SPECULATIVE_HOLD_FLOW_TERM)
static uint16_t flow_tap_prev_keycode = KC_NO;
static uint16_t flow_tap_prev_time = 0;
static bool flow_tap_expired = true;
+# endif // defined(FLOW_TAP_TERM) || defined(SPECULATIVE_HOLD_FLOW_TERM)
+# if defined(FLOW_TAP_TERM)
static bool flow_tap_key_if_within_term(keyrecord_t *record, uint16_t prev_time);
# endif // defined(FLOW_TAP_TERM)
@@ -191,11 +193,11 @@ void action_tapping_process(keyrecord_t record) {
if (IS_EVENT(record.event)) {
ac_dprintf("\n");
} else {
-# ifdef FLOW_TAP_TERM
+# if defined(FLOW_TAP_TERM) || defined(SPECULATIVE_HOLD_FLOW_TERM)
if (!flow_tap_expired && TIMER_DIFF_16(record.event.time, flow_tap_prev_time) >= INT16_MAX / 2) {
flow_tap_expired = true;
}
-# endif // FLOW_TAP_TERM
+# endif // defined(FLOW_TAP_TERM) || defined(SPECULATIVE_HOLD_FLOW_TERM)
}
}
@@ -770,17 +772,28 @@ static void speculative_key_press(keyrecord_t *record) {
if (speculative_keys_find(record->event.key) < num_speculative_keys) {
return; // Don't trigger: key is already in speculative_keys.
}
+# ifdef SPECULATIVE_HOLD_FLOW_TERM
+ if (!flow_tap_expired && TIMER_DIFF_16(record->event.time, flow_tap_prev_time) <= SPECULATIVE_HOLD_FLOW_TERM) {
+ return; // Don't trigger: within flow term of previous key.
+ }
+# endif // SPECULATIVE_HOLD_FLOW_TERM
const uint16_t keycode = get_record_keycode(record, false);
if (!IS_QK_MOD_TAP(keycode)) {
return; // Don't trigger: not a mod-tap key.
}
- uint8_t mods = mod_config(QK_MOD_TAP_GET_MODS(keycode));
+ uint8_t mods = mod_config(QK_MOD_TAP_GET_MODS(keycode));
+ const uint8_t active_mods = get_mods() | speculative_mods;
+# ifdef SPECULATIVE_HOLD_ONE_KEY
+ if (active_mods != 0) {
+ return; // Don't trigger: some mod is already active.
+ }
+# endif // SPECULATIVE_HOLD_ONE_KEY
if ((mods & 0x10) != 0) { // Unpack 5-bit mods to 8-bit representation.
mods <<= 4;
}
- if ((~(get_mods() | speculative_mods) & mods) == 0) {
+ if ((~active_mods & mods) == 0) {
return; // Don't trigger: mods are already active.
}
@@ -988,7 +1001,7 @@ static void waiting_buffer_process_regular(void) {
}
# endif // CHORDAL_HOLD
-# ifdef FLOW_TAP_TERM
+# if defined(FLOW_TAP_TERM) || defined(SPECULATIVE_HOLD_FLOW_TERM)
void flow_tap_update_last_event(keyrecord_t *record) {
const uint16_t keycode = get_record_keycode(record, false);
// Don't update while a tap-hold key is unsettled.
@@ -1028,7 +1041,9 @@ void flow_tap_update_last_event(keyrecord_t *record) {
flow_tap_prev_time = record->event.time;
flow_tap_expired = false;
}
+# endif // defined(FLOW_TAP_TERM) || defined(SPECULATIVE_HOLD_FLOW_TERM)
+# ifdef FLOW_TAP_TERM
static bool flow_tap_key_if_within_term(keyrecord_t *record, uint16_t prev_time) {
const uint16_t idle_time = TIMER_DIFF_16(record->event.time, prev_time);
if (flow_tap_expired || idle_time >= 500) {
diff --git a/quantum/action_tapping.h b/quantum/action_tapping.h
index 227e3330e12..22453296339 100644
--- a/quantum/action_tapping.h
+++ b/quantum/action_tapping.h
@@ -197,9 +197,6 @@ bool is_flow_tap_key(uint16_t keycode);
*/
uint16_t get_flow_tap_term(uint16_t keycode, keyrecord_t *record, uint16_t prev_keycode);
-/** Updates the Flow Tap last key and timer. */
-void flow_tap_update_last_event(keyrecord_t *record);
-
/**
* Checks if the pressed key is within the flow tap term.
* Can be used to avoid triggering combos or other actions within the flow tap term.
@@ -211,6 +208,11 @@ void flow_tap_update_last_event(keyrecord_t *record);
bool within_flow_tap_term(uint16_t keycode, keyrecord_t *record);
#endif // FLOW_TAP_TERM
+#if defined(FLOW_TAP_TERM) || defined(SPECULATIVE_HOLD_FLOW_TERM)
+/** Updates the Flow Tap last key and timer. */
+void flow_tap_update_last_event(keyrecord_t *record);
+#endif // defined(FLOW_TAP_TERM) || defined(SPECULATIVE_HOLD_FLOW_TERM)
+
#ifdef DYNAMIC_TAPPING_TERM_ENABLE
extern uint16_t g_tapping_term;
#endif
diff --git a/tests/tap_hold_configurations/speculative_hold/speculative_hold_flow_term/config.h b/tests/tap_hold_configurations/speculative_hold/speculative_hold_flow_term/config.h
new file mode 100644
index 00000000000..92b4e61b1d7
--- /dev/null
+++ b/tests/tap_hold_configurations/speculative_hold/speculative_hold_flow_term/config.h
@@ -0,0 +1,23 @@
+/* Copyright 2022 Vladislav Kucheriavykh
+ * Copyright 2026 Google LLC
+ *
+ * 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 SPECULATIVE_HOLD
+#define SPECULATIVE_HOLD_FLOW_TERM 200
diff --git a/tests/tap_hold_configurations/speculative_hold/speculative_hold_flow_term/test.mk b/tests/tap_hold_configurations/speculative_hold/speculative_hold_flow_term/test.mk
new file mode 100644
index 00000000000..e716b592107
--- /dev/null
+++ b/tests/tap_hold_configurations/speculative_hold/speculative_hold_flow_term/test.mk
@@ -0,0 +1,16 @@
+# Copyright 2022 Vladislav Kucheriavykh
+# Copyright 2026 Google LLC
+#
+# 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 .
+
diff --git a/tests/tap_hold_configurations/speculative_hold/speculative_hold_flow_term/test_tap_hold.cpp b/tests/tap_hold_configurations/speculative_hold/speculative_hold_flow_term/test_tap_hold.cpp
new file mode 100644
index 00000000000..70a7713bb01
--- /dev/null
+++ b/tests/tap_hold_configurations/speculative_hold/speculative_hold_flow_term/test_tap_hold.cpp
@@ -0,0 +1,195 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include
+#include
+
+#include "keyboard_report_util.hpp"
+#include "keycode.h"
+#include "test_common.hpp"
+#include "action_tapping.h"
+#include "test_fixture.hpp"
+#include "test_keymap_key.hpp"
+
+using testing::_;
+using testing::AnyNumber;
+using testing::InSequence;
+
+namespace {
+
+class SpeculativeHoldFlowTerm : public TestFixture {};
+
+TEST_F(SpeculativeHoldFlowTerm, within_flow_term_one_modtap) {
+ TestDriver driver;
+ InSequence s;
+ auto regular_key = KeymapKey(0, 1, 0, KC_SPC);
+ auto mod_tap_key = KeymapKey(0, 2, 0, RSFT_T(KC_B));
+ set_keymap({regular_key, mod_tap_key});
+
+ // Tap regular key.
+ EXPECT_REPORT(driver, (KC_SPC));
+ EXPECT_EMPTY_REPORT(driver);
+ tap_key(regular_key);
+ VERIFY_AND_CLEAR(driver);
+
+ // Press mod-tap key.
+ EXPECT_NO_REPORT(driver);
+ mod_tap_key.press();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_speculative_mods(), 0); // Don't speculate.
+ EXPECT_EQ(get_mods(), 0);
+
+ EXPECT_REPORT(driver, (KC_RSFT));
+ idle_for(TAPPING_TERM + 1);
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_speculative_mods(), 0);
+ EXPECT_EQ(get_mods(), MOD_BIT_RSHIFT);
+
+ // Release mod-tap key.
+ EXPECT_EMPTY_REPORT(driver);
+ mod_tap_key.release();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+}
+
+TEST_F(SpeculativeHoldFlowTerm, within_flow_term_two_modtaps) {
+ TestDriver driver;
+ InSequence s;
+ auto regular_key = KeymapKey(0, 0, 0, KC_A);
+ auto mod_tap_key1 = KeymapKey(0, 1, 0, SFT_T(KC_B));
+ auto mod_tap_key2 = KeymapKey(0, 2, 0, CTL_T(KC_C));
+
+ set_keymap({regular_key, mod_tap_key1, mod_tap_key2});
+
+ // Tap regular key.
+ EXPECT_REPORT(driver, (KC_A));
+ EXPECT_EMPTY_REPORT(driver);
+ tap_key(regular_key);
+ VERIFY_AND_CLEAR(driver);
+
+ // Press mod-tap key 1 quickly after regular key. The mod-tap should settle
+ // immediately as tapped, sending `KC_B`.
+ EXPECT_NO_REPORT(driver);
+ mod_tap_key1.press();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_speculative_mods(), 0); // Don't speculate.
+
+ // Press mod-tap key 2 quickly.
+ EXPECT_NO_REPORT(driver);
+ mod_tap_key2.press();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_speculative_mods(), 0); // Don't speculate.
+
+ // Hold for longer than the tapping term.
+ EXPECT_REPORT(driver, (KC_LSFT));
+ EXPECT_REPORT(driver, (KC_LSFT, KC_LCTL));
+ idle_for(TAPPING_TERM + 1);
+ VERIFY_AND_CLEAR(driver);
+
+ // Release mod-tap keys.
+ EXPECT_REPORT(driver, (KC_LCTL));
+ EXPECT_EMPTY_REPORT(driver);
+ mod_tap_key1.release();
+ run_one_scan_loop();
+ mod_tap_key2.release();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+}
+
+TEST_F(SpeculativeHoldFlowTerm, after_flow_term_one_modtap) {
+ TestDriver driver;
+ InSequence s;
+ auto regular_key = KeymapKey(0, 1, 0, KC_SPC);
+ auto mod_tap_key = KeymapKey(0, 2, 0, RSFT_T(KC_B));
+ set_keymap({regular_key, mod_tap_key});
+
+ // Tap regular key.
+ EXPECT_REPORT(driver, (KC_SPC));
+ EXPECT_EMPTY_REPORT(driver);
+ tap_key(regular_key);
+ idle_for(SPECULATIVE_HOLD_FLOW_TERM + 1);
+ VERIFY_AND_CLEAR(driver);
+
+ // Press mod-tap key.
+ EXPECT_REPORT(driver, (KC_RSFT));
+ mod_tap_key.press();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_speculative_mods(), MOD_BIT_RSHIFT);
+ EXPECT_EQ(get_mods(), 0);
+
+ EXPECT_NO_REPORT(driver);
+ idle_for(TAPPING_TERM + 1);
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_speculative_mods(), 0);
+ EXPECT_EQ(get_mods(), MOD_BIT_RSHIFT);
+
+ // Release mod-tap key.
+ EXPECT_EMPTY_REPORT(driver);
+ mod_tap_key.release();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+}
+
+TEST_F(SpeculativeHoldFlowTerm, after_flow_term_two_modtaps) {
+ TestDriver driver;
+ InSequence s;
+ auto regular_key = KeymapKey(0, 1, 0, KC_SPC);
+ auto mod_tap_key1 = KeymapKey(0, 2, 0, RSFT_T(KC_B));
+ auto mod_tap_key2 = KeymapKey(0, 3, 0, RCTL_T(KC_C));
+ set_keymap({regular_key, mod_tap_key1, mod_tap_key2});
+
+ // Tap regular key.
+ EXPECT_REPORT(driver, (KC_SPC));
+ EXPECT_EMPTY_REPORT(driver);
+ tap_key(regular_key);
+ idle_for(SPECULATIVE_HOLD_FLOW_TERM + 1);
+ VERIFY_AND_CLEAR(driver);
+
+ // Press mod-tap key 1.
+ EXPECT_REPORT(driver, (KC_RSFT));
+ mod_tap_key1.press();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_speculative_mods(), MOD_BIT_RSHIFT);
+ EXPECT_EQ(get_mods(), 0);
+
+ // Press mod-tap key 2.
+ EXPECT_REPORT(driver, (KC_RSFT, KC_RCTL));
+ mod_tap_key2.press();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_speculative_mods(), MOD_BIT_RSHIFT | MOD_BIT_RCTRL);
+ EXPECT_EQ(get_mods(), 0);
+
+ EXPECT_NO_REPORT(driver);
+ idle_for(TAPPING_TERM + 1);
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_speculative_mods(), 0);
+ EXPECT_EQ(get_mods(), MOD_BIT_RSHIFT | MOD_BIT_RCTRL);
+
+ // Release mod-tap keys.
+ EXPECT_REPORT(driver, (KC_RCTL));
+ EXPECT_EMPTY_REPORT(driver);
+ mod_tap_key1.release();
+ run_one_scan_loop();
+ mod_tap_key2.release();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+}
+
+} // namespace
diff --git a/tests/tap_hold_configurations/speculative_hold/speculative_hold_one_key/config.h b/tests/tap_hold_configurations/speculative_hold/speculative_hold_one_key/config.h
new file mode 100644
index 00000000000..0ae84c2cac1
--- /dev/null
+++ b/tests/tap_hold_configurations/speculative_hold/speculative_hold_one_key/config.h
@@ -0,0 +1,24 @@
+/* Copyright 2022 Vladislav Kucheriavykh
+ * Copyright 2026 Google LLC
+ *
+ * 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 SPECULATIVE_HOLD
+#define SPECULATIVE_HOLD_ONE_KEY
+#define DUMMY_MOD_NEUTRALIZER_KEYCODE KC_F24
diff --git a/tests/tap_hold_configurations/speculative_hold/speculative_hold_one_key/test.mk b/tests/tap_hold_configurations/speculative_hold/speculative_hold_one_key/test.mk
new file mode 100644
index 00000000000..e716b592107
--- /dev/null
+++ b/tests/tap_hold_configurations/speculative_hold/speculative_hold_one_key/test.mk
@@ -0,0 +1,16 @@
+# Copyright 2022 Vladislav Kucheriavykh
+# Copyright 2026 Google LLC
+#
+# 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 .
+
diff --git a/tests/tap_hold_configurations/speculative_hold/speculative_hold_one_key/test_tap_hold.cpp b/tests/tap_hold_configurations/speculative_hold/speculative_hold_one_key/test_tap_hold.cpp
new file mode 100644
index 00000000000..60f7574517e
--- /dev/null
+++ b/tests/tap_hold_configurations/speculative_hold/speculative_hold_one_key/test_tap_hold.cpp
@@ -0,0 +1,189 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include
+#include
+
+#include "keyboard_report_util.hpp"
+#include "keycode.h"
+#include "test_common.hpp"
+#include "action_tapping.h"
+#include "test_fixture.hpp"
+#include "test_keymap_key.hpp"
+
+using testing::_;
+using testing::AnyNumber;
+using testing::InSequence;
+
+namespace {
+
+// Gets the unpacked 8-bit mods corresponding to a given mod-tap keycode.
+uint8_t unpack_mod_tap_mods(uint16_t keycode) {
+ const uint8_t mods5 = QK_MOD_TAP_GET_MODS(keycode);
+ return (mods5 & 0x10) != 0 ? (mods5 << 4) : mods5;
+}
+
+std::vector mods_report(uint8_t mods) {
+ std::vector keycodes;
+ if ((mods & MOD_BIT_LCTRL)) {
+ keycodes.push_back(KC_LCTL);
+ }
+ if ((mods & MOD_BIT_LSHIFT)) {
+ keycodes.push_back(KC_LSFT);
+ }
+ if ((mods & MOD_BIT_LALT)) {
+ keycodes.push_back(KC_LALT);
+ }
+ if ((mods & MOD_BIT_LGUI)) {
+ keycodes.push_back(KC_LGUI);
+ }
+ if ((mods & MOD_BIT_RCTRL)) {
+ keycodes.push_back(KC_RCTL);
+ }
+ if ((mods & MOD_BIT_RSHIFT)) {
+ keycodes.push_back(KC_RSFT);
+ }
+ if ((mods & MOD_BIT_RALT)) {
+ keycodes.push_back(KC_RALT);
+ }
+ if ((mods & MOD_BIT_RGUI)) {
+ keycodes.push_back(KC_RGUI);
+ }
+ return keycodes;
+}
+
+extern "C" bool get_speculative_hold(uint16_t keycode, keyrecord_t *record) {
+ return true; // Enable Speculative Hold for all mod-tap keys.
+}
+
+class SpeculativeHoldOneKey : public TestFixture {};
+
+TEST_F(SpeculativeHoldOneKey, modtap_dont_speculate) {
+ TestDriver driver;
+ InSequence s;
+
+ struct Params {
+ uint8_t initial_mods;
+ uint16_t key;
+ };
+ for (Params params : std::vector({
+ {MOD_BIT_LSHIFT, LALT_T(KC_1)},
+ {MOD_BIT_RCTRL, LGUI_T(KC_1)},
+ {MOD_BIT_LALT, LSFT_T(KC_1)},
+ {MOD_BIT_RALT, RCTL_T(KC_1)},
+ {MOD_MASK_SHIFT, RGUI_T(KC_1)},
+ })) {
+ std::string scope = "initial_mods=";
+ scope += testing::PrintToString(params.initial_mods);
+ scope += std::string(", key=") + get_keycode_string(params.key);
+ SCOPED_TRACE(scope);
+
+ const uint8_t initial_mods = params.initial_mods;
+ auto mod_tap_key = KeymapKey(0, 1, 0, params.key);
+ const uint8_t mod_tap_mods = unpack_mod_tap_mods(params.key);
+
+ set_keymap({mod_tap_key});
+
+ // Activate mods.
+ set_mods(initial_mods);
+
+ // Press mod-tap key.
+ EXPECT_NO_REPORT(driver);
+ mod_tap_key.press();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_mods(), initial_mods);
+ EXPECT_EQ(get_speculative_mods(), 0);
+
+ EXPECT_REPORT(driver, (mods_report(initial_mods | mod_tap_mods)));
+ EXPECT_NO_REPORT(driver);
+ idle_for(TAPPING_TERM + 1);
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_speculative_mods(), 0);
+ EXPECT_EQ(get_mods(), initial_mods | mod_tap_mods);
+
+ // Release mod-tap key.
+ EXPECT_REPORT(driver, (mods_report(initial_mods & ~mod_tap_mods)));
+ mod_tap_key.release();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+ }
+
+ EXPECT_EMPTY_REPORT(driver);
+ clear_mods();
+ send_keyboard_report();
+ VERIFY_AND_CLEAR(driver);
+}
+
+TEST_F(SpeculativeHoldOneKey, two_modtaps_dont_speculate_second_key) {
+ TestDriver driver;
+ InSequence s;
+
+ for (auto keys : std::vector>({
+ {LSFT_T(KC_A), LCTL_T(KC_1)},
+ {RSFT_T(KC_A), RCTL_T(KC_1)},
+ {LGUI_T(KC_A), LSFT_T(KC_1)},
+ {RALT_T(KC_A), RSFT_T(KC_1)},
+ {MEH_T(KC_A), LGUI_T(KC_1)},
+ {RCS_T(KC_A), RSG_T(KC_1)},
+ {LCS_T(KC_A), HYPR_T(KC_1)},
+ })) {
+ std::string scope = "keys = ";
+ scope += get_keycode_string(keys.first);
+ scope += std::string(", ") + get_keycode_string(keys.second);
+ SCOPED_TRACE(scope);
+
+ auto mod_tap_key1 = KeymapKey(0, 1, 0, keys.first);
+ auto mod_tap_key2 = KeymapKey(0, 2, 0, keys.second);
+ const uint8_t mods1 = unpack_mod_tap_mods(keys.first);
+ const uint8_t mods2 = unpack_mod_tap_mods(keys.second);
+
+ set_keymap({mod_tap_key1, mod_tap_key2});
+
+ // Press first mod-tap key.
+ EXPECT_REPORT(driver, (mods_report(mods1)));
+ mod_tap_key1.press();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_speculative_mods(), mods1);
+
+ // Press second mod-tap key.
+ EXPECT_NO_REPORT(driver);
+ mod_tap_key2.press();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_speculative_mods(), mods1);
+
+ EXPECT_REPORT(driver, (mods_report(mods1 | mods2)));
+ EXPECT_NO_REPORT(driver);
+ idle_for(TAPPING_TERM + 1);
+ VERIFY_AND_CLEAR(driver);
+ EXPECT_EQ(get_speculative_mods(), 0);
+ EXPECT_EQ(get_mods(), mods1 | mods2);
+
+ // Release first mod-tap key.
+ EXPECT_REPORT(driver, (mods_report(mods2 & ~mods1)));
+ mod_tap_key1.release();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+
+ // Release second mod-tap key.
+ EXPECT_EMPTY_REPORT(driver);
+ mod_tap_key2.release();
+ run_one_scan_loop();
+ VERIFY_AND_CLEAR(driver);
+ }
+}
+
+} // namespace
diff --git a/tests/test_common/keyboard_report_util.hpp b/tests/test_common/keyboard_report_util.hpp
index 666791a5b61..d4a4622c247 100644
--- a/tests/test_common/keyboard_report_util.hpp
+++ b/tests/test_common/keyboard_report_util.hpp
@@ -33,6 +33,10 @@ class KeyboardReportMatcher : public testing::MatcherInterface KeyboardReport(const std::vector& keys) {
+ return testing::MakeMatcher(new KeyboardReportMatcher(keys));
+}
+
template
inline testing::Matcher KeyboardReport(Ts... keys) {
return testing::MakeMatcher(new KeyboardReportMatcher({static_cast(keys)...}));