Search code examples
pyqt5

Shortcuts not working when QComboBox popup is showing


I've added some keyboard shortcuts to my app (PyQt5), like so:

w = MyWidget()
QShortcut(QKeySequence("Ctrl+Up"), w).activated.connect(some_function)

Unfortunately, when w has a child widget of type QComboBox, and the popup window of the combobox is active, the keyboard shortcuts don't work. I've tried subclassing QComboBox so that it adds an event filter to the popup view:

class Combo(QComboBox):
    def __init__(self, parent):
        super().__init__(parent)
    
    def showPopup(self):
        self.view().installEventFilter(self)
        super().showPopup()

    def eventFilter(self, source, e):
        if (e.type() == QEvent.KeyPress
                and (e.modifiers() & QtCore.Qt.ControlModifier
                     or e.modifiers() & QtCore.Qt.AltModifier)):
            self.keyPressEvent(e)
            return True
        return super().eventFilter(source, e)

    def keyPressEvent(self, e):
        super().keyPressEvent(e)
        self.parent().keyPressEvent(e)

I'm not sure if I'm doing the right thing by manually calling self.keyPressEvent(). Anyway, by doing that, and by propagating the event by calling self.parent().keyPressEvent(), the events get passed to MyWidget.keyPressEvent(); however, the keyboard shortcuts still aren't activated.

How can I make sure that these events trigger the keyboard shortcuts? Do I need to emit a signal somewhere?


Solution

  • Your attempt with the event filter doesn't change the result because the problem is caused by the fact the context() of the shortcut (which, by default, is WindowShortcut) is different, meaning that the shortcut only works if its window is the active one.

    Since the popup of a combobox is a top level window, the shortcut isn't triggered because the active window is a different one than its context.

    The solution is then to change the context.

    The easiest looking way would be to change the context to ApplicationShortcut, but that would require some care.

    First of all, the shortcut will then become global, and will therefore be activated when any "window" of your program is active; this is probably not desirable, especially if its parent window is temporarily hidden by another one, and the currently focused widget actually uses a similar key combination for something else. Note that this is also valid for any popup widget, including context menus.
    There may be workarounds for that (possibly by carefully verifying the currently active window/popup when the global shortcut is activated), but they are difficult to implement properly and probably not completely reliable.

    Interestingly enough, Qt also provides a WidgetWithChildrenShortcut context, that doesn't affect popups that are children of the shortcut context, meaning that it will still be triggered as long as the activation context is a child of the shortcut parent, which could even be a top level window, but only as long as it's a Qt.Popup (which is the case of combo box popups). Still, be aware that the popup may ignore the shortcut. For example, a QMenu will "eat" (accept) the Ctrl+Up ShortcutOverride event, just selecting the menu item above the current one.

    Remember, though, that if you do that for other key sequences, they may be in contrast with the expected behavior of the popup. Consider the case of single letter shortcuts (which are uncommon, but sometimes useful), which would then make text search inconsistent.

    Be also aware that most people are used to the fact that when a combo popup is shown, no other keyboard interaction is available (including system ones, under certain circumstances and OS): it is expected that any keyboard event will be received by that popup and that popup only for the time it's visible. Altering this behavior may be annoying to some, and (negatively) surprising to others.

    Another issue with this approach is that if the parent of the shortcut is not a top level window, the shortcut will not be activated if its parent (or any of its children) have focus. In that case, using ApplicationShortcut is the only option, but that presents the caveats explained above. Work arounds exist, but they are complex to achieve, difficult to maintain and somehow unreliable.

    Consider the following case, which is very elementary: a QScrollArea that shows a list of combo boxes and opens the next or previous popup; the scroll area is then added to a parent, which includes other widgets.

    from random import choice, randrange
    from string import ascii_letters as letters
    
    from PyQt5.QtCore import *
    from PyQt5.QtGui import *
    from PyQt5.QtWidgets import *
    
    class Test(QScrollArea):
        def __init__(self):
            super().__init__()
            container = QWidget()
            self.setWidget(container)
            self.setWidgetResizable(True)
            scrollLayout = QFormLayout(container)
            for i in range(20):
                combo = QComboBox()
                combo.addItems(
                    ''.join(choice(letters) for _ in range(randrange(5, 10)))
                    for _ in range(10))
                scrollLayout.addRow('Item {}'.format(i+1), combo)
    
            openUp = QShortcut('Ctrl+Up', self)
            openUp.activated.connect(self.popupPrev)
            openDown = QShortcut('Ctrl+Down', self)
            openDown.activated.connect(self.popupNext)
    
        def popupPrev(self):
            focusWidget = self.focusWidget()
            if focusWidget:
                focusWidget = focusWidget.previousInFocusChain()
            else:
                focusWidget = self.previousInFocusChain()
            while not isinstance(focusWidget, QComboBox):
                focusWidget = focusWidget.previousInFocusChain()
            self.ensureWidgetVisible(focusWidget)
            focusWidget.setFocus()
            focusWidget.showPopup()
    
        def popupNext(self):
            focusWidget = self.focusWidget()
            if focusWidget:
                focusWidget = focusWidget.nextInFocusChain()
            else:
                focusWidget = self.nextInFocusChain()
            while not isinstance(focusWidget, QComboBox):
                focusWidget = focusWidget.nextInFocusChain()
            self.ensureWidgetVisible(focusWidget)
            focusWidget.setFocus()
            focusWidget.showPopup()
    
    
    app = QApplication([])
    w = QWidget()
    l = QVBoxLayout(w)
    l.addWidget(QLineEdit())
    l.addWidget(Test())
    w.show()
    app.exec()
    

    The above example shows a typical case that doesn't trigger the shortcut when a combo popup is shown.
    Let's apply the WidgetWithChildrenShortcut context above, by adding the following lines at the bottom of __init__():

            openUp.setContext(Qt.WidgetWithChildrenShortcut)
            openDown.setContext(Qt.WidgetWithChildrenShortcut)
    

    This partially solves the problem, but has two major issues:

    • it only works as long as the scroll bar (or any of its children) has focus;
    • it doesn't hide previously shown popups;

    Let's try to solve the first problem (and also fix the second in the meantime) by making the shortcut context application global, since we cannot rely on the current focus:

    class Test(QScrollArea):
        def __init__(self):
            ...
            openUp.setContext(Qt.ApplicationShortcut)
            openDown.setContext(Qt.ApplicationShortcut)
    
        def canAcceptShortcut(self):
            if QApplication.activeWindow() != self.window():
                return False
            popup = QApplication.activePopupWidget()
            if not popup:
                return True
            if self.isAncestorOf(popup.parent()):
                if isinstance(popup.parent(), QComboBox):
                    popup.hide()
                else:
                    self.activateWindow()
                return True
            return False
    
        def popupPrev(self):
            if not self.canAcceptShortcut():
                return
            ... as above
    
        def popupNext(self):
            if not self.canAcceptShortcut():
                return
            ... as above
    

    As you can see, this requires a higher level of complexity (which may require major changes and more difficult maintenance for more complex cases), and can also shows the weakness of this approach: let's add some context menus to the form labels, and we'll see that the shortcuts are completely ignored; consider the following changes:

    class Test(QScrollArea):
        def __init__(self):
            ...
            for i in range(20):
                combo = QComboBox()
                combo.addItems(
                    ''.join(choice(letters) for _ in range(randrange(5, 10)))
                    for _ in range(10))
                scrollLayout.addRow('Item {}'.format(i+1), combo)
                # new lines
                label = scrollLayout.labelForField(combo)
                label.setContextMenuPolicy(Qt.CustomContextMenu)
                label.customContextMenuRequested.connect(self.showMenu)
    
            ... as above
    
        ...
        def showMenu(self):
            menu = QMenu(self)
            menu.addAction('test')
            menu.exec(QCursor.pos())
            menu.deleteLater()
    

    The above will probably make the shortcut unavailable for the context menus (depending on OS and OS implementation, especially on *nix).


    As you can see, trying to achieve what you want presents potential and important issues, not only programmatically, but also on the UX experience. That's why you should probably not try to do that to begin with, unless you're completely aware of those aspects.

    Finally, here are the potential issues about your attempt:

    • as explained above, the shortcut is not triggered if its context doesn't match the activation source window/widget, therefore, using event filtering or trying to call the parent event handler is inadequate other than pointless (other than potentially dangerous if done like that, see below);
    • while, in some cases, calling the known event handler (keyPressEvent()) of the parent may work, it should only be done with awareness, as some widgets directly handle events within their event() override even ignoring the default handler function; others call it only under certain circumstances (QAbstractScrollAreas subclasses);
    • a more appropriate modifier checking that considers the possibility of at least one in a list of modifiers could be done with e.modifiers() & (QtCore.Qt.ControlModifier|QtCore.Qt.AltModifier);
    • you must always be careful in assuming that a parent always exists: while having a QComboBox as top level window is obviously a rare and odd situation, doing self.parent().keyPressEvent(e) may raise an exception in that case, because parent() returns None, which obviously doesn't have any keyPressEvent attribute;