From f7cba59e61a2da418fe04c3c7b28d1d881b98fed Mon Sep 17 00:00:00 2001 From: Athanasius Date: Thu, 22 Dec 2022 13:21:14 +0000 Subject: [PATCH] hotkey: Re-factor into a module, per-arch files This helps avoid some pre-commit/mypy carping. --- hotkey.py | 703 --------------------------------------------- hotkey/__init__.py | 59 ++++ hotkey/darwin.py | 274 ++++++++++++++++++ hotkey/linux.py | 29 ++ hotkey/windows.py | 359 +++++++++++++++++++++++ 5 files changed, 721 insertions(+), 703 deletions(-) delete mode 100644 hotkey.py create mode 100644 hotkey/__init__.py create mode 100644 hotkey/darwin.py create mode 100644 hotkey/linux.py create mode 100644 hotkey/windows.py diff --git a/hotkey.py b/hotkey.py deleted file mode 100644 index 5a72cde1..00000000 --- a/hotkey.py +++ /dev/null @@ -1,703 +0,0 @@ -"""Handle keyboard input for manual update triggering.""" -# -*- coding: utf-8 -*- - -import abc -import pathlib -import sys -import tkinter as tk -from abc import abstractmethod -from typing import Optional, Tuple, Union - -from config import config -from EDMCLogging import get_main_logger - -logger = get_main_logger() - - -class AbstractHotkeyMgr(abc.ABC): - """Abstract root class of all platforms specific HotKeyMgr.""" - - @abstractmethod - def register(self, root, keycode, modifiers) -> None: - """Register the hotkey handler.""" - pass - - @abstractmethod - def unregister(self) -> None: - """Unregister the hotkey handling.""" - pass - - @abstractmethod - def play_good(self) -> None: - """Play the 'good' sound.""" - pass - - @abstractmethod - def play_bad(self) -> None: - """Play the 'bad' sound.""" - pass - - -if sys.platform == 'darwin': - - import objc - from AppKit import ( - NSAlternateKeyMask, NSApplication, NSBeep, NSClearLineFunctionKey, NSCommandKeyMask, NSControlKeyMask, - NSDeleteFunctionKey, NSDeviceIndependentModifierFlagsMask, NSEvent, NSF1FunctionKey, NSF35FunctionKey, - NSFlagsChanged, NSKeyDown, NSKeyDownMask, NSKeyUp, NSNumericPadKeyMask, NSShiftKeyMask, NSSound, NSWorkspace - ) - - -class MacHotkeyMgr(AbstractHotkeyMgr): - """Hot key management.""" - - POLL = 250 - # https://developer.apple.com/library/mac/documentation/Cocoa/Reference/ApplicationKit/Classes/NSEvent_Class/#//apple_ref/doc/constant_group/Function_Key_Unicodes - DISPLAY = { - 0x03: u'⌅', 0x09: u'⇥', 0xd: u'↩', 0x19: u'⇤', 0x1b: u'esc', 0x20: u'⏘', 0x7f: u'⌫', - 0xf700: u'↑', 0xf701: u'↓', 0xf702: u'←', 0xf703: u'→', - 0xf727: u'Ins', - 0xf728: u'⌦', 0xf729: u'↖', 0xf72a: u'Fn', 0xf72b: u'↘', - 0xf72c: u'⇞', 0xf72d: u'⇟', 0xf72e: u'PrtScr', 0xf72f: u'ScrollLock', - 0xf730: u'Pause', 0xf731: u'SysReq', 0xf732: u'Break', 0xf733: u'Reset', - 0xf739: u'⌧', - } - (ACQUIRE_INACTIVE, ACQUIRE_ACTIVE, ACQUIRE_NEW) = range(3) - - def __init__(self): - self.MODIFIERMASK = NSShiftKeyMask | NSControlKeyMask | NSAlternateKeyMask | NSCommandKeyMask \ - | NSNumericPadKeyMask - self.root = None - - self.keycode = 0 - self.modifiers = 0 - self.activated = False - self.observer = None - - self.acquire_key = 0 - self.acquire_state = MacHotkeyMgr.ACQUIRE_INACTIVE - - self.tkProcessKeyEvent_old = None - - self.snd_good = NSSound.alloc().initWithContentsOfFile_byReference_( - pathlib.Path(config.respath_path) / 'snd_good.wav', False - ) - self.snd_bad = NSSound.alloc().initWithContentsOfFile_byReference_( - pathlib.Path(config.respath_path) / 'snd_bad.wav', False - ) - - def register(self, root: tk.Tk, keycode, modifiers) -> None: - """ - Register current hotkey for monitoring. - - :param root: parent window. - :param keycode: Key to monitor. - :param modifiers: Any modifiers to take into account. - """ - self.root = root - self.keycode = keycode - self.modifiers = modifiers - self.activated = False - - if keycode: - if not self.observer: - self.root.after_idle(self._observe) - self.root.after(MacHotkeyMgr.POLL, self._poll) - - # Monkey-patch tk (tkMacOSXKeyEvent.c) - if not self.tkProcessKeyEvent_old: - sel = b'tkProcessKeyEvent:' - cls = NSApplication.sharedApplication().class__() # type: ignore - self.tkProcessKeyEvent_old = NSApplication.sharedApplication().methodForSelector_(sel) # type: ignore - newmethod = objc.selector( # type: ignore - self.tkProcessKeyEvent, - selector=self.tkProcessKeyEvent_old.selector, - signature=self.tkProcessKeyEvent_old.signature - ) - objc.classAddMethod(cls, sel, newmethod) # type: ignore - - def tkProcessKeyEvent(self, cls, the_event): # noqa: N802 - """ - Monkey-patch tk (tkMacOSXKeyEvent.c). - - - workaround crash on OSX 10.9 & 10.10 on seeing a composing character - - notice when modifier key state changes - - keep a copy of NSEvent.charactersIgnoringModifiers, which is what we need for the hotkey - - (Would like to use a decorator but need to ensure the application is created before this is installed) - :param cls: ??? - :param the_event: tk event - :return: ??? - """ - if self.acquire_state: - if the_event.type() == NSFlagsChanged: - self.acquire_key = the_event.modifierFlags() & NSDeviceIndependentModifierFlagsMask - self.acquire_state = MacHotkeyMgr.ACQUIRE_NEW - # suppress the event by not chaining the old function - return the_event - - elif the_event.type() in (NSKeyDown, NSKeyUp): - c = the_event.charactersIgnoringModifiers() - self.acquire_key = (c and ord(c[0]) or 0) | \ - (the_event.modifierFlags() & NSDeviceIndependentModifierFlagsMask) - self.acquire_state = MacHotkeyMgr.ACQUIRE_NEW - # suppress the event by not chaining the old function - return the_event - - # replace empty characters with charactersIgnoringModifiers to avoid crash - elif the_event.type() in (NSKeyDown, NSKeyUp) and not the_event.characters(): - the_event = NSEvent.keyEventWithType_location_modifierFlags_timestamp_windowNumber_context_characters_charactersIgnoringModifiers_isARepeat_keyCode_( # noqa: E501 - # noqa: E501 - the_event.type(), - the_event.locationInWindow(), - the_event.modifierFlags(), - the_event.timestamp(), - the_event.windowNumber(), - the_event.context(), - the_event.charactersIgnoringModifiers(), - the_event.charactersIgnoringModifiers(), - the_event.isARepeat(), - the_event.keyCode() - ) - return self.tkProcessKeyEvent_old(cls, the_event) - - def _observe(self): - # Must be called after root.mainloop() so that the app's message loop has been created - self.observer = NSEvent.addGlobalMonitorForEventsMatchingMask_handler_(NSKeyDownMask, self._handler) - - def _poll(self): - if config.shutting_down: - return - - # No way of signalling to Tkinter from within the callback handler block that doesn't - # cause Python to crash, so poll. - if self.activated: - self.activated = False - self.root.event_generate('<>', when="tail") - - if self.keycode or self.modifiers: - self.root.after(MacHotkeyMgr.POLL, self._poll) - - def unregister(self) -> None: - """Remove hotkey registration.""" - self.keycode = None - self.modifiers = None - - if sys.platform == 'darwin': # noqa: C901 - @objc.callbackFor(NSEvent.addGlobalMonitorForEventsMatchingMask_handler_) - def _handler(self, event) -> None: - # use event.charactersIgnoringModifiers to handle composing characters like Alt-e - if ((event.modifierFlags() & self.MODIFIERMASK) == self.modifiers - and ord(event.charactersIgnoringModifiers()[0]) == self.keycode): - if config.get_int('hotkey_always'): - self.activated = True - - else: # Only trigger if game client is front process - front = NSWorkspace.sharedWorkspace().frontmostApplication() - if front and front.bundleIdentifier() == 'uk.co.frontier.EliteDangerous': - self.activated = True - - def acquire_start(self) -> None: - """Start acquiring hotkey state via polling.""" - self.acquire_state = MacHotkeyMgr.ACQUIRE_ACTIVE - self.root.after_idle(self._acquire_poll) - - def acquire_stop(self) -> None: - """Stop acquiring hotkey state.""" - self.acquire_state = MacHotkeyMgr.ACQUIRE_INACTIVE - - def _acquire_poll(self) -> None: - """Perform a poll of current hotkey state.""" - if config.shutting_down: - return - - # No way of signalling to Tkinter from within the monkey-patched event handler that doesn't - # cause Python to crash, so poll. - if self.acquire_state: - if self.acquire_state == MacHotkeyMgr.ACQUIRE_NEW: - # Abuse tkEvent's keycode field to hold our acquired key & modifier - self.root.event_generate('', keycode=self.acquire_key) - self.acquire_state = MacHotkeyMgr.ACQUIRE_ACTIVE - self.root.after(50, self._acquire_poll) - - def fromevent(self, event) -> Optional[Union[bool, Tuple]]: - """ - Return configuration (keycode, modifiers) or None=clear or False=retain previous. - - :param event: tk event ? - :return: False to retain previous, None to not use, else (keycode, modifiers) - """ - (keycode, modifiers) = (event.keycode & 0xffff, event.keycode & 0xffff0000) # Set by _acquire_poll() - if (keycode - and not (modifiers & (NSShiftKeyMask | NSControlKeyMask | NSAlternateKeyMask | NSCommandKeyMask))): - if keycode == 0x1b: # Esc = retain previous - self.acquire_state = MacHotkeyMgr.ACQUIRE_INACTIVE - return False - - # BkSp, Del, Clear = clear hotkey - elif keycode in [0x7f, ord(NSDeleteFunctionKey), ord(NSClearLineFunctionKey)]: - self.acquire_state = MacHotkeyMgr.ACQUIRE_INACTIVE - return None - - # don't allow keys needed for typing in System Map - elif keycode in [0x13, 0x20, 0x2d] or 0x61 <= keycode <= 0x7a: - NSBeep() - self.acquire_state = MacHotkeyMgr.ACQUIRE_INACTIVE - return None - - return (keycode, modifiers) - - def display(self, keycode, modifiers) -> str: - """ - Return displayable form of given hotkey + modifiers. - - :param keycode: - :param modifiers: - :return: string form - """ - text = '' - if modifiers & NSControlKeyMask: - text += u'⌃' - - if modifiers & NSAlternateKeyMask: - text += u'⌥' - - if modifiers & NSShiftKeyMask: - text += u'⇧' - - if modifiers & NSCommandKeyMask: - text += u'⌘' - - if (modifiers & NSNumericPadKeyMask) and keycode <= 0x7f: - text += u'№' - - if not keycode: - pass - - elif ord(NSF1FunctionKey) <= keycode <= ord(NSF35FunctionKey): - text += f'F{keycode + 1 - ord(NSF1FunctionKey)}' - - elif keycode in MacHotkeyMgr.DISPLAY: # specials - text += MacHotkeyMgr.DISPLAY[keycode] - - elif keycode < 0x20: # control keys - text += chr(keycode + 0x40) - - elif keycode < 0xf700: # key char - text += chr(keycode).upper() - - else: - text += u'⁈' - - return text - - def play_good(self): - """Play the 'good' sound.""" - self.snd_good.play() - - def play_bad(self): - """Play the 'bad' sound.""" - self.snd_bad.play() - - -if sys.platform == 'win32': - - import atexit - import ctypes - import threading - import winsound - from ctypes.wintypes import DWORD, HWND, LONG, LPWSTR, MSG, ULONG, WORD - - RegisterHotKey = ctypes.windll.user32.RegisterHotKey - UnregisterHotKey = ctypes.windll.user32.UnregisterHotKey - MOD_ALT = 0x0001 - MOD_CONTROL = 0x0002 - MOD_SHIFT = 0x0004 - MOD_WIN = 0x0008 - MOD_NOREPEAT = 0x4000 - - GetMessage = ctypes.windll.user32.GetMessageW - TranslateMessage = ctypes.windll.user32.TranslateMessage - DispatchMessage = ctypes.windll.user32.DispatchMessageW - PostThreadMessage = ctypes.windll.user32.PostThreadMessageW - WM_QUIT = 0x0012 - WM_HOTKEY = 0x0312 - WM_APP = 0x8000 - WM_SND_GOOD = WM_APP + 1 - WM_SND_BAD = WM_APP + 2 - - GetKeyState = ctypes.windll.user32.GetKeyState - MapVirtualKey = ctypes.windll.user32.MapVirtualKeyW - VK_BACK = 0x08 - VK_CLEAR = 0x0c - VK_RETURN = 0x0d - VK_SHIFT = 0x10 - VK_CONTROL = 0x11 - VK_MENU = 0x12 - VK_CAPITAL = 0x14 - VK_MODECHANGE = 0x1f - VK_ESCAPE = 0x1b - VK_SPACE = 0x20 - VK_DELETE = 0x2e - VK_LWIN = 0x5b - VK_RWIN = 0x5c - VK_NUMPAD0 = 0x60 - VK_DIVIDE = 0x6f - VK_F1 = 0x70 - VK_F24 = 0x87 - VK_OEM_MINUS = 0xbd - VK_NUMLOCK = 0x90 - VK_SCROLL = 0x91 - VK_PROCESSKEY = 0xe5 - VK_OEM_CLEAR = 0xfe - - GetForegroundWindow = ctypes.windll.user32.GetForegroundWindow - GetWindowText = ctypes.windll.user32.GetWindowTextW - GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int] - GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW - - def window_title(h) -> str: - """ - Determine the title for a window. - - :param h: Window handle. - :return: Window title. - """ - if h: - title_length = GetWindowTextLength(h) + 1 - buf = ctypes.create_unicode_buffer(title_length) - if GetWindowText(h, buf, title_length): - return buf.value - - return '' - - class MOUSEINPUT(ctypes.Structure): - """Mouse Input structure.""" - - _fields_ = [ - ('dx', LONG), - ('dy', LONG), - ('mouseData', DWORD), - ('dwFlags', DWORD), - ('time', DWORD), - ('dwExtraInfo', ctypes.POINTER(ULONG)) - ] - - class KEYBDINPUT(ctypes.Structure): - """Keyboard Input structure.""" - - _fields_ = [ - ('wVk', WORD), - ('wScan', WORD), - ('dwFlags', DWORD), - ('time', DWORD), - ('dwExtraInfo', ctypes.POINTER(ULONG)) - ] - - class HARDWAREINPUT(ctypes.Structure): - """Hardware Input structure.""" - - _fields_ = [ - ('uMsg', DWORD), - ('wParamL', WORD), - ('wParamH', WORD) - ] - - class INPUTUNION(ctypes.Union): - """Input union.""" - - _fields_ = [ - ('mi', MOUSEINPUT), - ('ki', KEYBDINPUT), - ('hi', HARDWAREINPUT) - ] - - class INPUT(ctypes.Structure): - """Input structure.""" - - _fields_ = [ - ('type', DWORD), - ('union', INPUTUNION) - ] - - SendInput = ctypes.windll.user32.SendInput - SendInput.argtypes = [ctypes.c_uint, ctypes.POINTER(INPUT), ctypes.c_int] - - INPUT_MOUSE = 0 - INPUT_KEYBOARD = 1 - INPUT_HARDWARE = 2 - - -class WindowsHotkeyMgr(AbstractHotkeyMgr): - """Hot key management.""" - - # https://msdn.microsoft.com/en-us/library/windows/desktop/dd375731%28v=vs.85%29.aspx - # Limit ourselves to symbols in Windows 7 Segoe UI - DISPLAY = { - 0x03: 'Break', 0x08: 'Bksp', 0x09: u'↹', 0x0c: 'Clear', 0x0d: u'↵', 0x13: 'Pause', - 0x14: u'Ⓐ', 0x1b: 'Esc', - 0x20: u'⏘', 0x21: 'PgUp', 0x22: 'PgDn', 0x23: 'End', 0x24: 'Home', - 0x25: u'←', 0x26: u'↑', 0x27: u'→', 0x28: u'↓', - 0x2c: 'PrtScn', 0x2d: 'Ins', 0x2e: 'Del', 0x2f: 'Help', - 0x5d: u'▤', 0x5f: u'☾', - 0x90: u'➀', 0x91: 'ScrLk', - 0xa6: u'⇦', 0xa7: u'⇨', 0xa9: u'⊗', 0xab: u'☆', 0xac: u'⌂', 0xb4: u'✉', - } - - def __init__(self) -> None: - self.root: tk.Tk = None # type: ignore - self.thread: threading.Thread = None # type: ignore - with open(pathlib.Path(config.respath) / 'snd_good.wav', 'rb') as sg: - self.snd_good = sg.read() - with open(pathlib.Path(config.respath) / 'snd_bad.wav', 'rb') as sb: - self.snd_bad = sb.read() - atexit.register(self.unregister) - - def register(self, root: tk.Tk, keycode, modifiers) -> None: - """Register the hotkey handler.""" - self.root = root - - if self.thread: - logger.debug('Was already registered, unregistering...') - self.unregister() - - if keycode or modifiers: - logger.debug('Creating thread worker...') - self.thread = threading.Thread( - target=self.worker, - name=f'Hotkey "{keycode}:{modifiers}"', - args=(keycode, modifiers) - ) - self.thread.daemon = True - logger.debug('Starting thread worker...') - self.thread.start() - logger.debug('Done.') - - def unregister(self) -> None: - """Unregister the hotkey handling.""" - thread = self.thread - - if thread: - logger.debug('Thread is/was running') - self.thread = None # type: ignore - logger.debug('Telling thread WM_QUIT') - PostThreadMessage(thread.ident, WM_QUIT, 0, 0) - logger.debug('Joining thread') - thread.join() # Wait for it to unregister hotkey and quit - - else: - logger.debug('No thread') - - logger.debug('Done.') - - def worker(self, keycode, modifiers) -> None: # noqa: CCR001 - """Handle hotkeys.""" - logger.debug('Begin...') - # Hotkey must be registered by the thread that handles it - if not RegisterHotKey(None, 1, modifiers | MOD_NOREPEAT, keycode): - logger.debug("We're not the right thread?") - self.thread = None # type: ignore - return - - fake = INPUT(INPUT_KEYBOARD, INPUTUNION(ki=KEYBDINPUT(keycode, keycode, 0, 0, None))) - - msg = MSG() - logger.debug('Entering GetMessage() loop...') - while GetMessage(ctypes.byref(msg), None, 0, 0) != 0: - logger.debug('Got message') - if msg.message == WM_HOTKEY: - logger.debug('WM_HOTKEY') - - if ( - config.get_int('hotkey_always') - or window_title(GetForegroundWindow()).startswith('Elite - Dangerous') - ): - if not config.shutting_down: - logger.debug('Sending event <>') - self.root.event_generate('<>', when="tail") - - else: - logger.debug('Passing key on') - UnregisterHotKey(None, 1) - SendInput(1, fake, ctypes.sizeof(INPUT)) - if not RegisterHotKey(None, 1, modifiers | MOD_NOREPEAT, keycode): - logger.debug("We aren't registered for this ?") - break - - elif msg.message == WM_SND_GOOD: - logger.debug('WM_SND_GOOD') - winsound.PlaySound(self.snd_good, winsound.SND_MEMORY) # synchronous - - elif msg.message == WM_SND_BAD: - logger.debug('WM_SND_BAD') - winsound.PlaySound(self.snd_bad, winsound.SND_MEMORY) # synchronous - - else: - logger.debug('Something else') - TranslateMessage(ctypes.byref(msg)) - DispatchMessage(ctypes.byref(msg)) - - logger.debug('Exited GetMessage() loop.') - UnregisterHotKey(None, 1) - self.thread = None # type: ignore - logger.debug('Done.') - - def acquire_start(self) -> None: - """Start acquiring hotkey state via polling.""" - pass - - def acquire_stop(self) -> None: - """Stop acquiring hotkey state.""" - pass - - def fromevent(self, event) -> Optional[Union[bool, Tuple]]: # noqa: CCR001 - """ - Return configuration (keycode, modifiers) or None=clear or False=retain previous. - - event.state is a pain - it shows the state of the modifiers *before* a modifier key was pressed. - event.state *does* differentiate between left and right Ctrl and Alt and between Return and Enter - by putting KF_EXTENDED in bit 18, but RegisterHotKey doesn't differentiate. - - :param event: tk event ? - :return: False to retain previous, None to not use, else (keycode, modifiers) - """ - modifiers = ((GetKeyState(VK_MENU) & 0x8000) and MOD_ALT) \ - | ((GetKeyState(VK_CONTROL) & 0x8000) and MOD_CONTROL) \ - | ((GetKeyState(VK_SHIFT) & 0x8000) and MOD_SHIFT) \ - | ((GetKeyState(VK_LWIN) & 0x8000) and MOD_WIN) \ - | ((GetKeyState(VK_RWIN) & 0x8000) and MOD_WIN) - keycode = event.keycode - - if keycode in [VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN, VK_RWIN]: - return (0, modifiers) - - if not modifiers: - if keycode == VK_ESCAPE: # Esc = retain previous - return False - - elif keycode in [VK_BACK, VK_DELETE, VK_CLEAR, VK_OEM_CLEAR]: # BkSp, Del, Clear = clear hotkey - return None - - elif keycode in [VK_RETURN, VK_SPACE, VK_OEM_MINUS] or ord('A') <= keycode <= ord( - 'Z'): # don't allow keys needed for typing in System Map - winsound.MessageBeep() - return None - - elif (keycode in [VK_NUMLOCK, VK_SCROLL, VK_PROCESSKEY] - or VK_CAPITAL <= keycode <= VK_MODECHANGE): # ignore unmodified mode switch keys - return (0, modifiers) - - # See if the keycode is usable and available - if RegisterHotKey(None, 2, modifiers | MOD_NOREPEAT, keycode): - UnregisterHotKey(None, 2) - return (keycode, modifiers) - - else: - winsound.MessageBeep() - return None - - def display(self, keycode, modifiers) -> str: - """ - Return displayable form of given hotkey + modifiers. - - :param keycode: - :param modifiers: - :return: string form - """ - text = '' - if modifiers & MOD_WIN: - text += u'❖+' - - if modifiers & MOD_CONTROL: - text += u'Ctrl+' - - if modifiers & MOD_ALT: - text += u'Alt+' - - if modifiers & MOD_SHIFT: - text += u'⇧+' - - if VK_NUMPAD0 <= keycode <= VK_DIVIDE: - text += u'№' - - if not keycode: - pass - - elif VK_F1 <= keycode <= VK_F24: - text += f'F{keycode + 1 - VK_F1}' - - elif keycode in WindowsHotkeyMgr.DISPLAY: # specials - text += WindowsHotkeyMgr.DISPLAY[keycode] - - else: - c = MapVirtualKey(keycode, 2) # printable ? - if not c: # oops not printable - text += u'⁈' - - elif c < 0x20: # control keys - text += chr(c + 0x40) - - else: - text += chr(c).upper() - - return text - - def play_good(self) -> None: - """Play the 'good' sound.""" - if self.thread: - PostThreadMessage(self.thread.ident, WM_SND_GOOD, 0, 0) - - def play_bad(self) -> None: - """Play the 'bad' sound.""" - if self.thread: - PostThreadMessage(self.thread.ident, WM_SND_BAD, 0, 0) - - -class LinuxHotKeyMgr(AbstractHotkeyMgr): - """ - Hot key management. - - Not actually implemented on Linux. It's a no-op instead. - """ - - def register(self, root, keycode, modifiers) -> None: - """Register the hotkey handler.""" - pass - - def unregister(self) -> None: - """Unregister the hotkey handling.""" - pass - - def play_good(self) -> None: - """Play the 'good' sound.""" - pass - - def play_bad(self) -> None: - """Play the 'bad' sound.""" - pass - - -def get_hotkeymgr() -> AbstractHotkeyMgr: - """ - Determine platform-specific HotkeyMgr. - - :param args: - :param kwargs: - :return: Appropriate class instance. - :raises ValueError: If unsupported platform. - """ - if sys.platform == 'darwin': - return MacHotkeyMgr() - - elif sys.platform == 'win32': - return WindowsHotkeyMgr() - - elif sys.platform == 'linux': - return LinuxHotKeyMgr() - - else: - raise ValueError(f'Unknown platform: {sys.platform}') - - -# singleton -hotkeymgr = get_hotkeymgr() diff --git a/hotkey/__init__.py b/hotkey/__init__.py new file mode 100644 index 00000000..782a16fa --- /dev/null +++ b/hotkey/__init__.py @@ -0,0 +1,59 @@ +"""Handle keyboard input for manual update triggering.""" +# -*- coding: utf-8 -*- + +import abc +import sys +from abc import abstractmethod + + +class AbstractHotkeyMgr(abc.ABC): + """Abstract root class of all platforms specific HotKeyMgr.""" + + @abstractmethod + def register(self, root, keycode, modifiers) -> None: + """Register the hotkey handler.""" + pass + + @abstractmethod + def unregister(self) -> None: + """Unregister the hotkey handling.""" + pass + + @abstractmethod + def play_good(self) -> None: + """Play the 'good' sound.""" + pass + + @abstractmethod + def play_bad(self) -> None: + """Play the 'bad' sound.""" + pass + + +def get_hotkeymgr() -> AbstractHotkeyMgr: + """ + Determine platform-specific HotkeyMgr. + + :param args: + :param kwargs: + :return: Appropriate class instance. + :raises ValueError: If unsupported platform. + """ + if sys.platform == 'darwin': + from hotkey.darwin import MacHotkeyMgr + return MacHotkeyMgr() + + elif sys.platform == 'win32': + from hotkey.windows import WindowsHotkeyMgr + return WindowsHotkeyMgr() + + elif sys.platform == 'linux': + from hotkey.linux import LinuxHotKeyMgr + return LinuxHotKeyMgr() + + else: + raise ValueError(f'Unknown platform: {sys.platform}') + + +# singleton +hotkeymgr = get_hotkeymgr() diff --git a/hotkey/darwin.py b/hotkey/darwin.py new file mode 100644 index 00000000..9989b4f9 --- /dev/null +++ b/hotkey/darwin.py @@ -0,0 +1,274 @@ +"""darwin/macOS implementation of hotkey.AbstractHotkeyMgr.""" +import pathlib +import sys +import tkinter as tk +from typing import Callable, Optional, Tuple, Union + +import objc +from AppKit import ( + NSAlternateKeyMask, NSApplication, NSBeep, NSClearLineFunctionKey, NSCommandKeyMask, NSControlKeyMask, + NSDeleteFunctionKey, NSDeviceIndependentModifierFlagsMask, NSEvent, NSF1FunctionKey, NSF35FunctionKey, + NSFlagsChanged, NSKeyDown, NSKeyDownMask, NSKeyUp, NSNumericPadKeyMask, NSShiftKeyMask, NSSound, NSWorkspace +) + +from config import config +from EDMCLogging import get_main_logger +from hotkey import AbstractHotkeyMgr + +logger = get_main_logger() + + +class MacHotkeyMgr(AbstractHotkeyMgr): + """Hot key management.""" + + POLL = 250 + # https://developer.apple.com/library/mac/documentation/Cocoa/Reference/ApplicationKit/Classes/NSEvent_Class/#//apple_ref/doc/constant_group/Function_Key_Unicodes + DISPLAY = { + 0x03: u'⌅', 0x09: u'⇥', 0xd: u'↩', 0x19: u'⇤', 0x1b: u'esc', 0x20: u'⏘', 0x7f: u'⌫', + 0xf700: u'↑', 0xf701: u'↓', 0xf702: u'←', 0xf703: u'→', + 0xf727: u'Ins', + 0xf728: u'⌦', 0xf729: u'↖', 0xf72a: u'Fn', 0xf72b: u'↘', + 0xf72c: u'⇞', 0xf72d: u'⇟', 0xf72e: u'PrtScr', 0xf72f: u'ScrollLock', + 0xf730: u'Pause', 0xf731: u'SysReq', 0xf732: u'Break', 0xf733: u'Reset', + 0xf739: u'⌧', + } + (ACQUIRE_INACTIVE, ACQUIRE_ACTIVE, ACQUIRE_NEW) = range(3) + + def __init__(self): + self.MODIFIERMASK = NSShiftKeyMask | NSControlKeyMask | NSAlternateKeyMask | NSCommandKeyMask \ + | NSNumericPadKeyMask + self.root: tk.Tk + + self.keycode = 0 + self.modifiers = 0 + self.activated = False + self.observer = None + + self.acquire_key = 0 + self.acquire_state = MacHotkeyMgr.ACQUIRE_INACTIVE + + self.tkProcessKeyEvent_old: Callable + + self.snd_good = NSSound.alloc().initWithContentsOfFile_byReference_( + pathlib.Path(config.respath_path) / 'snd_good.wav', False + ) + self.snd_bad = NSSound.alloc().initWithContentsOfFile_byReference_( + pathlib.Path(config.respath_path) / 'snd_bad.wav', False + ) + + def register(self, root: tk.Tk, keycode, modifiers) -> None: + """ + Register current hotkey for monitoring. + + :param root: parent window. + :param keycode: Key to monitor. + :param modifiers: Any modifiers to take into account. + """ + self.root = root + self.keycode = keycode + self.modifiers = modifiers + self.activated = False + + if keycode: + if not self.observer: + self.root.after_idle(self._observe) + self.root.after(MacHotkeyMgr.POLL, self._poll) + + # Monkey-patch tk (tkMacOSXKeyEvent.c) + if not callable(self.tkProcessKeyEvent_old): + sel = b'tkProcessKeyEvent:' + cls = NSApplication.sharedApplication().class__() # type: ignore + self.tkProcessKeyEvent_old = NSApplication.sharedApplication().methodForSelector_(sel) # type: ignore + newmethod = objc.selector( # type: ignore + self.tkProcessKeyEvent, + selector=self.tkProcessKeyEvent_old.selector, + signature=self.tkProcessKeyEvent_old.signature + ) + objc.classAddMethod(cls, sel, newmethod) # type: ignore + + def tkProcessKeyEvent(self, cls, the_event): # noqa: N802 + """ + Monkey-patch tk (tkMacOSXKeyEvent.c). + + - workaround crash on OSX 10.9 & 10.10 on seeing a composing character + - notice when modifier key state changes + - keep a copy of NSEvent.charactersIgnoringModifiers, which is what we need for the hotkey + + (Would like to use a decorator but need to ensure the application is created before this is installed) + :param cls: ??? + :param the_event: tk event + :return: ??? + """ + if self.acquire_state: + if the_event.type() == NSFlagsChanged: + self.acquire_key = the_event.modifierFlags() & NSDeviceIndependentModifierFlagsMask + self.acquire_state = MacHotkeyMgr.ACQUIRE_NEW + # suppress the event by not chaining the old function + return the_event + + elif the_event.type() in (NSKeyDown, NSKeyUp): + c = the_event.charactersIgnoringModifiers() + self.acquire_key = (c and ord(c[0]) or 0) | \ + (the_event.modifierFlags() & NSDeviceIndependentModifierFlagsMask) + self.acquire_state = MacHotkeyMgr.ACQUIRE_NEW + # suppress the event by not chaining the old function + return the_event + + # replace empty characters with charactersIgnoringModifiers to avoid crash + elif the_event.type() in (NSKeyDown, NSKeyUp) and not the_event.characters(): + the_event = NSEvent.keyEventWithType_location_modifierFlags_timestamp_windowNumber_context_characters_charactersIgnoringModifiers_isARepeat_keyCode_( # noqa: E501 + # noqa: E501 + the_event.type(), + the_event.locationInWindow(), + the_event.modifierFlags(), + the_event.timestamp(), + the_event.windowNumber(), + the_event.context(), + the_event.charactersIgnoringModifiers(), + the_event.charactersIgnoringModifiers(), + the_event.isARepeat(), + the_event.keyCode() + ) + return self.tkProcessKeyEvent_old(cls, the_event) + + def _observe(self): + # Must be called after root.mainloop() so that the app's message loop has been created + self.observer = NSEvent.addGlobalMonitorForEventsMatchingMask_handler_(NSKeyDownMask, self._handler) + + def _poll(self): + if config.shutting_down: + return + + # No way of signalling to Tkinter from within the callback handler block that doesn't + # cause Python to crash, so poll. + if self.activated: + self.activated = False + self.root.event_generate('<>', when="tail") + + if self.keycode or self.modifiers: + self.root.after(MacHotkeyMgr.POLL, self._poll) + + def unregister(self) -> None: + """Remove hotkey registration.""" + self.keycode = 0 + self.modifiers = 0 + + if sys.platform == 'darwin': # noqa: C901 + @objc.callbackFor(NSEvent.addGlobalMonitorForEventsMatchingMask_handler_) + def _handler(self, event) -> None: + # use event.charactersIgnoringModifiers to handle composing characters like Alt-e + if ( + (event.modifierFlags() & self.MODIFIERMASK) == self.modifiers + and ord(event.charactersIgnoringModifiers()[0]) == self.keycode + ): + if config.get_int('hotkey_always'): + self.activated = True + + else: # Only trigger if game client is front process + front = NSWorkspace.sharedWorkspace().frontmostApplication() + if front and front.bundleIdentifier() == 'uk.co.frontier.EliteDangerous': + self.activated = True + + def acquire_start(self) -> None: + """Start acquiring hotkey state via polling.""" + self.acquire_state = MacHotkeyMgr.ACQUIRE_ACTIVE + self.root.after_idle(self._acquire_poll) + + def acquire_stop(self) -> None: + """Stop acquiring hotkey state.""" + self.acquire_state = MacHotkeyMgr.ACQUIRE_INACTIVE + + def _acquire_poll(self) -> None: + """Perform a poll of current hotkey state.""" + if config.shutting_down: + return + + # No way of signalling to Tkinter from within the monkey-patched event handler that doesn't + # cause Python to crash, so poll. + if self.acquire_state: + if self.acquire_state == MacHotkeyMgr.ACQUIRE_NEW: + # Abuse tkEvent's keycode field to hold our acquired key & modifier + self.root.event_generate('', keycode=self.acquire_key) + self.acquire_state = MacHotkeyMgr.ACQUIRE_ACTIVE + self.root.after(50, self._acquire_poll) + + def fromevent(self, event) -> Optional[Union[bool, Tuple]]: + """ + Return configuration (keycode, modifiers) or None=clear or False=retain previous. + + :param event: tk event ? + :return: False to retain previous, None to not use, else (keycode, modifiers) + """ + (keycode, modifiers) = (event.keycode & 0xffff, event.keycode & 0xffff0000) # Set by _acquire_poll() + if ( + keycode + and not (modifiers & (NSShiftKeyMask | NSControlKeyMask | NSAlternateKeyMask | NSCommandKeyMask)) + ): + if keycode == 0x1b: # Esc = retain previous + self.acquire_state = MacHotkeyMgr.ACQUIRE_INACTIVE + return False + + # BkSp, Del, Clear = clear hotkey + elif keycode in [0x7f, ord(NSDeleteFunctionKey), ord(NSClearLineFunctionKey)]: + self.acquire_state = MacHotkeyMgr.ACQUIRE_INACTIVE + return None + + # don't allow keys needed for typing in System Map + elif keycode in [0x13, 0x20, 0x2d] or 0x61 <= keycode <= 0x7a: + NSBeep() + self.acquire_state = MacHotkeyMgr.ACQUIRE_INACTIVE + return None + + return (keycode, modifiers) + + def display(self, keycode, modifiers) -> str: + """ + Return displayable form of given hotkey + modifiers. + + :param keycode: + :param modifiers: + :return: string form + """ + text = '' + if modifiers & NSControlKeyMask: + text += u'⌃' + + if modifiers & NSAlternateKeyMask: + text += u'⌥' + + if modifiers & NSShiftKeyMask: + text += u'⇧' + + if modifiers & NSCommandKeyMask: + text += u'⌘' + + if (modifiers & NSNumericPadKeyMask) and keycode <= 0x7f: + text += u'№' + + if not keycode: + pass + + elif ord(NSF1FunctionKey) <= keycode <= ord(NSF35FunctionKey): + text += f'F{keycode + 1 - ord(NSF1FunctionKey)}' + + elif keycode in MacHotkeyMgr.DISPLAY: # specials + text += MacHotkeyMgr.DISPLAY[keycode] + + elif keycode < 0x20: # control keys + text += chr(keycode + 0x40) + + elif keycode < 0xf700: # key char + text += chr(keycode).upper() + + else: + text += u'⁈' + + return text + + def play_good(self): + """Play the 'good' sound.""" + self.snd_good.play() + + def play_bad(self): + """Play the 'bad' sound.""" + self.snd_bad.play() diff --git a/hotkey/linux.py b/hotkey/linux.py new file mode 100644 index 00000000..9a60515f --- /dev/null +++ b/hotkey/linux.py @@ -0,0 +1,29 @@ +"""Linux implementation of hotkey.AbstractHotkeyMgr.""" +from EDMCLogging import get_main_logger +from hotkey import AbstractHotkeyMgr + +logger = get_main_logger() + + +class LinuxHotKeyMgr(AbstractHotkeyMgr): + """ + Hot key management. + + Not actually implemented on Linux. It's a no-op instead. + """ + + def register(self, root, keycode, modifiers) -> None: + """Register the hotkey handler.""" + pass + + def unregister(self) -> None: + """Unregister the hotkey handling.""" + pass + + def play_good(self) -> None: + """Play the 'good' sound.""" + pass + + def play_bad(self) -> None: + """Play the 'bad' sound.""" + pass diff --git a/hotkey/windows.py b/hotkey/windows.py new file mode 100644 index 00000000..4dd110af --- /dev/null +++ b/hotkey/windows.py @@ -0,0 +1,359 @@ +"""Windows implementation of hotkey.AbstractHotkeyMgr.""" +import atexit +import ctypes +import pathlib +import threading +import tkinter as tk +import winsound +from ctypes.wintypes import DWORD, HWND, LONG, LPWSTR, MSG, ULONG, WORD +from typing import Optional, Tuple, Union + +from config import config +from EDMCLogging import get_main_logger +from hotkey import AbstractHotkeyMgr + +logger = get_main_logger() + +RegisterHotKey = ctypes.windll.user32.RegisterHotKey +UnregisterHotKey = ctypes.windll.user32.UnregisterHotKey +MOD_ALT = 0x0001 +MOD_CONTROL = 0x0002 +MOD_SHIFT = 0x0004 +MOD_WIN = 0x0008 +MOD_NOREPEAT = 0x4000 + +GetMessage = ctypes.windll.user32.GetMessageW +TranslateMessage = ctypes.windll.user32.TranslateMessage +DispatchMessage = ctypes.windll.user32.DispatchMessageW +PostThreadMessage = ctypes.windll.user32.PostThreadMessageW +WM_QUIT = 0x0012 +WM_HOTKEY = 0x0312 +WM_APP = 0x8000 +WM_SND_GOOD = WM_APP + 1 +WM_SND_BAD = WM_APP + 2 + +GetKeyState = ctypes.windll.user32.GetKeyState +MapVirtualKey = ctypes.windll.user32.MapVirtualKeyW +VK_BACK = 0x08 +VK_CLEAR = 0x0c +VK_RETURN = 0x0d +VK_SHIFT = 0x10 +VK_CONTROL = 0x11 +VK_MENU = 0x12 +VK_CAPITAL = 0x14 +VK_MODECHANGE = 0x1f +VK_ESCAPE = 0x1b +VK_SPACE = 0x20 +VK_DELETE = 0x2e +VK_LWIN = 0x5b +VK_RWIN = 0x5c +VK_NUMPAD0 = 0x60 +VK_DIVIDE = 0x6f +VK_F1 = 0x70 +VK_F24 = 0x87 +VK_OEM_MINUS = 0xbd +VK_NUMLOCK = 0x90 +VK_SCROLL = 0x91 +VK_PROCESSKEY = 0xe5 +VK_OEM_CLEAR = 0xfe + +GetForegroundWindow = ctypes.windll.user32.GetForegroundWindow +GetWindowText = ctypes.windll.user32.GetWindowTextW +GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int] +GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW + +def window_title(h) -> str: + """ + Determine the title for a window. + + :param h: Window handle. + :return: Window title. + """ + if h: + title_length = GetWindowTextLength(h) + 1 + buf = ctypes.create_unicode_buffer(title_length) + if GetWindowText(h, buf, title_length): + return buf.value + + return '' + +class MOUSEINPUT(ctypes.Structure): + """Mouse Input structure.""" + + _fields_ = [ + ('dx', LONG), + ('dy', LONG), + ('mouseData', DWORD), + ('dwFlags', DWORD), + ('time', DWORD), + ('dwExtraInfo', ctypes.POINTER(ULONG)) + ] + +class KEYBDINPUT(ctypes.Structure): + """Keyboard Input structure.""" + + _fields_ = [ + ('wVk', WORD), + ('wScan', WORD), + ('dwFlags', DWORD), + ('time', DWORD), + ('dwExtraInfo', ctypes.POINTER(ULONG)) + ] + +class HARDWAREINPUT(ctypes.Structure): + """Hardware Input structure.""" + + _fields_ = [ + ('uMsg', DWORD), + ('wParamL', WORD), + ('wParamH', WORD) + ] + +class INPUTUNION(ctypes.Union): + """Input union.""" + + _fields_ = [ + ('mi', MOUSEINPUT), + ('ki', KEYBDINPUT), + ('hi', HARDWAREINPUT) + ] + +class INPUT(ctypes.Structure): + """Input structure.""" + + _fields_ = [ + ('type', DWORD), + ('union', INPUTUNION) + ] + +SendInput = ctypes.windll.user32.SendInput +SendInput.argtypes = [ctypes.c_uint, ctypes.POINTER(INPUT), ctypes.c_int] + +INPUT_MOUSE = 0 +INPUT_KEYBOARD = 1 +INPUT_HARDWARE = 2 + + +class WindowsHotkeyMgr(AbstractHotkeyMgr): + """Hot key management.""" + + # https://msdn.microsoft.com/en-us/library/windows/desktop/dd375731%28v=vs.85%29.aspx + # Limit ourselves to symbols in Windows 7 Segoe UI + DISPLAY = { + 0x03: 'Break', 0x08: 'Bksp', 0x09: u'↹', 0x0c: 'Clear', 0x0d: u'↵', 0x13: 'Pause', + 0x14: u'Ⓐ', 0x1b: 'Esc', + 0x20: u'⏘', 0x21: 'PgUp', 0x22: 'PgDn', 0x23: 'End', 0x24: 'Home', + 0x25: u'←', 0x26: u'↑', 0x27: u'→', 0x28: u'↓', + 0x2c: 'PrtScn', 0x2d: 'Ins', 0x2e: 'Del', 0x2f: 'Help', + 0x5d: u'▤', 0x5f: u'☾', + 0x90: u'➀', 0x91: 'ScrLk', + 0xa6: u'⇦', 0xa7: u'⇨', 0xa9: u'⊗', 0xab: u'☆', 0xac: u'⌂', 0xb4: u'✉', + } + + def __init__(self) -> None: + self.root: tk.Tk = None # type: ignore + self.thread: threading.Thread = None # type: ignore + with open(pathlib.Path(config.respath) / 'snd_good.wav', 'rb') as sg: + self.snd_good = sg.read() + with open(pathlib.Path(config.respath) / 'snd_bad.wav', 'rb') as sb: + self.snd_bad = sb.read() + atexit.register(self.unregister) + + def register(self, root: tk.Tk, keycode, modifiers) -> None: + """Register the hotkey handler.""" + self.root = root + + if self.thread: + logger.debug('Was already registered, unregistering...') + self.unregister() + + if keycode or modifiers: + logger.debug('Creating thread worker...') + self.thread = threading.Thread( + target=self.worker, + name=f'Hotkey "{keycode}:{modifiers}"', + args=(keycode, modifiers) + ) + self.thread.daemon = True + logger.debug('Starting thread worker...') + self.thread.start() + logger.debug('Done.') + + def unregister(self) -> None: + """Unregister the hotkey handling.""" + thread = self.thread + + if thread: + logger.debug('Thread is/was running') + self.thread = None # type: ignore + logger.debug('Telling thread WM_QUIT') + PostThreadMessage(thread.ident, WM_QUIT, 0, 0) + logger.debug('Joining thread') + thread.join() # Wait for it to unregister hotkey and quit + + else: + logger.debug('No thread') + + logger.debug('Done.') + + def worker(self, keycode, modifiers) -> None: # noqa: CCR001 + """Handle hotkeys.""" + logger.debug('Begin...') + # Hotkey must be registered by the thread that handles it + if not RegisterHotKey(None, 1, modifiers | MOD_NOREPEAT, keycode): + logger.debug("We're not the right thread?") + self.thread = None # type: ignore + return + + fake = INPUT(INPUT_KEYBOARD, INPUTUNION(ki=KEYBDINPUT(keycode, keycode, 0, 0, None))) + + msg = MSG() + logger.debug('Entering GetMessage() loop...') + while GetMessage(ctypes.byref(msg), None, 0, 0) != 0: + logger.debug('Got message') + if msg.message == WM_HOTKEY: + logger.debug('WM_HOTKEY') + + if ( + config.get_int('hotkey_always') + or window_title(GetForegroundWindow()).startswith('Elite - Dangerous') + ): + if not config.shutting_down: + logger.debug('Sending event <>') + self.root.event_generate('<>', when="tail") + + else: + logger.debug('Passing key on') + UnregisterHotKey(None, 1) + SendInput(1, fake, ctypes.sizeof(INPUT)) + if not RegisterHotKey(None, 1, modifiers | MOD_NOREPEAT, keycode): + logger.debug("We aren't registered for this ?") + break + + elif msg.message == WM_SND_GOOD: + logger.debug('WM_SND_GOOD') + winsound.PlaySound(self.snd_good, winsound.SND_MEMORY) # synchronous + + elif msg.message == WM_SND_BAD: + logger.debug('WM_SND_BAD') + winsound.PlaySound(self.snd_bad, winsound.SND_MEMORY) # synchronous + + else: + logger.debug('Something else') + TranslateMessage(ctypes.byref(msg)) + DispatchMessage(ctypes.byref(msg)) + + logger.debug('Exited GetMessage() loop.') + UnregisterHotKey(None, 1) + self.thread = None # type: ignore + logger.debug('Done.') + + def acquire_start(self) -> None: + """Start acquiring hotkey state via polling.""" + pass + + def acquire_stop(self) -> None: + """Stop acquiring hotkey state.""" + pass + + def fromevent(self, event) -> Optional[Union[bool, Tuple]]: # noqa: CCR001 + """ + Return configuration (keycode, modifiers) or None=clear or False=retain previous. + + event.state is a pain - it shows the state of the modifiers *before* a modifier key was pressed. + event.state *does* differentiate between left and right Ctrl and Alt and between Return and Enter + by putting KF_EXTENDED in bit 18, but RegisterHotKey doesn't differentiate. + + :param event: tk event ? + :return: False to retain previous, None to not use, else (keycode, modifiers) + """ + modifiers = ((GetKeyState(VK_MENU) & 0x8000) and MOD_ALT) \ + | ((GetKeyState(VK_CONTROL) & 0x8000) and MOD_CONTROL) \ + | ((GetKeyState(VK_SHIFT) & 0x8000) and MOD_SHIFT) \ + | ((GetKeyState(VK_LWIN) & 0x8000) and MOD_WIN) \ + | ((GetKeyState(VK_RWIN) & 0x8000) and MOD_WIN) + keycode = event.keycode + + if keycode in [VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN, VK_RWIN]: + return (0, modifiers) + + if not modifiers: + if keycode == VK_ESCAPE: # Esc = retain previous + return False + + elif keycode in [VK_BACK, VK_DELETE, VK_CLEAR, VK_OEM_CLEAR]: # BkSp, Del, Clear = clear hotkey + return None + + elif keycode in [VK_RETURN, VK_SPACE, VK_OEM_MINUS] or ord('A') <= keycode <= ord( + 'Z'): # don't allow keys needed for typing in System Map + winsound.MessageBeep() + return None + + elif (keycode in [VK_NUMLOCK, VK_SCROLL, VK_PROCESSKEY] + or VK_CAPITAL <= keycode <= VK_MODECHANGE): # ignore unmodified mode switch keys + return (0, modifiers) + + # See if the keycode is usable and available + if RegisterHotKey(None, 2, modifiers | MOD_NOREPEAT, keycode): + UnregisterHotKey(None, 2) + return (keycode, modifiers) + + else: + winsound.MessageBeep() + return None + + def display(self, keycode, modifiers) -> str: + """ + Return displayable form of given hotkey + modifiers. + + :param keycode: + :param modifiers: + :return: string form + """ + text = '' + if modifiers & MOD_WIN: + text += u'❖+' + + if modifiers & MOD_CONTROL: + text += u'Ctrl+' + + if modifiers & MOD_ALT: + text += u'Alt+' + + if modifiers & MOD_SHIFT: + text += u'⇧+' + + if VK_NUMPAD0 <= keycode <= VK_DIVIDE: + text += u'№' + + if not keycode: + pass + + elif VK_F1 <= keycode <= VK_F24: + text += f'F{keycode + 1 - VK_F1}' + + elif keycode in WindowsHotkeyMgr.DISPLAY: # specials + text += WindowsHotkeyMgr.DISPLAY[keycode] + + else: + c = MapVirtualKey(keycode, 2) # printable ? + if not c: # oops not printable + text += u'⁈' + + elif c < 0x20: # control keys + text += chr(c + 0x40) + + else: + text += chr(c).upper() + + return text + + def play_good(self) -> None: + """Play the 'good' sound.""" + if self.thread: + PostThreadMessage(self.thread.ident, WM_SND_GOOD, 0, 0) + + def play_bad(self) -> None: + """Play the 'bad' sound.""" + if self.thread: + PostThreadMessage(self.thread.ident, WM_SND_BAD, 0, 0)