2

I want to zoom in a QGraphicsView so that the scene position under the mouse stays under the mouse. The following code achieves this:

from PySide6.QtCore import QPoint
from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QApplication
import math

class MyGraphicsView(QGraphicsView):
    def wheelEvent(self, event):
        self.setTransformationAnchor(self.ViewportAnchor.AnchorUnderMouse)
        self.setResizeAnchor(self.ViewportAnchor.AnchorUnderMouse)
        if event.angleDelta().y() > 0:
            self.scale(1.1, 1.1)
        elif event.angleDelta().y() < 0:
            self.scale(1 / 1.1, 1 / 1.1)
        self.setTransformationAnchor(self.ViewportAnchor.NoAnchor)
        self.setResizeAnchor(self.ViewportAnchor.NoAnchor)


class MyTestScene(QGraphicsScene):
    def drawBackground(self, painter, rect, PySide6_QtCore_QRectF=None, PySide6_QtCore_QRect=None):
        left = int(math.floor(rect.left()))
        top = int(math.floor(rect.top()))
        right = int(math.ceil(rect.right()))
        bottom = int(math.ceil(rect.bottom()))
        first_left = left - (left % 100)
        first_top = top - (top % 100)

        for x in range(first_left, right + 1, 100):
            for y in range(first_top, bottom + 1, 100):
                painter.drawEllipse(QPoint(x, y), 2.5, 2.5)
                painter.drawText(x, y, f"{x},{y}")


if __name__ == '__main__':
    app = QApplication([])
    scene = MyTestScene()
    scene.setSceneRect(-2000, -2000, 4000, 4000)
    view = MyGraphicsView()
    view.setScene(scene)
    view.setGeometry(100, 100, 900, 600)
    view.setVisible(True)
    # view.setInteractive(False)
    app.exec()

There is the following issue with this code:

  • If the user clicked in the view before the first wheel-zooming attempt (and the view is "interactive"), everything works as expected.
  • If not, with the first wheel event, the scene "jumps" (translates, in addition to the correct zooming) so that the (0, 0) is under the mouse. After that, everything works as expected.
  • If the view is set to be "non-interactive", the scene "jumps" so that the (0, 0) is under the mouse with every wheel event.

Can someone explain this behaviour? Am I missing something? Or is this a bug in Qt?

I tried PySide 6.7.2 under python 3.12.4 and PySide 6.8.1 under python 3.13.1 (both on Windows) with the same outcome.

1 Answer 1

0

It's only a partial and indirect "bug", caused by your specific approach.

Both setTransformationAnchor() and setResizeAnchor() automatically enable mouseTracking of the viewport when using AnchorUnderMouse, which is mandatory to let the view always keep track of the last known mouse position, required for proper scaling/resizing. Note that changing again the anchors will not disable the mouse tracking.

Since you enable the resize/transformation anchor for the mouse only within the wheelEvent(), no "last known mouse position" has been stored yet.

It works after clicking because you coincidentally scrolled the wheel in the same point you clicked, but if you clicked in a point, then moved the mouse somewhere else and scrolled the wheel the first time, you would still get an inconsistent behavior, because the anchor was placed in the last known position (where you clicked). As soon as you move the mouse after the first scroll (the first time the anchors have changed), it will work as expected.

The simple fix is to just enable the mouse tracking by default, but remember that it has to be done on the viewport, non on the graphics view, because all input events of Qt scroll areas are always received on the viewport and then "rerouted" to the related event handlers.

    view.viewport().setMouseTracking(True)

Unfortunately, this is not enough in case the view is not interactive, because in that case no mouse position is tracked.

To work around this, the solution is to temporarily set the interactive mode, send a fake mouse move event based on the current position, then unset the mode. This approach can also take care of the mouse tracking (but we don't have to restore it, since setting the anchor would override it anyway, as noted above).

class MyGraphicsView(QGraphicsView):
    def wheelEvent(self, event):
        hasTracking = self.viewport().hasMouseTracking()
        isInteractive = self.isInteractive()
        if not hasTracking or not isInteractive:
            vp = self.viewport()
            if not hasTracking:
                vp.setMouseTracking(True)
            if not isInteractive:
                self.setInteractive(True)
            ev = QMouseEvent(
                QEvent.Type.MouseMove, 
                event.position(), 
                event.globalPosition(), 
                Qt.MouseButton.NoButton, 
                Qt.MouseButton.NoButton, 
                Qt.KeyboardModifier.NoModifier
            )

            QApplication.sendEvent(self.viewport(), ev)

            if not isInteractive:
                self.setInteractive(False)

        ... # the rest remains unchanged

Note that the usage of QApplication.sendEvent() is normally preferable, but it will become invalid again in case you implemented mouseMoveEvent() on the view without calling the function of the super class there. In that case, you may consider replacing that line with super().mouseMoveEvent(ev).

In any case, calling the super mouseMoveEvent() is always necessary, because the mouse position is eventually stored only in the original implementation of the QGraphicsView mouse move handler.

An alternative approach

Besides the implementation requirements noted above, considering the effects in changing anchors and other aspects not mentioned here, I'd suggest a more direct approach based on what Qt actually does (and the reason it needs to be aware about the previous mouse position).

What QGraphicsView actually does when scaling and considering the mouse position, is:

  • get the last known mouse position in scene coordinates before scaling;
  • scale the view;
  • get the offset between the center of the view and the mouse position, in scene coordinates;
  • center the view (using centerOn()) by adding the above offset to the initial position;

So, we can easily implement our own "scale to mouse" function accordingly:

class MyGraphicsView(QGraphicsView):
    def wheelEvent(self, event):
        if event.angleDelta().y() > 0:
            factor = 1.1
        elif event.angleDelta().y() < 0:
            factor = 1 / 1.1
        else:
            return
        if self.underMouse():
            self.scaleOnPos(factor, factor, event.position())
        else:
            self.scale(factor, factor)

    def scaleOnPos(self, sx, sy, pos):
        if isinstance(pos, QPointF):
            pos = pos.toPoint()
        t = self.transform()
        oldPos = (
            self.mapToScene(pos)
            + QPointF(.5 / t.m11(), .5 / t.m22())
        )

        self.scale(sx, sy)

        diff = (
            self.mapToScene(self.viewport().rect().center())
            - self.mapToScene(pos)
        )
        self.centerOn(oldPos + diff)

The result is fundamentally identical, but the implementation is more effective and consistent, since it doesn't alter the state of the view (anchors, mouse tracking, interaction).

Note that the QPointF offset added for oldPos is to take into account the fact that the mouse positions are integer based, which would obviously cause a left/top offset especially when scaling in. Adding a 0.5 offset multiplied by the current transform matrix (before further scaling) should result in a more accurate offset of the desired center position.

An even more accurate approach should consider the relation between the two different scalings, but I'll leave that to the reader to eventually implement that.

Sign up to request clarification or add additional context in comments.

2 Comments

I wouldn't blame the approach here, since AnchorUnderMouse is supposed to do exactly what is expected here. The weird thing is that the position given by the wheelEvent is always correct, so it's not like Qt wouldn't know the current mouse position. Thanks for all the details to be considered when working around, and the alternative approach which seems to be also working well.
@julian Yes, QWheelEvent knows the mouse position, but the private centerView() (internally called when transforms are set) needs to know the reliable previous mouse position in scene coordinates: multiple transformations may be set sequentially, and the offset should always be based on the original scene position, not the one with the current transform. I agree it may not be optimal, but consider that anchors are normally set in a larger context (eg: on start up) which is sufficient for common usage; and since implementing the above isn't that difficult, the current behavior is adequate.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.