diff --git a/.github/workflows/ci/skiplist_os_linux.txt b/.github/workflows/ci/skiplist_os_linux.txt index 6eaafbafb..3d82cce45 100644 --- a/.github/workflows/ci/skiplist_os_linux.txt +++ b/.github/workflows/ci/skiplist_os_linux.txt @@ -1,10 +1,7 @@ .github/workflows/ci/skiplist_os_Windows.txt .github/workflows/ci/skiplist_os_macOS.txt osx/* -plover/oslayer/log_osx.py -plover/oslayer/osxkeyboardcontrol.py -plover/oslayer/osxkeyboardlayout.py -plover/oslayer/winkeyboardcontrol.py -plover/oslayer/winkeyboardlayout.py +plover/oslayer/osx/* +plover/oslayer/windows/* windows/* {EOF} diff --git a/.github/workflows/ci/skiplist_os_macos.txt b/.github/workflows/ci/skiplist_os_macos.txt index e571846b3..f71f08207 100644 --- a/.github/workflows/ci/skiplist_os_macos.txt +++ b/.github/workflows/ci/skiplist_os_macos.txt @@ -1,10 +1,7 @@ .github/workflows/ci/skiplist_os_Linux.txt .github/workflows/ci/skiplist_os_Windows.txt linux/* -plover/oslayer/log_dbus.py -plover/oslayer/winkeyboardcontrol.py -plover/oslayer/winkeyboardlayout.py -plover/oslayer/xkeyboardcontrol.py -plover/oslayer/xwmctrl.py +plover/oslayer/linux/* +plover/oslayer/windows/* windows/* {EOF} diff --git a/.github/workflows/ci/skiplist_os_windows.txt b/.github/workflows/ci/skiplist_os_windows.txt index e1116a512..5c1363214 100644 --- a/.github/workflows/ci/skiplist_os_windows.txt +++ b/.github/workflows/ci/skiplist_os_windows.txt @@ -2,10 +2,6 @@ .github/workflows/ci/skiplist_os_macOS.txt linux/* osx/* -plover/oslayer/log_dbus.py -plover/oslayer/log_osx.py -plover/oslayer/osxkeyboardcontrol.py -plover/oslayer/osxkeyboardlayout.py -plover/oslayer/xkeyboardcontrol.py -plover/oslayer/xwmctrl.py +plover/oslayer/linux/* +plover/oslayer/osx/* {EOF} diff --git a/plover/i18n.py b/plover/i18n.py index 3b176b9ae..d628cf50e 100644 --- a/plover/i18n.py +++ b/plover/i18n.py @@ -1,31 +1,22 @@ import os -import locale import gettext from plover.oslayer.config import CONFIG_DIR, PLATFORM +from plover.oslayer.i18n import get_system_language from plover.resource import ASSET_SCHEME, resource_filename def get_language(): - env_vars = ['LANGUAGE'] - if PLATFORM in {'linux', 'bsd'}: - env_vars.extend(('LC_ALL', 'LC_MESSAGES', 'LANG')) - for var in env_vars: - lang = os.environ.get(var) - if lang is not None: - return lang - if PLATFORM in {'linux', 'bsd'}: - lang, enc = locale.getdefaultlocale() - elif PLATFORM == 'mac': - from AppKit import NSLocale - lang_list = NSLocale.preferredLanguages() - lang = lang_list[0] if lang_list else None - elif PLATFORM == 'win': - from ctypes import windll - lang = locale.windows_locale[windll.kernel32.GetUserDefaultUILanguage()] - if lang is None: - lang = 'en' - return lang + # Give priority to LANGUAGE environment variable. + lang = os.environ.get('LANGUAGE') + if lang is not None: + return lang + # Try to get system language. + lang = get_system_language() + if lang is not None: + return lang + # Fallback to English. + return 'en' def get_locale_dir(package, resource_dir): locale_dir = os.path.join(CONFIG_DIR, 'messages') diff --git a/plover/key_combo.py b/plover/key_combo.py index 00285b1a1..a81081082 100644 --- a/plover/key_combo.py +++ b/plover/key_combo.py @@ -8,7 +8,7 @@ # Generated using: # # from Xlib import XK - # from plover.oslayer.xkeyboardcontrol import keysym_to_string + # from plover.oslayer.linux.keyboardcontrol_x11 import keysym_to_string # for kn, ks in sorted({ # name[3:].lower(): getattr(XK, name) # for name in sorted(dir(XK)) diff --git a/plover/log.py b/plover/log.py index 23a38dc3b..4f40958a3 100644 --- a/plover/log.py +++ b/plover/log.py @@ -82,25 +82,19 @@ def has_platform_handler(self): def setup_platform_handler(self): if self.has_platform_handler(): return - handler_class = None + NotificationHandler = None try: - if PLATFORM == 'linux': - from plover.oslayer.log_dbus import DbusNotificationHandler - handler_class = DbusNotificationHandler - elif PLATFORM == 'mac': - from plover.oslayer.log_osx import OSXNotificationHandler - handler_class = OSXNotificationHandler + from plover.oslayer.log import NotificationHandler except Exception: self.info('could not import platform gui log', exc_info=True) - if handler_class is None: return try: - handler = handler_class() + handler = NotificationHandler() + self.addHandler(handler) except Exception: self.info('could not initialize platform gui log', exc_info=True) - else: - self.addHandler(handler) - self._platform_handler = handler + return + self._platform_handler = handler def set_level(self, level): self._print_handler.setLevel(level) diff --git a/plover/machine/keyboard.py b/plover/machine/keyboard.py index dfde833a6..bced4c060 100644 --- a/plover/machine/keyboard.py +++ b/plover/machine/keyboard.py @@ -23,7 +23,15 @@ class Keyboard(StenotypeBase): """ - KEYS_LAYOUT = KeyboardCapture.SUPPORTED_KEYS_LAYOUT + KEYS_LAYOUT = ''' + Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 + + ` 1 2 3 4 5 6 7 8 9 0 - = \\ BackSpace Insert Home Page_Up + Tab q w e r t y u i o p [ ] Delete End Page_Down + a s d f g h j k l ; ' Return + z x c v b n m , . / Up + space Left Down Right + ''' ACTIONS = ('arpeggiate',) def __init__(self, params): @@ -40,11 +48,11 @@ def __init__(self, params): self._stroke_key_down_count = 0 self._update_bindings() - def _suppress(self): + def _update_suppression(self): if self._keyboard_capture is None: return suppressed_keys = self._bindings.keys() if self._is_suppressed else () - self._keyboard_capture.suppress_keyboard(suppressed_keys) + self._keyboard_capture.suppress(suppressed_keys) def _update_bindings(self): self._arpeggiate_key = None @@ -59,7 +67,7 @@ def _update_bindings(self): else: # Don't suppress arpeggiate key if it's not used. del self._bindings[key] - self._suppress() + self._update_suppression() def set_keymap(self, keymap): super().set_keymap(keymap) @@ -72,8 +80,8 @@ def start_capture(self): self._keyboard_capture = KeyboardCapture() self._keyboard_capture.key_down = self._key_down self._keyboard_capture.key_up = self._key_up - self._suppress() self._keyboard_capture.start() + self._update_suppression() except: self._error() raise @@ -83,14 +91,14 @@ def stop_capture(self): """Stop listening for output from the stenotype machine.""" if self._keyboard_capture is not None: self._is_suppressed = False - self._suppress() + self._update_suppression() self._keyboard_capture.cancel() self._keyboard_capture = None self._stopped() def set_suppression(self, enabled): self._is_suppressed = enabled - self._suppress() + self._update_suppression() def suppress_last_stroke(self, send_backspaces): send_backspaces(self._last_stroke_key_down_count) diff --git a/plover/machine/keyboard_capture/__init__.py b/plover/machine/keyboard_capture/__init__.py new file mode 100644 index 000000000..3eb3ff471 --- /dev/null +++ b/plover/machine/keyboard_capture/__init__.py @@ -0,0 +1,19 @@ +class Capture: + + """Keyboard capture interface.""" + + # Callbacks for keyboard press/release events. + key_down = lambda key: None + key_up = lambda key: None + + def start(self): + """Start capturing key events.""" + raise NotImplementedError() + + def cancel(self): + """Stop capturing key events.""" + raise NotImplementedError() + + def suppress(self, suppressed_keys=()): + """Setup suppression.""" + raise NotImplementedError() diff --git a/plover/oslayer/__init__.py b/plover/oslayer/__init__.py index a0bff861f..f55bb5a6c 100644 --- a/plover/oslayer/__init__.py +++ b/plover/oslayer/__init__.py @@ -3,3 +3,25 @@ """This package abstracts os details for plover.""" +import os + +from plover import log + +from .config import PLATFORM + + +PLATFORM_PACKAGE = { + 'bsd' : 'linux', + 'linux': 'linux', + 'mac' : 'osx', + 'win' : 'windows', +} + +def _add_platform_package_to_path(): + platform_package = PLATFORM_PACKAGE.get(PLATFORM) + if platform_package is None: + log.warning('No platform-specific oslayer package for: %s' % PLATFORM) + return + __path__.insert(0, os.path.join(__path__[0], platform_package)) + +_add_platform_package_to_path() diff --git a/plover/oslayer/keyboardcontrol.py b/plover/oslayer/keyboardcontrol.py deleted file mode 100644 index 1b892c4e3..000000000 --- a/plover/oslayer/keyboardcontrol.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2010 Joshua Harlan Lifton. -# See LICENSE.txt for details. -# -# keyboardcontrol.py - Abstracted keyboard control. -# -# Uses OS appropriate module. - -"""Keyboard capture and control. - -This module provides an interface for basic keyboard event capture and -emulation. Set the key_up and key_down functions of the -KeyboardCapture class to capture keyboard input. Call the send_string -and send_backspaces functions of the KeyboardEmulation class to -emulate keyboard input. - -""" - -from plover.oslayer.config import PLATFORM - -KEYBOARDCONTROL_NOT_FOUND_FOR_OS = \ - "No keyboard control module was found for platform: %s" % PLATFORM - -if PLATFORM in {'linux', 'bsd'}: - from plover.oslayer import xkeyboardcontrol as keyboardcontrol -elif PLATFORM == 'win': - from plover.oslayer import winkeyboardcontrol as keyboardcontrol -elif PLATFORM == 'mac': - from plover.oslayer import osxkeyboardcontrol as keyboardcontrol -else: - raise Exception(KEYBOARDCONTROL_NOT_FOUND_FOR_OS) - - -class KeyboardCapture(keyboardcontrol.KeyboardCapture): - """Listen to keyboard events.""" - - # Supported keys. - SUPPORTED_KEYS_LAYOUT = ''' - Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 - - ` 1 2 3 4 5 6 7 8 9 0 - = \\ BackSpace Insert Home Page_Up - Tab q w e r t y u i o p [ ] Delete End Page_Down - a s d f g h j k l ; ' Return - z x c v b n m , . / Up - space Left Down Right - ''' - SUPPORTED_KEYS = tuple(SUPPORTED_KEYS_LAYOUT.split()) - - -class KeyboardEmulation(keyboardcontrol.KeyboardEmulation): - """Emulate printable key presses and backspaces.""" - pass - - -if __name__ == '__main__': - - import time - - kc = KeyboardCapture() - ke = KeyboardEmulation() - - pressed = set() - status = 'pressed: ' - - def test(key, action): - global status - print(key, action) - if 'pressed' == action: - pressed.add(key) - elif key in pressed: - pressed.remove(key) - new_status = 'pressed: ' + '+'.join(pressed) - if status != new_status: - ke.send_backspaces(len(status)) - ke.send_string(new_status) - status = new_status - - kc.key_down = lambda k: test(k, 'pressed') - kc.key_up = lambda k: test(k, 'released') - kc.suppress_keyboard(KeyboardCapture.SUPPORTED_KEYS) - kc.start() - print('Press CTRL-c to quit.') - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - kc.cancel() diff --git a/plover/oslayer/linux/__init__.py b/plover/oslayer/linux/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plover/oslayer/linux/i18n.py b/plover/oslayer/linux/i18n.py new file mode 100644 index 000000000..76c3c5707 --- /dev/null +++ b/plover/oslayer/linux/i18n.py @@ -0,0 +1,12 @@ +import locale +import os + + +# Note: highest priority first. +LANG_ENV_VARS = ('LC_ALL', 'LC_MESSAGES', 'LANG') + +def get_system_language(): + try: + return next(filter(None, map(os.environ.get, LANG_ENV_VARS))) + except StopIteration: + return locale.getdefaultlocale()[0] diff --git a/plover/oslayer/linux/keyboardcontrol.py b/plover/oslayer/linux/keyboardcontrol.py new file mode 100644 index 000000000..bb1356144 --- /dev/null +++ b/plover/oslayer/linux/keyboardcontrol.py @@ -0,0 +1 @@ +from .keyboardcontrol_x11 import KeyboardCapture, KeyboardEmulation # pylint: disable=unused-import diff --git a/plover/oslayer/xkeyboardcontrol.py b/plover/oslayer/linux/keyboardcontrol_x11.py similarity index 95% rename from plover/oslayer/xkeyboardcontrol.py rename to plover/oslayer/linux/keyboardcontrol_x11.py index b3e120e0c..1482f0f8d 100644 --- a/plover/oslayer/xkeyboardcontrol.py +++ b/plover/oslayer/linux/keyboardcontrol_x11.py @@ -21,18 +21,19 @@ """ -from functools import wraps import os -import string import select import threading -from Xlib import X, XK, display +from Xlib import X, XK +from Xlib.display import Display from Xlib.ext import xinput, xtest from Xlib.ext.ge import GenericEventCode -from plover.key_combo import add_modifiers_aliases, parse_key_combo 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 # Enable support for media keys. @@ -143,40 +144,33 @@ KEY_TO_KEYCODE = dict(zip(KEYCODE_TO_KEY.values(), KEYCODE_TO_KEY.keys())) -def with_display_lock(func): - """ - Use this function as a decorator on a method of the XEventLoop class (or - one of its subclasses) to acquire the _display_lock of the object before - calling the function and release it afterwards. - """ - # To keep __doc__/__name__ attributes of the initial function. - @wraps(func) - def wrapped(self, *args, **kwargs): - with self._display_lock: - return func(self, *args, **kwargs) - return wrapped +class XEventLoop: -class XEventLoop(threading.Thread): - - def __init__(self, name='xev'): - super().__init__() - self.name += '-' + name - self._display = display.Display() + def __init__(self, on_event, name='XEventLoop'): + self._on_event = on_event + self._lock = threading.Lock() + self._display = Display() + self._thread = threading.Thread(name=name, target=self._run) self._pipe = os.pipe() - self._display_lock = threading.Lock() self._readfds = (self._pipe[0], self._display.fileno()) - def _on_event(self, event): - pass + def __enter__(self): + self._lock.__enter__() + return self._display + + def __exit__(self, exc_type, exc_value, traceback): + if exc_type is None and self._display is not None: + self._display.sync() + self._lock.__exit__(exc_type, exc_value, traceback) - @with_display_lock def _process_pending_events(self): for __ in range(self._display.pending_events()): self._on_event(self._display.next_event()) - def run(self): + def _run(self): while True: - self._process_pending_events() + with self._lock: + self._process_pending_events() # No more events: sleep until we get new data on the # display connection, or on the pipe used to signal # the end of the loop. @@ -188,35 +182,36 @@ def run(self): # If we're here, rlist should contains the display fd, # and the next iteration will find some pending events. + def start(self): + self._thread.start() + def cancel(self): - # Wake up the capture thread... - os.write(self._pipe[1], b'quit') - # ...and wait for it to terminate. - self.join() + if self._thread.is_alive(): + # Wake up the capture thread... + os.write(self._pipe[1], b'quit') + # ...and wait for it to terminate. + self._thread.join() for fd in self._pipe: os.close(fd) + self._display.close() + self._display = None -class KeyboardCapture(XEventLoop): - """Listen to keyboard press and release events.""" +class KeyboardCapture(Capture): def __init__(self): - """Prepare to listen for keyboard events.""" - super().__init__(name='capture') - self._window = self._display.screen().root - if not self._display.has_extension('XInputExtension'): - raise Exception('Xlib\'s XInput extension is required, but could not be found.') + super().__init__() + self._event_loop = None + self._window = None self._suppressed_keys = set() self._devices = [] - self.key_down = lambda key: None - self.key_up = lambda key: None - def _update_devices(self): + def _update_devices(self, display): # Find all keyboard devices. # This function is never called while the event loop thread is running, # so it is unnecessary to lock self._display_lock. keyboard_devices = [] - for devinfo in self._display.xinput_query_device(xinput.AllDevices).devices: + for devinfo in display.xinput_query_device(xinput.AllDevices).devices: # Only keep slave devices. # Note: we look at pointer devices too, as some keyboards (like the # VicTop mechanical gaming keyboard) register 2 devices, including @@ -261,19 +256,28 @@ def _on_event(self, event): self.key_up(key) def start(self): - self._update_devices() - self._window.xinput_select_events([ - (deviceid, XINPUT_EVENT_MASK) - for deviceid in self._devices - ]) - suppressed_keys = self._suppressed_keys - self._suppressed_keys = set() - self.suppress_keyboard(suppressed_keys) - super().start() + self._event_loop = XEventLoop(self._on_event, name='KeyboardCapture') + with self._event_loop as display: + if not display.has_extension('XInputExtension'): + raise Exception('X11\'s XInput extension is required, but could not be found.') + self._update_devices(display) + self._window = display.screen().root + self._window.xinput_select_events([ + (deviceid, XINPUT_EVENT_MASK) + for deviceid in self._devices + ]) + self._event_loop.start() def cancel(self): - self.suppress_keyboard() - super().cancel() + if self._event_loop is None: + return + with self._event_loop: + self._suppress_keys(()) + self._event_loop.cancel() + + def suppress(self, suppressed_keys=()): + with self._event_loop: + self._suppress_keys(suppressed_keys) def _grab_key(self, keycode): for deviceid in self._devices: @@ -292,8 +296,7 @@ def _ungrab_key(self, keycode): keycode, (0, X.Mod2Mask)) - @with_display_lock - def suppress_keyboard(self, suppressed_keys=()): + def _suppress_keys(self, suppressed_keys): suppressed_keys = set(suppressed_keys) if self._suppressed_keys == suppressed_keys: return @@ -304,7 +307,6 @@ def suppress_keyboard(self, suppressed_keys=()): self._grab_key(KEY_TO_KEYCODE[key]) self._suppressed_keys.add(key) assert self._suppressed_keys == suppressed_keys - self._display.sync() # Keysym to Unicode conversion table. @@ -1118,15 +1120,14 @@ def keysym_to_string(keysym): if keysym_str is None: keysym_str = '' for c in keysym_str: - if c not in string.printable: + if not c.isprintable(): keysym_str = '' break return keysym_str return chr(code) -class KeyboardEmulation: - """Emulate keyboard events.""" +class KeyboardEmulation(Output): class Mapping: @@ -1153,8 +1154,8 @@ def __str__(self): UNUSED_KEYSYM = 0xffffff # XK_VoidSymbol def __init__(self): - """Prepare to emulate keyboard events.""" - self._display = display.Display() + super().__init__() + self._display = Display() self._update_keymap() def _update_keymap(self): @@ -1215,32 +1216,14 @@ def _update_keymap(self): # Get modifier mapping. self.modifier_mapping = self._display.get_modifier_mapping() - def send_backspaces(self, number_of_backspaces): - """Emulate the given number of backspaces. - - The emulated backspaces are not detected by KeyboardCapture. - - Argument: - - number_of_backspace -- The number of backspaces to emulate. - - """ - for x in range(number_of_backspaces): + def send_backspaces(self, count): + for x in range(count): self._send_keycode(self._backspace_mapping.keycode, self._backspace_mapping.modifiers) self._display.sync() - def send_string(self, s): - """Emulate the given string. - - The emulated string is not detected by KeyboardCapture. - - Argument: - - s -- The string to emulate. - - """ - for char in s: + def send_string(self, string): + for char in string: keysym = uchr_to_keysym(char) mapping = self._get_mapping(keysym) if mapping is None: @@ -1249,32 +1232,11 @@ def send_string(self, s): mapping.modifiers) self._display.sync() - def send_key_combination(self, combo_string): - """Emulate a sequence of key combinations. - - KeyboardCapture instance would normally detect the emulated - key events. In order to prevent this, all KeyboardCapture - instances are told to ignore the emulated key events. - - Argument: - - combo_string -- A string representing a sequence of key - combinations. Keys are represented by their names in the - Xlib.XK module, without the 'XK_' prefix. For example, the - left Alt key is represented by 'Alt_L'. Keys are either - separated by a space or a left or right parenthesis. - Parentheses must be properly formed in pairs and may be - nested. A key immediately followed by a parenthetical - indicates that the key is pressed down while all keys enclosed - in the parenthetical are pressed and released in turn. For - example, Alt_L(Tab) means to hold the left Alt key down, press - and release the Tab key, and then release the left Alt key. - - """ + def send_key_combination(self, combo): # Parse and validate combo. key_events = [ (keycode, X.KeyPress if pressed else X.KeyRelease) for keycode, pressed - in parse_key_combo(combo_string, self._get_keycode_from_keystring) + 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: diff --git a/plover/oslayer/linux/log.py b/plover/oslayer/linux/log.py new file mode 100644 index 000000000..14765de80 --- /dev/null +++ b/plover/oslayer/linux/log.py @@ -0,0 +1 @@ +from .log_dbus import DbusNotificationHandler as NotificationHandler # pylint: disable=unused-import diff --git a/plover/oslayer/log_dbus.py b/plover/oslayer/linux/log_dbus.py similarity index 100% rename from plover/oslayer/log_dbus.py rename to plover/oslayer/linux/log_dbus.py diff --git a/plover/oslayer/linux/wmctrl.py b/plover/oslayer/linux/wmctrl.py new file mode 100644 index 000000000..8f6e23303 --- /dev/null +++ b/plover/oslayer/linux/wmctrl.py @@ -0,0 +1,7 @@ +from .wmctrl_x11 import WmCtrl + + +_wmctrl = WmCtrl() + +GetForegroundWindow = _wmctrl.get_foreground_window +SetForegroundWindow = _wmctrl.set_foreground_window diff --git a/plover/oslayer/xwmctrl.py b/plover/oslayer/linux/wmctrl_x11.py similarity index 100% rename from plover/oslayer/xwmctrl.py rename to plover/oslayer/linux/wmctrl_x11.py diff --git a/plover/oslayer/osx/__init__.py b/plover/oslayer/osx/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plover/oslayer/osx/i18n.py b/plover/oslayer/osx/i18n.py new file mode 100644 index 000000000..02e805e11 --- /dev/null +++ b/plover/oslayer/osx/i18n.py @@ -0,0 +1,6 @@ +from AppKit import NSLocale + + +def get_system_language(): + lang_list = NSLocale.preferredLanguages() + return lang_list[0] if lang_list else None diff --git a/plover/oslayer/osxkeyboardcontrol.py b/plover/oslayer/osx/keyboardcontrol.py similarity index 65% rename from plover/oslayer/osxkeyboardcontrol.py rename to plover/oslayer/osx/keyboardcontrol.py index b2bfbf505..f3d52f929 100644 --- a/plover/oslayer/osxkeyboardcontrol.py +++ b/plover/oslayer/osx/keyboardcontrol.py @@ -44,8 +44,11 @@ ) from plover import log -from plover.oslayer.osxkeyboardlayout import KeyboardLayout 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 .keyboardlayout import KeyboardLayout BACK_SPACE = 51 @@ -163,153 +166,153 @@ def keycode_needs_fn_mask(keycode): and keycode not in MODIFIER_KEYS_TO_MASKS ) -class KeyboardCapture(threading.Thread): - """Implementation of KeyboardCapture for OSX.""" - _KEYBOARD_EVENTS = {kCGEventKeyDown, kCGEventKeyUp} +class KeyboardCaptureLoop: - def __init__(self): - threading.Thread.__init__(self, name="KeyboardEventTapThread") + def __init__(self, callback): + self._callback = callback self._loop = None - self._event_queue = Queue() # Drained by event handler thread. - - self._suppressed_keys = set() - self.key_down = lambda key: None - self.key_up = lambda key: None + self._source = None + self._tap = None + self._thread = None - # Returning the event means that it is passed on - # for further processing by others. - # - # Returning None means that the event is intercepted. - # - # Delaying too long in returning appears to cause the - # system to ignore the tap forever after - # (https://github.com/openstenoproject/plover/issues/484#issuecomment-214743466). - # - # This motivates pushing callbacks to the other side - # of a queue of received events, so that we can return - # from this callback as soon as possible. - def callback(proxy, event_type, event, reference): - SUPPRESS_EVENT = None - PASS_EVENT_THROUGH = event + def _run(self, loop_is_set): + self._loop = CFRunLoopGetCurrent() + loop_is_set.set() + del loop_is_set + CFRunLoopAddSource(self._loop, self._source, kCFRunLoopCommonModes) + CFRunLoopRun() - # Don't pass on meta events meant for this event tap. - is_unexpected_event = event_type not in self._KEYBOARD_EVENTS - if is_unexpected_event: - if event_type == kCGEventTapDisabledByTimeout: - # Re-enable the tap and hope we act faster next time - CGEventTapEnable(self._tap, True) - log.warning( - "Keystrokes may have been missed, " - + "keyboard event tap has been re-enabled.") - return SUPPRESS_EVENT - - # Don't intercept the event if it has modifiers, allow - # Fn and Numeric flags so we can suppress the arrow and - # extended (home, end, etc...) keys. - suppressible_modifiers = (kCGEventFlagMaskNumericPad | - kCGEventFlagMaskSecondaryFn | - kCGEventFlagMaskNonCoalesced) - has_nonsupressible_modifiers = \ - CGEventGetFlags(event) & ~suppressible_modifiers - if has_nonsupressible_modifiers: - return PASS_EVENT_THROUGH - - keycode = CGEventGetIntegerValueField( - event, kCGKeyboardEventKeycode) - key = KEYCODE_TO_KEY.get(keycode) - self._async_dispatch(key, event_type) - if key in self._suppressed_keys: - return SUPPRESS_EVENT - return PASS_EVENT_THROUGH - - self._tap = CGEventTapCreate( - kCGSessionEventTap, - kCGHeadInsertEventTap, - kCGEventTapOptionDefault, - CGEventMaskBit(kCGEventKeyDown) | CGEventMaskBit(kCGEventKeyUp), - callback, None) + def start(self): + self._tap = CGEventTapCreate(kCGSessionEventTap, + kCGHeadInsertEventTap, + kCGEventTapOptionDefault, + CGEventMaskBit(kCGEventKeyDown) + | CGEventMaskBit(kCGEventKeyUp), + self._callback, None) if self._tap is None: - # Todo(hesky): See if there is a nice way to show the user what's - # needed (or do it for them). + # TODO: See if there is a nice way to show + # the user what's needed (or do it for them). raise Exception("Enable access for assistive devices.") - CGEventTapEnable(self._tap, False) - - def run(self): - source = CFMachPortCreateRunLoopSource(None, self._tap, 0) - handler_thread = threading.Thread( - target=self._event_handler, - name="KeyEventDispatcher") - handler_thread.start() - self._loop = CFRunLoopGetCurrent() - CFRunLoopAddSource( - self._loop, - source, - kCFRunLoopCommonModes - ) - CGEventTapEnable(self._tap, True) + self._source = CFMachPortCreateRunLoopSource(None, self._tap, 0) + loop_is_set = threading.Event() + self._thread = threading.Thread(target=self._run, args=(loop_is_set,), + name="KeyboardCaptureLoop") + self._thread.start() + loop_is_set.wait() + self.toggle_tap(True) + + def toggle_tap(self, enabled): + CGEventTapEnable(self._tap, enabled) + + def cancel(self): + if self._loop is not None: + CFRunLoopStop(self._loop) + if self._thread is not None: + self._thread.join() + if self._tap is not None: + CFMachPortInvalidate(self._tap) + CFRelease(self._tap) + if self._source is not None: + CFRunLoopSourceInvalidate(self._source) - CFRunLoopRun() - # Wake up event handler. - self._event_queue.put_nowait(None) - handler_thread.join() - CFMachPortInvalidate(self._tap) - CFRelease(self._tap) - CFRunLoopSourceInvalidate(source) +class KeyboardCapture(Capture): + + _KEYBOARD_EVENTS = {kCGEventKeyDown, kCGEventKeyUp} + + # Don't ignore Fn and Numeric flags so we can handle + # the arrow and extended (home, end, etc...) keys. + _PASSTHROUGH_MODIFIERS = ~(kCGEventFlagMaskNumericPad | + kCGEventFlagMaskSecondaryFn | + kCGEventFlagMaskNonCoalesced) + + def __init__(self): + super().__init__() + self.key_down = lambda key: None + self.key_up = lambda key: None + self._suppressed_keys = set() + self._event_queue = Queue() + self._event_thread = None + self._capture_loop = None + + def _callback(self, proxy, event_type, event, reference): + ''' + Returning the event means that it is passed on + for further processing by others. + + Returning None means that the event is intercepted. + + Delaying too long in returning appears to cause the + system to ignore the tap forever after + (https://github.com/openstenoproject/plover/issues/484#issuecomment-214743466). + + This motivates pushing callbacks to the other side + of a queue of received events, so that we can return + from this callback as soon as possible. + ''' + if event_type not in self._KEYBOARD_EVENTS: + # Don't pass on meta events meant for this event tap. + if event_type == kCGEventTapDisabledByTimeout: + # Re-enable the tap and hope we act faster next time. + self._capture_loop.toggle_tap(True) + log.warning("Keystrokes may have been missed, the" + "keyboard event tap has been re-enabled.") + return None + if CGEventGetFlags(event) & self._PASSTHROUGH_MODIFIERS: + # Don't intercept or suppress the event if it has + # modifiers and the whole keyboard is not suppressed. + return event + keycode = CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode) + key = KEYCODE_TO_KEY.get(keycode) + if key is None: + # Not a supported key, don't intercept or suppress. + return event + # Notify supported key event. + self._event_queue.put_nowait((key, event_type)) + # And suppressed it if asked too. + return None if key in self._suppressed_keys else event + + def start(self): + self._capture_loop = KeyboardCaptureLoop(self._callback) + self._capture_loop.start() + self._event_thread = threading.Thread(name="KeyboardCapture", + target=self._run) + self._event_thread.start() def cancel(self): - CFRunLoopStop(self._loop) - self.join() - self._loop = None + if self._event_thread is not None: + self._event_queue.put(None) + self._event_thread.join() + if self._capture_loop is not None: + self._capture_loop.cancel() - def suppress_keyboard(self, suppressed_keys=()): + def suppress(self, suppressed_keys=()): self._suppressed_keys = set(suppressed_keys) - def _async_dispatch(self, key, event_type): - """ - Dispatches a key string in KEYCODE_TO_KEY.values() and a CGEventType - to the appropriate KeyboardCapture callback - without blocking execution of its caller. - """ - if key is None: - return - - is_keyup = event_type == kCGEventKeyUp - pair = (key, is_keyup) - self._event_queue.put_nowait(pair) - - def _event_handler(self): - """ - Event dispatching thread launched during run(). - Loops until None is received from _event_queue. - Avoids busy-waiting by blocking on _event_queue. - - In normal operation, it gets a pair of - (key_string, is_keyup_bool) from _event_queue - and routes the string to self.key_up or self.key_down, - then waits for a new pair to arrive. - """ + def _run(self): while True: - pair = self._event_queue.get(block=True, timeout=None) - if pair is None: + event = self._event_queue.get() + if event is None: return - - key, is_keyup = pair - handler = self.key_up if is_keyup else self.key_down - handler(key) + key, event_type = event + if event_type == kCGEventKeyUp: + self.key_up(key) + else: + self.key_down(key) -class KeyboardEmulation: +class KeyboardEmulation(Output): RAW_PRESS, STRING_PRESS = range(2) def __init__(self): + super().__init__() self._layout = KeyboardLayout() @staticmethod - def send_backspaces(number_of_backspaces): - for _ in range(number_of_backspaces): + def send_backspaces(count): + for _ in range(count): backspace_down = CGEventCreateKeyboardEvent( OUTPUT_SOURCE, BACK_SPACE, True) backspace_up = CGEventCreateKeyboardEvent( @@ -317,22 +320,7 @@ def send_backspaces(number_of_backspaces): CGEventPost(kCGSessionEventTap, backspace_down) CGEventPost(kCGSessionEventTap, backspace_up) - def send_string(self, s): - """ - - Args: - s: The string to emulate. - - We can send keys by keycodes or by SetUnicodeString. - Setting the string is less ideal, but necessary for things like emoji. - We want to try to group modifier presses, where convenient. - So, a string like 'THIS dog [dog emoji]' might be processed like: - 'Raw: Shift down t h i s shift up, - Raw: space d o g space, - String: [dog emoji]' - There are 3 groups, the shifted group, the spaces and dog string, - and the emoji. - """ + def send_string(self, string): # Key plan will store the type of output # (raw keycodes versus setting string) # and the list of keycodes or the goal character. @@ -351,7 +339,7 @@ def apply_raw(): apply_raw() last_modifier = None - for c in s: + for c in string: for keycode, modifier in self._layout.char_to_key_sequence(c): if keycode is not None: if modifier is not last_modifier: @@ -388,23 +376,7 @@ def _send_string_press(c): KeyboardEmulation._set_event_string(event, c) CGEventPost(kCGSessionEventTap, event) - def send_key_combination(self, combo_string): - """Emulate a sequence of key combinations. - - Args: - combo_string: A string representing a sequence of key - combinations. Keys are represented by their names in the - Xlib.XK module, without the 'XK_' prefix. For example, the - left Alt key is represented by 'Alt_L'. Keys are either - separated by a space or a left or right parenthesis. - Parentheses must be properly formed in pairs and may be - nested. A key immediately followed by a parenthetical - indicates that the key is pressed down while all keys enclosed - in the parenthetical are pressed and released in turn. For - example, Alt_L(Tab) means to hold the left Alt key down, press - and release the Tab key, and then release the left Alt key. - - """ + def send_key_combination(self, combo): def name_to_code(name): # Static key codes code = KEYNAME_TO_KEYCODE.get(name) @@ -421,7 +393,7 @@ def name_to_code(name): code, mods = self._layout.char_to_key_sequence(char)[0] return code # Parse and validate combo. - key_events = parse_key_combo(combo_string, name_to_code) + key_events = parse_key_combo(combo, name_to_code) # Send events... self._send_sequence(key_events) diff --git a/plover/oslayer/osxkeyboardlayout.py b/plover/oslayer/osx/keyboardlayout.py similarity index 100% rename from plover/oslayer/osxkeyboardlayout.py rename to plover/oslayer/osx/keyboardlayout.py diff --git a/plover/oslayer/log_osx.py b/plover/oslayer/osx/log.py similarity index 96% rename from plover/oslayer/log_osx.py rename to plover/oslayer/osx/log.py index f138d2ba7..71a424ee2 100644 --- a/plover/oslayer/log_osx.py +++ b/plover/oslayer/osx/log.py @@ -7,7 +7,7 @@ import logging -class OSXNotificationHandler(logging.Handler): +class NotificationHandler(logging.Handler): """ Handler using OS X Notification Center to show messages. """ def __init__(self): diff --git a/plover/oslayer/osx/wmctrl.py b/plover/oslayer/osx/wmctrl.py new file mode 100644 index 000000000..3607982fa --- /dev/null +++ b/plover/oslayer/osx/wmctrl.py @@ -0,0 +1,9 @@ +from Cocoa import NSWorkspace, NSRunningApplication, NSApplicationActivateIgnoringOtherApps + + +def GetForegroundWindow(): + return NSWorkspace.sharedWorkspace().frontmostApplication().processIdentifier() + +def SetForegroundWindow(pid): + target_window = NSRunningApplication.runningApplicationWithProcessIdentifier_(pid) + target_window.activateWithOptions_(NSApplicationActivateIgnoringOtherApps) diff --git a/plover/oslayer/windows/__init__.py b/plover/oslayer/windows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plover/oslayer/windows/i18n.py b/plover/oslayer/windows/i18n.py new file mode 100644 index 000000000..53faaa360 --- /dev/null +++ b/plover/oslayer/windows/i18n.py @@ -0,0 +1,7 @@ +import locale + +from ctypes import windll + + +def get_system_language(): + return locale.windows_locale[windll.kernel32.GetUserDefaultUILanguage()] diff --git a/plover/oslayer/winkeyboardcontrol.py b/plover/oslayer/windows/keyboardcontrol.py similarity index 90% rename from plover/oslayer/winkeyboardcontrol.py rename to plover/oslayer/windows/keyboardcontrol.py index eadcd1ec2..c03dd1795 100644 --- a/plover/oslayer/winkeyboardcontrol.py +++ b/plover/oslayer/windows/keyboardcontrol.py @@ -22,11 +22,13 @@ import threading import winreg -from plover.key_combo import parse_key_combo -from plover.oslayer.winkeyboardlayout import KeyboardLayout from plover import log +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 .keyboardlayout import KeyboardLayout # For the purposes of this class, we'll only report key presses that # result in these outputs in order to exclude special key combos. @@ -382,7 +384,7 @@ def stop(self): # Wake up capture thread, so it gets a chance to check if it must stop. self._queue.put((None, None, None)) - def suppress_keyboard(self, suppressed_keys): + def suppress(self, suppressed_keys): bitmask = [0] * len(self._suppressed_keys_bitmask) for key in suppressed_keys: code = KEY_TO_SCANCODE[key] @@ -393,46 +395,48 @@ def get(self): return self._queue.get() -class KeyboardCapture(threading.Thread): - """Listen to all keyboard events.""" +class KeyboardCapture(Capture): def __init__(self): super().__init__() self._suppressed_keys = set() - self.key_down = lambda key: None - self.key_up = lambda key: None - self._proc = KeyboardCaptureProcess() - self._finished = threading.Event() + self._finished = None + self._thread = None + self._proc = None def start(self): + self._finished = threading.Event() + self._proc = KeyboardCaptureProcess() self._proc.start() - self._proc.suppress_keyboard(self._suppressed_keys) - super().start() + self._thread = threading.Thread(target=self._run) + self._thread.start() - def run(self): + def _run(self): while True: error, key, pressed = self._proc.get() if error is not None: log.error(*error) if self._finished.is_set(): break - if key is not None: - (self.key_down if pressed else self.key_up)(key) + (self.key_down if pressed else self.key_up)(key) def cancel(self): - self._finished.set() - self._proc.stop() - if self.is_alive(): - self.join() - - def suppress_keyboard(self, suppressed_keys=()): + if self._finished is not None: + self._finished.set() + if self._proc is not None: + self._proc.stop() + if self._thread is not None: + self._thread.join() + + def suppress(self, suppressed_keys=()): self._suppressed_keys = set(suppressed_keys) - self._proc.suppress_keyboard(self._suppressed_keys) + self._proc.suppress(self._suppressed_keys) -class KeyboardEmulation: +class KeyboardEmulation(Output): def __init__(self): + super().__init__() self.keyboard_layout = KeyboardLayout() # Sends input types to buffer @@ -503,13 +507,13 @@ def _key_unicode(self, char): for code in pairs] self._send_input(*inputs) - def send_backspaces(self, number_of_backspaces): - for _ in range(number_of_backspaces): + def send_backspaces(self, count): + for _ in range(count): self._key_press('\x08') - def send_string(self, s): + def send_string(self, string): self._refresh_keyboard_layout() - for char in s: + for char in string: if char in self.keyboard_layout.char_to_vk_ss: # We know how to simulate the character. self._key_press(char) @@ -517,25 +521,11 @@ def send_string(self, s): # Otherwise, we send it as a Unicode string. self._key_unicode(char) - def send_key_combination(self, combo_string): - """Emulate a sequence of key combinations. - Argument: - combo_string -- A string representing a sequence of key - combinations. Keys are represented by their names in the - self.keyboard_layout.keyname_to_keycode above. For example, the - left Alt key is represented by 'Alt_L'. Keys are either - separated by a space or a left or right parenthesis. - Parentheses must be properly formed in pairs and may be - nested. A key immediately followed by a parenthetical - indicates that the key is pressed down while all keys enclosed - in the parenthetical are pressed and released in turn. For - example, Alt_L(Tab) means to hold the left Alt key down, press - and release the Tab key, and then release the left Alt key. - """ + def send_key_combination(self, combo): # Make sure keyboard layout is up-to-date. self._refresh_keyboard_layout() # Parse and validate combo. - key_events = parse_key_combo(combo_string, self.keyboard_layout.keyname_to_vk.get) + key_events = parse_key_combo(combo, self.keyboard_layout.keyname_to_vk.get) # Send events... for keycode, pressed in key_events: self._key_event(keycode, pressed) diff --git a/plover/oslayer/winkeyboardlayout.py b/plover/oslayer/windows/keyboardlayout.py similarity index 99% rename from plover/oslayer/winkeyboardlayout.py rename to plover/oslayer/windows/keyboardlayout.py index 0ab8b057b..a3bf42d97 100644 --- a/plover/oslayer/winkeyboardlayout.py +++ b/plover/oslayer/windows/keyboardlayout.py @@ -7,9 +7,10 @@ import sys from plover.key_combo import CHAR_TO_KEYNAME, add_modifiers_aliases -from plover.oslayer.wmctrl import GetForegroundWindow from plover.misc import popcount_8 +from .wmctrl import GetForegroundWindow + GetKeyboardLayout = windll.user32.GetKeyboardLayout GetKeyboardLayout.argtypes = [ diff --git a/plover/oslayer/windows/log.py b/plover/oslayer/windows/log.py new file mode 100644 index 000000000..8af1a2fe6 --- /dev/null +++ b/plover/oslayer/windows/log.py @@ -0,0 +1 @@ +from ..log_plyer import PlyerNotificationHandler as NotificationHandler # pylint: disable=unused-import diff --git a/plover/oslayer/windows/wmctrl.py b/plover/oslayer/windows/wmctrl.py new file mode 100644 index 000000000..e8d647f29 --- /dev/null +++ b/plover/oslayer/windows/wmctrl.py @@ -0,0 +1,12 @@ +from ctypes import windll, wintypes + + +GetForegroundWindow = windll.user32.GetForegroundWindow +GetForegroundWindow.argtypes = [] +GetForegroundWindow.restype = wintypes.HWND + +SetForegroundWindow = windll.user32.SetForegroundWindow +SetForegroundWindow.argtypes = [ + wintypes.HWND, # hWnd +] +SetForegroundWindow.restype = wintypes.BOOL diff --git a/plover/oslayer/wmctrl.py b/plover/oslayer/wmctrl.py deleted file mode 100644 index 0aeb4304f..000000000 --- a/plover/oslayer/wmctrl.py +++ /dev/null @@ -1,37 +0,0 @@ -from plover.oslayer.config import PLATFORM - - -if PLATFORM == 'win': - - from ctypes import windll, wintypes - - GetForegroundWindow = windll.user32.GetForegroundWindow - GetForegroundWindow.argtypes = [] - GetForegroundWindow.restype = wintypes.HWND - - SetForegroundWindow = windll.user32.SetForegroundWindow - SetForegroundWindow.argtypes = [ - wintypes.HWND, # hWnd - ] - SetForegroundWindow.restype = wintypes.BOOL - - -elif PLATFORM == 'mac': - - from Cocoa import NSWorkspace, NSRunningApplication, NSApplicationActivateIgnoringOtherApps - - def GetForegroundWindow(): - return NSWorkspace.sharedWorkspace().frontmostApplication().processIdentifier() - - def SetForegroundWindow(pid): - target_window = NSRunningApplication.runningApplicationWithProcessIdentifier_(pid) - target_window.activateWithOptions_(NSApplicationActivateIgnoringOtherApps) - -elif PLATFORM in {'linux', 'bsd'}: - - from plover.oslayer.xwmctrl import WmCtrl - - wmctrl = WmCtrl() - - GetForegroundWindow = wmctrl.get_foreground_window - SetForegroundWindow = wmctrl.set_foreground_window diff --git a/plover/output/__init__.py b/plover/output/__init__.py new file mode 100644 index 000000000..0546696af --- /dev/null +++ b/plover/output/__init__.py @@ -0,0 +1,18 @@ +class Output: + + """Output interface.""" + + def send_backspaces(self, count): + """Output the given number of backspaces.""" + raise NotImplementedError() + + def send_string(self, string): + """Output the given string.""" + raise NotImplementedError() + + def send_key_combination(self, combo): + """Output a sequence of key combinations. + + See `plover.key_combo` for the format of the `combo` string. + """ + raise NotImplementedError() diff --git a/setup.cfg b/setup.cfg index 77b8af72c..d0141f4a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,9 +42,14 @@ packages = plover.gui_none plover.gui_qt plover.machine + plover.machine.keyboard_capture plover.macro plover.meta plover.oslayer + plover.oslayer.linux + plover.oslayer.osx + plover.oslayer.windows + plover.output plover.scripts plover.system plover_build_utils diff --git a/test/test_keyboard.py b/test/test_keyboard.py index 8cd8382df..c6ca233de 100644 --- a/test/test_keyboard.py +++ b/test/test_keyboard.py @@ -47,8 +47,8 @@ def test_lifecycle(capture, machine, strokes): # Start machine. machine.start_capture() assert capture.mock_calls == [ - call.suppress_keyboard(()), call.start(), + call.suppress(()), ] capture.reset_mock() machine.set_suppression(True) @@ -56,7 +56,7 @@ def test_lifecycle(capture, machine, strokes): del suppressed_keys['space'] assert strokes == [] assert capture.mock_calls == [ - call.suppress_keyboard(suppressed_keys.keys()), + call.suppress(suppressed_keys.keys()), ] # Trigger some strokes. capture.reset_mock() @@ -71,7 +71,7 @@ def test_lifecycle(capture, machine, strokes): machine.stop_capture() assert strokes == [] assert capture.mock_calls == [ - call.suppress_keyboard(()), + call.suppress(()), call.cancel(), ]