mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
Add hover areas for rotating items
This commit is contained in:
parent
476c061071
commit
1ff957bca4
4 changed files with 140 additions and 53 deletions
|
|
@ -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}')
|
||||
|
|
|
|||
30
beeref/utils.py
Normal file
30
beeref/utils.py
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
23
tests/test_utils.py
Normal file
23
tests/test_utils.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue