From 1ff957bca4e172c5a01564dd6d5058cc6a6decbf Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Sun, 4 Apr 2021 22:10:07 +0200 Subject: [PATCH] Add hover areas for rotating items --- beeref/selection.py | 79 +++++++++++++++++++++++------------------ beeref/utils.py | 30 ++++++++++++++++ tests/test_selection.py | 61 +++++++++++++++++++++---------- tests/test_utils.py | 23 ++++++++++++ 4 files changed, 140 insertions(+), 53 deletions(-) create mode 100644 beeref/utils.py create mode 100644 tests/test_utils.py diff --git a/beeref/selection.py b/beeref/selection.py index 1fa23ee..2bf2ef2 100644 --- a/beeref/selection.py +++ b/beeref/selection.py @@ -23,6 +23,7 @@ from PyQt6.QtWidgets import QGraphicsItem from beeref import commands from beeref.config import CommandlineArgs +from beeref import utils commandline_args = CommandlineArgs() @@ -133,21 +134,39 @@ class SelectableMixin(BaseItemMixin): return [self.mapToScene(corner) for corner in self.corners] - def get_scale_bounds(self, center): + def get_scale_bounds(self, corner, margin=0): """The interactable shape of the scale handles.""" - return QtCore.QRectF( - center.x() - self.select_resize_size/2, - center.y() - self.select_resize_size/2, - self.select_resize_size, - self.select_resize_size) + path = QtGui.QPainterPath() + path.addRect(QtCore.QRectF( + corner.x() - self.select_resize_size/2 - margin, + corner.y() - self.select_resize_size/2 - margin, + self.select_resize_size + 2 * margin, + self.select_resize_size + 2 * margin)) + return path - @property - def bottom_right_rotate_bounds(self): - """The interactable shape of the bottom right rotate handle""" - return QtCore.QRectF( - self.width + self.select_resize_size / 2, - self.height + self.select_resize_size / 2, - self.select_rotate_size, self.select_rotate_size) + def get_rotate_bounds(self, corner): + """The interactable shape of the rotation area. It sits around the + scale area like an L shape, e.g. for the bottom right corner: + │ + ┌┴┬─┐ + ─┤S│R│ + ├─┘ │ + │R R│ + └───┘ + """ + + path = QtGui.QPainterPath() + + # The whole square containing the rotate area: + d = self.get_corner_direction(corner) + p1 = corner - d * self.select_resize_size / 2 + p2 = p1 + d * (self.select_resize_size + self.select_rotate_size) + path.addRect(utils.get_rect_from_points(p1, p2)) + + # Substract the scale area: + # We need to make the substracted shape slighty bigger due to this bug: + # https://bugreports.qt.io/browse/QTBUG-57567 + return path - self.get_scale_bounds(corner, margin=0.001) def boundingRect(self): bounds = super().boundingRect() @@ -163,15 +182,13 @@ class SelectableMixin(BaseItemMixin): bounds.bottomRight().y() + 2 * margin) def shape(self): - shape_ = super().shape() + path = super().shape() if self.has_selection_handles(): # Add extra space for scale and rotate interactive areas - path = QtGui.QPainterPath() for corner in self.corners: - path.addRect(self.get_scale_bounds(corner)) - path.addRect(self.bottom_right_rotate_bounds) - shape_ = shape_ + path - return shape_ + path += self.get_scale_bounds(corner) + path += self.get_rotate_bounds(corner) + return path def hoverMoveEvent(self, event): if not self.has_selection_handles(): @@ -180,16 +197,15 @@ class SelectableMixin(BaseItemMixin): for corner in self.corners: # See if we need to change the cursor for scale areas if self.get_scale_bounds(corner).contains(event.pos()): - direction = self.get_scale_direction(corner) + direction = self.get_corner_direction(corner) if direction.x() == direction.y(): self.setCursor(Qt.CursorShape.SizeFDiagCursor) else: self.setCursor(Qt.CursorShape.SizeBDiagCursor) return - - if self.bottom_right_rotate_bounds.contains(event.pos()): - self.setCursor(Qt.CursorShape.ForbiddenCursor) - return + if self.get_rotate_bounds(corner).contains(event.pos()): + self.setCursor(Qt.CursorShape.ForbiddenCursor) + return self.setCursor(Qt.CursorShape.ArrowCursor) @@ -207,7 +223,7 @@ class SelectableMixin(BaseItemMixin): # Start scale action for this corner self.scale_active = True self.scale_start = event.scenePos() - self.scale_direction = self.get_scale_direction(corner) + self.scale_direction = self.get_corner_direction(corner) for item in self.selection_action_items(): item.scale_anchor = self.get_scale_anchor(item, corner) item.scale_orig_factor = item.scale() @@ -229,8 +245,9 @@ class SelectableMixin(BaseItemMixin): return item.mapFromScene( self.mapToScene(self.width - corner.x(), self.height - corner.y())) - def get_scale_direction(self, corner): - """Get the direction in which the scale for this corner increases""" + def get_corner_direction(self, corner): + """Get the direction facing away from the center, e.g. the direction + in which the scale for this corner increases.""" return QtCore.QPointF(1 if corner.x() > 0 else -1, 1 if corner.y() > 0 else -1) @@ -358,11 +375,5 @@ class RubberbandItem(BaseItemMixin, QtWidgets.QGraphicsRectItem): def fit(self, point1, point2): """Updates itself to fit the two given points.""" - topleft = QtCore.QPointF( - min(point1.x(), point2.x()), - min(point1.y(), point2.y())) - bottomright = QtCore.QPointF( - max(point1.x(), point2.x()), - max(point1.y(), point2.y())) - self.setRect(QtCore.QRectF(topleft, bottomright)) + self.setRect(utils.get_rect_from_points(point1, point2)) logger.debug(f'Updated rubberband {self}') diff --git a/beeref/utils.py b/beeref/utils.py new file mode 100644 index 0000000..86dc85c --- /dev/null +++ b/beeref/utils.py @@ -0,0 +1,30 @@ +# This file is part of BeeRef. +# +# BeeRef is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BeeRef is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BeeRef. If not, see . + + +from PyQt6 import QtCore + + +def get_rect_from_points(point1, point2): + """Constructs a QRectF from the given QPointF. The points can be *any* + two opposing corners of the rectangle.""" + + topleft = QtCore.QPointF( + min(point1.x(), point2.x()), + min(point1.y(), point2.y())) + bottomright = QtCore.QPointF( + max(point1.x(), point2.x()), + max(point1.y(), point2.y())) + return QtCore.QRectF(topleft, bottomright) diff --git a/tests/test_selection.py b/tests/test_selection.py index c4023be..f36e3e7 100644 --- a/tests/test_selection.py +++ b/tests/test_selection.py @@ -190,21 +190,44 @@ class SelectableMixinTestCase(SelectableMixinBaseTestCase): def test_get_scale_bounds(self): self.view.get_scale = MagicMock(return_value=1) self.item.SELECT_RESIZE_SIZE = 10 - rect = self.item.get_scale_bounds(QtCore.QPointF(100, 100)) + rect = self.item.get_scale_bounds( + QtCore.QPointF(100, 80)).boundingRect() assert rect.topLeft().x() == 95 - assert rect.topLeft().y() == 95 + assert rect.topLeft().y() == 75 assert rect.bottomRight().x() == 105 - assert rect.bottomRight().y() == 105 + assert rect.bottomRight().y() == 85 - def test_bottom_right_rotate_bounds(self): + def test_get_scale_bounds_with_margin(self): + self.view.get_scale = MagicMock(return_value=1) + self.item.SELECT_RESIZE_SIZE = 10 + rect = self.item.get_scale_bounds( + QtCore.QPointF(100, 80), margin=1).boundingRect() + assert rect.topLeft().x() == 94 + assert rect.topLeft().y() == 74 + assert rect.bottomRight().x() == 106 + assert rect.bottomRight().y() == 86 + + def test_rotate_bounds_bottomright(self): self.view.get_scale = MagicMock(return_value=1) self.item.SELECT_RESIZE_SIZE = 10 self.item.SELECT_ROTATE_SIZE = 10 - rect = self.item.bottom_right_rotate_bounds - assert rect.topLeft().x() == 105 - assert rect.topLeft().y() == 85 - assert rect.bottomRight().x() == 115 - assert rect.bottomRight().y() == 95 + path = self.item.get_rotate_bounds(QtCore.QPointF(100, 80)) + assert path.boundingRect().topLeft().x() == 95 + assert path.boundingRect().topLeft().y() == 75 + assert path.boundingRect().bottomRight().x() == 115 + assert path.boundingRect().bottomRight().y() == 95 + assert path.contains(QtCore.QPointF(104, 84)) is False + + def test_rotate_bounds_topleft(self): + self.view.get_scale = MagicMock(return_value=1) + self.item.SELECT_RESIZE_SIZE = 10 + self.item.SELECT_ROTATE_SIZE = 10 + path = self.item.get_rotate_bounds(QtCore.QPointF(0, 0)) + assert path.boundingRect().topLeft().x() == -15 + assert path.boundingRect().topLeft().y() == -15 + assert path.boundingRect().bottomRight().x() == 5 + assert path.boundingRect().bottomRight().y() == 5 + assert path.contains(QtCore.QPointF(-4, -4)) is False def test_bounding_rect_when_not_selected(self): self.view.get_scale = MagicMock(return_value=1) @@ -255,8 +278,8 @@ class SelectableMixinTestCase(SelectableMixinBaseTestCase): with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.shape', return_value=path): shape = self.item.shape().boundingRect() - assert shape.topLeft().x() == -5 - assert shape.topLeft().y() == -5 + assert shape.topLeft().x() == -15 + assert shape.topLeft().y() == -15 assert shape.bottomRight().x() == 115 assert shape.bottomRight().y() == 95 @@ -325,20 +348,20 @@ class SelectableMixinScalingTestCase(SelectableMixinBaseTestCase): assert anchor.x() == 78 assert anchor.y() == 47 - def test_get_scale_direction_topleft(self): - assert self.item.get_scale_direction( + def test_get_corner_direction_topleft(self): + assert self.item.get_corner_direction( QtCore.QPointF(0, 0)) == QtCore.QPointF(-1, -1) - def test_get_scale_direction_bottomright(self): - assert self.item.get_scale_direction( + def test_get_corner_direction_bottomright(self): + assert self.item.get_corner_direction( QtCore.QPointF(100, 80)) == QtCore.QPointF(1, 1) - def test_get_scale_direction_topright(self): - assert self.item.get_scale_direction( + def test_get_corner_direction_topright(self): + assert self.item.get_corner_direction( QtCore.QPointF(100, 0)) == QtCore.QPointF(1, -1) - def test_get_scale_direction_bottomleft(self): - assert self.item.get_scale_direction( + def test_get_corner_direction_bottomleft(self): + assert self.item.get_corner_direction( QtCore.QPointF(0, 80)) == QtCore.QPointF(-1, 1) def test_translate_for_scale_anchor(self): diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..3c24f2c --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,23 @@ +from PyQt6 import QtCore + +from beeref import utils +from .base import BeeTestCase + + +class GetRectFromPointsTestCase(BeeTestCase): + + def test_given_topleft_bottomright(self): + rect = utils.get_rect_from_points(QtCore.QPointF(-10, -20), + QtCore.QPointF(30, 40)) + assert rect.topLeft().x() == -10 + assert rect.topLeft().y() == -20 + assert rect.bottomRight().x() == 30 + assert rect.bottomRight().y() == 40 + + def test_given_topright_bottomleft(self): + rect = utils.get_rect_from_points(QtCore.QPointF(50, -20), + QtCore.QPointF(-30, 40)) + assert rect.topLeft().x() == -30 + assert rect.topLeft().y() == -20 + assert rect.bottomRight().x() == 50 + assert rect.bottomRight().y() == 40