Search code examples
pythonmouseeventpysidepyside6qscrollarea

Widget with WA_TransparentForMouseEvents attribute in QSplitter. How to make it?


I have QMainWindow class with QScrollArea with content as central widget. And I created a main_layout with my scroll area as parent.

Thus I have ability to access to background layout (self.scroll_area_layout) as well as a forward layout (self.main_layout).

It works well, except one: When I use splitter and want to interact with my scroll area throught widget with WA_TransparentForMouseEvents attribute in this splitter, seems like splitter handle this events.

Also I tried to handle mouse events from widget and redirect it to my scroll area, but works only wheelEvents (and I need to be able to select the text from labels in scroll area).

This is my widget class to handle and redirect events to scroll area:

class SignalTransmitterWidget(QWidget):
    def __init__(self, scroll_area: QScrollArea, parent=None):
        super().__init__(parent)
        self.scroll_area = scroll_area
        self.setStyleSheet('background: rgba(0,200,0,200)')
    
    def event(self, event):
        print(event)
        QApplication.sendEvent(self.scroll_area.viewport(), event)
        return True

I have this code (without SignalTransmitterWidget):

from PySide6.QtWidgets import QMainWindow, QApplication, QVBoxLayout, QWidget, QScrollArea, QLabel, QSplitter
from PySide6.QtCore import Qt

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.resize(500, 400)

        self.scroll_area_layout = QVBoxLayout()
        messages_widget = QWidget()
        messages_widget.setLayout(self.scroll_area_layout)

        self.messages_scroll = QScrollArea()
        self.messages_scroll.setWidgetResizable(True)
        self.messages_scroll.setWidget(messages_widget)
        self.setCentralWidget(self.messages_scroll)

        self.main_layout = QVBoxLayout(self.messages_scroll)
        self.main_layout.setSpacing(0)
        
        for i in range(20):
            label = QLabel(f"Selectable Text {i+1}")
            label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
            self.scroll_area_layout.addWidget(label)
        self.splitter = QSplitter(Qt.Orientation.Vertical)

        widget1 = QLabel("widget1 no interaction")
        widget1.setStyleSheet("background: rgba(0,0,0,100)")
        widget1.setAlignment(Qt.AlignmentFlag.AlignCenter)

        widget2 = QLabel("This widget2 must be transparent for mouse events as well as widget3") 
        widget2.setStyleSheet("background: rgba(0,0,0,100)")
        widget2.setAlignment(Qt.AlignmentFlag.AlignCenter)
        widget2.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)

        widget3 = QLabel("This widget3 is transparent for mouse events, but it must be in splitter")
        widget3.setAlignment(Qt.AlignmentFlag.AlignCenter)
        widget3.setFixedHeight(200)
        widget3.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)

        self.splitter.addWidget(widget1)
        self.splitter.addWidget(widget2)

        self.main_layout.addWidget(self.splitter)
        self.main_layout.addWidget(widget3)

if __name__ == "__main__":
    app = QApplication([])
    window = MainWindow()
    window.show()

Result:

Result

And I need to interact with my scroll area in widget2 like you can interact from widget3.


Solution

  • A premise: while I may understand the graphic design requirement, allowing mouse interaction for a widget shown behind another that partially obfuscates them is probably not a good choice from the UX perspective. The fact that what's above also allows partial mouse interaction also makes this approach quite unintuitive.

    The result of making a widget that is transparent to mouse events is that the QApplication will not consider them in the stacking order of the widget tree. The result is that Qt will try to find what's behind those widgets and eventually send the event to them if they do accept mouse events.

    Since QSplitter is a container, and it obviously has a geometry that contains its children, if those children do not receive mouse events they're automatically sent to their parent, the QSplitter.

    By default, and like any other standard widget, QSplitter does receive mouse events, it simply doesn't handle them. If Qt finds that a widget can receive mouse events but does not handle them, it will not "pass through" them, it will simply send the event to the parent, recursively up in the widget tree, until some widget eventually does handle it.

    There are at least two possible workarounds I could think of, each of them with their pros and cons.

    Handle the mouse events in the splitter and redirect them

    This is probably the simplest solution: use a QSplitter subclass, implement its mouse event handlers, and redirect those events based on a given context.

    The concept is to create a QSplitter by giving a reference to the widget that would eventually receive the mouse events: whenever it receives those events, it will redirect them accordingly, ensuring that their coordinates are properly adjusted.

    class PassThroughSplitter(QSplitter):
        altChild = None
        def __init__(self, orientation, altWidget):
            super().__init__(orientation)
            self.altWidget = altWidget
    
        def _sendAltEvent(self, event):
            if self.altChild is None:
                return
            QApplication.sendEvent(self.altChild, QMouseEvent(
                event.type(), 
                self.altChild.mapFromGlobal(event.globalPos()), 
                event.button(), 
                event.buttons(), 
                event.modifiers()
            ))
    
        def mousePressEvent(self, event):
            self.altChild = self.altWidget.childAt(
                self.altWidget.mapFromGlobal(event.globalPos()))
            self._sendAltEvent(event)
    
        def mouseMoveEvent(self, event):
            self._sendAltEvent(event)
    
        def mouseReleaseEvent(self, event):
            self._sendAltEvent(event)
            if self.altChild:
                self.altChild = None
    
    
    class MainWindow(QMainWindow):
        def __init__(self):
            ...
            self.splitter = PassThroughSplitter(
                Qt.Orientation.Vertical, self.messages_scroll.viewport())
            ...
    

    Pros:

    • it's simple enough;
    • respects the object structure;

    Cons:

    • it doesn't handle focus changes caused by mouse buttons:
      • try to select some text in a label and then in another, then do the same for labels that are not under the splitter;
      • Tabing won't be properly managed;
    • it doesn't handle hover events (if you use widgets that show hover changes, they won't be affected);

    While the above issues may be fixed with some workarounds (programmatically setting the focus and redirecting hover events), doing so in a reliable way may be quite difficult, if not impossible.

    Use "fake" QSplitterHandles

    This approach is much more complex, as it requires subclassing QSplitter and creating two separate QSplitterHandle subclasses: one as the real handle, the other as the displayed one and used as a "proxy" for mouse events.

    The QSplitter subclass (which completely ignores mouse events) will create custom QSplitterHandles, used to actually manage QSplitter changes, which, in turn, will create "cousin" handles which are the ones that will be actually displayed and used for mouse movements.

    class FakeSplitterHandle(QSplitterHandle):
        def __init__(self, realHandle, orientation, parent):
            super().__init__(orientation, parent)
            self.realHandle = realHandle
            self.realHandle.destroyed.connect(self.deleteLater)
    
        def mousePressEvent(self, event):
            self.realHandle.mousePressEvent(event)
    
        def mouseMoveEvent(self, event):
            self.realHandle.mouseMoveEvent(event)
    
        def mouseReleaseEvent(self, event):
            self.realHandle.mouseReleaseEvent(event)
    
    
    class RealSplitterHandle(QSplitterHandle):
        _isMoving = False
        fakeParent = None
        def __init__(self, orientation, parent):
            super().__init__(orientation, parent)
            self.fake = FakeSplitterHandle(self, orientation, parent)
            self.setUpdatesEnabled(False)
    
        def setFakeParent(self, parent):
            self.fake.setParent(parent)
            self.fakeParent = parent
            if self.isVisible() and parent is not None:
                self.fake.show()
                self.fake.raise_()
            else:
                self.fake.hide()
    
        def showEvent(self, event):
            super().showEvent(event)
            self.fake.show()
            self.fake.raise_()
    
        def hideEvent(self, event):
            super().hideEvent(event)
            self.fake.hide()
    
        def setFakeGeometry(self):
            if not self.fakeParent:
                return
            offset = self.parent().pos()
            self.fake.setGeometry(self.geometry().translated(offset))
    
        def moveEvent(self, event):
            self._isMoving = True
            self.setFakeGeometry()
    
        def resizeEvent(self, event):
            super().resizeEvent(event)
            if not self._isMoving:
                self.setFakeGeometry()
            else:
                self._isMoving = False
    
    
    class MouseIgnoreSplitter(QSplitter):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
    
        def changeEvent(self, event):
            super().changeEvent(event)
            if event.type() == event.ParentChange:
                parent = self.parent()
                for i in range(self.count()):
                    self.handle(i).setFakeParent(parent)
    
        def updateFakeHandles(self):
            for i, fake in enumerate(self.fakeHandles):
                if i == 0:
                    fake.hide()
                else:
                    handle = self.handle(i)
                    fake.setGeometry(handle.geometry())
    
        def createHandle(self):
            return RealSplitterHandle(self.orientation(), self)
    
    
    class MainWindow(QMainWindow):
        def __init__(self):
            ...
            self.splitter = MouseIgnoreSplitter(Qt.Orientation.Vertical)
            self.splitter.setAttribute(
                Qt.WidgetAttribute.WA_TransparentForMouseEvents)
            ...
    

    The WA_TransparentForMouseEvents is mandatory and obviously makes it unnecessary setting it for any child added to the splitter.

    Pros:

    • click-focus and hover events for child widgets are properly handled;

    Cons:

    • it's much more complex;
    • it may cause issues with Qt Style Sheets, since the visible handle is not a direct child of the splitter;
    • the object/widget structure and tree is not completely consistent and may cause issues when reparenting;
    • it doesn't consider hover events for the splitter handles in case the style changes their appearance (but it could be fixed, probably easily);

    Conclusions

    First of all, as already said, this UI approach is probably not a good idea to begin with (but the simplicity of your example doesn't clarify what the "stacked" labels would actually show).

    When choosing which of the approaches above you'd use, don't consider the "amount" of pros/cons, but how they may affect the usage to the user. Do proper testing.

    Finally, while it's common to feel "attached" to an idea, we should always consider that, as developers, our hopes or needs are rarely those of final users. What may seem fine (or even "cool") to us, may just be annoying to others. Consider if that UI approach is actually valid and really improves the UX experience: a good looking UI is worth nothing if it makes user interaction difficult to understand or use. Visual design must make intuitive and effective its usage, not the other way around.