diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b614645..5a0c2e4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,8 @@ Fixed ----- * Various typos (by luzpaz) +* Ensure that small items always have an area in the middle for + moving/editing that doesn't trigger transform actions 0.2.0 - 2021-09-06 diff --git a/beeref/selection.py b/beeref/selection.py index 2ae0c06..d499875 100644 --- a/beeref/selection.py +++ b/beeref/selection.py @@ -126,6 +126,7 @@ class SelectableMixin(BaseItemMixin): SELECT_HANDLE_SIZE = 15 # size of selection handles for scaling SELECT_RESIZE_SIZE = 20 # size of hover area for scaling SELECT_ROTATE_SIZE = 10 # size of hover area for rotating + SELECT_FREE_CENTER = 20 # size of handle-free area in the center def init_selectable(self): self.setAcceptHoverEvents(True) @@ -170,6 +171,18 @@ class SelectableMixin(BaseItemMixin): def select_rotate_size(self): return self.fixed_length_for_viewport(self.SELECT_ROTATE_SIZE) + def select_handle_free_center(self): + """This area should always trigger regular move operations, + even if it is covered by selection scale/flip/... handles. + This ensures that small items can always still be moved/edited. + """ + size = self.fixed_length_for_viewport(self.SELECT_FREE_CENTER) + return QtCore.QRectF( + self.center.x() - size/2, + self.center.y() - size/2, + size, + size) + def draw_debug_shape(self, painter, shape, r, g, b): color = QtGui.QColor(r, g, b, 50) if isinstance(shape, QtCore.QRectF): @@ -190,6 +203,8 @@ class SelectableMixin(BaseItemMixin): painter, self.get_rotate_bounds(corner), 0, 255, 255) for edge in self.get_flip_bounds(): self.draw_debug_shape(painter, edge['rect'], 255, 255, 0) + self.draw_debug_shape( + painter, self.select_handle_free_center(), 255, 0, 255) def paint_selectable(self, painter, option, widget): self.paint_debug(painter, option, widget) @@ -345,6 +360,13 @@ class SelectableMixin(BaseItemMixin): if not self.has_selection_handles(): return + if event.pos() in self.select_handle_free_center(): + # This area should always trigger regular move operations, + # even if it is covered by selection scale/flip/... handles. + # This ensures that small items can always still be moved/edited. + self.setCursor(Qt.CursorShape.ArrowCursor) + return + for corner in self.corners: # See if we need to change the cursor for interactable areas if self.get_scale_bounds(corner).contains(event.pos()): @@ -373,6 +395,18 @@ class SelectableMixin(BaseItemMixin): self.scene().views()[0].reset_previous_transform(toggle_item=self) if not self.isSelected(): self.just_selected = True + super().mousePressEvent(event) + return + + self.just_selected = False + + if event.pos() in self.select_handle_free_center(): + # This area should always trigger regular move operations, + # even if it is covered by selection scale/flip/... handles. + # This ensures that small items can always still be moved/edited. + super().mousePressEvent(event) + return + if (event.button() == Qt.MouseButton.LeftButton and self.has_selection_handles()): for corner in self.corners: diff --git a/tests/selection/test_selectable_mixin.py b/tests/selection/test_selectable_mixin.py index d94bd1a..df335cf 100644 --- a/tests/selection/test_selectable_mixin.py +++ b/tests/selection/test_selectable_mixin.py @@ -75,6 +75,16 @@ def test_rotate_size_when_scaled(view, item): assert item.select_rotate_size == 25 +def test_select_handle_free_center(view, item): + view.scene.addItem(item) + view.scale(0.5, 0.5) + item.SELECT_FREE_CENTER = 10 + with patch.object(item, 'bounding_rect_unselected', + return_value=QtCore.QRectF(0, 0, 100, 80)): + assert item.select_handle_free_center() == QtCore.QRectF( + 40, 30, 20, 20) + + def test_draw_debug_shape_rect(view, item): view.scene.addItem(item) painter = MagicMock() @@ -605,6 +615,18 @@ def test_hover_move_event_no_selection(view, item): item.setCursor.assert_not_called() +def test_hover_move_event_small_item_inside_handle_free_center(view, item): + view.scene.addItem(item) + item.setSelected(True) + event = MagicMock() + event.pos.return_value = QtCore.QPointF(10, 10) + item.setCursor = MagicMock() + with patch.object(item, 'bounding_rect_unselected', + return_value=QtCore.QRectF(0, 0, 20, 20)): + item.hoverMoveEvent(event) + item.setCursor.assert_called_once_with(Qt.CursorShape.ArrowCursor) + + @mark.parametrize('pos,flipped,rotation, expected', [((0, 0), False, 0, 'SizeFDiagCursor'), ((100, 80), False, 0, 'SizeFDiagCursor'), @@ -735,7 +757,7 @@ def test_hover_move_event_not_in_handles(view, item): event = MagicMock() event.pos.return_value = QtCore.QPointF(50, 50) with patch.object(item, 'bounding_rect_unselected', - return_value=QtCore.QRectF(0, 0, 100, 80)): + return_value=QtCore.QRectF(0, 0, 1000, 800)): item.hoverMoveEvent(event) assert item.cursor() == Qt.CursorShape.ArrowCursor @@ -759,23 +781,46 @@ def test_hover_enter_event_when_not_selected(view, item): def test_mouse_press_event_just_selected(view, item): view.scene.addItem(item) + item.just_selected = False event = MagicMock() - event.pos.return_value = QtCore.QPointF(-100, -100) - with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent'): - item.mousePressEvent(event) + event.pos.return_value = QtCore.QPointF(0, 0) + event.button.return_value = Qt.MouseButton.LeftButton + with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent') as m: + with patch.object(item, 'bounding_rect_unselected', + return_value=QtCore.QRectF(0, 0, 100, 80)): + item.mousePressEvent(event) assert item.just_selected is True event.accept.assert_not_called() + m.assert_called_once_with(event) def test_mouse_press_event_previously_selected(view, item): view.scene.addItem(item) item.setSelected(True) + item.just_selected = True event = MagicMock() - event.pos.return_value = QtCore.QPointF(-100, -100) - with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent'): + event.pos.return_value = QtCore.QPointF(0, 0) + event.scenePos.return_value = QtCore.QPointF(-1, -1) + event.button.return_value = Qt.MouseButton.LeftButton + with patch.object(item, 'bounding_rect_unselected', + return_value=QtCore.QRectF(0, 0, 100, 80)): item.mousePressEvent(event) assert item.just_selected is False + + +def test_mouse_press_event_small_item_inside_handle_free_center(view, item): + view.scene.addItem(item) + item.setSelected(True) + event = MagicMock() + event.pos.return_value = QtCore.QPointF(10, 10) + event.button.return_value = Qt.MouseButton.LeftButton + with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent') as m: + with patch.object(item, 'bounding_rect_unselected', + return_value=QtCore.QRectF(0, 0, 20, 20)): + item.mousePressEvent(event) + event.accept.assert_not_called() + m.assert_called_once_with(event) def test_mouse_press_event_topleft_scale(view, item): @@ -785,13 +830,15 @@ def test_mouse_press_event_topleft_scale(view, item): event.pos.return_value = QtCore.QPointF(2, 2) event.scenePos.return_value = QtCore.QPointF(-1, -1) event.button.return_value = Qt.MouseButton.LeftButton - item.mousePressEvent(event) - assert item.scale_active is True - assert item.event_start == QtCore.QPointF(-1, -1) - assert item.event_direction.x() < 0 - assert item.event_direction.y() < 0 - assert item.scale_orig_factor == 1 - event.accept.assert_called_once_with() + with patch.object(item, 'bounding_rect_unselected', + return_value=QtCore.QRectF(0, 0, 100, 80)): + item.mousePressEvent(event) + assert item.scale_active is True + assert item.event_start == QtCore.QPointF(-1, -1) + assert item.event_direction.x() < 0 + assert item.event_direction.y() < 0 + assert item.scale_orig_factor == 1 + event.accept.assert_called_once_with() def test_mouse_press_event_bottomright_scale(view, item): @@ -850,20 +897,6 @@ def test_mouse_press_event_flip(view, item): event.accept.assert_called_once_with() -def test_mouse_press_event_not_selected(view, item): - view.scene.addItem(item) - view.reset_previous_transform = MagicMock() - item.setSelected(False) - event = MagicMock() - with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent') as m: - item.mousePressEvent(event) - m.assert_called_once_with(event) - assert item.scale_active is False - assert item.rotate_active is False - assert item.flip_active is False - event.accept.assert_not_called() - - def test_mouse_press_event_not_in_handles(view, item): view.scene.addItem(item) view.reset_previous_transform = MagicMock()