I lately got irritated with how often I do the following:
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 =  * (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]) 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.lower(), token)) self.shuffled = sorted(self.shuffled, key=lambda pair: not pair.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!