From 09dc72639995114d213265bac4af58a4dd0e3672 Mon Sep 17 00:00:00 2001 From: Lisa Ugray Date: Tue, 28 Mar 2023 11:49:38 -0400 Subject: [PATCH 1/2] Add Chord plugin This Kaleidoscope plugin allows you to define a chord of keys on your keyboard which, when pressed simultaneously, produce a single keycode. This differs from MagicCombo in that the individual keys making up a chord are suppressed, producing only the singular result. Signed-off-by: Lisa Ugray --- plugins/Kaleidoscope-Chord/README.md | 53 +++++ plugins/Kaleidoscope-Chord/library.properties | 7 + .../src/Kaleidoscope-Chord.h | 21 ++ .../src/kaleidoscope/plugin/Chord.cpp | 189 ++++++++++++++++++ .../src/kaleidoscope/plugin/Chord.h | 81 ++++++++ 5 files changed, 351 insertions(+) create mode 100644 plugins/Kaleidoscope-Chord/README.md create mode 100644 plugins/Kaleidoscope-Chord/library.properties create mode 100644 plugins/Kaleidoscope-Chord/src/Kaleidoscope-Chord.h create mode 100644 plugins/Kaleidoscope-Chord/src/kaleidoscope/plugin/Chord.cpp create mode 100644 plugins/Kaleidoscope-Chord/src/kaleidoscope/plugin/Chord.h diff --git a/plugins/Kaleidoscope-Chord/README.md b/plugins/Kaleidoscope-Chord/README.md new file mode 100644 index 0000000000..d0cad32a26 --- /dev/null +++ b/plugins/Kaleidoscope-Chord/README.md @@ -0,0 +1,53 @@ +# Chord + +## Concept + +This Kaleidoscope plugin allows you to define a chord of keys on your keyboard +which, when pressed simultaneously, produce a single keycode. This differs from +[MagicCombo](https://github.com/keyboardio/Kaleidoscope/tree/master/plugins/Kaleidoscope-MagicCombo) +in that the individual keys making up a chord are suppressed, producing only +the singular result. + + +## Setup + +- Include the header file: +``` +#include +``` +- Use the plugin in the `KALEIDOSCOPE_INIT_PLUGINS` macro: +``` +KALEIDOSCOPE_INIT_PLUGINS(Chord); +``` + +And define some chords in `setup` such as: + +``` +CHORDS( + CHORD(Key_J, Key_K), Key_Escape, + CHORD(Key_D, Key_F), Key_LeftShift, + CHORD(Key_S, Key_D), TOPSY(Semicolon), + CHORD(Key_S, Key_D, Key_F), Key_Spacebar, +) +``` + +As can be seen from the example, chords can be overlapping or subsets of each +other, and can result in regular keys, modifier keys or special keys (such as a +[TopsyTurvey](https://github.com/keyboardio/Kaleidoscope/tree/master/plugins/Kaleidoscope-TopsyTurvy) +key). The resulting key will be held for as long as the last key pressed in the +chord is held. + +## Configuration + +### `.setTimeout(timeout)` + +> Sets the time (in milliseconds) after which a key or set of keys that could be +> part of a larger chord is pressed before the pressed keys are resolved. It's +> generally not necessary to explicitly wait for this timeout, since as soon as +> a key is pressed that could not be part of a chord with existing key presses, +> the existing keys will resolve. For instance, with the example above, pressing +> and holding S, D, L in quick succession would result in a held Shift + L. It's +> only if you wanted to type Shift + F, that you'd need to add a pause (S, D, +> wait for timeout, F), since otherwise it would be interpreted as a space. +> +> Defaults to `50`. diff --git a/plugins/Kaleidoscope-Chord/library.properties b/plugins/Kaleidoscope-Chord/library.properties new file mode 100644 index 0000000000..da29149444 --- /dev/null +++ b/plugins/Kaleidoscope-Chord/library.properties @@ -0,0 +1,7 @@ +name=Kaleidoscope-Chord +version=0.0.0 +sentence=Respond to chords of keys as a single keystroke +maintainer=Kaleidoscope's Developers +url=https://github.com/keyboardio/Kaleidoscope +author=Lisa Ugray +paragraph= diff --git a/plugins/Kaleidoscope-Chord/src/Kaleidoscope-Chord.h b/plugins/Kaleidoscope-Chord/src/Kaleidoscope-Chord.h new file mode 100644 index 0000000000..7e93de3009 --- /dev/null +++ b/plugins/Kaleidoscope-Chord/src/Kaleidoscope-Chord.h @@ -0,0 +1,21 @@ +/* -*- mode: c++ -*- + * Kaleidoscope-Chord -- Respond to chords of keys as a single keystroke + * Copyright (C) 2023 Lisa Ugray + * + * 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 3 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 "kaleidoscope/plugin/Chord.h" // IWYU pragma: export diff --git a/plugins/Kaleidoscope-Chord/src/kaleidoscope/plugin/Chord.cpp b/plugins/Kaleidoscope-Chord/src/kaleidoscope/plugin/Chord.cpp new file mode 100644 index 0000000000..ad63fb7d2b --- /dev/null +++ b/plugins/Kaleidoscope-Chord/src/kaleidoscope/plugin/Chord.cpp @@ -0,0 +1,189 @@ +/* -*- mode: c++ -*- + * Kaleidoscope-Chord -- Respond to chords of keys as a single keystroke + * Copyright (C) 2023 Lisa Ugray + * + * 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 3 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 "kaleidoscope/plugin/Chord.h" + +#include // for PROGMEM +#include // for uint8_t, uint16_t, int8_t + +#include "kaleidoscope/KeyAddr.h" // for KeyAddr +#include "kaleidoscope/KeyEvent.h" // for KeyEvent +#include "kaleidoscope/KeyEventTracker.h" // for KeyEventTracker +#include "kaleidoscope/event_handler_result.h" // for EventHandlerResult +#include "kaleidoscope/plugin.h" // for Plugin +#include "kaleidoscope/key_defs.h" // for Key, Key_Transparent +#include "kaleidoscope/progmem_helpers.h" // for cloneFromProgmem +#include "kaleidoscope/keyswitch_state.h" // for keyToggledOn +#include "kaleidoscope/Runtime.h" // for Runtime + +namespace kaleidoscope { +namespace plugin { +EventHandlerResult Chord::onKeyswitchEvent(KeyEvent &event) { + if (event_tracker_.shouldIgnore(event)) { + return EventHandlerResult::OK; + } + + if (!keyToggledOn(event.state)) { + resolveOrArpeggiate(); + return EventHandlerResult::OK; + } + + appendEvent(event); + + if (isChordStrictSubset()) { + start_time_ = Runtime.millisAtCycleStart(); + return EventHandlerResult::ABORT; + } + + Key target_key = getChord(); + + if (target_key == Key_NoKey) { + potential_chord_size_--; + resolveOrArpeggiate(); + return EventHandlerResult::OK; + } + + potential_chord_size_ = 0; + event.key = target_key; + return EventHandlerResult::OK; +} + +EventHandlerResult Chord::afterEachCycle() { + if (Runtime.hasTimeExpired(start_time_, timeout_) && potential_chord_size_ > 0) { + resolveOrArpeggiate(); + } + return EventHandlerResult::OK; +} + +void Chord::setTimeout(uint8_t timeout) { + timeout_ = timeout; +} + +void Chord::resolveOrArpeggiate() { + Key target_key = getChord(); + if (target_key == Key_NoKey) { + arpeggiate(); + } else { + resolve(target_key); + } +} + +void Chord::resolve(Key target_key) { + KeyEvent event = potential_chord_[potential_chord_size_ - 1]; + potential_chord_size_ = 0; + + KeyEventId stored_id = event.id(); + KeyEvent restored_event = KeyEvent(event.addr, event.state, target_key, stored_id); + Runtime.handleKeyEvent(restored_event); +} + +bool Chord::inChord(uint8_t index, Key key) { + for (uint8_t i = index; index < chord_defs_size_ - 1; i++) { + Key chord_key = cloneFromProgmem(chord_defs_[i]); + if (chord_key == Key_NoKey) { + return false; + } + if (chord_key == key) { + return true; + } + } + return false; +} + +bool Chord::isChordStrictSubset() { + uint8_t c = 0; + while (c < chord_defs_size_) { + uint8_t cs = chordSize(c); + if (isChordStrictSubsetOf(c, cs)) { + return true; + } + c += cs + 2; + } + return false; +} + +bool Chord::isChordStrictSubsetOf(uint8_t c, uint8_t cs) { + if (cs <= potential_chord_size_) { + return false; + } + for (uint8_t i = 0; i < potential_chord_size_; i++) { + if (!inChord(c, potential_chord_[i].key)) { + return false; + } + } + return true; +} + +uint8_t Chord::chordSize(uint8_t index) { + uint8_t size = 0; + uint8_t i = index; + while (i < chord_defs_size_ - 1) { + Key key = cloneFromProgmem(chord_defs_[i]); + if (key == Key_NoKey) { + break; + } + size++; + i++; + } + return size; +} + +bool Chord::isChord(uint8_t c, uint8_t cs) { + if (cs != potential_chord_size_) { + return false; + } + for (uint8_t i = 0; i < potential_chord_size_; i++) { + if (!inChord(c, potential_chord_[i].key)) { + return false; + } + } + return true; +} + +Key Chord::getChord() { + uint8_t c = 0; + while (c < chord_defs_size_) { + uint8_t cs = chordSize(c); + if (isChord(c, cs)) { + return cloneFromProgmem(chord_defs_[c + cs + 1]); + } + c += cs + 2; + } + return Key_NoKey; +} + +void Chord::appendEvent(KeyEvent event) { + if (potential_chord_size_ < kMaxChordSize) { + potential_chord_[potential_chord_size_] = event; + potential_chord_size_++; + } +} + +void Chord::arpeggiate() { + for (uint8_t i = 0; i < potential_chord_size_; i++) { + KeyEvent event = potential_chord_[i]; + KeyEventId stored_id = event.id(); + KeyEvent restored_event = KeyEvent(event.addr, event.state, event.key, stored_id); + Runtime.handleKeyEvent(restored_event); + } + potential_chord_size_ = 0; +} +} // namespace plugin +} // namespace kaleidoscope + +kaleidoscope::plugin::Chord Chord; diff --git a/plugins/Kaleidoscope-Chord/src/kaleidoscope/plugin/Chord.h b/plugins/Kaleidoscope-Chord/src/kaleidoscope/plugin/Chord.h new file mode 100644 index 0000000000..000a3e01e2 --- /dev/null +++ b/plugins/Kaleidoscope-Chord/src/kaleidoscope/plugin/Chord.h @@ -0,0 +1,81 @@ +/* -*- mode: c++ -*- + * Kaleidoscope-Chord -- Respond to chords of keys as a single keystroke + * Copyright (C) 2023 Lisa Ugray + * + * 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 3 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 // for PROGMEM +#include // for uint8_t, uint16_t, int8_t + +#include "kaleidoscope/KeyAddr.h" // for KeyAddr +#include "kaleidoscope/KeyEvent.h" // for KeyEvent +#include "kaleidoscope/KeyEventTracker.h" // for KeyEventTracker +#include "kaleidoscope/event_handler_result.h" // for EventHandlerResult +#include "kaleidoscope/plugin.h" // for Plugin +#include "kaleidoscope/key_defs.h" // for Key, Key_Transparent +#include "kaleidoscope/progmem_helpers.h" // for cloneFromProgmem + + +namespace kaleidoscope { +namespace plugin { +class Chord : public kaleidoscope::Plugin { + public: + EventHandlerResult onKeyswitchEvent(KeyEvent &event); + EventHandlerResult afterEachCycle(); + void setTimeout(uint8_t timeout); + + template + void configure(Key const (&chords)[_chord_defs_size]) { + chord_defs_ = chords; + chord_defs_size_ = _chord_defs_size; + } + + private: + void resolveOrArpeggiate(); + void resolve(Key target_key); + bool inChord(uint8_t index, Key key); + bool isChordStrictSubset(); + bool isChordStrictSubsetOf(uint8_t c, uint8_t cs); + uint8_t chordSize(uint8_t index); + bool isChord(uint8_t c, uint8_t cs); + Key getChord(); + void appendEvent(KeyEvent event); + void arpeggiate(); + + KeyEventTracker event_tracker_; + uint16_t start_time_; + + static constexpr uint8_t kMaxChordSize{10}; + KeyEvent potential_chord_[kMaxChordSize]; + uint8_t potential_chord_size_{0}; + + Key const *chord_defs_{nullptr}; + uint8_t chord_defs_size_{0}; + + uint8_t timeout_ = 50; +}; +} // namespace plugin +} // namespace kaleidoscope + +extern kaleidoscope::plugin::Chord Chord; + +#define CHORDS(chord_defs...) \ + { \ + static Key const chord_def[] PROGMEM = {chord_defs}; \ + Chord.configure(chord_def); \ + } +#define CHORD(chord_keys...) chord_keys, Key_NoKey From 4791bf6ee6d7d16e4e5c16ae9bcb412e8e338556 Mon Sep 17 00:00:00 2001 From: Lisa Ugray Date: Wed, 29 Mar 2023 10:28:48 -0400 Subject: [PATCH 2/2] Add example sketch and tests Signed-off-by: Lisa Ugray --- examples/Keystrokes/Chord/Chord.ino | 43 ++++++++++++++ examples/Keystrokes/Chord/sketch.json | 6 ++ examples/Keystrokes/Chord/sketch.yaml | 1 + plugins/Kaleidoscope-Chord/README.md | 4 ++ tests/plugins/Chord/basic/basic.ino | 56 ++++++++++++++++++ tests/plugins/Chord/basic/common.h | 27 +++++++++ tests/plugins/Chord/basic/sketch.json | 6 ++ tests/plugins/Chord/basic/sketch.yaml | 1 + tests/plugins/Chord/basic/test.ktest | 85 +++++++++++++++++++++++++++ 9 files changed, 229 insertions(+) create mode 100644 examples/Keystrokes/Chord/Chord.ino create mode 100644 examples/Keystrokes/Chord/sketch.json create mode 100644 examples/Keystrokes/Chord/sketch.yaml create mode 100644 tests/plugins/Chord/basic/basic.ino create mode 100644 tests/plugins/Chord/basic/common.h create mode 100644 tests/plugins/Chord/basic/sketch.json create mode 100644 tests/plugins/Chord/basic/sketch.yaml create mode 100644 tests/plugins/Chord/basic/test.ktest diff --git a/examples/Keystrokes/Chord/Chord.ino b/examples/Keystrokes/Chord/Chord.ino new file mode 100644 index 0000000000..cc320d4114 --- /dev/null +++ b/examples/Keystrokes/Chord/Chord.ino @@ -0,0 +1,43 @@ +// -*- mode: c++ -*- + +#include +#include "Kaleidoscope-TopsyTurvy.h" +#include + +// clang-format off +KEYMAPS( + [0] = KEYMAP_STACKED + ( + Key_NoKey, Key_1, Key_2, Key_3, Key_4, Key_5, Key_NoKey, + Key_Backtick, Key_Q, Key_W, Key_E, Key_R, Key_T, Key_Tab, + Key_PageUp, Key_A, Key_S, Key_D, Key_F, Key_G, + Key_PageDown, Key_Z, Key_X, Key_C, Key_V, Key_B, Key_Escape, + + Key_LeftControl, Key_Backspace, Key_LeftGui, Key_LeftShift, + Key_NoKey, + + Key_NoKey, Key_6, Key_7, Key_8, Key_9, Key_0, Key_skip, + Key_Enter, Key_Y, Key_U, Key_I, Key_O, Key_P, Key_Equals, + Key_H, Key_J, Key_K, Key_L, Key_Semicolon, Key_Quote, + Key_skip, Key_N, Key_M, Key_Comma, Key_Period, Key_Slash, Key_Minus, + + Key_RightShift, Key_RightAlt, Key_Spacebar, Key_RightControl, + Key_NoKey + ), +) + +KALEIDOSCOPE_INIT_PLUGINS(TopsyTurvy, Chord); + +void setup() { + CHORDS( + CHORD(Key_J, Key_K), Key_Escape, + CHORD(Key_D, Key_F), Key_LeftShift, + CHORD(Key_S, Key_D), TOPSY(Semicolon), + CHORD(Key_S, Key_D, Key_F), Key_Spacebar, + ) + Kaleidoscope.setup(); +} + +void loop() { + Kaleidoscope.loop(); +} diff --git a/examples/Keystrokes/Chord/sketch.json b/examples/Keystrokes/Chord/sketch.json new file mode 100644 index 0000000000..884ed009eb --- /dev/null +++ b/examples/Keystrokes/Chord/sketch.json @@ -0,0 +1,6 @@ +{ + "cpu": { + "fqbn": "keyboardio:avr:model01", + "port": "" + } +} diff --git a/examples/Keystrokes/Chord/sketch.yaml b/examples/Keystrokes/Chord/sketch.yaml new file mode 100644 index 0000000000..9902e2986c --- /dev/null +++ b/examples/Keystrokes/Chord/sketch.yaml @@ -0,0 +1 @@ +default_fqbn: keyboardio:avr:model01 diff --git a/plugins/Kaleidoscope-Chord/README.md b/plugins/Kaleidoscope-Chord/README.md index d0cad32a26..622184c05e 100644 --- a/plugins/Kaleidoscope-Chord/README.md +++ b/plugins/Kaleidoscope-Chord/README.md @@ -51,3 +51,7 @@ chord is held. > wait for timeout, F), since otherwise it would be interpreted as a space. > > Defaults to `50`. + +## Further reading + +The [example](/examples/Keystrokes/Chord/Chord.ino) can help to learn how to use this plugin. diff --git a/tests/plugins/Chord/basic/basic.ino b/tests/plugins/Chord/basic/basic.ino new file mode 100644 index 0000000000..c19962b522 --- /dev/null +++ b/tests/plugins/Chord/basic/basic.ino @@ -0,0 +1,56 @@ +/* -*- mode: c++ -*- + * Copyright (C) 2020 Keyboard.io, Inc. + * + * 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, version 3. + * + * 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 +#include + +#include "./common.h" + +// *INDENT-OFF* +KEYMAPS( + [0] = KEYMAP_STACKED + ( + Key_A, Key_B, Key_C, Key_D, ___, ___, ___, + ___, ___, ___, ___, ___, ___, ___, + ___, ___, ___, ___, ___, ___, + ___, ___, ___, ___, ___, ___, ___, + ___, ___, ___, ___, + ___, + + ___, ___, ___, ___, ___, ___, ___, + ___, ___, ___, ___, ___, ___, ___, + ___, ___, ___, ___, ___, ___, + ___, ___, ___, ___, ___, ___, ___, + ___, ___, ___, ___, + ___ + ), +) +// *INDENT-ON* + +KALEIDOSCOPE_INIT_PLUGINS(Chord); + +void setup() { + Kaleidoscope.setup(); + CHORDS( + CHORD(Key_A, Key_B), Key_E, + CHORD(Key_B, Key_C), Key_LeftShift, + CHORD(Key_A, Key_B, Key_C), Key_F, + ) +} + +void loop() { + Kaleidoscope.loop(); +} diff --git a/tests/plugins/Chord/basic/common.h b/tests/plugins/Chord/basic/common.h new file mode 100644 index 0000000000..dcfcc35b25 --- /dev/null +++ b/tests/plugins/Chord/basic/common.h @@ -0,0 +1,27 @@ +// -*- mode: c++ -*- + +/* Kaleidoscope - Firmware for computer input devices + * Copyright (C) 2020 Keyboard.io, Inc. + * + * 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, version 3. + * + * 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 + +namespace kaleidoscope { +namespace testing { + +} // namespace testing +} // namespace kaleidoscope diff --git a/tests/plugins/Chord/basic/sketch.json b/tests/plugins/Chord/basic/sketch.json new file mode 100644 index 0000000000..43dc4c7e2d --- /dev/null +++ b/tests/plugins/Chord/basic/sketch.json @@ -0,0 +1,6 @@ +{ + "cpu": { + "fqbn": "keyboardio:virtual:model01", + "port": "" + } +} \ No newline at end of file diff --git a/tests/plugins/Chord/basic/sketch.yaml b/tests/plugins/Chord/basic/sketch.yaml new file mode 100644 index 0000000000..4d94810065 --- /dev/null +++ b/tests/plugins/Chord/basic/sketch.yaml @@ -0,0 +1 @@ +default_fqbn: keyboardio:virtual:model01 diff --git a/tests/plugins/Chord/basic/test.ktest b/tests/plugins/Chord/basic/test.ktest new file mode 100644 index 0000000000..cc867fbc02 --- /dev/null +++ b/tests/plugins/Chord/basic/test.ktest @@ -0,0 +1,85 @@ +VERSION 1 + +KEYSWITCH A 0 0 +KEYSWITCH B 0 1 +KEYSWITCH C 0 2 +KEYSWITCH D 0 3 + +# ============================================================================== +NAME Chord simple +RUN 5 ms +PRESS A +RUN 1 cycle +PRESS B +RUN 1 cycle +RELEASE A +RUN 1 cycle +EXPECT keyboard-report Key_E # Report should contain E +RELEASE B +RUN 1 cycle +EXPECT keyboard-report empty # Report should be empty + +NAME Chord timeout +RUN 5 ms +PRESS A +RUN 1 cycle +PRESS B +RUN 1 cycle +RUN 50 ms +EXPECT keyboard-report Key_E # Report should contain E +RELEASE A +RUN 1 cycle +RELEASE B +RUN 1 cycle +EXPECT keyboard-report empty # Report should be empty + +NAME Chord Modifier +RUN 5 ms +PRESS B +RUN 1 cycle +PRESS C +RUN 1 cycle +PRESS D +RUN 1 cycle +EXPECT keyboard-report Key_LeftShift # Report should contain shift +EXPECT keyboard-report Key_LeftShift Key_D # Report should contain shift + D +RELEASE D +RUN 1 cycle +EXPECT keyboard-report Key_LeftShift # Report should contain only shift again +RELEASE C +RUN 1 cycle +EXPECT keyboard-report empty # Report should be empty +RELEASE B + +NAME Chord Modifier Timeout +RUN 5 ms +PRESS B +RUN 1 cycle +PRESS C +RUN 1 cycle +RUN 50 ms +EXPECT keyboard-report Key_LeftShift # Report should contain shift +PRESS A +RUN 1 cycle +RELEASE A +RUN 1 cycle +EXPECT keyboard-report Key_LeftShift Key_A # Report should contain shift + A +EXPECT keyboard-report Key_LeftShift # Report should contain only shift again +RELEASE C +RUN 1 cycle +EXPECT keyboard-report empty # Report should be empty +RELEASE B + +NAME Chord not a subset +RUN 5 ms +PRESS A +RUN 1 cycle +PRESS B +RUN 1 cycle +PRESS C +RUN 1 cycle +EXPECT keyboard-report Key_F # Report should contain F without release/timeout +RUN 100 ms # F should remain held +RELEASE C +RUN 1 cycle +EXPECT keyboard-report empty # Report should be empty