mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
Add rubberband selection.
There's a slight inaccuracy when getting an already selected item outside of the rubberband: the additional margin of the selection handles needs to be outside of the rubberband too (since it's part of the item's bounding box), making it slightly harder to get an item out of the selection than it should be. Let's see if it's very noticeable/annoying in practice.
This commit is contained in:
parent
0a8f59ec36
commit
37a01f80a2
4 changed files with 244 additions and 53 deletions
|
|
@ -20,7 +20,7 @@ from PyQt6 import QtCore, QtWidgets
|
|||
from PyQt6.QtCore import Qt
|
||||
|
||||
from beeref import commands
|
||||
from beeref.selection import MultiSelectItem
|
||||
from beeref.selection import MultiSelectItem, RubberbandItem
|
||||
|
||||
|
||||
logger = logging.getLogger('BeeRef')
|
||||
|
|
@ -31,9 +31,11 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
|
|||
def __init__(self, undo_stack):
|
||||
super().__init__()
|
||||
self.move_active = False
|
||||
self.rubberband_active = False
|
||||
self.undo_stack = undo_stack
|
||||
self.max_z = 0
|
||||
self.multi_select_item = MultiSelectItem()
|
||||
self.rubberband_item = RubberbandItem()
|
||||
self.selectionChanged.connect(self.on_selection_change)
|
||||
|
||||
def normalize_width_or_height(self, mode):
|
||||
|
|
@ -105,15 +107,30 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
|
|||
return
|
||||
|
||||
if event.button() == Qt.MouseButtons.LeftButton:
|
||||
self.move_active = True
|
||||
self.move_start = event.scenePos()
|
||||
self.event_start = event.scenePos()
|
||||
if self.itemAt(event.scenePos(), self.views()[0].transform()):
|
||||
self.move_active = True
|
||||
else:
|
||||
self.rubberband_active = True
|
||||
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
def mouseMoveEvent(self, event):
|
||||
if self.rubberband_active:
|
||||
if not self.rubberband_item.scene():
|
||||
logger.debug('Activating rubberband selection')
|
||||
self.addItem(self.rubberband_item)
|
||||
self.rubberband_item.bring_to_front()
|
||||
self.rubberband_item.fit(self.event_start, event.scenePos())
|
||||
self.setSelectionArea(self.rubberband_item.shape())
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if self.rubberband_active:
|
||||
self.removeItem(self.rubberband_item)
|
||||
self.rubberband_active = False
|
||||
if self.move_active and self.has_selection():
|
||||
delta = event.scenePos() - self.move_start
|
||||
delta = event.scenePos() - self.event_start
|
||||
if not delta.isNull():
|
||||
self.undo_stack.push(
|
||||
commands.MoveItemsBy(self.selectedItems(),
|
||||
|
|
|
|||
|
|
@ -27,12 +27,31 @@ from beeref.config import CommandlineArgs
|
|||
|
||||
commandline_args = CommandlineArgs()
|
||||
logger = logging.getLogger('BeeRef')
|
||||
SELECT_COLOR = QtGui.QColor(116, 234, 231, 255)
|
||||
|
||||
|
||||
class SelectableMixin:
|
||||
class BaseItemMixin:
|
||||
|
||||
def setScale(self, factor):
|
||||
if factor <= 0:
|
||||
return
|
||||
|
||||
logger.debug(f'Setting scale for {self} to {factor}')
|
||||
self.prepareGeometryChange()
|
||||
super().setScale(factor)
|
||||
|
||||
def setZValue(self, value):
|
||||
logger.debug(f'Setting z-value for {self} to {value}')
|
||||
super().setZValue(value)
|
||||
self.scene().max_z = max(self.scene().max_z, value)
|
||||
|
||||
def bring_to_front(self):
|
||||
self.setZValue(self.scene().max_z + 0.001)
|
||||
|
||||
|
||||
class SelectableMixin(BaseItemMixin):
|
||||
"""Common code for selectable items: Selection outline, handles etc."""
|
||||
|
||||
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 = 20 # size of hover area for scaling
|
||||
|
|
@ -48,22 +67,6 @@ class SelectableMixin:
|
|||
self.viewport_scale = 1
|
||||
self.conf_debug_shapes = commandline_args.draw_debug_shapes
|
||||
|
||||
def setScale(self, factor):
|
||||
if factor <= 0:
|
||||
return
|
||||
|
||||
logger.debug(f'Setting scale for {self} to {factor}')
|
||||
self.prepareGeometryChange()
|
||||
super().setScale(factor)
|
||||
|
||||
def setZValue(self, value):
|
||||
logger.debug(f'Setting z-value for image {self} to {value}')
|
||||
super().setZValue(value)
|
||||
self.scene().max_z = max(self.scene().max_z, value)
|
||||
|
||||
def bring_to_front(self):
|
||||
self.setZValue(self.scene().max_z + 0.001)
|
||||
|
||||
def fixed_length_for_viewport(self, value):
|
||||
"""The interactable areas need to stay the same size on the
|
||||
screen so we need to adjust the values according to the scale
|
||||
|
|
@ -101,7 +104,7 @@ class SelectableMixin:
|
|||
if not self.has_selection_outline():
|
||||
return
|
||||
|
||||
pen = QtGui.QPen(self.select_color)
|
||||
pen = QtGui.QPen(SELECT_COLOR)
|
||||
pen.setWidth(self.SELECT_LINE_WIDTH)
|
||||
pen.setCosmetic(True)
|
||||
painter.setPen(pen)
|
||||
|
|
@ -277,8 +280,9 @@ class SelectableMixin:
|
|||
return super().itemChange(change, value)
|
||||
|
||||
|
||||
class MultiSelectItem(SelectableMixin, QtWidgets.QGraphicsRectItem):
|
||||
"""Class for images added by the user."""
|
||||
class MultiSelectItem(SelectableMixin,
|
||||
QtWidgets.QGraphicsRectItem):
|
||||
"""The multi selection outline around all selected items."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
|
@ -329,3 +333,36 @@ class MultiSelectItem(SelectableMixin, QtWidgets.QGraphicsRectItem):
|
|||
return
|
||||
|
||||
super().mousePressEvent(event)
|
||||
|
||||
|
||||
class RubberbandItem(BaseItemMixin, QtWidgets.QGraphicsRectItem):
|
||||
"""The outline for the rubber band selection."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
color = QtGui.QColor(SELECT_COLOR)
|
||||
color.setAlpha(40)
|
||||
self.setBrush(QtGui.QBrush(color))
|
||||
|
||||
def __str__(self):
|
||||
return (f'RubberbandItem {self.width} x {self.height}')
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
return self.rect().width()
|
||||
|
||||
@property
|
||||
def height(self):
|
||||
return self.rect().height()
|
||||
|
||||
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))
|
||||
logger.debug(f'Updated rubberband {self}')
|
||||
|
|
|
|||
|
|
@ -127,16 +127,115 @@ class BeeGraphicsSceneNormalizeTestCase(BeeTestCase):
|
|||
mouse_mock.assert_not_called()
|
||||
|
||||
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
|
||||
def test_mouse_press_event_when_left_click(self, mouse_mock):
|
||||
def test_mouse_press_event_when_left_click_over_item(self, mouse_mock):
|
||||
self.scene.itemAt = MagicMock(
|
||||
return_value=BeePixmapItem(QtGui.QImage()))
|
||||
event = MagicMock(
|
||||
button=MagicMock(return_value=Qt.MouseButtons.LeftButton),
|
||||
scenePos=MagicMock(return_value=QtCore.QPoint(10, 20)),
|
||||
scenePos=MagicMock(return_value=QtCore.QPointF(10, 20)),
|
||||
)
|
||||
self.scene.mousePressEvent(event)
|
||||
event.accept.assert_not_called()
|
||||
mouse_mock.assert_called_once_with(event)
|
||||
assert self.scene.move_active is True
|
||||
assert self.scene.move_start == QtCore.QPoint(10, 20)
|
||||
assert self.scene.rubberband_active is False
|
||||
assert self.scene.event_start == QtCore.QPointF(10, 20)
|
||||
|
||||
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
|
||||
def test_mouse_press_event_when_left_click_not_over_item(self, mouse_mock):
|
||||
self.scene.itemAt = MagicMock(return_value=None)
|
||||
event = MagicMock(
|
||||
button=MagicMock(return_value=Qt.MouseButtons.LeftButton),
|
||||
scenePos=MagicMock(return_value=QtCore.QPointF(10, 20)),
|
||||
)
|
||||
self.scene.mousePressEvent(event)
|
||||
event.accept.assert_not_called()
|
||||
mouse_mock.assert_called_once_with(event)
|
||||
assert self.scene.move_active is False
|
||||
assert self.scene.rubberband_active is True
|
||||
assert self.scene.event_start == QtCore.QPointF(10, 20)
|
||||
|
||||
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseMoveEvent')
|
||||
def test_mouse_move_event_when_rubberband_new(self, mouse_mock):
|
||||
item = BeePixmapItem(QtGui.QImage(self.imgfilename3x3))
|
||||
self.scene.addItem(item)
|
||||
self.scene.rubberband_active = True
|
||||
self.scene.addItem = MagicMock()
|
||||
self.scene.event_start = QtCore.QPointF(0, 0)
|
||||
self.scene.rubberband_item.bring_to_front = MagicMock()
|
||||
self.scene.removeItem(self.scene.rubberband_item)
|
||||
event = MagicMock(
|
||||
scenePos=MagicMock(return_value=QtCore.QPointF(10, 20)),
|
||||
)
|
||||
|
||||
self.scene.mouseMoveEvent(event)
|
||||
|
||||
self.scene.addItem.assert_called_once_with(self.scene.rubberband_item)
|
||||
self.scene.rubberband_item.bring_to_front.assert_called_once()
|
||||
self.scene.rubberband_item.rect().topLeft().x() == 0
|
||||
self.scene.rubberband_item.rect().topLeft().y() == 0
|
||||
self.scene.rubberband_item.rect().bottomRight().x() == 10
|
||||
self.scene.rubberband_item.rect().bottomRight().y() == 20
|
||||
assert item.isSelected() is True
|
||||
assert mouse_mock.called_once_with(event)
|
||||
|
||||
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseMoveEvent')
|
||||
def test_mouse_move_event_when_rubberband_not_new(self, mouse_mock):
|
||||
item = BeePixmapItem(QtGui.QImage(self.imgfilename3x3))
|
||||
self.scene.addItem(item)
|
||||
self.scene.rubberband_active = True
|
||||
self.scene.event_start = QtCore.QPointF(0, 0)
|
||||
self.scene.rubberband_item.bring_to_front = MagicMock()
|
||||
self.scene.addItem(self.scene.rubberband_item)
|
||||
self.scene.addItem = MagicMock()
|
||||
event = MagicMock(
|
||||
scenePos=MagicMock(return_value=QtCore.QPointF(10, 20)),
|
||||
)
|
||||
|
||||
self.scene.mouseMoveEvent(event)
|
||||
|
||||
self.scene.addItem.assert_not_called()
|
||||
self.scene.rubberband_item.bring_to_front.assert_not_called()
|
||||
self.scene.rubberband_item.rect().topLeft().x() == 0
|
||||
self.scene.rubberband_item.rect().topLeft().y() == 0
|
||||
self.scene.rubberband_item.rect().bottomRight().x() == 10
|
||||
self.scene.rubberband_item.rect().bottomRight().y() == 20
|
||||
assert item.isSelected() is True
|
||||
assert mouse_mock.called_once_with(event)
|
||||
|
||||
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseMoveEvent')
|
||||
def test_mouse_move_event_when_no_rubberband(self, mouse_mock):
|
||||
item = BeePixmapItem(QtGui.QImage(self.imgfilename3x3))
|
||||
self.scene.addItem(item)
|
||||
self.scene.rubberband_active = False
|
||||
self.scene.event_start = QtCore.QPointF(0, 0)
|
||||
self.scene.rubberband_item.bring_to_front = MagicMock()
|
||||
self.scene.addItem = MagicMock()
|
||||
event = MagicMock(
|
||||
scenePos=MagicMock(return_value=QtCore.QPointF(10, 20)),
|
||||
)
|
||||
|
||||
self.scene.mouseMoveEvent(event)
|
||||
|
||||
self.scene.addItem.assert_not_called()
|
||||
self.scene.rubberband_item.bring_to_front.assert_not_called()
|
||||
self.scene.rubberband_item.rect().topLeft().x() == 0
|
||||
self.scene.rubberband_item.rect().topLeft().y() == 0
|
||||
self.scene.rubberband_item.rect().bottomRight().x() == 0
|
||||
self.scene.rubberband_item.rect().bottomRight().y() == 0
|
||||
assert item.isSelected() is False
|
||||
assert mouse_mock.called_once_with(event)
|
||||
|
||||
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent')
|
||||
def test_mouse_release_event_when_rubberband_active(self, mouse_mock):
|
||||
event = MagicMock()
|
||||
self.scene.rubberband_active = True
|
||||
self.scene.removeItem = MagicMock()
|
||||
|
||||
self.scene.mouseReleaseEvent(event)
|
||||
self.scene.removeItem.assert_called_once_with(
|
||||
self.scene.rubberband_item)
|
||||
self.scene.rubberband_active is False
|
||||
|
||||
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent')
|
||||
def test_mouse_release_event_when_move_active(self, mouse_mock):
|
||||
|
|
@ -146,7 +245,7 @@ class BeeGraphicsSceneNormalizeTestCase(BeeTestCase):
|
|||
event = MagicMock(
|
||||
scenePos=MagicMock(return_value=QtCore.QPoint(10, 20)))
|
||||
self.scene.move_active = True
|
||||
self.scene.move_start = QtCore.QPoint(0, 0)
|
||||
self.scene.event_start = QtCore.QPoint(0, 0)
|
||||
self.scene.undo_stack = MagicMock(push=MagicMock())
|
||||
|
||||
self.scene.mouseReleaseEvent(event)
|
||||
|
|
|
|||
|
|
@ -5,34 +5,14 @@ from PyQt6.QtCore import Qt
|
|||
|
||||
from beeref.items import BeePixmapItem
|
||||
from beeref.scene import BeeGraphicsScene
|
||||
from beeref.selection import MultiSelectItem
|
||||
from beeref.selection import MultiSelectItem, RubberbandItem
|
||||
from .base import BeeTestCase
|
||||
|
||||
|
||||
class SelectableMixinBaseTestCase(BeeTestCase):
|
||||
class BaseItemMixinTestCase(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',
|
||||
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 SelectableMixinTestCase(SelectableMixinBaseTestCase):
|
||||
|
||||
def test_set_scale(self):
|
||||
item = BeePixmapItem(
|
||||
|
|
@ -79,6 +59,32 @@ class SelectableMixinTestCase(SelectableMixinBaseTestCase):
|
|||
assert item2.zValue() > item1.zValue()
|
||||
assert item2.zValue() == self.scene.max_z
|
||||
|
||||
|
||||
class SelectableMixinBaseTestCase(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',
|
||||
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 SelectableMixinTestCase(SelectableMixinBaseTestCase):
|
||||
|
||||
def test_on_view_scale_change(self):
|
||||
item = BeePixmapItem(QtGui.QImage())
|
||||
with patch('beeref.items.BeePixmapItem.prepareGeometryChange') as m:
|
||||
|
|
@ -466,7 +472,7 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
|
|||
assert self.item.scale_active is False
|
||||
|
||||
|
||||
class MultiSelectItemItemTestCase(BeeTestCase):
|
||||
class MultiSelectItemTestCase(BeeTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.scene = BeeGraphicsScene(None)
|
||||
|
|
@ -555,3 +561,35 @@ class MultiSelectItemItemTestCase(BeeTestCase):
|
|||
item.mousePressEvent(event)
|
||||
event.ignore.assert_not_called()
|
||||
mouse_mock.assert_called_once_with(event)
|
||||
|
||||
|
||||
class RubberbandItemTestCase(BeeTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.scene = BeeGraphicsScene(None)
|
||||
|
||||
def test_width(self):
|
||||
item = RubberbandItem()
|
||||
item.setRect(5, 5, 100, 80)
|
||||
assert item.width == 100
|
||||
|
||||
def test_height(self):
|
||||
item = RubberbandItem()
|
||||
item.setRect(5, 5, 100, 80)
|
||||
assert item.height == 80
|
||||
|
||||
def test_fit_topleft_to_bottomright(self):
|
||||
item = RubberbandItem()
|
||||
item.fit(QtCore.QPointF(-10, -20), QtCore.QPointF(30, 40))
|
||||
assert item.rect().topLeft().x() == -10
|
||||
assert item.rect().topLeft().y() == -20
|
||||
assert item.rect().bottomRight().x() == 30
|
||||
assert item.rect().bottomRight().y() == 40
|
||||
|
||||
def test_fit_topright_to_bottomleft(self):
|
||||
item = RubberbandItem()
|
||||
item.fit(QtCore.QPointF(50, -20), QtCore.QPointF(-30, 40))
|
||||
assert item.rect().topLeft().x() == -30
|
||||
assert item.rect().topLeft().y() == -20
|
||||
assert item.rect().bottomRight().x() == 50
|
||||
assert item.rect().bottomRight().y() == 40
|
||||
|
|
|
|||
Loading…
Reference in a new issue