Skip to content

Commit

Permalink
Merge pull request #1633 from sammdot/key-press-delay
Browse files Browse the repository at this point in the history
Add configurable key press delay
  • Loading branch information
sammdot committed Sep 27, 2023
2 parents 6949a2a + a127694 commit d037605
Show file tree
Hide file tree
Showing 12 changed files with 148 additions and 28 deletions.
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

0 comments on commit d037605

Please sign in to comment.