diff --git a/beeref/commands.py b/beeref/commands.py index 9d33092..8148d42 100644 --- a/beeref/commands.py +++ b/beeref/commands.py @@ -74,37 +74,31 @@ class MoveItemsBy(QtGui.QUndoCommand): class ScaleItemsBy(QtGui.QUndoCommand): - """Scale items by a given factor around the given anchor point.""" + """Scale items by a given factor around the given anchor.""" - def __init__(self, items, factor, ignore_first_redo=False): + def __init__(self, items, factor, anchor, ignore_first_redo=False): super().__init__('Scale items') self.ignore_first_redo = ignore_first_redo self.items = items self.factor = factor - self.item_data = [ - {'anchor': item.scale_anchor, - 'orig_factor': item.scale_orig_factor, - 'orig_pos': item.scale_orig_pos} for item in items] + self.anchor = anchor def redo(self): if self.ignore_first_redo: self.ignore_first_redo = False return - for item, data in zip(self.items, self.item_data): - item.scale_orig_factors = data['orig_factor'] - item.scale_orig_pos = data['orig_pos'] - item.scale_anchor = data['anchor'] - item.setScale(item.scale() * self.factor) - item.translate_for_scale_anchor(self.factor) + for item in self.items: + item.setScale(item.scale() * self.factor, + item.mapFromScene(self.anchor)) def undo(self): - for item, data in zip(self.items, self.item_data): - item.setScale(item.scale() / self.factor) - item.setPos(data['orig_pos']) + for item in self.items: + item.setScale(item.scale() / self.factor, + item.mapFromScene(self.anchor)) class RotateItemsBy(QtGui.QUndoCommand): - """Rotate items by a given deltan around the given anchor.""" + """Rotate items by a given delta around the given anchor.""" def __init__(self, items, delta, anchor, ignore_first_redo=False): super().__init__('Scale items') diff --git a/beeref/selection.py b/beeref/selection.py index 5efc4c4..20bc1e2 100644 --- a/beeref/selection.py +++ b/beeref/selection.py @@ -33,15 +33,35 @@ logger = logging.getLogger('BeeRef') SELECT_COLOR = QtGui.QColor(116, 234, 231, 255) +def with_anchor(func): + """Decorator that handles anchors for translate operations. + + The anchor is given in item coordinates. + """ + + def wrapper(self, value, anchor=None): + # We caculate where the anchor is before and after the transformation + # and then move the item accordingly to keep the anchor fixed + + anchor = anchor if anchor else QtCore.QPointF(0, 0) + prev = self.mapToScene(anchor) + func(self, value, anchor) + diff = self.mapToScene(anchor) - prev + self.setPos(self.pos() - diff) + + return wrapper + + class BaseItemMixin: - def setScale(self, factor): - if factor <= 0: + @with_anchor + def setScale(self, value, anchor=None): + if value <= 0: return - logger.debug(f'Setting scale for {self} to {factor}') + logger.debug(f'Setting scale for {self} to {value}') self.prepareGeometryChange() - super().setScale(factor) + super().setScale(value) def setZValue(self, value): logger.debug(f'Setting z-value for {self} to {value}') @@ -51,15 +71,15 @@ class BaseItemMixin: def bring_to_front(self): self.setZValue(self.scene().max_z + 0.001) - def setRotation(self, rotation, anchor=None): - anchor = anchor or QtCore.QPointF(0, 0) - prev = self.mapToScene(anchor) - super().setRotation(rotation) + @with_anchor + def setRotation(self, value, anchor): + logger.debug(f'Setting rotation for {self} to {value}') + super().setRotation(value) - # Calculate where the anchor moved to so that we can shift the - # item back to keep the anchor fixed - diff = self.mapToScene(anchor) - prev - self.setPos(self.pos() - diff) + @property + def center_scene_coords(self): + """The item's center in scene coordinates.""" + return self.mapToScene(QtCore.QPointF(self.width/2, self.height/2)) class SelectableMixin(BaseItemMixin): @@ -237,22 +257,22 @@ class SelectableMixin(BaseItemMixin): # Start scale action for this corner self.scale_active = True self.event_start = event.scenePos() + self.event_direction = self.get_mouse_event_direction( + event) + self.event_anchor = self.mapToScene( + self.get_scale_anchor(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() - item.scale_orig_pos = item.pos() event.accept() return # Check if we are in one of the corner's rotate areas if self.get_rotate_bounds(corner).contains(event.pos()): # Start rotate action self.rotate_active = True - self.rotate_anchor = self.mapToScene( - QtCore.QPointF(self.width/2, self.height/2)) + self.event_anchor = self.center_scene_coords self.rotate_start_angle = self.get_rotate_angle(event) for item in self.selection_action_items(): - item.rotate_anchor = self.rotate_anchor item.rotate_orig_degrees = item.rotation() event.accept() return @@ -261,16 +281,16 @@ class SelectableMixin(BaseItemMixin): def get_scale_factor(self, event): """Get the scale factor for the current mouse movement.""" - imgsize = self.width + self.height + imgsize = math.sqrt(self.width**2 + self.height**2) p = event.scenePos() - self.event_start - direction = self.scale_direction + direction = self.event_direction delta = QtCore.QPointF.dotProduct(direction, p) / imgsize return (self.scale_orig_factor + delta) / self.scale_orig_factor - def get_scale_anchor(self, item, corner): + def get_scale_anchor(self, corner): """Get the anchor around which the scale for this corner operates.""" - return item.mapFromScene( - self.mapToScene(self.width - corner.x(), self.height - corner.y())) + return QtCore.QPointF(self.width - corner.x(), + self.height - corner.y()) def get_corner_direction(self, corner): """Get the direction facing away from the center, e.g. the direction @@ -278,24 +298,18 @@ class SelectableMixin(BaseItemMixin): return QtCore.QPointF(1 if corner.x() > 0 else -1, 1 if corner.y() > 0 else -1) - def translate_for_scale_anchor(self, scale_factor): - """Adjust the item's position so that a scale with the given scale - factor appears to operate around the scale anchor. ``setScale`` - needs to be called separately with ``scale_factor`` multiplied by - the item's current scale factor. + def get_mouse_event_direction(self, event): + """The direction of a mouse event in relation to the item's center. """ - - factor = self.scale_orig_factor * (scale_factor - 1) - self.setPos( - self.scale_orig_pos.x() - self.scale_anchor.x() * factor, - self.scale_orig_pos.y() - self.scale_anchor.y() * factor, - ) + diff = event.scenePos() - self.center_scene_coords + length = math.sqrt(QtCore.QPointF.dotProduct(diff, diff)) + return diff / length def get_rotate_angle(self, event): """Get the angle of the current mouse position towards the scale center.""" - diff = event.scenePos() - self.rotate_anchor + diff = event.scenePos() - self.event_anchor return -math.degrees(math.atan2(diff.x(), diff.y())) def get_rotate_delta(self, event): @@ -306,14 +320,14 @@ class SelectableMixin(BaseItemMixin): if self.scale_active: factor = self.get_scale_factor(event) for item in self.selection_action_items(): - item.setScale(item.scale_orig_factor * factor) - item.translate_for_scale_anchor(factor) + item.setScale(item.scale_orig_factor * factor, + item.mapFromScene(self.event_anchor)) event.accept() elif self.rotate_active: delta = self.get_rotate_delta(event) for item in self.selection_action_items(): item.setRotation(item.rotate_orig_degrees + delta, - item.mapFromScene(self.rotate_anchor)) + item.mapFromScene(self.event_anchor)) else: super().mouseMoveEvent(event) @@ -323,6 +337,7 @@ class SelectableMixin(BaseItemMixin): commands.ScaleItemsBy( self.selection_action_items(), self.get_scale_factor(event), + self.event_anchor, ignore_first_redo=True)) self.scale_active = False event.accept() @@ -332,7 +347,7 @@ class SelectableMixin(BaseItemMixin): commands.RotateItemsBy( self.selection_action_items(), self.get_rotate_delta(event), - self.rotate_anchor, + self.event_anchor, ignore_first_redo=True)) self.rotate_active = False event.accept() diff --git a/tests/test_commands.py b/tests/test_commands.py index 00d0022..3dc12e5 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -95,16 +95,11 @@ class ScaleItemsByTestCase(BeeTestCase): def test_redo_undo(self): item1 = BeePixmapItem(QtGui.QImage()) item1.setScale(1) - item1.scale_orig_factor = 1 - item1.scale_anchor = QtCore.QPointF(100, 100) - item1.scale_orig_pos = QtCore.QPointF(0, 0) item2 = BeePixmapItem(QtGui.QImage()) item2.setScale(3) - item2.scale_orig_factor = 3 - item2.scale_anchor = QtCore.QPointF(0, 0) item2.setPos(100, 100) - item2.scale_orig_pos = QtCore.QPointF(100, 100) - command = commands.ScaleItemsBy([item1, item2], 2) + command = commands.ScaleItemsBy([item1, item2], 2, + QtCore.QPointF(100, 100)) command.redo() assert item1.scale() == 2 assert item1.pos().x() == -100 @@ -123,22 +118,17 @@ class ScaleItemsByTestCase(BeeTestCase): def test_ignore_first_redo(self): item1 = BeePixmapItem(QtGui.QImage()) item1.setScale(1) - item1.scale_orig_factor = 1 - item1.scale_anchor = QtCore.QPointF(100, 100) - item1.scale_orig_pos = QtCore.QPointF(0, 0) item2 = BeePixmapItem(QtGui.QImage()) item2.setScale(3) - item2.scale_orig_factor = 3 - item2.scale_anchor = QtCore.QPointF(0, 0) item2.setPos(100, 100) - item2.scale_orig_pos = QtCore.QPointF(100, 100) command = commands.ScaleItemsBy([item1, item2], 2, + QtCore.QPointF(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 + assert item2.scale() == 3 assert item2.pos().x() == 100 assert item2.pos().y() == 100 command.redo() diff --git a/tests/test_selection.py b/tests/test_selection.py index 5d3689b..70e2a50 100644 --- a/tests/test_selection.py +++ b/tests/test_selection.py @@ -1,3 +1,5 @@ +import math +from pytest import approx from unittest.mock import patch, MagicMock, PropertyMock from PyQt6 import QtCore, QtGui @@ -35,6 +37,12 @@ class BaseItemMixinTestCase(BeeTestCase): item.setScale(-0.1) assert item.scale() == 1 + def test_set_scale_with_anchor(self): + item = BeePixmapItem(QtGui.QImage()) + item.setScale(2, anchor=QtCore.QPointF(100, 100)) + assert item.scale() == 2 + assert item.pos() == QtCore.QPointF(-100, -100) + def test_set_zvalue_sets_new_max(self): item = BeePixmapItem(QtGui.QImage()) self.scene.addItem(item) @@ -60,7 +68,7 @@ class BaseItemMixinTestCase(BeeTestCase): assert item2.zValue() > item1.zValue() assert item2.zValue() == self.scene.max_z - def test_set_rotation_anchor_topleft(self): + def test_set_rotation_anchor(self): item = BeePixmapItem(QtGui.QImage()) item.setRotation(45) assert item.rotation() == 45 @@ -81,6 +89,17 @@ class BaseItemMixinTestCase(BeeTestCase): assert item.pos().x() == 0 assert item.pos().y() == 200 + def test_center_scene_coords(self): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + item.setPos(5, 5) + item.setScale(2) + with patch('beeref.items.BeePixmapItem.width', + new_callable=PropertyMock, return_value=100): + with patch('beeref.items.BeePixmapItem.height', + new_callable=PropertyMock, return_value=80): + assert item.center_scene_coords == QtCore.QPointF(105, 85) + class SelectableMixinBaseTestCase(BeeTestCase): @@ -329,47 +348,40 @@ class SelectableMixinScalingTestCase(SelectableMixinBaseTestCase): def test_get_scale_factor_bottomright(self): self.item.event_start = QtCore.QPointF(10, 10) - self.item.scale_direction = QtCore.QPointF(1, 1) + self.item.event_direction = QtCore.QPointF(1, 1) / math.sqrt(2) self.item.scale_orig_factor = 1 event = MagicMock() event.scenePos = MagicMock(return_value=QtCore.QPointF(20, 90)) - assert self.item.get_scale_factor(event) == 1.5 + assert self.item.get_scale_factor(event) == approx(1.5, 0.01) def test_get_scale_factor_topleft(self): self.item.event_start = QtCore.QPointF(10, 10) - self.item.scale_direction = QtCore.QPointF(-1, -1) + self.item.event_direction = QtCore.QPointF(-1, -1) / math.sqrt(2) self.item.scale_orig_factor = 0.5 event = MagicMock() event.scenePos = MagicMock(return_value=QtCore.QPointF(-10, -60)) - assert self.item.get_scale_factor(event) == 2 + assert self.item.get_scale_factor(event) == approx(2, 0.01) def test_get_scale_anchor_topleft(self): - anchor = self.item.get_scale_anchor(self.item, QtCore.QPointF(0, 0)) + anchor = self.item.get_scale_anchor(QtCore.QPointF(0, 0)) assert anchor.x() == 100 assert anchor.y() == 80 def test_get_scale_anchor_bottomright(self): - anchor = self.item.get_scale_anchor(self.item, QtCore.QPointF(100, 80)) + anchor = self.item.get_scale_anchor(QtCore.QPointF(100, 80)) assert anchor.x() == 0 assert anchor.y() == 0 def test_get_scale_anchor_topright(self): - anchor = self.item.get_scale_anchor(self.item, QtCore.QPointF(100, 0)) + anchor = self.item.get_scale_anchor(QtCore.QPointF(100, 0)) assert anchor.x() == 0 assert anchor.y() == 80 def test_get_scale_anchor_bottomleft(self): - anchor = self.item.get_scale_anchor(self.item, QtCore.QPointF(0, 80)) + anchor = self.item.get_scale_anchor(QtCore.QPointF(0, 80)) assert anchor.x() == 100 assert anchor.y() == 0 - def test_get_scale_anchor_topleft_when_anchor_other_item(self): - item2 = BeePixmapItem(QtGui.QImage()) - item2.setPos(22, 33) - anchor = self.item.get_scale_anchor(item2, QtCore.QPointF(0, 0)) - assert anchor.x() == 78 - assert anchor.y() == 47 - def test_get_corner_direction_topleft(self): assert self.item.get_corner_direction( QtCore.QPointF(0, 0)) == QtCore.QPointF(-1, -1) @@ -386,22 +398,33 @@ class SelectableMixinScalingTestCase(SelectableMixinBaseTestCase): assert self.item.get_corner_direction( QtCore.QPointF(0, 80)) == QtCore.QPointF(-1, 1) - def test_translate_for_scale_anchor(self): - self.item.scale_orig_pos = QtCore.QPoint(50, 70) - self.item.scale_anchor = QtCore.QPoint(100, 80) - self.item.scale_orig_factor = 1 - self.item.translate_for_scale_anchor(3) - assert self.item.pos().x() == -150 - assert self.item.pos().y() == -90 + def test_get_mouse_event_direction_bottomright(self): + event = MagicMock() + event.scenePos = MagicMock(return_value=QtCore.QPointF(100, 90)) + direction = self.item.get_mouse_event_direction(event) + assert direction == approx(QtCore.QPointF(1, 1) / math.sqrt(2)) + + def test_get_mouse_event_direction_topleft(self): + event = MagicMock() + event.scenePos = MagicMock(return_value=QtCore.QPointF(0, -10)) + direction = self.item.get_mouse_event_direction(event) + assert direction == approx(QtCore.QPointF(-1, -1) / math.sqrt(2)) + + def test_get_mouse_event_direction_bottomright_when_rotated_180(self): + self.item.setRotation(180, QtCore.QPointF(50, 40)) + event = MagicMock() + event.scenePos = MagicMock(return_value=QtCore.QPointF(100, 90)) + direction = self.item.get_mouse_event_direction(event) + assert direction == approx(QtCore.QPointF(1, 1) / math.sqrt(2)) def test_get_rotate_angle(self): - self.item.rotate_anchor = QtCore.QPointF(10, 20) + self.item.event_anchor = QtCore.QPointF(10, 20) event = MagicMock() event.scenePos = MagicMock(return_value=QtCore.QPointF(15, 25)) assert self.item.get_rotate_angle(event) == -45 def test_get_rotate_delta(self): - self.item.rotate_anchor = QtCore.QPointF(10, 20) + self.item.event_anchor = QtCore.QPointF(10, 20) self.item.rotate_start_angle = -3 event = MagicMock() event.scenePos = MagicMock(return_value=QtCore.QPointF(15, 25)) @@ -470,28 +493,28 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): 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.scenePos = MagicMock(return_value=QtCore.QPointF(-1, -1)) self.event.button = MagicMock( return_value=Qt.MouseButtons.LeftButton) self.item.mousePressEvent(self.event) assert self.item.scale_active is True - assert self.item.event_start == QtCore.QPointF(66, 99) - assert self.item.scale_direction == QtCore.QPointF(-1, -1) + assert self.item.event_start == QtCore.QPointF(-1, -1) + assert self.item.event_direction.x() < 0 + assert self.item.event_direction.y() < 0 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.scenePos = MagicMock(return_value=QtCore.QPointF(101, 81)) self.event.button = MagicMock( return_value=Qt.MouseButtons.LeftButton) self.item.mousePressEvent(self.event) assert self.item.scale_active is True - assert self.item.scale_direction == QtCore.QPointF(1, 1) - assert self.item.event_start == QtCore.QPointF(66, 99) + assert self.item.event_start == QtCore.QPointF(101, 81) + assert self.item.event_direction.x() > 0 + assert self.item.event_direction.y() > 0 assert self.item.scale_orig_factor == 1 - assert self.item.scale_orig_pos == QtCore.QPointF(0, 0) def test_mouse_press_event_rotate(self): self.item.setSelected(True) @@ -501,7 +524,7 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): return_value=Qt.MouseButtons.LeftButton) self.item.mousePressEvent(self.event) assert self.item.rotate_active is True - assert self.item.rotate_anchor == QtCore.QPointF(50, 40) + assert self.item.event_anchor == QtCore.QPointF(50, 40) assert self.item.rotate_orig_degrees == 0 def test_mouse_press_event_not_selected(self): @@ -511,7 +534,7 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): m.assert_called_once_with(self.event) assert self.item.scale_active is False - def test_mouse_press_not_in_handles(self): + def test_mouse_press_event_not_in_handles(self): self.item.setSelected(True) self.event.pos = MagicMock(return_value=QtCore.QPointF(50, 40)) self.event.button = MagicMock( @@ -529,21 +552,20 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): def test_mouse_move_event_when_scale_action(self): self.event.scenePos = MagicMock(return_value=QtCore.QPointF(20, 90)) self.item.scale_active = True - self.item.scale_direction = QtCore.QPointF(1, 1) - self.item.scale_anchor = QtCore.QPointF(100, 80) + self.item.event_direction = QtCore.QPointF(1, 1) / math.sqrt(2) + self.item.event_anchor = QtCore.QPointF(100, 80) self.item.event_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 + assert self.item.scale() == approx(1.5, 0.01) def test_mouse_move_event_when_rotate_action(self): self.event.scenePos = MagicMock(return_value=QtCore.QPointF(15, 25)) self.item.rotate_active = True self.item.rotate_orig_degrees = 0 self.item.rotate_start_angle = -3 - self.item.rotate_anchor = QtCore.QPointF(10, 20) + self.item.event_anchor = QtCore.QPointF(10, 20) self.item.mouseMoveEvent(self.event) assert self.item.rotation() == -42 @@ -556,11 +578,10 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): def test_mouse_release_event_when_scale_action(self): self.event.scenePos = MagicMock(return_value=QtCore.QPointF(20, 90)) self.item.scale_active = True - self.item.scale_direction = QtCore.QPointF(1, 1) - self.item.scale_anchor = QtCore.QPointF(100, 80) + self.item.event_direction = QtCore.QPointF(1, 1) / math.sqrt(2) + self.item.event_anchor = QtCore.QPointF(100, 80) self.item.event_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(push=MagicMock()) self.item.mouseReleaseEvent(self.event) @@ -568,11 +589,8 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): args = self.scene.undo_stack.push.call_args_list[0][0] cmd = args[0] assert cmd.items == [self.item] - assert cmd.factor == 1.5 - assert cmd.item_data == [{ - 'anchor': QtCore.QPointF(100, 80), - 'orig_factor': 1, - 'orig_pos': QtCore.QPointF(0, 0)}] + assert cmd.factor == approx(1.5, 0.01) + assert cmd.anchor == QtCore.QPointF(100, 80) assert cmd.ignore_first_redo is True assert self.item.scale_active is False @@ -581,7 +599,7 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): self.item.rotate_active = True self.item.rotate_orig_degrees = 0 self.item.rotate_start_angle = -3 - self.item.rotate_anchor = QtCore.QPointF(10, 20) + self.item.event_anchor = QtCore.QPointF(10, 20) self.scene.undo_stack = MagicMock(push=MagicMock()) self.item.mouseReleaseEvent(self.event)