pythonpython-multithreadingpywin32pywinauto

How to listen for hotkeys in a separate thread using Python with Win32 API and PySide6?


I’m setting up a hotkey system for Windows in Python, using the Win32 API and PySide6. I want to register hotkeys in a HotkeyManager class and listen for them in a separate thread, so the GUI remains responsive. However, when I move the listening logic to a thread, the hotkey events are not detected correctly.

Here’s the code that works without using threads, where hotkeys are registered and detected on the main thread:

from threading import Thread
from typing import Callable, Dict
from win32gui import RegisterHotKey, UnregisterHotKey, GetMessage
from win32con import VK_NUMPAD0, MOD_NOREPEAT

class HotkeyManager:
    def __init__(self):
        self.hotkey_id = 1
        self._callbacks: Dict[int, Callable] = {}

    def register_hotkey(self, key_code: int, callback: Callable):
        self._callbacks[self.hotkey_id] = callback
        RegisterHotKey(0, self.hotkey_id, MOD_NOREPEAT, key_code)
        self.hotkey_id += 1

    def listen(self):
        while True:
            print("Listener started.")
            msg = GetMessage(None, 0, 0)
            hotkey_id = msg[1]
            if hotkey_id in self._callbacks:
                self._callbacks[hotkey_id]()

In the main code, this setup works as expected:

from PySide6 import QtWidgets
from win32con import VK_NUMPAD0

def on_press():
    print("Numpad 0 pressed!")

app = QtWidgets.QApplication([])
manager = HotkeyManager()
manager.register_hotkey(VK_NUMPAD0, on_press)
manager.listen()

# Initialize window
widget = QtWidgets.QMainWindow()
widget.show()
app.exec()

When I try to move the listen() method to a separate thread, however, the hotkey doesn’t respond properly:

class HotkeyManager:
    def listen(self):
        def run():
            while True:
                print("Listener started.")
                msg = GetMessage(None, 0, 0)
                hotkey_id = msg[1]
                if hotkey_id in self._callbacks:
                    self._callbacks[hotkey_id]()
        
        thread = Thread(target=run, daemon=True)
        thread.start()

How can I correctly listen for hotkeys in a separate thread without losing functionality? It seems that the issue may be due to the hotkeys being registered on the main thread while the listening logic runs in a secondary thread. How could I solve this so everything works as expected?


Solution

  • Regarding the statement: "Here’s the code that works without using threads", there's nothing about code in the question that actually works. Let me detail:

    This way of having a thread monitoring for WM_HOTKEY, seems a bit clumsy as the window has its own message loop, and if it was for me to chose, I'd try to handle these messages in the window's message processing function, but a shallow Google search didn't reveal anything useful.
    Posting a solution that works based on your code. The idea is to wrap the hotkey registering and listening in a function to be executed in a thread.

    code00.py:

    #!/usr/bin/env python
    
    import sys
    import threading
    import typing
    
    import win32con as wcon
    import win32gui as wgui
    from PySide6 import QtWidgets
    
    MOD_NOREPEAT = 0x4000
    
    
    class HotkeyManager:
        def __init__(self):
            self.hotkey_id = 1
            self._callbacks: typing.Dict[int, typing.Callable] = {}
            self.threads = []
    
        def __del__(self):
            self.clear()
    
        def register_hotkey(self, key_code: int, callback: typing.Callable):
            t = threading.Thread(target=self._register_hotkey, args=(key_code, callback), daemon=True)
            self.threads.append(t)
            t.start()
    
        def _register_hotkey(self, key_code: int, callback: typing.Callable):
            self._callbacks[self.hotkey_id] = callback
            wgui.RegisterHotKey(None, self.hotkey_id, MOD_NOREPEAT, key_code)
            self.hotkey_id += 1
            self._listen()
    
        def _listen(self):
            print(f"Listener started ({threading.get_ident()}, {threading.get_native_id()})")
            while True:
                res = wgui.GetMessage(None, 0, 0)
                print(f" GetMessage returned: {res}")  # @TODO - cfati: Check what it returns!
                rc, msg = res
                hotkey_id = msg[2]
                if hotkey_id in self._callbacks:
                    self._callbacks[hotkey_id]()
    
        def clear(self):
            for hkid in self._callbacks:
                wgui.UnregisterHotKey(None, hkid)
            self._callbacks = {}
    
    
    def on_press_np0():
        print(f"NumPad0 pressed!")
    
    
    def main(*argv):
        print(f"Main thread ({threading.get_ident()}, {threading.get_native_id()})")
        app = QtWidgets.QApplication([])
        widget = QtWidgets.QMainWindow()
        widget.setWindowTitle("SO q079161068")
        widget.show()
        print("HotKeys")
        hkm = HotkeyManager()
        hkm.register_hotkey(wcon.VK_NUMPAD0, on_press_np0)
        print("App mesage loop")
        app.exec()
    
    
    if __name__ == "__main__":
        print(
            "Python {:s} {:03d}bit on {:s}\n".format(
                " ".join(elem.strip() for elem in sys.version.split("\n")),
                64 if sys.maxsize > 0x100000000 else 32,
                sys.platform,
            )
        )
        rc = main(*sys.argv[1:])
        print("\nDone.\n")
        sys.exit(rc)
    

    Output:

    [cfati@CFATI-5510-0:e:\Work\Dev\StackExchange\StackOverflow\q079161068]> "e:\Work\Dev\VEnvs\py_pc064_03.10_test0\Scripts\python.exe" ./code00.py
    Python 3.10.11 (tags/v3.10.11:7d4cc5a, Apr  5 2023, 00:38:17) [MSC v.1929 64 bit (AMD64)] 064bit on win32
    
    Main thread (23772, 23772)
    HotKeys
    App mesage loop
    Listener started (23672, 23672)
     GetMessage returned: [1, (0, 786, 1, 6291456, 514232828, (1, 0))]
    NumPad0 pressed!
     GetMessage returned: [1, (0, 786, 1, 6291456, 514234437, (1919, 0))]
    NumPad0 pressed!
     GetMessage returned: [1, (0, 786, 1, 6291456, 514235703, (1919, 1079))]
    NumPad0 pressed!
     GetMessage returned: [1, (0, 786, 1, 6291456, 514237421, (0, 1079))]
    NumPad0 pressed!
     GetMessage returned: [1, (0, 786, 1, 6291456, 514238718, (933, 534))]
    NumPad0 pressed!
     GetMessage returned: [1, (0, 786, 1, 6291456, 514244375, (1650, 301))]
    NumPad0 pressed!
    
    Done.
    

    Also posting a screenshot:

    img00

    As a note, while thread is running NumPad 0 keystroke doesn't reach to other windows.