Creating a (windows) system tray application with Qt & PySide2

I lately got irritated with how often I do the following:

  • Press windows key.
  • Type application name.
  • Hit return.
  • Either close the uninstaller for that application.
  • Or close internet explorer searching for my application name.
  • So I wanted to make my own!

    Big shoutout to this blog post from which I learned how to implement levenshtein distance for the search functionality:
    paperspace levenshtein distance

    So read that to learn what this is:

    def levenshteinDistance(a, b):
        m = len(a) + 1
        n = len(b) + 1
        matrix = [0] * (m * n)
        matrix[:m] = range(m)
        matrix[::m] = range(n)
        for x in range(1, m):
            for y in range(1, n):
                l = a[x - 1]
                r = b[y - 1]
                if l == r:
                    matrix[x + y * m] = matrix[x + y * m - 1 - m]
                else:
                    matrix[x + y * m] = min(matrix[x + y * m - 1 - m],
                                            matrix[x + y * m - 1],
                                            matrix[x + y * m - m]) + 1
        return matrix[-1]
    

    Next I wrote the bulk of my application as a custom widget:

    class MenuItems(QWidget):
        def __init__(self, commands):
    

    The commands here is a list of tuples, first is the (user friendly) label and second is what you can consider what you would write in a “.bat” file. So something along the lines of start “” “explorer”.

            super(MenuItems, self).__init__()
            self.content = list(commands)
            self.shuffled = None
            self.focus = 0
    

    The shuffled variable is either None or a sorted version of the commands (to put search results in order of best match).
    The focus is to indicate what item has keyboard focus (press return to launch that command).
    As a bonus, we can now put together bat files next to lnk and exe files (something the start menu and task bar can not).

        def eventFilter(self, widget, event):
            if event.type() != QEvent.KeyPress:
                return False
            if event.key() == Qt.Key_Return:
                os.system((self.shuffled or self.content)[self.focus][1])
                trayMenu.hide()
                event.accept()
                return True
            if event.key() == Qt.Key_Up:
                self.focus = max(self.focus - 1, 0)
            elif event.key() == Qt.Key_Down:
                self.focus = min(self.focus + 1, len(self.content) - 1)
            elif event.key() == Qt.Key_PageUp:
                self.focus = 0
            elif event.key() == Qt.Key_PageDown:
                self.focus = len(self.content) - 1
            else:
                return False
            event.accept()
            self.repaint()
            return True
    

    The event filter intercepts keyboard events for the popup that is going to contain this widget.
    The popup will be a qmenu so we want to take ownership of certain navigation events as we manage our own actions.

        def setFilter(self, text):
            if not text:
                self.shuffled = None
                self.repaint()
                return
            token = text.lower()
            self.shuffled = sorted(self.content, key=lambda pair: levenshteinDistance(pair[0].lower(), token))
            self.shuffled = sorted(self.shuffled, key=lambda pair: not pair[0].lower().startswith(token))
            self.focus = 0
            self.repaint()
    

    The real fun begins here, a search field will forward user input here and we will use the levenshtein distance to sort the results.
    Because the levenshtein distance is not weighted and I wanted exact matches at the top I also use a startswith filter after that.
    I.e. press “e” to list all items starting with “e”, not all items closest to “e”, which are all the shortest items btw.

        def sizeHint(self):
            return QSize(100, len(self.content) * 20)
    
        def paintEvent(self, _):
            painter = QPainter(self)
            y = 0
            for label, _ in (self.shuffled or self.content):
                r = QRect(0, y, self.width(), 20)
                if y / 20 == self.focus:
                    painter.fillRect(r, QColor(0, 127, 255, 127))
                painter.drawText(r, 0, label)
                y += 20
    
        def mouseReleaseEvent(self, _):
            cy = self.mapFromGlobal(QCursor.pos()).y()
            y = 0
            for _, cmd in (self.shuffled or self.content):
                if y <= cy < y + 20:
                    os.system(cmd)
                    trayMenu.hide()
                    return
                y += 20
    

    Basic widget stuff, draw our actions, click on them.

    Here are some example commands:

    import os
    codes = os.path.expandvars(r'''everything|"%USERPROFILE%\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Everything\Search Everything.lnk"
    unity hub|"C:\Program Files\Unity Hub\Unity Hub.exe"''')
    

    Now our application is just a system tray icon:

    app = QApplication([])
    trayIcon = QSystemTrayIcon(QIcon(iconPath))
    
    # ... omitting other code
    
    trayIcon.show()
    trayIcon.setVisible(True)
    
    app.exec_()
    
    # end of file
    

    that spawns a menu:

    # create components 
    actionList = MenuItems([code.split('|', 1) for code in codes.splitlines()])
    
    searchField = QLineEdit()
    searchField.setPlaceholderText('Search...')
    
    trayMenu = QMenu()
    
    # always on top
    trayMenu.setWindowFlag(Qt.WindowStaysOnTopHint)
    # make sure field steals focus
    trayMenu.setFocusProxy(searchField)
    
    # add components to menu
    actionListContainer = QWidgetAction(trayMenu)
    actionListContainer.setDefaultWidget(actionList)
    trayMenu.addAction(actionListContainer)
    
    searchFieldContainer = QWidgetAction(trayMenu)
    searchFieldContainer.setDefaultWidget(searchField)
    trayMenu.addAction(searchFieldContainer)
    
    # connect search field and action list
    searchField.installEventFilter(actionList)
    searchField.textChanged.connect(actionList.setFilter)
    
    # add quit button
    trayMenu.addSeparator()
    trayMenu.addAction('Quit').triggered.connect(QApplication.quit)
    

    Now there are some complications. A tray icon can have a right click menu, but I just want left click to open that menu.

    def activate():
        searchField.clear()
        trayMenu.popup(QCursor.pos())
        searchField.setFocus(Qt.PopupFocusReason)
    
    trayIcon.activated.connect(activate)
    

    When an application is minimized to the tray, it no longer listens to shortcuts, so we have to use the windows API to check key presses instead.

    _user32 = ctypes.WinDLL('user32.dll')  # load the right DLL & don't believe the people saying you need to pip install win32api
    _keyReleaseDetect = -1  # we will only know when a key is down or not, so we have to track changes (for release event) manually
    
    
    def shortcut():
        # current implementation shortcut is WIN+ALT shortcut
        # https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
        # alt = 0x12
        # ctrl = 0xA2
        # win = 0x5B
        global _keyReleaseDetect
        if _keyReleaseDetect == -1: # waiting for key press
            if _user32.GetAsyncKeyState(0x5B) and _user32.GetAsyncKeyState(0x12):
                _keyReleaseDetect = 1
            return
        if _keyReleaseDetect == 1: # key was pressed, waiting for release
            if _user32.GetAsyncKeyState(0x5B) and _user32.GetAsyncKeyState(0x12):
                return
            # key release, begin processing
            _keyReleaseDetect = 0
            activate()
            # fake mouse click on the search field
            QTimer.singleShot(1, delay) # <-- NOTE: delay is the next problem, explained below, also the reason _keyReleaseDetect = 0 not -1
    
    # ... insert delay here ...
    
    keyPoller = QTimer()
    keyPoller.setInterval(1000 / 60)
    keyPoller.timeout.connect(shortcut)
    keyPoller.start()
    

    Bringing keyboard focus to a window in either Qt or the windows API is basically impossible (because you're not supposed to according to windows' design principles).
    So the beautiful hack we end up doing is as follows. Let Qt show the popup menu in activate() and set it's internal focus as usual. Then let windows simulate a mouse click to bring focus to the same widget.

    def delay():
        p = searchField.mapToGlobal(QPoint(1, 1))
        cache = QCursor.pos()
        QCursor.setPos(p)
        _user32.mouse_event(0x02 | 0x04, p.x(), p.y(), 0, 0)
        QCursor.setPos(cache)
        global _keyReleaseDetect
        _keyReleaseDetect = -1
    
    # ... all this goes before trayIcon.show() as mentioned before
    

    Windows click events have a mouse position argument, but it's ignored (at least on my machine), so we also use Qt to move the mouse back and forth. Pure poetry.
    I have exhausted a lot of other options before resorting to this hack (which is also why I know to use GetAsyncKeystate instead of QShortcut, and I know raise_(), setActive(), setFocus(), show(), grabKeyboard() all have variable success rates, even depending on the app that currently has focus).
    So while I don't expect this to work indefinitely, or on all machines, I hope it gives you another avenue to explore when trying to grab keyboard focus on windows!

    Leave a Reply

    Your email address will not be published. Required fields are marked *