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