Search code examples
pythonpyside6pyqt6

QGraphicsView.DragMode with middle mouse button


I encountered the problem indicated in the title. The only solutions I found were quite old and perhaps something has changed since then and now it is possible to make the DragMode.ScrollHandDrag action for the middle mouse key?

class InfiniteCanvas(QGraphicsView):
    def __init__(self, parent=None):
        super(InfiniteCanvas, self).__init__(parent)
        self.setAcceptDrops(True)
        self.setScene(QGraphicsScene(self))
        self.setRenderHint(QPainter.Antialiasing)
        self.setRenderHint(QPainter.SmoothPixmapTransform)
        self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.left_mouse_active = False

        screen = QApplication.primaryScreen()
        screen_size = screen.size()
        width = screen_size.width() * 0.7
        height = screen_size.height() * 0.7

        self.setGeometry(self.x(), self.y(), width, height)

        self.setStyleSheet(f"background-color: #2a2a2a;")

   def mousePressEvent(self, event):
    if event.button() == Qt.MidButton:
        self.viewport().setCursor(Qt.ClosedHandCursor)  
        self.original_event = event
        handmade_event = QMouseEvent(QEvent.MouseButtonPress,QPointF(event.pos()),Qt.LeftButton,event.buttons(),Qt.KeyboardModifiers())
        self.mousePressEvent(handmade_event)

    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.acceptProposedAction()

    def dragMoveEvent(self, event):
        if event.mimeData().hasUrls():
            event.acceptProposedAction()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    view = InfiniteCanvas()
    view.show()
    sys.exit(app.exec())

This is the sample code I use in the mousePressEvent method however it completely rules out the possibility of using the left mouse button for anything else.


Solution

  • You are trying to call the default mousePressEvent with the synthesized left click event, but since you overrode that function without ever calling the base implementation, nothing will happen.

    When you do the following:

        def mousePressEvent(self, event):
            if event.button() == Qt.MidButton:
                self.viewport().setCursor(Qt.ClosedHandCursor)  
                self.original_event = event
                handmade_event = QMouseEvent(...)
                self.mousePressEvent(handmade_event)
    

    then the same mousePressEvent() will be called again from itself, but since now the button is the left one, the block within if event.button() == Qt.MidButton is skipped and there's nothing else to do after that: the event is completely ignored, because you are overriding its expected behavior.

    Your attempt to fix that with dragEnterEvent() and dragMoveEvent() is pointless and wrong, because those handlers are related to actual drag and drop, which is a different type of event management, used to drag objects that may eventually be dropped onto other components, even between different applications.

    The correct procedure would be to add an else clause and call super() with the default event handler:

        def mousePressEvent(self, event):
            if event.button() == Qt.MouseButton.MiddleButton:
                handmade_event = QMouseEvent(
                    QEvent.Type.MouseButtonPress, 
                    event.position(), Qt.MouseButton.LeftButton, 
                    event.buttons(), event.modifiers())
                super().mousePressEvent(handmade_event)
            else:
                super().mousePressEvent(event)
    

    Note the changes done above:

    1. There is no need to change the cursor, since the call to mousePressEvent() with the left button will do that on its own when the ScrollHandDrag mode is set;
    2. Since Qt6, event.pos() has been deprecated, and you should use event.position() instead (which already is a QPointF);
    3. Since Qt6 both PySide and PyQt use real Python enums, which need their specific namespaces; PySide6 has a "forgiveness mode" that still allows the old syntax, but its usage is now discouraged (as annoying as it can be);
    4. MidButton has been virtually considered obsolete for years, and it's deprecated since 5.15; the correct name is MiddleButton;

    Then, QGraphicsView assumes the scrolling state set from the left button event, and it ignores the actual buttons() in mouseMoveEvent(), allowing scrolling movements even if the left button is not actually pressed while moving. In reality, though, we should still theoretically override that too, similarly to what done before. Be aware of that, in case it will stop working for you in a future Qt version.

    Finally, we must do the same in the mouseReleaseEvent() in order to tell the internal state of graphics view that the mouse button has been released (even if virtually), which will also restore the cursor.

        def mouseReleaseEvent(self, event):
            if event.button() == Qt.MouseButton.MiddleButton:
                handmade_event = QMouseEvent(
                    QEvent.Type.MouseButtonRelease, 
                    event.position(), Qt.MouseButton.LeftButton, 
                    event.buttons(), event.modifiers())
                super().mouseReleaseEvent(handmade_event)
            else:
                super().mouseReleaseEvent(event)
    

    Be aware that all the above will never work as soon as the scene rect shown on the the view can fit its geometry, because the ScrollHandDrag only works within the scroll bar range (the fact that you made them invisible is irrelevant).

    If you actually want to be able to scroll no matter the scene rect size (which is what you probably need, given the name of your class), you cannot do it with the above.

    Instead, you should set a scene rect on the view that is large enough to allow (virtually) infinite scrolling, ensure that you properly set the transformationAnchor to NoAnchor, and then call translate() in mouseMoveEvent() with the delta of the current mouse movement.

    from random import randrange
    from PyQt6.QtCore import *
    from PyQt6.QtGui import *
    from PyQt6.QtWidgets import *
    
    class InfiniteCanvas(QGraphicsView):
        _isScrolling = _isSpacePressed = False
        def __init__(self, parent=None):
            super(InfiniteCanvas, self).__init__(parent)
    
            scene = QGraphicsScene(self)
            for _ in range(10):
                item = scene.addRect(randrange(1000), randrange(1000), 50, 50)
                item.setFlag(item.GraphicsItemFlag.ItemIsMovable)
            self.setScene(scene)
    
            self.setRenderHints(
                QPainter.RenderHint.Antialiasing
                | QPainter.RenderHint.SmoothPixmapTransform
            )
            # Important! Without this, self.translate() will not work!
            self.setTransformationAnchor(self.ViewportAnchor.NoAnchor)
    
            self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
            self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
    
            screen = QApplication.screenAt(QCursor.pos())
            geo = screen.availableGeometry()
            geo.setSize(geo.size() * .7)
            geo.moveCenter(screen.availableGeometry().center())
            self.setGeometry(geo)
    
            self.setSceneRect(-32000, -32000, 64000, 64000)
    
            self.fitInView(scene.itemsBoundingRect())
    
        def mousePressEvent(self, event):
            if (
                event.button() == Qt.MouseButton.MiddleButton
                or self._isSpacePressed
                and event.button() == Qt.MouseButton.LeftButton
            ):
                self._isScrolling = True
                self.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
                self.scrollPos = event.position()
            else:
                super().mousePressEvent(event)
    
        def mouseMoveEvent(self, event):
            if self._isScrolling:
                newPos = event.position()
                delta = newPos - self.scrollPos
                t = self.transform()
                self.translate(delta.x() / t.m11(), delta.y() / t.m22())
                self.scrollPos = newPos
            else:
                super().mouseMoveEvent(event)
    
        def mouseReleaseEvent(self, event):
            if self._isScrolling:
                self._isScrolling = False
                if self._isSpacePressed:
                    self.viewport().setCursor(Qt.CursorShape.OpenHandCursor)
                else:
                    self.viewport().unsetCursor()
            super().mouseReleaseEvent(event)
    
        def keyPressEvent(self, event):
            if event.key() == Qt.Key.Key_Space and not event.isAutoRepeat():
                self._isSpacePressed = True
                self.viewport().setCursor(Qt.CursorShape.OpenHandCursor)
            else:
                super().keyPressEvent(event)
    
        def keyReleaseEvent(self, event):
            if event.key() == Qt.Key.Key_Space and not event.isAutoRepeat():
                self._isSpacePressed = False
                if not self._isScrolling:
                    self.viewport().unsetCursor()
            else:
                super().keyReleaseEvent(event)
    
    
    if __name__ == '__main__':
        import sys
        app = QApplication(sys.argv)
        view = InfiniteCanvas()
        view.show()
        sys.exit(app.exec())
    

    As an unrelated note, you shall never, ever set generic QSS properties on complex widgets like scroll areas (which is the case of QGraphicsView) or combo boxes, as you did with this line:

    self.setStyleSheet(f"background-color: #2a2a2a;")
    

    Doing so will propagate that property to any child widget (including scroll bars or context menus), which is very problematic for complex widgets like scroll areas or comboboxes.

    You should always use proper selector types instead, unless you are completely sure that you're applying the QSS to a simple standalone widget (like a button or a label).

    Also see these related posts: