Add scaling handles for all corners

This commit is contained in:
Rebecca Breu 2021-04-01 16:31:43 +02:00
parent e9444dd8e3
commit 7bb8cd200d
5 changed files with 349 additions and 84 deletions

View file

@ -74,12 +74,15 @@ class MoveItemsBy(QtGui.QUndoCommand):
item.moveBy(-self.delta_x, -self.delta_y)
class ScaleItemsBy(QtGui.QUndoCommand):
class ScaleItemsByDelta(QtGui.QUndoCommand):
"""Scale Items by a given delta around the given anchor point.
Delta will be *added* to the current scale factor."""
def __init__(self, items, factor, ignore_first_redo=False):
def __init__(self, items, delta, anchor, ignore_first_redo=False):
super().__init__('Scale items')
self.items = items
self.factor = factor
self.delta = delta
self.anchor = anchor
self.ignore_first_redo = ignore_first_redo
def redo(self):
@ -87,11 +90,15 @@ class ScaleItemsBy(QtGui.QUndoCommand):
self.ignore_first_redo = False
return
for item in self.items:
item.setScale(item.scale() + self.factor)
item.setScale(item.scale() + self.delta)
item.translate_for_scale_anchor(
item.pos(), self.delta, self.anchor)
def undo(self):
for item in self.items:
item.setScale(item.scale() - self.factor)
item.setScale(item.scale() - self.delta)
item.translate_for_scale_anchor(
item.pos(), -self.delta, self.anchor)
class NormalizeItems(QtGui.QUndoCommand):

View file

@ -35,8 +35,8 @@ class BeePixmapItem(QtWidgets.QGraphicsPixmapItem):
select_color = QtGui.QColor(116, 234, 231, 255)
SELECT_LINE_WIDTH = 4 # line width for the selection box
SELECT_HANDLE_SIZE = 15 # size of selection handles for scaling
SELECT_RESIZE_SIZE = 30 # size of hover area for scaling
SELECT_ROTATE_SIZE = 30 # size of hover area for rotating
SELECT_RESIZE_SIZE = 20 # size of hover area for scaling
SELECT_ROTATE_SIZE = 20 # size of hover area for rotating
select_debug = False # Draw debug shapes
def __init__(self, image, filename=None):
@ -50,8 +50,7 @@ class BeePixmapItem(QtWidgets.QGraphicsPixmapItem):
QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsMovable
| QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsSelectable)
self.single_select_mode = False
self.scale_active = False
self.scale_active_corner = None
self.viewport_scale = 1
def __str__(self):
@ -163,14 +162,22 @@ class BeePixmapItem(QtWidgets.QGraphicsPixmapItem):
if single_select_mode:
pen.setWidth(self.SELECT_HANDLE_SIZE)
painter.setPen(pen)
painter.drawPoint(self.width, self.height)
for corner in self.corners:
painter.drawPoint(*corner)
@property
def bottom_right_scale_bounds(self):
"""The interactable shape of the bottom right scale handle"""
def corners(self):
"""The corners of the items. Used for scale and rotate handles."""
return ((0, 0),
(0, self.height),
(self.width, 0),
(self.width, self.height))
def get_scale_bounds(self, center):
"""The interactable shape of the scale handles."""
return QtCore.QRectF(
self.width - self.select_resize_size/2,
self.height - self.select_resize_size/2,
center[0] - self.select_resize_size/2,
center[1] - self.select_resize_size/2,
self.select_resize_size,
self.select_resize_size)
@ -186,6 +193,8 @@ class BeePixmapItem(QtWidgets.QGraphicsPixmapItem):
bounds = super().boundingRect()
if not self.isSelected():
return bounds
# Add extra space for scale and rotate interactive areas
margin = self.select_resize_size / 2 + self.select_rotate_size
return QtCore.QRectF(
bounds.topLeft().x() - margin,
@ -196,8 +205,10 @@ class BeePixmapItem(QtWidgets.QGraphicsPixmapItem):
def shape(self):
shape_ = super().shape()
if self.isSelected():
# Add extra space for scale and rotate interactive areas
path = QtGui.QPainterPath()
path.addRect(self.bottom_right_scale_bounds)
for corner in self.corners:
path.addRect(self.get_scale_bounds(corner))
path.addRect(self.bottom_right_rotate_bounds)
shape_ = shape_ + path
return shape_
@ -205,48 +216,92 @@ class BeePixmapItem(QtWidgets.QGraphicsPixmapItem):
def hoverMoveEvent(self, event):
if not self.isSelected() or not self.scene().has_single_selection():
return
if self.bottom_right_scale_bounds.contains(event.pos()):
self.setCursor(Qt.CursorShape.SizeFDiagCursor)
elif self.bottom_right_rotate_bounds.contains(event.pos()):
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)
if direction[0] == direction[1]:
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)
else:
self.setCursor(Qt.CursorShape.ArrowCursor)
return
self.setCursor(Qt.CursorShape.ArrowCursor)
def hoverEnterEvent(self, event):
# Always return regular cursor when item isn't selected
if not self.isSelected() or not self.scene().has_single_selection():
self.setCursor(Qt.CursorShape.ArrowCursor)
def mousePressEvent(self, event):
if (event.button() == Qt.MouseButtons.LeftButton
and self.bottom_right_scale_bounds.contains(event.pos())
and self.isSelected()):
self.scale_active = True
self.orig_scale_factor = self.scale()
self.scale_start = event.scenePos()
event.accept()
else:
super().mousePressEvent(event)
and self.isSelected() and self.scene().has_single_selection()):
for corner in self.corners:
# Check if we are in one of the corner's scale areas
if self.get_scale_bounds(corner).contains(event.pos()):
# Start scale action for this corner
self.scale_active_corner = corner
self.scale_start = event.scenePos()
self.scale_orig_factor = self.scale()
self.scale_orig_pos = self.pos()
event.accept()
return
def get_scale_delta(self, event):
super().mousePressEvent(event)
def get_scale_delta(self, event, corner):
imgsize = self.width + self.height
p = event.scenePos() - self.scale_start
return (p.x() + p.y()) / imgsize
direction = self.get_scale_direction(corner)
return (direction[0] * p.x() + direction[1] * p.y()) / imgsize
def get_scale_anchor(self, corner):
"""Get the anchor around which the scale for this corner operates."""
return(self.width - corner[0], self.height - corner[1])
def get_scale_direction(self, corner):
"""Get the direction in which the scale for this corner increases"""
x = 1 if corner[0] > 0 else -1
y = 1 if corner[1] > 0 else -1
return (x, y)
def translate_for_scale_anchor(self, orig_pos, scale_delta, anchor):
"""Adjust the item's position so that a scale with the given scale
delta appears to operate around the given anchor. ``setScale`` needs
to be called separately with delta *added* to the current item's scale
factor."""
self.setPos(
orig_pos.x() - anchor[0] * scale_delta,
orig_pos.y() - anchor[1] * scale_delta,
)
def mouseMoveEvent(self, event):
if self.scale_active:
delta = self.get_scale_delta(event)
self.setScale(self.orig_scale_factor + delta)
if self.scale_active_corner:
delta = self.get_scale_delta(event, self.scale_active_corner)
self.setScale(self.scale_orig_factor + delta)
self.translate_for_scale_anchor(
self.scale_orig_pos,
delta,
self.get_scale_anchor(self.scale_active_corner))
event.accept()
else:
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if self.scale_active:
if self.scale_active_corner:
self.scene().undo_stack.push(
commands.ScaleItemsBy([self],
self.get_scale_delta(event),
ignore_first_redo=True))
self.scale_active = False
commands.ScaleItemsByDelta(
[self],
self.get_scale_delta(event, self.scale_active_corner),
self.get_scale_anchor(self.scale_active_corner),
ignore_first_redo=True))
self.scale_active_corner = None
event.accept()
else:
super().mouseReleaseEvent(event)

View file

@ -93,7 +93,6 @@ class SQLiteIOWriteTestCase(BeeTestCase):
item.setPos(44, 55)
item.setZValue(0.22)
item.pixmap_to_bytes = MagicMock(return_value=b'abc')
self.scene.addItem(item)
self.io.write()
assert item.save_id == 1
@ -130,7 +129,6 @@ class SQLiteIOWriteTestCase(BeeTestCase):
item.setPos(44, 55)
item.setZValue(0.22)
item.save_id = 1
self.scene.addItem(item)
item.pixmap_to_bytes = MagicMock(return_value=b'abc')
self.io.write()
item.setScale(0.7)

View file

@ -95,26 +95,39 @@ class ScaleItemsByTestCase(BeeTestCase):
item1.setScale(1)
item2 = BeePixmapItem(QtGui.QImage())
item2.setScale(3)
command = commands.ScaleItemsBy([item1, item2], 2)
command = commands.ScaleItemsByDelta([item1, item2], 2, (100, 100))
command.redo()
assert item1.scale() == 3
assert item1.pos().x() == -200
assert item1.pos().y() == -200
assert item2.scale() == 5
assert item2.pos().x() == -200
assert item2.pos().y() == -200
command.undo()
assert item1.scale() == 1
assert item1.pos().x() == 0
assert item1.pos().y() == 0
assert item2.scale() == 3
assert item2.pos().x() == 0
assert item2.pos().y() == 0
def test_ignore_first_redo(self):
item1 = BeePixmapItem(QtGui.QImage())
item1.setScale(1)
item2 = BeePixmapItem(QtGui.QImage())
item2.setScale(3)
command = commands.ScaleItemsBy([item1, item2], 2, True)
command = commands.ScaleItemsByDelta([item1, item2], 2, (100, 100),
ignore_first_redo=True)
command.redo()
assert item1.scale() == 1
assert item2.scale() == 3
assert item1.pos().x() == 0
assert item1.pos().y() == 0
command.redo()
assert item1.scale() == 3
assert item2.scale() == 5
assert item1.pos().x() == -200
assert item1.pos().y() == -200
class NormalizeItemsTestCase(BeeTestCase):

View file

@ -1,6 +1,7 @@
from unittest.mock import patch, MagicMock, PropertyMock
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt
from beeref.items import BeePixmapItem
from beeref.scene import BeeGraphicsScene
@ -25,10 +26,13 @@ class BeePixmapItemTestCase(BeeTestCase):
assert item.filename == self.imgfilename3x3
def test_set_scale(self):
item = BeePixmapItem(QtGui.QImage())
item = BeePixmapItem(
QtGui.QImage(self.imgfilename3x3), self.imgfilename3x3)
item.prepareGeometryChange = MagicMock()
item.setScale(3)
assert item.scale() == 3
assert item.pos().x() == 0
assert item.pos().y() == 0
item.prepareGeometryChange.assert_called_once()
def test_set_scale_ignores_zero(self):
@ -76,18 +80,37 @@ class BeePixmapItemTestCase(BeeTestCase):
assert item2.zValue() > item1.zValue()
assert item2.zValue() == self.scene.max_z
def test_on_view_scale_change(self):
item = BeePixmapItem(QtGui.QImage())
with patch('beeref.items.BeePixmapItem.prepareGeometryChange') as m:
item.on_view_scale_change()
m.assert_called_once()
class BeePixmapItemPaintstuffTestCase(BeeTestCase):
class BeePixmapItemWithViewBaseTestCase(BeeTestCase):
def setUp(self):
self.scene = BeeGraphicsScene(None)
self.item = BeePixmapItem(QtGui.QImage())
self.scene.addItem(self.item)
self.view = MagicMock(get_scale=MagicMock(return_value=1))
views_patcher = patch('beeref.scene.BeeGraphicsScene.views')
views_mock = views_patcher.start()
views_mock.return_value = [self.view]
views_patcher = patch('beeref.scene.BeeGraphicsScene.views',
return_value=[self.view])
views_patcher.start()
self.addCleanup(views_patcher.stop)
width_patcher = patch('beeref.items.BeePixmapItem.width',
new_callable=PropertyMock,
return_value=100)
width_patcher.start()
self.addCleanup(width_patcher.stop)
height_patcher = patch('beeref.items.BeePixmapItem.height',
new_callable=PropertyMock,
return_value=80)
height_patcher.start()
self.addCleanup(height_patcher.stop)
class BeePixmapItemPaintstuffTestCase(BeePixmapItemWithViewBaseTestCase):
def test_fixed_length_for_viewport_when_default_scales(self):
self.view.get_scale = MagicMock(return_value=1)
@ -150,32 +173,31 @@ class BeePixmapItemPaintstuffTestCase(BeeTestCase):
painter.drawRect.assert_called_once()
painter.drawPoint.assert_not_called()
def test_bottom_right_scale_bounds(self):
def test_corners(self):
assert set(self.item.corners) == set((
(0, 0),
(100, 0),
(0, 80),
(100, 80)))
def test_get_scale_bounds(self):
self.view.get_scale = MagicMock(return_value=1)
self.item.SELECT_RESIZE_SIZE = 10
with patch('beeref.items.BeePixmapItem.width',
new_callable=PropertyMock, return_value=100):
with patch('beeref.items.BeePixmapItem.height',
new_callable=PropertyMock, return_value=80):
rect = self.item.bottom_right_scale_bounds
assert rect.topLeft().x() == 95
assert rect.topLeft().y() == 75
assert rect.bottomRight().x() == 105
assert rect.bottomRight().y() == 85
rect = self.item.get_scale_bounds((100, 100))
assert rect.topLeft().x() == 95
assert rect.topLeft().y() == 95
assert rect.bottomRight().x() == 105
assert rect.bottomRight().y() == 105
def test_bottom_right_rotate_bounds(self):
self.view.get_scale = MagicMock(return_value=1)
self.item.SELECT_RESIZE_SIZE = 10
self.item.SELECT_ROTATE_SIZE = 10
with patch('beeref.items.BeePixmapItem.width',
new_callable=PropertyMock, return_value=100):
with patch('beeref.items.BeePixmapItem.height',
new_callable=PropertyMock, return_value=80):
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
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
def test_bounding_rect_when_not_selected(self):
self.view.get_scale = MagicMock(return_value=1)
@ -209,15 +231,11 @@ class BeePixmapItemPaintstuffTestCase(BeeTestCase):
with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.shape',
return_value=path):
with patch('beeref.items.BeePixmapItem.width',
new_callable=PropertyMock, return_value=100):
with patch('beeref.items.BeePixmapItem.height',
new_callable=PropertyMock, return_value=80):
shape = self.item.shape().boundingRect()
assert shape.topLeft().x() == 0
assert shape.topLeft().y() == 0
assert shape.bottomRight().x() == 100
assert shape.bottomRight().y() == 80
shape = self.item.shape().boundingRect()
assert shape.topLeft().x() == 0
assert shape.topLeft().y() == 0
assert shape.bottomRight().x() == 100
assert shape.bottomRight().y() == 80
def test_shape_when_selected(self):
self.item.SELECT_RESIZE_SIZE = 10
@ -229,12 +247,186 @@ class BeePixmapItemPaintstuffTestCase(BeeTestCase):
with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.shape',
return_value=path):
with patch('beeref.items.BeePixmapItem.width',
new_callable=PropertyMock, return_value=100):
with patch('beeref.items.BeePixmapItem.height',
new_callable=PropertyMock, return_value=80):
shape = self.item.shape().boundingRect()
assert shape.topLeft().x() == 0
assert shape.topLeft().y() == 0
assert shape.bottomRight().x() == 115
assert shape.bottomRight().y() == 95
shape = self.item.shape().boundingRect()
assert shape.topLeft().x() == -5
assert shape.topLeft().y() == -5
assert shape.bottomRight().x() == 115
assert shape.bottomRight().y() == 95
class BeePixmapItemScalingTestCase(BeePixmapItemWithViewBaseTestCase):
def test_get_scale_delta_bottomright(self):
self.item.scale_start = QtCore.QPoint(10, 10)
event = MagicMock()
event.scenePos = MagicMock(return_value=QtCore.QPoint(20, 90))
assert self.item.get_scale_delta(event, (100, 80)) == 0.5
def test_get_scale_delta_topleft(self):
self.item.scale_start = QtCore.QPoint(10, 10)
event = MagicMock()
event.scenePos = MagicMock(return_value=QtCore.QPoint(-10, -60))
assert self.item.get_scale_delta(event, (0, 0)) == 0.5
def test_get_scale_anchor_topleft(self):
assert self.item.get_scale_anchor((0, 0)) == (100, 80)
def test_get_scale_anchor_bottomright(self):
assert self.item.get_scale_anchor((100, 80)) == (0, 0)
def test_get_scale_anchor_topright(self):
assert self.item.get_scale_anchor((100, 0)) == (0, 80)
def test_get_scale_anchor_bottomleft(self):
assert self.item.get_scale_anchor((0, 80)) == (100, 0)
def test_get_scale_direction_topleft(self):
assert self.item.get_scale_direction((0, 0)) == (-1, -1)
def test_get_scale_direction_bottomright(self):
assert self.item.get_scale_direction((100, 80)) == (1, 1)
def test_get_scale_direction_topright(self):
assert self.item.get_scale_direction((100, 0)) == (1, -1)
def test_get_scale_direction_bottomleft(self):
assert self.item.get_scale_direction((0, 80)) == (-1, 1)
def test_translate_for_scale_anchor(self):
pos = QtCore.QPoint(50, 70)
self.item.translate_for_scale_anchor(pos, 2, (100, 80))
assert self.item.pos().x() == -150
assert self.item.pos().y() == -90
class BeePixmapItemEventsstuffTestCase(BeePixmapItemWithViewBaseTestCase):
def setUp(self):
super().setUp()
self.event = MagicMock()
self.item.setCursor = MagicMock()
def test_hover_move_event_no_selection(self):
self.event.pos = MagicMock(return_value=QtCore.QPointF(0, 0))
self.item.hoverMoveEvent(self.event)
self.item.setCursor.assert_not_called()
def test_hover_move_event_topleft_scale(self):
self.item.setSelected(True)
self.event.pos = MagicMock(return_value=QtCore.QPointF(0, 0))
self.item.hoverMoveEvent(self.event)
self.item.setCursor.assert_called_once_with(
Qt.CursorShape.SizeFDiagCursor)
def test_hover_move_event_bottomright_scale(self):
self.item.setSelected(True)
self.event.pos = MagicMock(return_value=QtCore.QPointF(100, 80))
self.item.hoverMoveEvent(self.event)
self.item.setCursor.assert_called_once_with(
Qt.CursorShape.SizeFDiagCursor)
def test_hover_move_event_topright_scale(self):
self.item.setSelected(True)
self.event.pos = MagicMock(return_value=QtCore.QPointF(100, 0))
self.item.hoverMoveEvent(self.event)
self.item.setCursor.assert_called_once_with(
Qt.CursorShape.SizeBDiagCursor)
def test_hover_move_event_not_in_handles(self):
self.item.setSelected(True)
self.event.pos = MagicMock(return_value=QtCore.QPointF(50, 50))
self.item.hoverMoveEvent(self.event)
self.item.setCursor.assert_called_once_with(
Qt.CursorShape.ArrowCursor)
def test_hover_enter_event_when_selected(self):
self.item.setSelected(True)
self.item.hoverEnterEvent(self.event)
self.item.setCursor.assert_not_called()
def test_hover_enter_event_when_not_selected(self):
self.item.setSelected(False)
self.item.hoverEnterEvent(self.event)
self.item.setCursor.assert_called_once_with(
Qt.CursorShape.ArrowCursor)
def test_mouse_press_event_topleft_scale(self):
self.item.setSelected(True)
self.event.pos = MagicMock(return_value=QtCore.QPointF(2, 2))
self.event.scenePos = MagicMock(return_value=QtCore.QPointF(66, 99))
self.event.button = MagicMock(
return_value=Qt.MouseButtons.LeftButton)
self.item.mousePressEvent(self.event)
assert self.item.scale_active_corner == (0, 0)
assert self.item.scale_start == QtCore.QPointF(66, 99)
assert self.item.scale_orig_factor == 1
assert self.item.scale_orig_pos == QtCore.QPointF(0, 0)
def test_mouse_press_event_bottomright_scale(self):
self.item.setSelected(True)
self.event.pos = MagicMock(return_value=QtCore.QPointF(99, 79))
self.event.scenePos = MagicMock(return_value=QtCore.QPointF(66, 99))
self.event.button = MagicMock(
return_value=Qt.MouseButtons.LeftButton)
self.item.mousePressEvent(self.event)
assert self.item.scale_active_corner == (100, 80)
assert self.item.scale_start == QtCore.QPointF(66, 99)
assert self.item.scale_orig_factor == 1
assert self.item.scale_orig_pos == QtCore.QPointF(0, 0)
def test_mouse_press_event_not_selected(self):
self.item.setSelected(False)
with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent') as m:
self.item.mousePressEvent(self.event)
m.assert_called_once_with(self.event)
assert self.item.scale_active_corner is None
def test_mouse_press_not_in_handles(self):
self.item.setSelected(True)
self.event.pos = MagicMock(return_value=QtCore.QPointF(50, 40))
self.event.button = MagicMock(
return_value=Qt.MouseButtons.LeftButton)
with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent') as m:
self.item.mousePressEvent(self.event)
m.assert_called_once_with(self.event)
assert self.item.scale_active_corner is None
def test_mouse_move_event_when_no_action(self):
with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mouseMoveEvent') as m:
self.item.mouseMoveEvent(self.event)
m.assert_called_once_with(self.event)
def test_move_event_when_scale_action(self):
self.event.scenePos = MagicMock(return_value=QtCore.QPointF(20, 90))
self.item.scale_active_corner = (100, 80)
self.item.scale_start = QtCore.QPointF(10, 10)
self.item.scale_orig_factor = 1
self.item.scale_orig_pos = QtCore.QPointF(0, 0)
self.item.mouseMoveEvent(self.event)
assert self.item.scale() == 1.5
def test_mouse_release_event_when_no_action(self):
with patch('PyQt6.QtWidgets.QGraphicsPixmapItem'
'.mouseReleaseEvent') as m:
self.item.mouseReleaseEvent(self.event)
m.assert_called_once_with(self.event)
def test_mouse_release_event_when_scale_action(self):
self.event.scenePos = MagicMock(return_value=QtCore.QPointF(20, 90))
self.item.scale_active_corner = (100, 80)
self.item.scale_start = QtCore.QPointF(10, 10)
self.item.scale_orig_factor = 1
self.item.scale_orig_pos = QtCore.QPointF(0, 0)
self.scene.undo_stack = MagicMock()
self.scene.undo_stack.push = MagicMock()
self.item.mouseReleaseEvent(self.event)
self.scene.undo_stack.push.assert_called_once()
args = self.scene.undo_stack.push.call_args_list[0][0]
cmd = args[0]
assert cmd.items == [self.item]
assert cmd.delta == 0.5
assert cmd.anchor == (0, 0)
assert cmd.ignore_first_redo is True
assert self.item.scale_active_corner is None