Add ability to zoom with ctrl + middle mouse drag

This commit is contained in:
Rebecca Breu 2021-05-20 19:44:49 +02:00
parent eee27fa441
commit d25f911561
3 changed files with 292 additions and 56 deletions

View file

@ -2,13 +2,21 @@
<p>
Middle Click + Drag<br/>
Left Click + Alt + Drag
Alt + Left Click + Drag
</p>
<h4>Zoom Canvas</h4>
<p>
Mouse wheel up/down
Mouse wheel up/down<br/>
Ctrl + Middle Click + Drag
</p>
<h4>Zoom in on Item</h4>
<p>
Double-click on item<br/>
Double-click again to get back to previous view
</p>
<h4>Select Items</h4>
@ -30,10 +38,3 @@
<p>
Shift or Ctrl while rotating
</p>
<h4>Zoom in on Item</h4>
<p>
Double-click on item<br/>
Double-click again to get back to previous view
</p>

View file

@ -40,8 +40,6 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
self.app = app
self.settings = BeeSettings()
self.setBackgroundBrush(QtGui.QBrush(QtGui.QColor(60, 60, 60)))
self.setTransformationAnchor(
QtWidgets.QGraphicsView.ViewportAnchor.AnchorUnderMouse)
self.undo_stack = QtGui.QUndoStack(self)
self.undo_stack.setUndoLimit(100)
@ -57,6 +55,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
self.previous_transform = None
self.pan_active = False
self.zoom_active = False
self.scene.changed.connect(self.on_scene_changed)
self.scene.selectionChanged.connect(self.on_selection_changed)
@ -473,6 +472,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
self.setSceneRect(QtCore.QRectF(topleft, bottomright))
except OverflowError:
logger.info('Maximum scene size reached')
logger.debug('Done recalculating scene rectangle')
def get_zoom_size(self, func):
"""Calculates the size of all items' bounding box in the view's
@ -501,60 +501,102 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
def get_scale(self):
return self.transform().m11()
def wheelEvent(self, event):
def pan(self, delta):
if not self.scene.items():
logger.debug('No items in scene; ignore pan')
return
hscroll = self.horizontalScrollBar()
hscroll.setValue(hscroll.value() + delta.x())
vscroll = self.verticalScrollBar()
vscroll.setValue(vscroll.value() + delta.y())
def zoom(self, delta, anchor):
if not self.scene.items():
logger.debug('No items in scene; ignore zoom')
return
factor = 1 + abs(event.angleDelta().y() / 1000)
if event.angleDelta().y() > 0:
# We caculate where the anchor is before and after the zoom
# and then move the view accordingly to keep the anchor fixed
# We can't use QGraphicsView's AnchorUnderMouse since it
# uses the current cursor position while we need the inital mouse
# press position for zooming with Ctrl + Middle Drag
anchor = QtCore.QPoint(round(anchor.x()),
round(anchor.y()))
ref_point = self.mapToScene(anchor)
if delta == 0:
return
factor = 1 + abs(delta / 1000)
if delta > 0:
if self.get_zoom_size(max) < 10000000:
self.scale(factor, factor)
else:
logger.debug('Maximum zoom size reached')
return
else:
if self.get_zoom_size(min) > 50:
self.scale(1/factor, 1/factor)
else:
logger.debug('Minimum zoom size reached')
return
self.pan(self.mapFromScene(ref_point) - anchor)
self.reset_previous_transform()
def wheelEvent(self, event):
self.zoom(event.angleDelta().y(), event.position())
event.accept()
def mouseMoveEvent(self, event):
if self.pan_active:
self.reset_previous_transform()
point = event.position()
hscroll = self.horizontalScrollBar()
hscroll.setValue(hscroll.value() + self.pan_start.x() - point.x())
vscroll = self.verticalScrollBar()
vscroll.setValue(vscroll.value() + self.pan_start.y() - point.y())
self.pan_start = point
event.accept()
return
super().mouseMoveEvent(event)
def mousePressEvent(self, event):
if (event.button() == Qt.MouseButton.MiddleButton
and event.modifiers() == Qt.KeyboardModifier.ControlModifier):
self.zoom_active = True
self.event_start = event.position()
self.event_anchor = event.position()
event.accept()
return
if (event.button() == Qt.MouseButton.MiddleButton
or (event.button() == Qt.MouseButton.LeftButton
and event.modifiers() == Qt.KeyboardModifier.AltModifier)):
if not self.scene.items():
logger.debug('No items in scene; ignore pan')
return
self.pan_active = True
self.pan_start = event.position()
self.event_start = event.position()
self.setCursor(Qt.CursorShape.ClosedHandCursor)
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self.pan_active:
self.reset_previous_transform()
pos = event.position()
self.pan(self.event_start - pos)
self.event_start = pos
event.accept()
return
if self.zoom_active:
self.reset_previous_transform()
pos = event.position()
delta = (self.event_start - pos).y()
self.event_start = pos
self.zoom(delta * 20, self.event_anchor)
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if self.pan_active:
self.setCursor(Qt.CursorShape.ArrowCursor)
self.pan_active = False
event.accept()
return
if self.zoom_active:
self.zoom_active = False
event.accept()
return
super().mouseReleaseEvent(event)

View file

@ -415,41 +415,236 @@ class UpdateWindowTitleTestCase(ViewBaseTestCase):
self.view.update_window_title()
assert self.parent.windowTitle() == 'test.bee* - BeeRef'
@patch('beeref.view.BeeGraphicsView.recalc_scene_rect')
@patch('beeref.scene.BeeGraphicsScene.on_view_scale_change')
def test_scale(self, view_scale_mock, recalc_mock):
self.view.scale(3.3, 3.3)
view_scale_mock.assert_called_once_with()
recalc_mock.assert_called_once_with()
assert self.view.get_scale() == 3.3
@patch('PyQt6.QtWidgets.QScrollBar.setValue')
def test_pan(self, scroll_value_mock):
item = BeePixmapItem(QtGui.QImage())
self.view.scene.addItem(item)
self.view.pan(QtCore.QPointF(5, 10))
assert scroll_value_mock.call_count == 2
@patch('PyQt6.QtWidgets.QScrollBar.setValue')
def test_pan_when_no_items(self, scroll_value_mock):
self.view.pan(QtCore.QPointF(5, 10))
scroll_value_mock.assert_not_called()
class ZoomTestCase(ViewBaseTestCase):
def setUp(self):
super().setUp()
pan_patcher = patch('beeref.view.BeeGraphicsView.pan')
self.pan_mock = pan_patcher.start()
self.addCleanup(pan_patcher.stop)
reset_patcher = patch(
'beeref.view.BeeGraphicsView.reset_previous_transform')
self.reset_mock = reset_patcher.start()
self.addCleanup(reset_patcher.stop)
self.item = BeePixmapItem(QtGui.QImage(self.imgfilename3x3))
def test_zoom_in(self):
self.view.scene.addItem(self.item)
self.view.zoom(40, QtCore.QPointF(10, 10))
assert self.view.get_scale() == 1.04
self.reset_mock.assert_called_once_with()
self.pan_mock.assert_called_once_with(QtCore.QPoint(-52, -15))
def test_zoom_in_max_zoom_size(self):
self.view.scale(10000000, 10000000)
self.view.scene.addItem(self.item)
self.view.zoom(40, QtCore.QPointF(10, 10))
assert self.view.get_scale() == 10000000
self.reset_mock.assert_not_called()
self.pan_mock.assert_not_called()
def test_zoom_out(self):
self.view.scale(100, 100)
self.view.scene.addItem(self.item)
self.view.zoom(-40, QtCore.QPointF(10, 10))
assert self.view.get_scale() == 100 / 1.04
self.reset_mock.assert_called_once_with()
self.pan_mock.assert_called_once_with(QtCore.QPoint(49, 14))
def test_zoom_out_min_zoom_size(self):
self.view.scene.addItem(self.item)
self.view.zoom(-40, QtCore.QPointF(10, 10))
assert self.view.get_scale() == 1
self.reset_mock.assert_not_called()
self.pan_mock.assert_not_called()
def test_no_items(self):
self.view.zoom(40, QtCore.QPointF(10, 10))
assert self.view.get_scale() == 1
self.reset_mock.assert_not_called()
self.pan_mock.assert_not_called()
def test_delta_zero(self):
self.view.scene.addItem(self.item)
self.view.zoom(0, QtCore.QPointF(10, 10))
assert self.view.get_scale() == 1
self.reset_mock.assert_not_called()
self.pan_mock.assert_not_called()
class MouseEventsTestCase(ViewBaseTestCase):
def setUp(self):
super().setUp()
self.event = MagicMock()
@patch('beeref.view.BeeGraphicsView.zoom')
def test_wheel_event(self, zoom_mock):
self.event.angleDelta.return_value = QtCore.QPointF(0, 40)
self.event.position.return_value = QtCore.QPointF(10, 20)
self.view.wheelEvent(self.event)
zoom_mock.assert_called_once_with(40, QtCore.QPointF(10, 20))
self.event.accept.assert_called_once_with()
@patch('PyQt6.QtWidgets.QGraphicsView.mousePressEvent')
def test_mouse_press_zoom(self, mouse_event_mock):
self.event.position.return_value = QtCore.QPointF(10, 20)
self.event.button.return_value = Qt.MouseButton.MiddleButton
self.event.modifiers.return_value = Qt.KeyboardModifier.ControlModifier
self.view.mousePressEvent(self.event)
assert self.view.zoom_active is True
assert self.view.pan_active is False
assert self.view.event_start == QtCore.QPointF(10, 20)
assert self.view.event_anchor == QtCore.QPointF(10, 20)
mouse_event_mock.assert_not_called()
self.event.accept.assert_called_once_with()
@patch('PyQt6.QtWidgets.QGraphicsView.mousePressEvent')
def test_mouse_press_pan_middle_drag(self, mouse_event_mock):
self.event.position.return_value = QtCore.QPointF(10, 20)
self.event.button.return_value = Qt.MouseButton.MiddleButton
self.event.modifiers.return_value = None
self.view.mousePressEvent(self.event)
assert self.view.pan_active is True
assert self.view.zoom_active is False
assert self.view.event_start == QtCore.QPointF(10, 20)
mouse_event_mock.assert_not_called()
self.view.cursor() == Qt.CursorShape.ClosedHandCursor
self.event.accept.assert_called_once_with()
@patch('PyQt6.QtWidgets.QGraphicsView.mousePressEvent')
def test_mouse_press_pan_alt_left_drag(self, mouse_event_mock):
self.event.position.return_value = QtCore.QPointF(10, 20)
self.event.button.return_value = Qt.MouseButton.LeftButton
self.event.modifiers.return_value = Qt.KeyboardModifier.AltModifier
self.view.mousePressEvent(self.event)
assert self.view.pan_active is True
assert self.view.zoom_active is False
assert self.view.event_start == QtCore.QPointF(10, 20)
mouse_event_mock.assert_not_called()
self.view.cursor() == Qt.CursorShape.ClosedHandCursor
self.event.accept.assert_called_once_with()
@patch('PyQt6.QtWidgets.QGraphicsView.mousePressEvent')
def test_mouse_press_unhandled(self, mouse_event_mock):
self.event.button.return_value = Qt.MouseButton.LeftButton
self.event.modifiers.return_value = None
self.view.mousePressEvent(self.event)
assert self.view.pan_active is False
assert self.view.zoom_active is False
mouse_event_mock.assert_called_once_with(self.event)
self.event.accept.assert_not_called()
@patch('PyQt6.QtWidgets.QGraphicsView.mouseMoveEvent')
@patch('beeref.view.BeeGraphicsView.pan')
def test_mouse_move_pan(self, pan_mock, mouse_event_mock):
self.view.pan_active = True
self.view.event_start = QtCore.QPointF(55, 66)
self.event.position.return_value = QtCore.QPointF(10, 20)
self.view.mouseMoveEvent(self.event)
pan_mock.assert_called_once_with(QtCore.QPointF(45, 46))
mouse_event_mock.assert_not_called()
self.event.accept.assert_called_once_with()
@patch('PyQt6.QtWidgets.QGraphicsView.mouseMoveEvent')
@patch('beeref.view.BeeGraphicsView.zoom')
def test_mouse_move_zoom(self, zoom_mock, mouse_event_mock):
self.view.zoom_active = True
self.view.event_anchor = QtCore.QPointF(55, 66)
self.view.event_start = QtCore.QPointF(10, 20)
self.event.position.return_value = QtCore.QPointF(10, 18)
self.view.mouseMoveEvent(self.event)
zoom_mock.assert_called_once_with(40, QtCore.QPointF(55, 66))
mouse_event_mock.assert_not_called()
self.event.accept.assert_called_once_with()
@patch('PyQt6.QtWidgets.QGraphicsView.mouseMoveEvent')
def test_mouse_move_unhandled(self, mouse_event_mock):
self.event.position.return_value = QtCore.QPointF(10, 20)
self.view.mouseMoveEvent(self.event)
mouse_event_mock.assert_called_once_with(self.event)
self.event.accept.assert_not_called()
@patch('PyQt6.QtWidgets.QGraphicsView.mouseReleaseEvent')
def test_mouse_release_pan(self, mouse_event_mock):
self.view.pan_active = True
self.view.setCursor(Qt.CursorShape.ClosedHandCursor)
self.view.mouseReleaseEvent(self.event)
mouse_event_mock.assert_not_called()
assert self.view.pan_active is False
self.event.accept.assert_called_once_with()
self.view.cursor() == Qt.CursorShape.ArrowCursor
@patch('PyQt6.QtWidgets.QGraphicsView.mouseReleaseEvent')
def test_mouse_release_zoom(self, mouse_event_mock):
self.view.zoom_active = True
self.view.mouseReleaseEvent(self.event)
mouse_event_mock.assert_not_called()
assert self.view.zoom_active is False
self.event.accept.assert_called_once_with()
@patch('PyQt6.QtWidgets.QGraphicsView.mouseReleaseEvent')
def test_mouse_release_unhandled(self, mouse_event_mock):
self.view.mouseReleaseEvent(self.event)
mouse_event_mock.assert_called_once_with(self.event)
self.event.accept.assert_not_called()
class DragDropTestCase(ViewBaseTestCase):
def setUp(self):
super().setUp()
self.event = MagicMock()
def test_drag_enter_when_url(self):
url = QtCore.QUrl()
url.fromLocalFile(self.imgfilename3x3)
mimedata = QtCore.QMimeData()
mimedata.setUrls([url])
event = MagicMock()
event.mimeData.return_value = mimedata
self.event.mimeData.return_value = mimedata
self.view.dragEnterEvent(event)
event.acceptProposedAction.assert_called_once()
self.view.dragEnterEvent(self.event)
self.event.acceptProposedAction.assert_called_once()
def test_drag_enter_when_img(self):
mimedata = QtCore.QMimeData()
mimedata.setImageData(QtGui.QImage(self.imgfilename3x3))
event = MagicMock()
event.mimeData.return_value = mimedata
self.event.mimeData.return_value = mimedata
self.view.dragEnterEvent(event)
event.acceptProposedAction.assert_called_once()
self.view.dragEnterEvent(self.event)
self.event.acceptProposedAction.assert_called_once()
def test_drag_enter_when_unsupported(self):
mimedata = QtCore.QMimeData()
event = MagicMock()
event.mimeData.return_value = mimedata
self.event.mimeData.return_value = mimedata
self.view.dragEnterEvent(event)
event.acceptProposedAction.assert_not_called()
self.view.dragEnterEvent(self.event)
self.event.acceptProposedAction.assert_not_called()
def test_drag_move(self):
event = MagicMock()
self.view.dragMoveEvent(event)
event.acceptProposedAction.assert_called_once()
self.view.dragMoveEvent(self.event)
self.event.acceptProposedAction.assert_called_once()
@patch('beeref.view.BeeGraphicsView.do_insert_images')
def test_drop_when_url(self, insert_mock):
@ -457,20 +652,18 @@ class DragDropTestCase(ViewBaseTestCase):
url.fromLocalFile(self.imgfilename3x3)
mimedata = QtCore.QMimeData()
mimedata.setUrls([url])
event = MagicMock()
event.mimeData.return_value = mimedata
event.position.return_value = QtCore.QPointF(10, 20)
self.event.mimeData.return_value = mimedata
self.event.position.return_value = QtCore.QPointF(10, 20)
self.view.dropEvent(event)
self.view.dropEvent(self.event)
insert_mock.assert_called_once_with([url], QtCore.QPoint(10, 20))
def test_drop_when_img(self):
mimedata = QtCore.QMimeData()
mimedata.setImageData(QtGui.QImage(self.imgfilename3x3))
event = MagicMock()
event.mimeData.return_value = mimedata
event.position.return_value = QtCore.QPointF(10, 20)
self.event.mimeData.return_value = mimedata
self.event.position.return_value = QtCore.QPointF(10, 20)
self.view.dropEvent(event)
self.view.dropEvent(self.event)
assert len(self.view.scene.items()) == 1
assert self.view.scene.items()[0].isSelected() is True