diff --git a/docs/keycodes.md b/docs/keycodes.md index 9c918b767698..08b913535883 100644 --- a/docs/keycodes.md +++ b/docs/keycodes.md @@ -736,7 +736,7 @@ See also: [Dynamic Tapping Term](tap_hold#dynamic-tapping-term) |-------------------------------|---------|-------------------------------------------------------------------------------------------| |`QK_DYNAMIC_TAPPING_TERM_PRINT`|`DT_PRNT`| Types the current tapping term, in milliseconds | |`QK_DYNAMIC_TAPPING_TERM_UP` |`DT_UP` | Increases the current tapping term by `DYNAMIC_TAPPING_TERM_INCREMENT`ms (5ms by default) | -|`QK_DYNAMIC_TAPPING_TERM_DOWN` |`DT_DOWN`| Decreases the current tapping term by `DYNAMIC_TAPPING_TERM_INCREMENT`ms (5ms by default) | +|`QK_DYNAMIC_TAPPING_TERM_DOWN` |`DT_DOWN`| Decreases the current tapping term by `DYNAMIC_TAPPING_TERM_INCREMENT`ms (5ms by default), never below the increment | ## RGB Lighting {#rgb-lighting} diff --git a/docs/tap_hold.md b/docs/tap_hold.md index 13e2f0b36797..9fc059e71cbb 100644 --- a/docs/tap_hold.md +++ b/docs/tap_hold.md @@ -84,7 +84,7 @@ uint16_t get_tapping_term(uint16_t keycode, keyrecord_t *record) { |-------------------------------|---------|-------------------------------------------------------------------------------------------| |`QK_DYNAMIC_TAPPING_TERM_PRINT`|`DT_PRNT`| Types the current tapping term, in milliseconds | |`QK_DYNAMIC_TAPPING_TERM_UP` |`DT_UP` | Increases the current tapping term by `DYNAMIC_TAPPING_TERM_INCREMENT`ms (5ms by default) | -|`QK_DYNAMIC_TAPPING_TERM_DOWN` |`DT_DOWN`| Decreases the current tapping term by `DYNAMIC_TAPPING_TERM_INCREMENT`ms (5ms by default) | +|`QK_DYNAMIC_TAPPING_TERM_DOWN` |`DT_DOWN`| Decreases the current tapping term by `DYNAMIC_TAPPING_TERM_INCREMENT`ms (5ms by default), never below the increment | Set the tapping term as usual with `#define TAPPING_TERM ` in `config.h` and add `DYNAMIC_TAPPING_TERM_ENABLE = yes` in `rules.mk`. Then, place the above three keys somewhere in your keymap and flash the new firmware onto your board. diff --git a/quantum/process_keycode/process_dynamic_tapping_term.c b/quantum/process_keycode/process_dynamic_tapping_term.c index cf52626e428c..2970c741b41a 100644 --- a/quantum/process_keycode/process_dynamic_tapping_term.c +++ b/quantum/process_keycode/process_dynamic_tapping_term.c @@ -46,7 +46,9 @@ bool process_dynamic_tapping_term(uint16_t keycode, keyrecord_t *record) { return false; case QK_DYNAMIC_TAPPING_TERM_DOWN: - g_tapping_term -= DYNAMIC_TAPPING_TERM_INCREMENT; + if (g_tapping_term > DYNAMIC_TAPPING_TERM_INCREMENT) { + g_tapping_term -= DYNAMIC_TAPPING_TERM_INCREMENT; + } return false; } } diff --git a/tests/dynamic_tapping_term/config.h b/tests/dynamic_tapping_term/config.h new file mode 100644 index 000000000000..c4ee7d4c4b6b --- /dev/null +++ b/tests/dynamic_tapping_term/config.h @@ -0,0 +1,6 @@ +// Copyright 2026 Arca +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "test_common.h" diff --git a/tests/dynamic_tapping_term/test.mk b/tests/dynamic_tapping_term/test.mk new file mode 100644 index 000000000000..40de87810f3d --- /dev/null +++ b/tests/dynamic_tapping_term/test.mk @@ -0,0 +1,8 @@ +# Copyright 2026 Arca +# SPDX-License-Identifier: GPL-2.0-or-later + +# -------------------------------------------------------------------------------- +# Keep this file, even if it is empty, as a marker that this folder contains tests +# -------------------------------------------------------------------------------- + +DYNAMIC_TAPPING_TERM_ENABLE = yes diff --git a/tests/dynamic_tapping_term/test_dynamic_tapping_term.cpp b/tests/dynamic_tapping_term/test_dynamic_tapping_term.cpp new file mode 100644 index 000000000000..6527c56fc81d --- /dev/null +++ b/tests/dynamic_tapping_term/test_dynamic_tapping_term.cpp @@ -0,0 +1,121 @@ +// Copyright 2026 Arca +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "test_common.hpp" + +using testing::_; +using testing::InSequence; + +class DynamicTappingTerm : public TestFixture { + public: + ~DynamicTappingTerm() override { + g_tapping_term = TAPPING_TERM; + } +}; + +TEST_F(DynamicTappingTerm, TappingUpRaisesTermByIncrementPerPress) { + TestDriver driver; + KeymapKey up_key = KeymapKey{0, 0, 0, DT_UP}; + + set_keymap({up_key}); + g_tapping_term = TAPPING_TERM; + + EXPECT_NO_REPORT(driver); + tap_key(up_key); + EXPECT_EQ(g_tapping_term, TAPPING_TERM + DYNAMIC_TAPPING_TERM_INCREMENT); + + tap_key(up_key); + EXPECT_EQ(g_tapping_term, TAPPING_TERM + 2 * DYNAMIC_TAPPING_TERM_INCREMENT); + VERIFY_AND_CLEAR(driver); +} + +TEST_F(DynamicTappingTerm, TappingDownLowersTermByIncrementPerPress) { + TestDriver driver; + KeymapKey down_key = KeymapKey{0, 0, 0, DT_DOWN}; + + set_keymap({down_key}); + g_tapping_term = 200; + + EXPECT_NO_REPORT(driver); + tap_key(down_key); + EXPECT_EQ(g_tapping_term, 200 - DYNAMIC_TAPPING_TERM_INCREMENT); + + tap_key(down_key); + EXPECT_EQ(g_tapping_term, 200 - 2 * DYNAMIC_TAPPING_TERM_INCREMENT); + VERIFY_AND_CLEAR(driver); +} + +TEST_F(DynamicTappingTerm, TappingDownPastFloorDoesNotUnderflow) { + TestDriver driver; + KeymapKey down_key = KeymapKey{0, 0, 0, DT_DOWN}; + + // An exact multiple of the increment, so the floor is reached precisely + // regardless of how DYNAMIC_TAPPING_TERM_INCREMENT is configured. + const uint16_t start = 40 * DYNAMIC_TAPPING_TERM_INCREMENT; + + set_keymap({down_key}); + g_tapping_term = start; + + EXPECT_NO_REPORT(driver); + for (int i = 0; i < 100; i++) { + tap_key(down_key); + EXPECT_LE(g_tapping_term, start); + EXPECT_GE(g_tapping_term, DYNAMIC_TAPPING_TERM_INCREMENT); + } + EXPECT_EQ(g_tapping_term, DYNAMIC_TAPPING_TERM_INCREMENT); + VERIFY_AND_CLEAR(driver); +} + +TEST_F(DynamicTappingTerm, LoweredTappingTermChangesModTapResolution) { + TestDriver driver; + InSequence s; + KeymapKey mod_tap_key = KeymapKey{0, 0, 0, SFT_T(KC_P)}; + KeymapKey down_key = KeymapKey{0, 1, 0, DT_DOWN}; + + set_keymap({mod_tap_key, down_key}); + g_tapping_term = TAPPING_TERM; + + // Strictly between the lowered term (TAPPING_TERM - 10 * INCREMENT) and the + // default TAPPING_TERM, so the same hold duration taps under the default + // term but holds once DT_DOWN has lowered the term below it. + const uint16_t hold_ms = TAPPING_TERM - 5 * DYNAMIC_TAPPING_TERM_INCREMENT; + + /* Part A: baseline, hold_ms held under the default tapping term resolves as a tap. */ + EXPECT_NO_REPORT(driver); + mod_tap_key.press(); + idle_for(hold_ms); + VERIFY_AND_CLEAR(driver); + + EXPECT_REPORT(driver, (KC_P)); + EXPECT_EMPTY_REPORT(driver); + mod_tap_key.release(); + run_one_scan_loop(); + VERIFY_AND_CLEAR(driver); + + // Flush the quick-tap window (QUICK_TAP_TERM defaults to TAPPING_TERM) so the + // next press of the same mod-tap key hold-resolves instead of auto-repeating + // the tap above. + EXPECT_NO_REPORT(driver); + idle_for(TAPPING_TERM + 1); + VERIFY_AND_CLEAR(driver); + + /* Part B: lower the tapping term below hold_ms. */ + EXPECT_NO_REPORT(driver); + for (int i = 0; i < 10; i++) { + tap_key(down_key); + } + EXPECT_EQ(g_tapping_term, TAPPING_TERM - 10 * DYNAMIC_TAPPING_TERM_INCREMENT); + VERIFY_AND_CLEAR(driver); + + /* Part C: the same hold duration now expires the lowered term mid-idle, + * so the mod-tap key resolves as a hold instead of a tap. */ + EXPECT_REPORT(driver, (KC_LSFT)); + mod_tap_key.press(); + idle_for(hold_ms); + VERIFY_AND_CLEAR(driver); + + EXPECT_EMPTY_REPORT(driver); + mod_tap_key.release(); + run_one_scan_loop(); + VERIFY_AND_CLEAR(driver); +}