diff --git a/news.d/api/1633.new.md b/news.d/api/1633.new.md new file mode 100644 index 000000000..510033df6 --- /dev/null +++ b/news.d/api/1633.new.md @@ -0,0 +1 @@ +Introduces the `GenericKeyboardEmulation` interface which automatically handles output delay. diff --git a/news.d/feature/1633.core.md b/news.d/feature/1633.core.md new file mode 100644 index 000000000..fbae304b0 --- /dev/null +++ b/news.d/feature/1633.core.md @@ -0,0 +1 @@ +Added a configurable delay between key presses, to accommodate applications that can't handle fast keyboard emulation. diff --git a/plover/config.py b/plover/config.py index fac9048e1..7ae6c3330 100644 --- a/plover/config.py +++ b/plover/config.py @@ -26,6 +26,8 @@ OUTPUT_CONFIG_SECTION = 'Output Configuration' DEFAULT_UNDO_LEVELS = 100 MINIMUM_UNDO_LEVELS = 1 +DEFAULT_TIME_BETWEEN_KEY_PRESSES = 0 +MINIMUM_TIME_BETWEEN_KEY_PRESSES = 0 DEFAULT_SYSTEM_NAME = 'English Stenotype' @@ -105,12 +107,14 @@ def setter(config, key, value): def int_option(name, default, minimum, maximum, section, option=None): option = option or name def getter(config, key): - return config._config[section].getint(option) + return config._config[section][option] def setter(config, key, value): config._set(section, option, str(value)) def validate(config, key, value): - if not isinstance(value, int): - raise InvalidConfigOption(value, default) + try: + value = int(value) + except ValueError as e: + raise InvalidConfigOption(value, default) from e if (minimum is not None and value < minimum) or \ (maximum is not None and value > maximum): message = '%s not in [%s, %s]' % (value, minimum or '-∞', maximum or '∞') @@ -333,6 +337,7 @@ def _set(self, section, option, value): boolean_option('start_attached', False, OUTPUT_CONFIG_SECTION), boolean_option('start_capitalized', False, OUTPUT_CONFIG_SECTION), int_option('undo_levels', DEFAULT_UNDO_LEVELS, MINIMUM_UNDO_LEVELS, None, OUTPUT_CONFIG_SECTION), + int_option('time_between_key_presses', DEFAULT_TIME_BETWEEN_KEY_PRESSES, MINIMUM_TIME_BETWEEN_KEY_PRESSES, None, OUTPUT_CONFIG_SECTION), # Logging. path_option('log_file_name', expand_path('strokes.log'), LOGGING_CONFIG_SECTION, 'log_file'), boolean_option('enable_stroke_logging', False, LOGGING_CONFIG_SECTION), diff --git a/plover/engine.py b/plover/engine.py index bc47d669c..cc83c5259 100644 --- a/plover/engine.py +++ b/plover/engine.py @@ -211,6 +211,7 @@ def _update(self, config_update=None, full=False, reset_machine=False): self._formatter.start_attached = config['start_attached'] self._formatter.start_capitalized = config['start_capitalized'] self._translator.set_min_undo_length(config['undo_levels']) + self._keyboard_emulation.set_key_press_delay(config['time_between_key_presses']) # Update system. system_name = config['system_name'] if system.NAME != system_name: diff --git a/plover/gui_qt/config_window.py b/plover/gui_qt/config_window.py index 7cc4b6948..544e372ad 100644 --- a/plover/gui_qt/config_window.py +++ b/plover/gui_qt/config_window.py @@ -26,7 +26,7 @@ ) from plover import _ -from plover.config import MINIMUM_UNDO_LEVELS +from plover.config import MINIMUM_UNDO_LEVELS, MINIMUM_TIME_BETWEEN_KEY_PRESSES from plover.misc import expand_path, shorten_path from plover.registry import registry @@ -381,6 +381,17 @@ def __init__(self, engine): '\n' 'Note: the effective value will take into account the\n' 'dictionaries entry with the maximum number of strokes.')), + ConfigOption(_('Time between key presses:'), 'time_between_key_presses', + partial(IntOption, + maximum=100000, + minimum=MINIMUM_TIME_BETWEEN_KEY_PRESSES), + _('Set the delay between emulated key presses (in milliseconds).\n' + '\n' + 'Some programs may drop key presses if too many are sent\n' + 'within a short period of time. Increasing the delay gives\n' + 'programs time to process each key press.\n' + 'Setting the delay too high will negatively impact the\n' + 'performance of key stroke output.')), )), # i18n: Widget: “ConfigWindow”. (_('Plugins'), ( diff --git a/plover/oslayer/linux/keyboardcontrol_x11.py b/plover/oslayer/linux/keyboardcontrol_x11.py index 1482f0f8d..a60eb4c25 100644 --- a/plover/oslayer/linux/keyboardcontrol_x11.py +++ b/plover/oslayer/linux/keyboardcontrol_x11.py @@ -24,6 +24,7 @@ import os import select import threading +from time import sleep from Xlib import X, XK from Xlib.display import Display @@ -33,7 +34,7 @@ from plover import log from plover.key_combo import add_modifiers_aliases, parse_key_combo from plover.machine.keyboard_capture import Capture -from plover.output import Output +from plover.output.keyboard import GenericKeyboardEmulation # Enable support for media keys. @@ -1127,7 +1128,7 @@ def keysym_to_string(keysym): return chr(code) -class KeyboardEmulation(Output): +class KeyboardEmulation(GenericKeyboardEmulation): class Mapping: @@ -1217,20 +1218,33 @@ def _update_keymap(self): self.modifier_mapping = self._display.get_modifier_mapping() def send_backspaces(self, count): - for x in range(count): + for x in self.with_delay(range(count)): self._send_keycode(self._backspace_mapping.keycode, self._backspace_mapping.modifiers) - self._display.sync() + self._display.sync() def send_string(self, string): for char in string: keysym = uchr_to_keysym(char) - mapping = self._get_mapping(keysym) + # TODO: can we find mappings for multiple keys at a time? + mapping = self._get_mapping(keysym, automatically_map=False) + mapping_changed = False if mapping is None: - continue + mapping = self._get_mapping(keysym, automatically_map=True) + if mapping is None: + continue + self._display.sync() + self.half_delay() + mapping_changed = True + self._send_keycode(mapping.keycode, mapping.modifiers) - self._display.sync() + + self._display.sync() + if mapping_changed: + self.half_delay() + else: + self.delay() def send_key_combination(self, combo): # Parse and validate combo. @@ -1239,9 +1253,9 @@ def send_key_combination(self, combo): in parse_key_combo(combo, self._get_keycode_from_keystring) ] # Emulate the key combination by sending key events. - for keycode, event_type in key_events: + for keycode, event_type in self.with_delay(key_events): xtest.fake_input(self._display, event_type, keycode) - self._display.sync() + self._display.sync() def _send_keycode(self, keycode, modifiers=0): """Emulate a key press and release. diff --git a/plover/oslayer/osx/keyboardcontrol.py b/plover/oslayer/osx/keyboardcontrol.py index f3d52f929..f230ffb92 100644 --- a/plover/oslayer/osx/keyboardcontrol.py +++ b/plover/oslayer/osx/keyboardcontrol.py @@ -46,7 +46,7 @@ from plover import log from plover.key_combo import add_modifiers_aliases, parse_key_combo, KEYNAME_TO_CHAR from plover.machine.keyboard_capture import Capture -from plover.output import Output +from plover.output.keyboard import GenericKeyboardEmulation from .keyboardlayout import KeyboardLayout @@ -302,7 +302,7 @@ def _run(self): self.key_down(key) -class KeyboardEmulation(Output): +class KeyboardEmulation(GenericKeyboardEmulation): RAW_PRESS, STRING_PRESS = range(2) @@ -310,9 +310,8 @@ def __init__(self): super().__init__() self._layout = KeyboardLayout() - @staticmethod - def send_backspaces(count): - for _ in range(count): + def send_backspaces(self, count): + for _ in self.with_delay(range(count)): backspace_down = CGEventCreateKeyboardEvent( OUTPUT_SOURCE, BACK_SPACE, True) backspace_up = CGEventCreateKeyboardEvent( @@ -361,7 +360,7 @@ def apply_raw(): apply_raw() # We have a key plan for the whole string, grouping modifiers. - for press_type, sequence in key_plan: + for press_type, sequence in self.with_delay(key_plan): if press_type is self.STRING_PRESS: self._send_string_press(sequence) elif press_type is self.RAW_PRESS: @@ -435,8 +434,7 @@ def _get_media_event(key_id, key_down): -1 ).CGEvent() - @staticmethod - def _send_sequence(sequence): + def _send_sequence(self, sequence): # There is a bug in the event system that seems to cause inconsistent # modifiers on key events: # http://stackoverflow.com/questions/2008126/cgeventpost-possible-bug-when-simulating-keyboard-events @@ -445,7 +443,7 @@ def _send_sequence(sequence): # If mods_flags is not zero at the end then bad things might happen. mods_flags = 0 - for keycode, key_down in sequence: + for keycode, key_down in self.with_delay(sequence): if keycode >= NX_KEY_OFFSET: # Handle media (NX) key. event = KeyboardEmulation._get_media_event( diff --git a/plover/oslayer/windows/keyboardcontrol.py b/plover/oslayer/windows/keyboardcontrol.py index 14067456d..bb8db9e7b 100644 --- a/plover/oslayer/windows/keyboardcontrol.py +++ b/plover/oslayer/windows/keyboardcontrol.py @@ -26,7 +26,7 @@ from plover.key_combo import parse_key_combo from plover.machine.keyboard_capture import Capture from plover.misc import to_surrogate_pair -from plover.output import Output +from plover.output.keyboard import GenericKeyboardEmulation from .keyboardlayout import KeyboardLayout @@ -425,7 +425,7 @@ def suppress(self, suppressed_keys=()): self._proc.suppress(self._suppressed_keys) -class KeyboardEmulation(Output): +class KeyboardEmulation(GenericKeyboardEmulation): def __init__(self): super().__init__() @@ -498,12 +498,12 @@ def _key_unicode(self, char): self._send_input(*inputs) def send_backspaces(self, count): - for _ in range(count): + for _ in self.with_delay(range(count)): self._key_press('\x08') def send_string(self, string): self._refresh_keyboard_layout() - for char in string: + for char in self.with_delay(string): if char in self.keyboard_layout.char_to_vk_ss: # We know how to simulate the character. self._key_press(char) @@ -517,5 +517,5 @@ def send_key_combination(self, combo): # Parse and validate combo. key_events = parse_key_combo(combo, self.keyboard_layout.keyname_to_vk.get) # Send events... - for keycode, pressed in key_events: + for keycode, pressed in self.with_delay(key_events): self._key_event(keycode, pressed) diff --git a/plover/output/__init__.py b/plover/output/__init__.py index 0546696af..ffb04fd3f 100644 --- a/plover/output/__init__.py +++ b/plover/output/__init__.py @@ -16,3 +16,7 @@ def send_key_combination(self, combo): See `plover.key_combo` for the format of the `combo` string. """ raise NotImplementedError() + + def set_key_press_delay(self, delay_ms): + """Sets the delay between outputting key press events.""" + raise NotImplementedError() diff --git a/plover/output/keyboard.py b/plover/output/keyboard.py new file mode 100644 index 000000000..740f0e6ca --- /dev/null +++ b/plover/output/keyboard.py @@ -0,0 +1,25 @@ +from time import sleep + +from plover.output import Output + + +class GenericKeyboardEmulation(Output): + def __init__(self): + super().__init__() + self._key_press_delay_ms = 0 + + def set_key_press_delay(self, delay_ms): + self._key_press_delay_ms = delay_ms + + def delay(self): + if self._key_press_delay_ms > 0: + sleep(self._key_press_delay_ms / 1000) + + def half_delay(self): + if self._key_press_delay_ms > 0: + sleep(self._key_press_delay_ms / 2000) + + def with_delay(self, iterable): + for item in iterable: + yield item + self.delay() diff --git a/test/test_config.py b/test/test_config.py index 70e6705c5..24577de24 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -396,6 +396,17 @@ def test_config_dict(): None, ), + ('invalid_options_3', + ''' + [Output Configuration] + undo_levels = foobar + ''', + DEFAULTS, + {}, + {}, + None, + ), + ('invalid_update_1', ''' [Translation Frame] @@ -507,6 +518,51 @@ def test_config(original_contents, original_config, assert config_file.read_text(encoding='utf-8').strip() == dedent_strip(resulting_contents) +CONFIG_MISSING_INTS_TESTS = ( + ('int_option', + config.OUTPUT_CONFIG_SECTION, + 'undo_levels', + config.DEFAULT_UNDO_LEVELS, + ), + + ('opacity_option', + 'Translation Frame', + 'translation_frame_opacity', + 100, + ), +) + + +@pytest.mark.parametrize(('which_section', 'which_option', 'fixed_value'), + [t[1:] for t in CONFIG_MISSING_INTS_TESTS], + ids=[t[0] for t in CONFIG_MISSING_INTS_TESTS]) +def test_config_missing_ints(which_section, which_option, fixed_value, + monkeypatch, tmpdir, caplog): + registry = Registry() + registry.register_plugin('machine', 'Keyboard', Keyboard) + registry.register_plugin('system', 'English Stenotype', english_stenotype) + monkeypatch.setattr('plover.config.registry', registry) + config_file = tmpdir / 'config.cfg' + + # Make config with the appropriate empty section + contents = f''' + [{which_section}] + ''' + config_file.write_text(contents, encoding='utf-8') + cfg = config.Config(config_file.strpath) + cfg.load() + + # Try to access an option under that section + # (should trigger validation) + assert cfg[which_option] == fixed_value + + # Ensure that missing options are handled + assert 'InvalidConfigOption: None' not in caplog.text + # ... or any that there aren't any unhandled errors + for record in caplog.records: + assert record.levelname != 'ERROR' + + CONFIG_DIR_TESTS = ( # Default to `user_config_dir`. (''' diff --git a/test/test_engine.py b/test/test_engine.py index dfb9535b5..1809341a4 100644 --- a/test/test_engine.py +++ b/test/test_engine.py @@ -16,6 +16,7 @@ from plover.machine.keymap import Keymap from plover.misc import normalize_path from plover.oslayer.controller import Controller +from plover.output import Output from plover.registry import Registry from plover.steno_dictionary import StenoDictionaryCollection @@ -50,7 +51,7 @@ def stop_capture(self): def set_suppression(self, enabled): self.is_suppressed = enabled -class FakeKeyboardEmulation: +class FakeKeyboardEmulation(Output): def send_backspaces(self, b): pass @@ -61,6 +62,9 @@ def send_string(self, s): def send_key_combination(self, c): pass + def set_key_press_delay(self, delay_ms): + pass + class FakeEngine(StenoEngine): def __init__(self, *args, **kwargs):