From ae63c0f509fae71270fb5885d504ee26cbad95ff Mon Sep 17 00:00:00 2001 From: Pascal Getreuer <50221757+getreuer@users.noreply.github.com> Date: Mon, 3 Apr 2023 16:11:26 -0700 Subject: [PATCH] [Core] Caps Word "Invert on shift" option: pressing Shift inverts the shift state. (#20092) Co-authored-by: Nick Brassel --- data/mappings/info_config.hjson | 1 + data/schemas/keyboard.jsonschema | 3 +- docs/feature_caps_word.md | 20 ++ quantum/process_keycode/process_caps_word.c | 66 ++++++ .../caps_word_invert_on_shift/config.h | 21 ++ .../caps_word_invert_on_shift/test.mk | 17 ++ .../test_caps_word_invert_on_shift.cpp | 215 ++++++++++++++++++ 7 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 tests/caps_word/caps_word_invert_on_shift/config.h create mode 100644 tests/caps_word/caps_word_invert_on_shift/test.mk create mode 100644 tests/caps_word/caps_word_invert_on_shift/test_caps_word_invert_on_shift.cpp diff --git a/data/mappings/info_config.hjson b/data/mappings/info_config.hjson index bc4f46c3533..7c1a4ee36ba 100644 --- a/data/mappings/info_config.hjson +++ b/data/mappings/info_config.hjson @@ -28,6 +28,7 @@ "BOOTMAGIC_LITE_COLUMN_RIGHT": {"info_key": "split.bootmagic.matrix.1", "value_type": "int"}, "BOTH_SHIFTS_TURNS_ON_CAPS_WORD": {"info_key": "caps_word.both_shifts_turns_on", "value_type": "bool"}, "CAPS_WORD_IDLE_TIMEOUT": {"info_key": "caps_word.idle_timeout", "value_type": "int"}, + "CAPS_WORD_INVERT_ON_SHIFT": {"info_key": "caps_word.invert_on_shift", "value_type": "bool"}, "COMBO_COUNT": {"info_key": "combo.count", "value_type": "int"}, "COMBO_TERM": {"info_key": "combo.term", "value_type": "int"}, "DEBOUNCE": {"info_key": "debounce", "value_type": "int"}, diff --git a/data/schemas/keyboard.jsonschema b/data/schemas/keyboard.jsonschema index ee6ecf28e48..6c4ff49855b 100644 --- a/data/schemas/keyboard.jsonschema +++ b/data/schemas/keyboard.jsonschema @@ -227,7 +227,8 @@ "enabled": {"type": "boolean"}, "both_shifts_turns_on": {"type": "boolean"}, "double_tap_shift_turns_on": {"type": "boolean"}, - "idle_timeout": {"$ref": "qmk.definitions.v1#/unsigned_int"} + "idle_timeout": {"$ref": "qmk.definitions.v1#/unsigned_int"}, + "invert_on_shift": {"type": "boolean"} } }, "combo": { diff --git a/docs/feature_caps_word.md b/docs/feature_caps_word.md index c58d1a56e2f..7f726b059d8 100644 --- a/docs/feature_caps_word.md +++ b/docs/feature_caps_word.md @@ -90,6 +90,26 @@ by defining `IS_COMMAND()` in config.h: ## Customizing Caps Word :id=customizing-caps-word +### Invert on shift :id=invert-on-shift + +By default, Caps Word turns off when Shift keys are pressed, considering them as +word-breaking. Alternatively with the `CAPS_WORD_INVERT_ON_SHIFT` option, +pressing the Shift key continues Caps Word and inverts the shift state. This +is convenient for uncapitalizing one or a few letters within a word, for +example with Caps Word on, typing "D, B, Shift+A, Shift+A, S" produces "DBaaS", +or typing "P, D, F, Shift+S" produces "PDFs". + +Enable it by adding in config.h + +```c +#define CAPS_WORD_INVERT_ON_SHIFT +``` + +This option works with regular Shift keys `KC_LSFT` and `KC_RSFT`, mod-tap Shift +keys, and one-shot Shift keys. Note that while Caps Word is on, one-shot Shift +keys behave like regular Shift keys, and have effect only while they are held. + + ### Idle timeout :id=idle-timeout Caps Word turns off automatically if no keys are pressed for diff --git a/quantum/process_keycode/process_caps_word.c b/quantum/process_keycode/process_caps_word.c index 94302b29aea..8f2ee1db8bf 100644 --- a/quantum/process_keycode/process_caps_word.c +++ b/quantum/process_keycode/process_caps_word.c @@ -14,6 +14,54 @@ #include "process_caps_word.h" +#ifdef CAPS_WORD_INVERT_ON_SHIFT +static uint8_t held_mods = 0; + +static bool handle_shift(uint16_t keycode, keyrecord_t* record) { + switch (keycode) { + case OSM(MOD_LSFT): + keycode = KC_LSFT; + break; + case OSM(MOD_RSFT): + keycode = KC_RSFT; + break; + +# ifndef NO_ACTION_TAPPING + case QK_MOD_TAP ... QK_MOD_TAP_MAX: + if (record->tap.count == 0) { // Mod-tap key is held. + switch (QK_MOD_TAP_GET_MODS(keycode)) { + case MOD_LSFT: + keycode = KC_LSFT; + break; + case MOD_RSFT: + keycode = KC_RSFT; + break; + } + } +# endif // NO_ACTION_TAPPING + } + + if (keycode == KC_LSFT || keycode == KC_RSFT) { + const uint8_t mod = MOD_BIT(keycode); + + if (is_caps_word_on()) { + if (record->event.pressed) { + held_mods |= mod; + } else { + held_mods &= ~mod; + } + return false; + } else if ((held_mods & mod) != 0) { + held_mods &= ~mod; + del_mods(mod); + return record->event.pressed; + } + } + + return true; +} +#endif // CAPS_WORD_INVERT_ON_SHIFT + bool process_caps_word(uint16_t keycode, keyrecord_t* record) { if (keycode == QK_CAPS_WORD_TOGGLE) { if (record->event.pressed) { @@ -21,6 +69,11 @@ bool process_caps_word(uint16_t keycode, keyrecord_t* record) { } return false; } +#ifdef CAPS_WORD_INVERT_ON_SHIFT + if (!handle_shift(keycode, record)) { + return false; + } +#endif // CAPS_WORD_INVERT_ON_SHIFT #ifndef NO_ACTION_ONESHOT const uint8_t mods = get_mods() | get_oneshot_mods(); @@ -111,12 +164,14 @@ bool process_caps_word(uint16_t keycode, keyrecord_t* record) { if (record->tap.count == 0) { // Mod-tap key is held. const uint8_t mods = QK_MOD_TAP_GET_MODS(keycode); switch (mods) { +# ifndef CAPS_WORD_INVERT_ON_SHIFT case MOD_LSFT: keycode = KC_LSFT; break; case MOD_RSFT: keycode = KC_RSFT; break; +# endif // CAPS_WORD_INVERT_ON_SHIFT case MOD_RSFT | MOD_RALT: keycode = RSFT(KC_RALT); break; @@ -124,6 +179,9 @@ bool process_caps_word(uint16_t keycode, keyrecord_t* record) { return true; default: caps_word_off(); +# ifdef CAPS_WORD_INVERT_ON_SHIFT + add_mods(held_mods); +# endif // CAPS_WORD_INVERT_ON_SHIFT return true; } } else { @@ -163,12 +221,20 @@ bool process_caps_word(uint16_t keycode, keyrecord_t* record) { clear_weak_mods(); #endif // AUTO_SHIFT_ENABLE if (caps_word_press_user(keycode)) { +#ifdef CAPS_WORD_INVERT_ON_SHIFT + if (held_mods) { + set_weak_mods(get_weak_mods() ^ MOD_BIT(KC_LSFT)); + } +#endif // CAPS_WORD_INVERT_ON_SHIFT send_keyboard_report(); return true; } } caps_word_off(); +#ifdef CAPS_WORD_INVERT_ON_SHIFT + add_mods(held_mods); +#endif // CAPS_WORD_INVERT_ON_SHIFT return true; } diff --git a/tests/caps_word/caps_word_invert_on_shift/config.h b/tests/caps_word/caps_word_invert_on_shift/config.h new file mode 100644 index 00000000000..7a3ec846f99 --- /dev/null +++ b/tests/caps_word/caps_word_invert_on_shift/config.h @@ -0,0 +1,21 @@ +// Copyright 2023 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 CAPS_WORD_INVERT_ON_SHIFT +#define PERMISSIVE_HOLD diff --git a/tests/caps_word/caps_word_invert_on_shift/test.mk b/tests/caps_word/caps_word_invert_on_shift/test.mk new file mode 100644 index 00000000000..319c04d67a9 --- /dev/null +++ b/tests/caps_word/caps_word_invert_on_shift/test.mk @@ -0,0 +1,17 @@ +# Copyright 2023 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 . + +CAPS_WORD_ENABLE = yes + diff --git a/tests/caps_word/caps_word_invert_on_shift/test_caps_word_invert_on_shift.cpp b/tests/caps_word/caps_word_invert_on_shift/test_caps_word_invert_on_shift.cpp new file mode 100644 index 00000000000..d3224481818 --- /dev/null +++ b/tests/caps_word/caps_word_invert_on_shift/test_caps_word_invert_on_shift.cpp @@ -0,0 +1,215 @@ +// Copyright 2023 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 . + +#include "keyboard_report_util.hpp" +#include "keycode.h" +#include "test_common.hpp" +#include "test_fixture.hpp" +#include "test_keymap_key.hpp" + +using ::testing::_; +using ::testing::AnyNumber; +using ::testing::AnyOf; +using ::testing::InSequence; +using ::testing::TestParamInfo; + +namespace { + +struct ShiftKeyParams { + std::string name; + uint16_t keycode; + uint16_t report_shift_code; + + static const std::string& GetName(const TestParamInfo& info) { + return info.param.name; + } +}; + +class CapsWordInvertOnShift : public ::testing::WithParamInterface, public TestFixture { + void SetUp() override { + caps_word_off(); + } +}; + +// With Caps Word on, type "A, 4, Shift(A, 4, A), A, Shift(A), 4". +TEST_P(CapsWordInvertOnShift, ShiftWithinWord) { + TestDriver driver; + KeymapKey key_shift(0, 0, 0, GetParam().keycode); + KeymapKey key_a(0, 1, 0, KC_A); + KeymapKey key_4(0, 2, 0, KC_4); + set_keymap({key_shift, key_a, key_4}); + + // Allow any number of reports with no keys or only KC_LSFT. + // clang-format off + EXPECT_CALL(driver, send_keyboard_mock(AnyOf( + KeyboardReport(), + KeyboardReport(KC_LSFT)))) + .Times(AnyNumber()); + // clang-format on + + { // Expect: "A4a$aAa4" + InSequence s; + EXPECT_REPORT(driver, (KC_LSFT, KC_A)); + EXPECT_REPORT(driver, (KC_4)); + EXPECT_REPORT(driver, (KC_A)); + EXPECT_REPORT(driver, (KC_LSFT, KC_4)); + EXPECT_REPORT(driver, (KC_A)); + EXPECT_REPORT(driver, (KC_LSFT, KC_A)); + EXPECT_REPORT(driver, (KC_A)); + EXPECT_REPORT(driver, (KC_4)); + } + + caps_word_on(); + tap_keys(key_a, key_4); // Type "A, 4". + + key_shift.press(); // Type "Shift(A, 4, A)". + run_one_scan_loop(); + tap_keys(key_a, key_4, key_a); + key_shift.release(); + run_one_scan_loop(); + + tap_key(key_a); // Type "A". + + key_shift.press(); // Type "Shift(A)". + run_one_scan_loop(); + tap_key(key_a); + key_shift.release(); + run_one_scan_loop(); + + tap_key(key_4); // Type "4". + + VERIFY_AND_CLEAR(driver); +} + +TEST_P(CapsWordInvertOnShift, ShiftHeldAtWordEnd) { + TestDriver driver; + KeymapKey key_shift(0, 0, 0, GetParam().keycode); + KeymapKey key_a(0, 1, 0, KC_A); + KeymapKey key_slsh(0, 2, 0, KC_SLSH); + set_keymap({key_shift, key_a, key_slsh}); + + // Allow any number of reports with no keys or only KC_LSFT. + // clang-format off + EXPECT_CALL(driver, send_keyboard_mock(AnyOf( + KeyboardReport(), + KeyboardReport(KC_LSFT), + KeyboardReport(KC_RSFT)))) + .Times(AnyNumber()); + // clang-format on + + { // Expect: "Aa?A" + InSequence s; + EXPECT_REPORT(driver, (KC_LSFT, KC_A)); + EXPECT_REPORT(driver, (KC_A)); + EXPECT_REPORT(driver, (GetParam().report_shift_code, KC_SLSH)); + EXPECT_REPORT(driver, (GetParam().report_shift_code, KC_A)); + } + + caps_word_on(); + tap_key(key_a); + + key_shift.press(); // Press Shift. + run_one_scan_loop(); + + EXPECT_EQ(get_mods(), 0); + + tap_key(key_a); + tap_key(key_slsh); // Tap '/' key, which is word breaking, ending Caps Word. + + EXPECT_FALSE(is_caps_word_on()); + EXPECT_EQ(get_mods(), MOD_BIT(GetParam().report_shift_code)); + + tap_key(key_a); + key_shift.release(); // Release Shift. + run_one_scan_loop(); + + EXPECT_EQ(get_mods(), 0); + VERIFY_AND_CLEAR(driver); +} + +TEST_P(CapsWordInvertOnShift, TwoShiftsHeld) { + TestDriver driver; + KeymapKey key_shift1(0, 0, 0, GetParam().keycode); + KeymapKey key_shift2(0, 1, 0, GetParam().report_shift_code); + KeymapKey key_a(0, 2, 0, KC_A); + KeymapKey key_slsh(0, 3, 0, KC_SLSH); + set_keymap({key_shift1, key_shift2, key_a, key_slsh}); + + // Allow any number of reports with no keys or only KC_LSFT. + // clang-format off + EXPECT_CALL(driver, send_keyboard_mock(AnyOf( + KeyboardReport(), + KeyboardReport(KC_LSFT), + KeyboardReport(KC_RSFT)))) + .Times(AnyNumber()); + // clang-format on + + { // Expect: "Aa?a" + InSequence s; + EXPECT_REPORT(driver, (KC_LSFT, KC_A)); + EXPECT_REPORT(driver, (KC_A)); + EXPECT_REPORT(driver, (GetParam().report_shift_code, KC_SLSH)); + EXPECT_REPORT(driver, (KC_A)); + } + + caps_word_on(); + tap_key(key_a); + + key_shift1.press(); // Press shift1. + run_one_scan_loop(); + + EXPECT_EQ(get_mods(), 0); + + tap_key(key_a); + tap_key(key_slsh); // Tap '/' key, which is word breaking, ending Caps Word. + + EXPECT_FALSE(is_caps_word_on()); + EXPECT_EQ(get_mods(), MOD_BIT(GetParam().report_shift_code)); + + key_shift2.press(); // Press shift2. + run_one_scan_loop(); + + EXPECT_EQ(get_mods(), MOD_BIT(GetParam().report_shift_code)); + + key_shift1.release(); // Release shift1. + run_one_scan_loop(); + + EXPECT_EQ(get_mods(), 0); + tap_key(key_a); + + key_shift2.release(); // Release shift2. + run_one_scan_loop(); + + EXPECT_EQ(get_mods(), 0); + VERIFY_AND_CLEAR(driver); +} + +// clang-format off +INSTANTIATE_TEST_CASE_P( + Shifts, + CapsWordInvertOnShift, + ::testing::Values( + ShiftKeyParams{"KC_LSFT", KC_LSFT, KC_LSFT}, + ShiftKeyParams{"KC_RSFT", KC_RSFT, KC_RSFT}, + ShiftKeyParams{"LSFT_T", LSFT_T(KC_A), KC_LSFT}, + ShiftKeyParams{"RSFT_T", RSFT_T(KC_A), KC_RSFT}, + ShiftKeyParams{"OSM_LSFT", OSM(MOD_LSFT), KC_LSFT}, + ShiftKeyParams{"OSM_RSFT", OSM(MOD_RSFT), KC_RSFT} + ), + ShiftKeyParams::GetName + ); +// clang-format on + +} // namespace