Search code examples
pythoneventsmousewheelpyside6qcombobox

How to handle QComboBox wheel-events with an event-filter?


In Qt6, many widgets (QComboBox, QSpinBox) will steal mouse wheel events that should be handled by their parent widget (like a QScrollArea), even when they don't have focus, and even when the focusPolicy has been set to StrongFocus.

I can subclass these widget classes and re-implement the wheelEvent handler to ignore these events to accomplish what I want, but it feels inelegant to do it this way because I need to subclass every widget class that exhibits this behavior.

class MyComboBox(QtWidgets.QComboBox):

    def wheelEvent(self, event: QtGui.QWheelEvent) -> None:
        if not self.hasFocus():
            event.ignore()
        else:
            super().wheelEvent(event)

Qt offers another way to ignore events using installEventFilter, which feels much more scalable and elegant, because I can create one Event Filter and apply it to any number of different widgets.

class WheelEventFilter(QtCore.QObject):
    """Ignores wheel events when a widget does not already have focus."""

    def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool:
        if (
            isinstance(watched, QtWidgets.QWidget)
            and not watched.hasFocus()
            and event.type() == QtCore.QEvent.Type.Wheel
        ):
            # This filters the event, but it also stops the event 
            # from propagating up to parent widget.
            return True
            # This doesn't actually ignore the event for the given widget.
            event.ignore()
            return False

        else:
            return super().eventFilter(watched, event)

My problem, though, is that this event filter doesn't seem to be filtering the events as I would expect. I expect it to filter out the event for the watched object only, while also allowing the event to be propogated up to the parent widget to handle, but that isn't happening.

Is it possible to achieve the same effect as the wheelEvent handler defined above using an eventFilter?


Here is a self-contained reproducible example that displays this behavior. If you try and scroll the scroll area with the mouse over one of the comboboxes, the combobox will steal focus and the wheel event.

import sys
from PySide6 import QtWidgets, QtCore


class MyWidget(QtWidgets.QWidget):

    def __init__(self) -> None:
        super().__init__()

        # # layout
        self._layout = QtWidgets.QVBoxLayout()
        self.setLayout(self._layout)

        # layout for widget
        self._mainwidget = QtWidgets.QWidget()
        self._mainlayout = QtWidgets.QVBoxLayout()
        self._mainwidget.setLayout(self._mainlayout)

        # widgets for widget
        self._widgets = {}
        num_widgets = 20
        for i in range(num_widgets):
            combo = QtWidgets.QComboBox()
            combo.addItems([str(x) for x in range(1, 11)])
            combo.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
            self._mainlayout.addWidget(combo)
            self._widgets[i] = combo

        # scroll area
        self._scrollarea = QtWidgets.QScrollArea(self)
        self._scrollarea.setWidgetResizable(True)
        self._scrollarea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
        self._scrollarea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
        self._layout.addWidget(self._scrollarea)

        # widget for scroll area
        self._scrollarea.setWidget(self._mainwidget)


def main() -> None:
    app = QtWidgets.QApplication(sys.argv)
    widget = MyWidget()
    widget.show()
    app.exec()


if __name__ == "__main__":
    main()

Solution

  • I have now found a solution that works with both Qt5 and Qt6.

    It seems that in Qt6, it's necessary to explicitly ignore the wheel-event before returning true in the event-filter, whereas in Qt5 it's not. But note that I only tested this with Qt-5.15.11 and Qt-6.6.0 / Qt-6.2.2 on arch-linux, so I cannot guarantee this will work with all possible Qt versions and/or platforms. [It's also worth pointing out here that the current focus-policy behaviour of QComboBox (and other similar widgets) is somewhat questionable, because even when the focus-policy is set to NoFocus, the widget still accepts wheel-scroll events - see QTBUG-19730. Really, reimplementing wheel-event handling is a somewhat crude work-around that shouldn't be necessary].

    Below is a simplified working demo based on the code in the question. I have assumed the desired behaviour is that wheel-scrolling over an unfocused combo-box should still scroll the parent scroll-area, but a focused combo-box should scroll through its items as normal:

    import sys
    from PySide6 import QtWidgets, QtCore
    # from PySide2 import QtWidgets, QtCore
    # from PyQt6 import QtWidgets, QtCore
    # from PyQt5 import QtWidgets, QtCore
    
    class MyWidget(QtWidgets.QWidget):
        def __init__(self):
            super().__init__()
            self._layout = QtWidgets.QVBoxLayout()
            self.setLayout(self._layout)
            self._mainwidget = QtWidgets.QWidget()
            self._mainlayout = QtWidgets.QVBoxLayout()
            self._mainwidget.setLayout(self._mainlayout)
    
            for i in range(20):
                combo = QtWidgets.QComboBox()
                combo.addItems(list('ABCDEF'))
                combo.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
                combo.installEventFilter(self)
                self._mainlayout.addWidget(combo)
    
            self._scrollarea = QtWidgets.QScrollArea(self)
            self._scrollarea.setWidgetResizable(True)
            self._layout.addWidget(self._scrollarea)
            self._scrollarea.setWidget(self._mainwidget)
    
        def eventFilter(self, watched, event):
            if (event.type() == QtCore.QEvent.Type.Wheel and
                not watched.hasFocus()):
                event.ignore()
                return True
            else:
                return super().eventFilter(watched, event)
    
    if __name__ == "__main__":
    
        app = QtWidgets.QApplication(sys.argv)
        widget = MyWidget()
        widget.show()
    
        print(f'{QtCore.__package__} (Qt-{QtCore.qVersion()})')
    
        if hasattr(app, 'exec'):
            app.exec()
        else:
            app.exec_()