diff --git a/news.d/feature/1611.core.md b/news.d/feature/1611.core.md new file mode 100644 index 000000000..8db10aae6 --- /dev/null +++ b/news.d/feature/1611.core.md @@ -0,0 +1 @@ +Implement first-up chord send for keyboard machine. diff --git a/plover/gui_qt/config_keyboard_widget.ui b/plover/gui_qt/config_keyboard_widget.ui index d2b987210..d8afb70c9 100644 --- a/plover/gui_qt/config_keyboard_widget.ui +++ b/plover/gui_qt/config_keyboard_widget.ui @@ -6,14 +6,25 @@ 0 0 - 117 - 38 + 159 + 66 - + + + + + When the first key in a chord is released, the chord is sent. +If the key is pressed and released again, another chord is sent. + + + First-up chord send + + + @@ -47,8 +58,25 @@ + + first_up_chord_send + clicked(bool) + KeyboardWidget + on_first_up_chord_send_changed(bool) + + + 79 + 46 + + + 79 + 32 + + + on_arpeggiate_changed(bool) + on_first_up_chord_send_changed(bool) diff --git a/plover/gui_qt/machine_options.py b/plover/gui_qt/machine_options.py index 3c6b69349..3a63fa14e 100644 --- a/plover/gui_qt/machine_options.py +++ b/plover/gui_qt/machine_options.py @@ -214,7 +214,12 @@ def __init__(self): def setValue(self, value): self._value = copy(value) self.arpeggiate.setChecked(value['arpeggiate']) + self.first_up_chord_send.setChecked(value['first_up_chord_send']) def on_arpeggiate_changed(self, value): self._value['arpeggiate'] = value self.valueChanged.emit(self._value) + + def on_first_up_chord_send_changed(self, value): + self._value['first_up_chord_send'] = value + self.valueChanged.emit(self._value) diff --git a/plover/machine/keyboard.py b/plover/machine/keyboard.py index bced4c060..7a8a084a7 100644 --- a/plover/machine/keyboard.py +++ b/plover/machine/keyboard.py @@ -38,11 +38,20 @@ def __init__(self, params): """Monitor the keyboard's events.""" super().__init__() self._arpeggiate = params['arpeggiate'] + self._first_up_chord_send = params['first_up_chord_send'] + if self._arpeggiate and self._first_up_chord_send: + self._error() + raise RuntimeError("Arpeggiate and first-up chord send cannot both be enabled!") self._is_suppressed = False # Currently held keys. self._down_keys = set() - # All keys part of the stroke. - self._stroke_keys = set() + if self._first_up_chord_send: + # If this is True, the first key in a stroke has already been released + # and subsequent key-up events should not send more strokes + self._chord_already_sent = False + else: + # Collect the keys in the stroke, in case first_up_chord_send is False + self._stroke_keys = set() self._keyboard_capture = None self._last_stroke_key_down_count = 0 self._stroke_key_down_count = 0 @@ -109,31 +118,46 @@ def _key_down(self, key): assert key is not None self._stroke_key_down_count += 1 self._down_keys.add(key) - self._stroke_keys.add(key) + if self._first_up_chord_send: + self._chord_already_sent = False + else: + self._stroke_keys.add(key) def _key_up(self, key): """Called when a key is released.""" assert key is not None + self._down_keys.discard(key) - # A stroke is complete if all pressed keys have been released, - # and — when arpeggiate mode is enabled — the arpeggiate key - # is part of it. - if ( - self._down_keys or - not self._stroke_keys or - (self._arpeggiate and self._arpeggiate_key not in self._stroke_keys) - ): - return + + if self._first_up_chord_send: + if self._chord_already_sent: + return + else: + # A stroke is complete if all pressed keys have been released, + # and — when arpeggiate mode is enabled — the arpeggiate key + # is part of it. + if ( + self._down_keys or + not self._stroke_keys or + (self._arpeggiate and self._arpeggiate_key not in self._stroke_keys) + ): + return + self._last_stroke_key_down_count = self._stroke_key_down_count - steno_keys = {self._bindings.get(k) for k in self._stroke_keys} + if self._first_up_chord_send: + steno_keys = {self._bindings.get(k) for k in self._down_keys | {key}} + self._chord_already_sent = True + else: + steno_keys = {self._bindings.get(k) for k in self._stroke_keys} + self._stroke_keys.clear() steno_keys -= {None} if steno_keys: self._notify(steno_keys) - self._stroke_keys.clear() self._stroke_key_down_count = 0 @classmethod def get_option_info(cls): return { 'arpeggiate': (False, boolean), + 'first_up_chord_send': (False, boolean), } diff --git a/test/test_command.py b/test/test_command.py index 64007ddc5..8eb91ace7 100644 --- a/test/test_command.py +++ b/test/test_command.py @@ -32,7 +32,7 @@ def config(self, options): lambda: ('"log_file_name":"c:/whatever/morestrokes.log"', "c:/whatever/morestrokes.log"), lambda: ('"enabled_extensions":[]', set()), lambda: ('"machine_type":"Keyboard"', "Keyboard"), - lambda: ('"machine_specific_options":{"arpeggiate":True}', {"arpeggiate": True}), + lambda: ('"machine_specific_options":{"arpeggiate":True}', {"arpeggiate": True, "first_up_chord_send": False}), lambda: ('"system_keymap":'+str(DEFAULT_KEYMAP), DEFAULT_KEYMAP), lambda: ('"dictionaries":("user.json","main.json")', list(map(DictionaryConfig, ("user.json", "main.json")))), ) diff --git a/test/test_config.py b/test/test_config.py index 8a06c4bbe..70e6705c5 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -127,7 +127,7 @@ def test_config_dict(): 'enabled_extensions': set(), 'auto_start': False, 'machine_type': 'Keyboard', - 'machine_specific_options': { 'arpeggiate': False }, + 'machine_specific_options': { 'arpeggiate': False, 'first_up_chord_send': False }, 'system_name': config.DEFAULT_SYSTEM_NAME, 'system_keymap': DEFAULT_KEYMAP, 'dictionaries': [DictionaryConfig(p) for p in english_stenotype.DEFAULT_DICTIONARIES] @@ -249,11 +249,13 @@ def test_config_dict(): { 'machine_specific_options': { 'arpeggiate': True, + 'first_up_chord_send': False, } }, ''' [Keyboard] arpeggiate = True + first_up_chord_send = False ''' ), diff --git a/test/test_keyboard.py b/test/test_keyboard.py index feafa0bbc..8fcccf92e 100644 --- a/test/test_keyboard.py +++ b/test/test_keyboard.py @@ -25,17 +25,21 @@ def capture(): with mock.patch('plover.machine.keyboard.KeyboardCapture', new=lambda: capture): yield capture -@pytest.fixture(params=[False]) +@pytest.fixture(params=[{'arpeggiate': False, 'first_up_chord_send': False}]) def machine(request, capture): - machine = Keyboard({'arpeggiate': request.param}) + machine = Keyboard(request.param) keymap = Keymap(Keyboard.KEYS_LAYOUT.split(), system.KEYS + Keyboard.ACTIONS) keymap.set_mappings(system.KEYMAPS['Keyboard']) machine.set_keymap(keymap) return machine -def arpeggiate(func): - return pytest.mark.parametrize('machine', [True], indirect=True)(func) +arpeggiate = pytest.mark.parametrize('machine', [{'arpeggiate': True, 'first_up_chord_send': False}], indirect=True) +first_up_chord_send = pytest.mark.parametrize('machine', [{'arpeggiate': False, 'first_up_chord_send': True}], indirect=True) +""" +These are decorators to be applied on test functions to modify the machine configuration. +Note that at the moment it's not possible to apply both at the same time. +""" @pytest.fixture def strokes(machine): @@ -97,3 +101,13 @@ def test_arpeggiate_2(capture, machine, strokes): machine.start_capture() send_input(capture, 'a +h +space -space -h w') assert strokes == [{'S-', '*'}] + +@first_up_chord_send +def test_first_up_chord_send(capture, machine, strokes): + machine.start_capture() + send_input(capture, '+a +w +l -l +l') + assert strokes == [{'S-', 'T-', '-G'}] + send_input(capture, '-l') + assert strokes == [{'S-', 'T-', '-G'}, {'S-', 'T-', '-G'}] + send_input(capture, '-a -w') + assert strokes == [{'S-', 'T-', '-G'}, {'S-', 'T-', '-G'}]