Search code examples
pythonpyqtpyqt5pyside2

Opening new window while out of focus using "keyboard"


I am trying to track my keypresses, using the module "keyboard" while a PySide2 Widget is not in focus, which works fine. However when I try to create a new Widget using a "keyboard" shortcut the program crashes. Opening a window on a button press works fine. I can also call non UI functions using "keyboard" eg. the print function without any problem.

Do you know a way to fix this and open a new window using "keyboard" or any other method, while a PySide2 window is not in focus. In this example I want to open a new window on "CTRL+D". The Problem exists both for PySide2 and PyQt5.

This is my shortened code:

import sys
import json
import os
import keyboard
from PySide2.QtWidgets import QApplication, QWidget, QGridLayout, QKeySequenceEdit, QLabel, QPushButton, QShortcut
from PySide2.QtCore import Qt, QObject, Signal, Slot # Qt.Key_W beispielsweise

#from PyQt5.QtWidgets import QApplication, QWidget, QGridLayout, QKeySequenceEdit, QLabel, QPushButton, QShortcut
#from PyQt5.QtCore import Qt, QObject, pyqtSignal as Signal, pyqtSlot as Slot # Qt.Key_W beispielsweise


class ConfigWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.initUi()
        self.init_shortcuts()
        self.show()

    def initUi(self):
        self.setGeometry(300,300, 400, 250)
        self.setWindowTitle("Settings")
        grid = QGridLayout()
        self.setLayout(grid)

        self.keyseq = QKeySequenceEdit("CTRL+D")
        grid.addWidget(self.keyseq, 0, 0)

        s_button = QPushButton("Safe")
        grid.addWidget(s_button, 1, 0)

        cl_button = QPushButton("Close")
        grid.addWidget(cl_button, 1, 1)
        cl_button.clicked.connect(self.close)

        open_button = QPushButton("openw")
        grid.addWidget(open_button, 2, 0)
        open_button.clicked.connect(self.call_item_parser)

    def keyPressEvent(self, event): #event:PySide2.QtGui.QKeyEvent
        if event.key() == Qt.Key_Escape:
            self.close()

    # shortcuts are listened to, while program is running
    def init_shortcuts(self):
        str_value = self.keyseq.keySequence().toString()
        print("Binding _price_keyseq to {}".format(str_value))
        keyboard.add_hotkey(str_value, self.call_item_parser)
        # keyboard.add_hotkey(str_value, print, args=("this works")) # this would work


    def call_item_parser(self):
        self.h_w = ParseWindow()
        self.h_w.setWindowTitle("New Window")
        self.h_w.setGeometry(100, 100, 100, 100)
        self.h_w.show()


class ParseWindow(QWidget):
    def __init__(self):
        super().__init__()


app = QApplication(sys.argv)
w = ConfigWindow()
sys.exit(app.exec_())

Solution

  • The problem is caused because the callback registered in keyboard is executed in a secondary thread as can be verified by modifying the following part of the code and printing threading.current_thread(). In Qt it is forbidden to create any widget in another thread since they are not thread-safe.

    def call_item_parser(self):
        print(threading.current_thread())
        self.h_w = ParseWindow()
        self.h_w.setWindowTitle("New Window")
        self.h_w.setGeometry(100, 100, 100, 100)
        self.h_w.show()
    print(threading.current_thread())
    app = QApplication(sys.argv)
    w = ConfigWindow()
    sys.exit(app.exec_())

    Output:

    <_MainThread(MainThread, started 140144979916608)>
    Binding _price_keyseq to ctrl+a
    <Thread(Thread-10, started daemon 140144220817152)>
    

    One possible solution is to use a signal to send the information to the main thread, and invoke the callback in the main thread.

    import sys
    from functools import partial
    import platform
    import threading
    
    import keyboard
    
    
    from PySide2.QtCore import Qt, QObject, Signal, Slot
    from PySide2.QtGui import QKeySequence
    from PySide2.QtWidgets import (
        QApplication,
        QWidget,
        QGridLayout,
        QKeySequenceEdit,
        QPushButton,
    )
    
    
    class KeyBoardManager(QObject):
        activated = Signal(str)
    
        def __init__(self, parent=None):
            super().__init__(parent)
            self._callbacks = dict()
            self.activated.connect(self._handle_activated)
    
        @property
        def callbacks(self):
            return self._callbacks
    
        def register(self, shortcut, callback, *, args=(), kwargs=None):
            self.callbacks[shortcut] = (callback, args, kwargs or {})
            keyboard.add_hotkey(shortcut, partial(self.activated.emit, shortcut))
    
        @Slot(str)
        def _handle_activated(self, shortcut):
            values = self.callbacks.get(shortcut)
            if values is not None:
                callback, args, kwargs = self._callbacks[shortcut]
    
                callback(*args, **kwargs)
    
    
    class ConfigWindow(QWidget):
        def __init__(self):
            super().__init__()
            self.initUi()
            self.init_shortcuts()
            self.show()
    
        def initUi(self):
            self.setGeometry(300, 300, 400, 250)
            self.setWindowTitle("Settings")
            grid = QGridLayout(self)
    
            self.keyseq = QKeySequenceEdit("CTRL+A")
            grid.addWidget(self.keyseq, 0, 0)
    
            s_button = QPushButton("Safe")
            grid.addWidget(s_button, 1, 0)
    
            cl_button = QPushButton("Close")
            grid.addWidget(cl_button, 1, 1)
            cl_button.clicked.connect(self.close)
    
            open_button = QPushButton("openw")
            grid.addWidget(open_button, 2, 0)
            open_button.clicked.connect(self.call_item_parser)
    
        def keyPressEvent(self, event):  # event:PySide2.QtGui.QKeyEvent
            if event.key() == Qt.Key_Escape:
                self.close()
    
        # shortcuts are listened to, while program is running
        def init_shortcuts(self):
            self.keyboard_manager = KeyBoardManager()
    
            str_value = self.keyseq.keySequence().toString()
            if platform.system() == "Linux":
                str_value = str_value.lower()
            print("Binding _price_keyseq to {}".format(str_value))
            self.keyboard_manager.register(str_value, self.call_item_parser)
    
        def call_item_parser(self):
            print(threading.current_thread())
            self.h_w = ParseWindow()
            self.h_w.setWindowTitle("New Window")
            self.h_w.setGeometry(100, 100, 100, 100)
            self.h_w.show()
    
    
    class ParseWindow(QWidget):
        pass
    
    
    def main():
        print(threading.current_thread())
        app = QApplication(sys.argv)
        w = ConfigWindow()
        sys.exit(app.exec_())
    
    
    if __name__ == "__main__":
        main()
    

    Output:

    <_MainThread(MainThread, started 140037641176896)>
    Binding _price_keyseq to ctrl+a
    <_MainThread(MainThread, started 140037641176896)>