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:
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.
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.
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:
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.