diff --git a/beeref/documentation/controls.html b/beeref/documentation/controls.html
index 4b882dc..807646b 100644
--- a/beeref/documentation/controls.html
+++ b/beeref/documentation/controls.html
@@ -2,13 +2,21 @@
Middle Click + Drag
- Left Click + Alt + Drag
+ Alt + Left Click + Drag
Zoom Canvas
- Mouse wheel up/down
+ Mouse wheel up/down
+ Ctrl + Middle Click + Drag
+
+
+Zoom in on Item
+
+
+ Double-click on item
+ Double-click again to get back to previous view
Select Items
@@ -30,10 +38,3 @@
Shift or Ctrl while rotating
-
-Zoom in on Item
-
-
- Double-click on item
- Double-click again to get back to previous view
-
diff --git a/beeref/view.py b/beeref/view.py
index ef263ed..1bab7a9 100644
--- a/beeref/view.py
+++ b/beeref/view.py
@@ -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)
diff --git a/tests/test_view.py b/tests/test_view.py
index 7b82482..ffab5b4 100644
--- a/tests/test_view.py
+++ b/tests/test_view.py
@@ -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