Search code examples
pythonpyqtpyqt5qsliderqtoolbutton

QToolButton with popup QSlider


I'm trying to make volume button, on click it should mute/unmute and and on hover it should popup QSlider, so user can set whatever level he wants. Now I'm trying to achieve this by showing slider window in enterEvent and hiding it in leaveEvent:

class VolumeButton(QToolButton):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setIcon(volumeicon)

        self.slider = QSlider()
        self.slider.setWindowFlags(Qt.FramelessWindowHint)
        self.slider.setWindowModality(Qt.NonModal)

    def enterEvent(self, event):
        self.slider.move(self.mapToGlobal(self.rect().topLeft()))
        self.slider.show()

    def leaveEvent(self, event):
        self.slider.hide()

The problem is that mapToGlobal seems to be connected in some way with enterEvent and it creates recursion, but without mapToGlobal I can't place slider at the right position. I'm not sure that QToolButton and FramelessWindow are the right widgets to achieve wished result, so let me know if there a better ways to do that.


Solution

  • The problem is not from mapToGlobal, but from the fact that the leaveEvent is fired as soon as the slider is shown: since the slider is in the same coordinates of the mouse, Qt considers that the mouse has "left" the button (and "entered" the slider).

    You cannot use the simple leaveEvent for this, as you need to check the cursor position against both the button and the slider.

    A possible solution is to create a QRegion that contains the geometry of both widgets and check if the cursor is inside it. In order to process the mouse events of the slider, an event filter must be installed on it:

    class VolumeButton(QToolButton):
        def __init__(self, parent=None):
            super().__init__(parent)
            self.setIcon(volumeicon)
    
            self.slider = QSlider()
            self.slider.setWindowFlags(Qt.FramelessWindowHint)
            self.slider.setWindowModality(Qt.NonModal)
            self.slider.installEventFilter(self)
    
        def isInside(self):
            buttonRect = self.rect().translated(self.mapToGlobal(QPoint()))
            if not self.slider.isVisible():
                return QCursor.pos() in buttonRect
            region = QRegion(buttonRect)
            region |= QRegion(self.slider.geometry())
            return region.contains(QCursor.pos())
    
        def enterEvent(self, event):
            if not self.slider.isVisible():
                self.slider.move(self.mapToGlobal(QPoint()))
                self.slider.show()
    
        def leaveEvent(self, event):
            if not self.isInside():
                self.slider.hide()
    
        def eventFilter(self, source, event):
            if source == self.slider and event.type() == event.Leave:
                if not self.isInside():
                    self.slider.hide()
            return super().eventFilter(source, event)
    

    Note that self.rect() is always at coordinates (0, 0), so you can just use self.mapToGlobal(QPoint()) to get the widget's global position. On the other hand, you might want to show the slider "outside" the button, so you could use self.mapToGlobal(self.rect().bottomLeft()).
    Be aware that trying to place the slider "outside" the button geometry might result in a problem if the user moves the mouse in the gap between the button and the slider; in that case, you will need to create a valid region that covers both widgets and a reasonable space between them.