diff --git a/hotkey.py b/hotkey.py index ba8c4560..256ed2f5 100644 --- a/hotkey.py +++ b/hotkey.py @@ -1,9 +1,11 @@ """Handle keyboard input for manual update triggering.""" # -*- coding: utf-8 -*- +import abc import pathlib +import sys import tkinter as tk -from sys import platform +from abc import abstractmethod from typing import Optional, Tuple, Union from config import config @@ -11,7 +13,32 @@ from EDMCLogging import get_main_logger logger = get_main_logger() -if platform == 'darwin': # noqa: C901 + +class AbstractHotkeyMgr(abc.abstractmethod): + """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 ( @@ -20,257 +47,258 @@ if platform == 'darwin': # noqa: C901 NSFlagsChanged, NSKeyDown, NSKeyDownMask, NSKeyUp, NSNumericPadKeyMask, NSShiftKeyMask, NSSound, NSWorkspace ) - class HotkeyMgr: - """Hot key management.""" - MODIFIERMASK = NSShiftKeyMask | NSControlKeyMask | NSAlternateKeyMask | NSCommandKeyMask | NSNumericPadKeyMask - 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) +class MacHotkeyMgr(AbstractHotkeyMgr): + """Hot key management.""" - def __init__(self): - self.root = None + MODIFIERMASK = NSShiftKeyMask | NSControlKeyMask | NSAlternateKeyMask | NSCommandKeyMask | NSNumericPadKeyMask + 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) - self.keycode = 0 - self.modifiers = 0 - self.activated = False - self.observer = None + def __init__(self): + self.root = None - self.acquire_key = 0 - self.acquire_state = HotkeyMgr.ACQUIRE_INACTIVE + self.keycode = 0 + self.modifiers = 0 + self.activated = False + self.observer = None - self.tkProcessKeyEvent_old = None + self.acquire_key = 0 + self.acquire_state = MacHotkeyMgr.ACQUIRE_INACTIVE - self.snd_good = NSSound.alloc().initWithContentsOfFile_byReference_( - pathlib.Path(config.respath_path) / 'snd_good.wav', False + 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__() + self.tkProcessKeyEvent_old = NSApplication.sharedApplication().methodForSelector_(sel) + newmethod = objc.selector( + self.tkProcessKeyEvent, + selector=self.tkProcessKeyEvent_old.selector, + signature=self.tkProcessKeyEvent_old.signature ) - self.snd_bad = NSSound.alloc().initWithContentsOfFile_byReference_( - pathlib.Path(config.respath_path) / 'snd_bad.wav', False + objc.classAddMethod(cls, sel, newmethod) + + 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 register(self, root: tk.Tk, keycode, modifiers) -> None: - """ - Register current hotkey for monitoring. + 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) - :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 + 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 keycode: - if not self.observer: - self.root.after_idle(self._observe) - self.root.after(HotkeyMgr.POLL, self._poll) + if self.keycode or self.modifiers: + self.root.after(MacHotkeyMgr.POLL, self._poll) - # Monkey-patch tk (tkMacOSXKeyEvent.c) - if not self.tkProcessKeyEvent_old: - sel = b'tkProcessKeyEvent:' - cls = NSApplication.sharedApplication().class__() - self.tkProcessKeyEvent_old = NSApplication.sharedApplication().methodForSelector_(sel) - newmethod = objc.selector( - self.tkProcessKeyEvent, - selector=self.tkProcessKeyEvent_old.selector, - signature=self.tkProcessKeyEvent_old.signature - ) - objc.classAddMethod(cls, sel, newmethod) + def unregister(self) -> None: + """Remove hotkey registration.""" + self.keycode = None + self.modifiers = None - def tkProcessKeyEvent(self, cls, the_event): # noqa: N802 - """ - Monkey-patch tk (tkMacOSXKeyEvent.c). + @objc.callbackFor(NSEvent.addGlobalMonitorForEventsMatchingMask_handler_) + def _handler(self, event) -> None: + # use event.charactersIgnoringModifiers to handle composing characters like Alt-e + if ((event.modifierFlags() & MacHotkeyMgr.MODIFIERMASK) == self.modifiers + and ord(event.charactersIgnoringModifiers()[0]) == self.keycode): + if config.get_int('hotkey_always'): + self.activated = True - - 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 = HotkeyMgr.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 = HotkeyMgr.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(HotkeyMgr.POLL, self._poll) - - def unregister(self) -> None: - """Remove hotkey registration.""" - self.keycode = None - self.modifiers = None - - @objc.callbackFor(NSEvent.addGlobalMonitorForEventsMatchingMask_handler_) - def _handler(self, event) -> None: - # use event.charactersIgnoringModifiers to handle composing characters like Alt-e - if ((event.modifierFlags() & HotkeyMgr.MODIFIERMASK) == self.modifiers - and ord(event.charactersIgnoringModifiers()[0]) == self.keycode): - if config.get_int('hotkey_always'): + 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 - 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_start(self) -> None: - """Start acquiring hotkey state via polling.""" - self.acquire_state = HotkeyMgr.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_stop(self) -> None: - """Stop acquiring hotkey state.""" - self.acquire_state = HotkeyMgr.ACQUIRE_INACTIVE + def _acquire_poll(self) -> None: + """Perform a poll of current hotkey state.""" + if config.shutting_down: + return - 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) - # 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 == HotkeyMgr.ACQUIRE_NEW: - # Abuse tkEvent's keycode field to hold our acquired key & modifier - self.root.event_generate('', keycode=self.acquire_key) - self.acquire_state = HotkeyMgr.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. - 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 - :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 = HotkeyMgr.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 - # BkSp, Del, Clear = clear hotkey - elif keycode in [0x7f, ord(NSDeleteFunctionKey), ord(NSClearLineFunctionKey)]: - self.acquire_state = HotkeyMgr.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 - # don't allow keys needed for typing in System Map - elif keycode in [0x13, 0x20, 0x2d] or 0x61 <= keycode <= 0x7a: - NSBeep() - self.acquire_state = HotkeyMgr.ACQUIRE_INACTIVE - return None + return (keycode, modifiers) - return (keycode, modifiers) + def display(self, keycode, modifiers) -> str: + """ + Return displayable form of given hotkey + 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'⌃' - :param keycode: - :param modifiers: - :return: string form - """ - text = '' - if modifiers & NSControlKeyMask: - text += u'⌃' + if modifiers & NSAlternateKeyMask: + text += u'⌥' - if modifiers & NSAlternateKeyMask: - text += u'⌥' + if modifiers & NSShiftKeyMask: + text += u'⇧' - if modifiers & NSShiftKeyMask: - text += u'⇧' + if modifiers & NSCommandKeyMask: + text += u'⌘' - if modifiers & NSCommandKeyMask: - text += u'⌘' + if (modifiers & NSNumericPadKeyMask) and keycode <= 0x7f: + text += u'№' - if (modifiers & NSNumericPadKeyMask) and keycode <= 0x7f: - text += u'№' + if not keycode: + pass - if not keycode: - pass + elif ord(NSF1FunctionKey) <= keycode <= ord(NSF35FunctionKey): + text += f'F{keycode + 1 - ord(NSF1FunctionKey)}' - 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 in HotkeyMgr.DISPLAY: # specials - text += HotkeyMgr.DISPLAY[keycode] + elif keycode < 0x20: # control keys + text += chr(keycode + 0x40) - elif keycode < 0x20: # control keys - text += chr(keycode + 0x40) + elif keycode < 0xf700: # key char + text += chr(keycode).upper() - elif keycode < 0xf700: # key char - text += chr(keycode).upper() + else: + text += u'⁈' - else: - text += u'⁈' + return text - return text + def play_good(self): + """Play the 'good' sound.""" + self.snd_good.play() - def play_good(self): - """Play the 'good' sound.""" - self.snd_good.play() - - def play_bad(self): - """Play the 'bad' sound.""" - self.snd_bad.play() + def play_bad(self): + """Play the 'bad' sound.""" + self.snd_bad.play() -elif platform == 'win32': +if sys.platform == 'win32': import atexit import ctypes @@ -397,250 +425,259 @@ elif platform == 'win32': INPUT_KEYBOARD = 1 INPUT_HARDWARE = 2 - class HotkeyMgr: - """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'✉', - } +class WindowsHotkeyMgr(AbstractHotkeyMgr): + """Hot key management.""" - def __init__(self): - self.root = None - self.thread = None - 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) + # 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 register(self, root: tk.Tk, keycode, modifiers) -> None: - """Register the hotkey handler.""" - self.root = root + def __init__(self): + self.root = None + self.thread = None + 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) - if self.thread: - logger.debug('Was already registered, unregistering...') - self.unregister() + def register(self, root: tk.Tk, keycode, modifiers) -> None: + """Register the hotkey handler.""" + self.root = root - 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 - 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') + 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 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 - return + def unregister(self) -> None: + """Unregister the hotkey handling.""" + thread = self.thread - fake = INPUT(INPUT_KEYBOARD, INPUTUNION(ki=KEYBDINPUT(keycode, keycode, 0, 0, None))) + if thread: + logger.debug('Thread is/was running') + self.thread = None + 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 - 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') + else: + logger.debug('No thread') - 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") + logger.debug('Done.') - 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 + 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 + return - elif msg.message == WM_SND_GOOD: - logger.debug('WM_SND_GOOD') - winsound.PlaySound(self.snd_good, winsound.SND_MEMORY) # synchronous + fake = INPUT(INPUT_KEYBOARD, INPUTUNION(ki=KEYBDINPUT(keycode, keycode, 0, 0, None))) - elif msg.message == WM_SND_BAD: - logger.debug('WM_SND_BAD') - winsound.PlaySound(self.snd_bad, winsound.SND_MEMORY) # synchronous + 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('Something else') - TranslateMessage(ctypes.byref(msg)) - DispatchMessage(ctypes.byref(msg)) + 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 - logger.debug('Exited GetMessage() loop.') - UnregisterHotKey(None, 1) - self.thread = None - logger.debug('Done.') + elif msg.message == WM_SND_GOOD: + logger.debug('WM_SND_GOOD') + winsound.PlaySound(self.snd_good, winsound.SND_MEMORY) # synchronous - 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) + 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 + 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 - def display(self, keycode, modifiers) -> str: - """ - Return displayable form of given hotkey + modifiers. + elif (keycode in [VK_NUMLOCK, VK_SCROLL, VK_PROCESSKEY] + or VK_CAPITAL <= keycode <= VK_MODECHANGE): # ignore unmodified mode switch keys + return (0, modifiers) - :param keycode: - :param modifiers: - :return: string form - """ - text = '' - if modifiers & MOD_WIN: - text += u'❖+' + # See if the keycode is usable and available + if RegisterHotKey(None, 2, modifiers | MOD_NOREPEAT, keycode): + UnregisterHotKey(None, 2) + return (keycode, modifiers) - if modifiers & MOD_CONTROL: - text += u'Ctrl+' + else: + winsound.MessageBeep() + return None - if modifiers & MOD_ALT: - text += u'Alt+' + def display(self, keycode, modifiers) -> str: + """ + Return displayable form of given hotkey + modifiers. - if modifiers & MOD_SHIFT: - text += u'⇧+' + :param keycode: + :param modifiers: + :return: string form + """ + text = '' + if modifiers & MOD_WIN: + text += u'❖+' - if VK_NUMPAD0 <= keycode <= VK_DIVIDE: - text += u'№' + if modifiers & MOD_CONTROL: + text += u'Ctrl+' - if not keycode: - pass + if modifiers & MOD_ALT: + text += u'Alt+' - elif VK_F1 <= keycode <= VK_F24: - text += f'F{keycode + 1 - VK_F1}' + if modifiers & MOD_SHIFT: + text += u'⇧+' - elif keycode in HotkeyMgr.DISPLAY: # specials - text += HotkeyMgr.DISPLAY[keycode] + 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: - c = MapVirtualKey(keycode, 2) # printable ? - if not c: # oops not printable - text += u'⁈' + text += chr(c).upper() - elif c < 0x20: # control keys - text += chr(c + 0x40) + return text - else: - text += chr(c).upper() + def play_good(self) -> None: + """Play the 'good' sound.""" + if self.thread: + PostThreadMessage(self.thread.ident, WM_SND_GOOD, 0, 0) - return text + def play_bad(self) -> None: + """Play the 'bad' sound.""" + if self.thread: + PostThreadMessage(self.thread.ident, WM_SND_BAD, 0, 0) - 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.""" -else: # Linux + pass - class HotkeyMgr: - """Hot key management.""" - def register(self, root, keycode, modifiers) -> None: - """Register the hotkey handler.""" - pass +def get_hotkeymgr(*args, **kwargs) -> AbstractHotkeyMgr: + """ + Determine platform-specific HotkeyMgr. - def unregister(self) -> None: - """Unregister the hotkey handling.""" - pass + :param args: + :param kwargs: + :return: Appropriate class instance. + :raises ValueError: If unsupported platform. + """ + if sys.platform == 'darwin': + return MacHotkeyMgr(*args, **kwargs) - def play_good(self) -> None: - """Play the 'good' sound.""" - pass + elif sys.platform == 'win32': + return WindowsHotkeyMgr(*args, **kwargs) + + elif sys.platform == 'linux': + return LinuxHotKeyMgr(*args, **kwargs) + + else: + raise ValueError(f'Unknown platform: {sys.platform}') - def play_bad(self) -> None: - """Play the 'bad' sound.""" - pass # singleton -hotkeymgr = HotkeyMgr() +hotkeymgr = get_hotkeymgr()