Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add configurable key press delay #1633

Merged
merged 12 commits into from
Sep 27, 2023
Merged
1 change: 1 addition & 0 deletions news.d/api/1633.new.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Introduces the `GenericKeyboardEmulation` interface which automatically handles output delay.
1 change: 1 addition & 0 deletions news.d/feature/1633.core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a configurable delay between key presses, to accommodate applications that can't handle fast keyboard emulation.
11 changes: 8 additions & 3 deletions plover/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 '∞')
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions plover/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion plover/gui_qt/config_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'), (
Expand Down
32 changes: 23 additions & 9 deletions plover/oslayer/linux/keyboardcontrol_x11.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import os
import select
import threading
from time import sleep

from Xlib import X, XK
from Xlib.display import Display
Expand All @@ -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.
Expand Down Expand Up @@ -1127,7 +1128,7 @@ def keysym_to_string(keysym):
return chr(code)


class KeyboardEmulation(Output):
class KeyboardEmulation(GenericKeyboardEmulation):

class Mapping:

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
16 changes: 7 additions & 9 deletions plover/oslayer/osx/keyboardcontrol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -302,17 +302,16 @@ def _run(self):
self.key_down(key)


class KeyboardEmulation(Output):
class KeyboardEmulation(GenericKeyboardEmulation):

RAW_PRESS, STRING_PRESS = range(2)

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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
10 changes: 5 additions & 5 deletions plover/oslayer/windows/keyboardcontrol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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__()
Expand Down Expand Up @@ -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)
Expand All @@ -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)
4 changes: 4 additions & 0 deletions plover/output/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
25 changes: 25 additions & 0 deletions plover/output/keyboard.py
Original file line number Diff line number Diff line change
@@ -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()
56 changes: 56 additions & 0 deletions test/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,17 @@ def test_config_dict():
None,
),

('invalid_options_3',
'''
[Output Configuration]
undo_levels = foobar
''',
DEFAULTS,
{},
{},
None,
),

('invalid_update_1',
'''
[Translation Frame]
Expand Down Expand Up @@ -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`.
('''
Expand Down
6 changes: 5 additions & 1 deletion test/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down