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'}]