"""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()