Add handle-free center so that small items can always be moved

This commit is contained in:
Rebecca Breu 2021-10-15 10:13:03 +02:00
parent f0aa1e86f7
commit 333515913a
3 changed files with 96 additions and 27 deletions

View file

@ -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

View file

@ -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:

View file

@ -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()