Rework selection code part 2

This commit is contained in:
Rebecca Breu 2021-03-29 18:22:45 +02:00
parent 667239b8b6
commit af5aa5e87a
6 changed files with 162 additions and 203 deletions

View file

@ -20,8 +20,9 @@ text).
import logging
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt
from beeref.selection import SelectionItem
from beeref import commands
logger = logging.getLogger('BeeRef')
@ -30,6 +31,13 @@ logger = logging.getLogger('BeeRef')
class BeePixmapItem(QtWidgets.QGraphicsPixmapItem):
"""Class for images added by the user."""
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_debug = False # Draw debug shapes
def __init__(self, image, filename=None):
super().__init__(QtGui.QPixmap.fromImage(image))
self.save_id = None
@ -40,6 +48,10 @@ class BeePixmapItem(QtWidgets.QGraphicsPixmapItem):
QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsMovable
| QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsSelectable)
self.single_select_mode = False
self.scale_active = False
self.viewport_scale = None
def __str__(self):
return (f'Image "{self.filename}" '
f'with dimensions {self.width} x {self.height}')
@ -49,8 +61,8 @@ class BeePixmapItem(QtWidgets.QGraphicsPixmapItem):
return
logger.debug(f'Setting scale for image "{self.filename}" to {factor}')
self.prepareGeometryChange()
super().setScale(factor)
SelectionItem.update_selection(self)
def set_pos_center(self, x, y):
"""Sets the position using the item's center as the origin point."""
@ -83,10 +95,138 @@ class BeePixmapItem(QtWidgets.QGraphicsPixmapItem):
def itemChange(self, change, value):
if change == self.GraphicsItemChange.ItemSelectedChange:
if value:
logger.debug(f'Item selected {self.filename}')
SelectionItem.activate_selection(self)
else:
logger.debug(f'Item deselected {self.filename}')
SelectionItem.clear_selection(self)
self.setAcceptHoverEvents(value)
return super().itemChange(change, value)
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
factor sof the view and the item."""
scale = self.scene().views()[0].get_scale()
return value / scale / self.scale()
@property
def select_resize_size(self):
return self.fixed_length_for_viewport(self.SELECT_RESIZE_SIZE)
@property
def select_rotate_size(self):
return self.fixed_length_for_viewport(self.SELECT_ROTATE_SIZE)
def draw_debug_shape(self, painter, shape, r, g, b):
color = QtGui.QColor(r, g, b, 20)
if isinstance(shape, QtCore.QRectF):
painter.fillRect(shape, color)
else:
painter.fillPath(shape, color)
def paint(self, painter, option, widget):
painter.drawPixmap(0, 0, self.pixmap())
if not self.isSelected():
return
pen = QtGui.QPen(self.select_color)
pen.setWidth(self.SELECT_LINE_WIDTH)
pen.setCosmetic(True)
painter.setPen(pen)
# Draw the main selection rectangle
painter.drawRect(0, 0, self.width, self.height)
single_select_mode = self.scene().has_single_selection()
# If it's a single selection, draw the handles:
if single_select_mode:
pen.setWidth(self.SELECT_HANDLE_SIZE)
painter.setPen(pen)
painter.drawPoint(self.width, self.height)
if self.select_debug:
self.draw_debug_shape(painter, self.boundingRect(), 0, 255, 0)
self.draw_debug_shape(painter, self.shape(), 255, 0, 0)
@property
def bottom_right_scale_bounds(self):
"""The interactable shape of the bottom right scale handle"""
return QtCore.QRectF(
self.width - self.select_resize_size/2,
self.height - self.select_resize_size/2,
self.select_resize_size,
self.select_resize_size)
@property
def bottom_right_rotate_bounds(self):
"""The interactable shape of the bottom right rotate handle"""
return QtCore.QRectF(
self.width + self.select_resize_size / 2,
self.height + self.select_resize_size / 2,
self.select_rotate_size, self.select_rotate_size)
def boundingRect(self):
bounds = super().boundingRect()
if not self.isSelected():
return bounds
margin = self.select_resize_size / 2 + self.select_rotate_size
return QtCore.QRectF(
bounds.topLeft().x() - margin,
bounds.topLeft().y() - margin,
bounds.bottomRight().x() + 2 * margin,
bounds.bottomRight().y() + 2 * margin)
def shape(self):
path = QtGui.QPainterPath()
path.addRect(self.bottom_right_scale_bounds)
path.addRect(self.bottom_right_rotate_bounds)
return path + super().shape()
def update_selection(self):
new_scale = self.fixed_length_for_viewport(1)
if new_scale != self.viewport_scale:
logger.debug('Selection geometry changed')
self.prepareGeometryChange()
self.viewport_scale = new_scale
def hoverMoveEvent(self, event):
if self.bottom_right_scale_bounds.contains(event.pos()):
self.setCursor(Qt.CursorShape.SizeFDiagCursor)
elif self.bottom_right_rotate_bounds.contains(event.pos()):
self.setCursor(Qt.CursorShape.ForbiddenCursor)
else:
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)
def get_scale_delta(self, event):
imgsize = self.width + self.height
p = event.scenePos() - self.scale_start
return (p.x() + p.y()) / imgsize
def mouseMoveEvent(self, event):
if self.scale_active:
delta = self.get_scale_delta(event)
self.setScale(self.orig_scale_factor + delta)
event.accept()
else:
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if self.scale_active:
self.scene().undo_stack.push(
commands.ScaleItemsBy([self],
self.get_scale_delta(event),
ignore_first_redo=True))
self.scale_active = False
event.accept()
else:
super().mouseReleaseEvent(event)

View file

@ -30,6 +30,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
super().__init__()
self.move_active = False
self.undo_stack = undo_stack
self.selectionChanged.connect(self.on_selection_changed)
def normalize_width_or_height(self, mode):
"""Scale the selected images to have the same width or height, as
@ -124,3 +125,10 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
def clear_save_ids(self):
for item in self.items_for_save():
item.save_id = None
def on_selection_changed(self):
self.update_selection()
def update_selection(self):
for item in self.selectedItems():
item.update_selection()

View file

@ -1,191 +0,0 @@
# This file is part of BeeRef.
#
# BeeRef is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# BeeRef is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
from collections.abc import Iterable
import logging
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt
from beeref import commands
logger = logging.getLogger('BeeRef')
class SelectionItem(QtWidgets.QGraphicsItem):
color = QtGui.QColor(116, 234, 231, 255)
LINE_WIDTH = 4
HANDLE_SIZE = 15 # scale handles
RESIZE_SIZE = 30 # area for scale hover events
ROTATE_SIZE = 30 # area for rotation hover events
debug = False
def __init__(self, item):
super().__init__(parent=item)
self.single_select_mode = False
self.setAcceptHoverEvents(True)
self.setFlags(
QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsSelectable
| QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsMovable)
self.scale_active = False
self.previous_scale = None
self.setZValue(1)
@property
def bottom_right_scale_bounds(self):
"""The interactable shape of the bottom right scale handle"""
return QtCore.QRectF(
self.parentItem().width - self.resize_size/2,
self.parentItem().height - self.resize_size/2,
self.resize_size,
self.resize_size)
@property
def bottom_right_rotate_bounds(self):
"""The interactable shape of the bottom right rotate handle"""
return QtCore.QRectF(
self.parentItem().width + self.resize_size / 2,
self.parentItem().height + self.resize_size / 2,
self.rotate_size, self.rotate_size)
def scale_with_view(self, value):
"""The interactable areas should always stay the same size on
the screen so we need to adjust the values according to the scale
factor of the view."""
scale = self.scene().views()[0].get_scale()
return value / scale / self.parentItem().scale()
def boundingRect(self):
bounds = self.parentItem().boundingRect()
margin = self.resize_size / 2 + self.rotate_size
return QtCore.QRectF(
bounds.topLeft().x() - margin,
bounds.topLeft().y() - margin,
bounds.bottomRight().x() + 2 * margin,
bounds.bottomRight().y() + 2 * margin)
@property
def resize_size(self):
return self.scale_with_view(self.RESIZE_SIZE)
@property
def rotate_size(self):
return self.scale_with_view(self.ROTATE_SIZE)
def shape(self):
path = QtGui.QPainterPath()
path.addRect(self.bottom_right_scale_bounds)
path.addRect(self.bottom_right_rotate_bounds)
return path
def draw_debug_shape(self, painter, shape):
color = QtGui.QColor(0, 255, 0, 20)
if isinstance(shape, QtCore.QRectF):
painter.fillRect(shape, color)
else:
painter.fillPath(shape, color)
def update_geometry(self):
current_scale = self.scale_with_view(1)
if current_scale != self.previous_scale:
logger.debug('Selection geometry update')
self.prepareGeometryChange()
self.update()
self.previous_scale = current_scale
def paint(self, painter, option, widget):
pen = QtGui.QPen(self.color)
pen.setWidth(self.LINE_WIDTH)
pen.setCosmetic(True)
painter.setPen(pen)
# Draw the main selection rectangle
painter.drawRect(
0, 0, self.parentItem().width, self.parentItem().height)
single_select_mode = self.scene().has_single_selection()
self.setEnabled(single_select_mode)
# If it's a single selection, draw the handles:
if single_select_mode:
pen.setWidth(self.HANDLE_SIZE)
painter.setPen(pen)
painter.drawPoint(
self.parentItem().width, self.parentItem().height)
if self.debug:
self.draw_debug_shape(painter, self.boundingRect())
self.draw_debug_shape(painter, self.shape())
def hoverMoveEvent(self, event):
# In bottomright scale area?
if self.bottom_right_scale_bounds.contains(event.pos()):
self.setCursor(Qt.CursorShape.SizeFDiagCursor)
elif self.bottom_right_rotate_bounds.contains(event.pos()):
self.setCursor(Qt.CursorShape.ForbiddenCursor)
else:
self.setCursor(Qt.CursorShape.ArrowCursor)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButtons.LeftButton:
self.scale_active = True
self.orig_scale_factor = self.parentItem().scale()
self.scale_start = event.scenePos()
def get_scale_delta(self, event):
imgsize = self.parentItem().width + self.parentItem().height
p = event.scenePos() - self.scale_start
return (p.x() + p.y()) / imgsize
def mouseMoveEvent(self, event):
if self.scale_active:
delta = self.get_scale_delta(event)
self.parentItem().setScale(self.orig_scale_factor + delta)
def mouseReleaseEvent(self, event):
self.scene().undo_stack.push(
commands.ScaleItemsBy(self.scene().selectedItems(),
self.get_scale_delta(event),
ignore_first_redo=True))
self.scale_active = False
@classmethod
def activate_selection(cls, item):
"""Activates/creates the selection for a given item."""
if item.childItems():
item.childItems()[0].setVisible(True)
else:
cls(item)
@classmethod
def clear_selection(cls, item):
"""Deactives the selection for a given item."""
# Is it a performance issue to keep the selection items and just
# hide them?
# Deleting them might have been the cause of segfaults when
# they are in the middle of receiving events...
item.childItems()[0].setVisible(False)
@classmethod
def update_selection(cls, items):
if not isinstance(items, Iterable):
items = [items]
for item in items:
if item.childItems():
item.childItems()[0].update_geometry()

View file

@ -23,7 +23,6 @@ from beeref import fileio
from beeref.gui import BeeProgressDialog, WelcomeOverlay
from beeref.items import BeePixmapItem
from beeref.scene import BeeGraphicsScene
from beeref import selection
logger = logging.getLogger('BeeRef')
@ -362,7 +361,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView):
len(self.scene.selectedItems()))
for action in self.actions_active_when_selection:
action.setEnabled(self.scene.has_selection())
selection.SelectionItem.update_selection(self.scene.selectedItems())
self.viewport().repaint()
def recalc_scene_rect(self):
"""Resize the scene rectangle so that it is always one view width
@ -408,7 +407,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView):
def scale(self, *args, **kwargs):
super().scale(*args, **kwargs)
self.recalc_scene_rect()
selection.SelectionItem.update_selection(self.scene.selectedItems())
self.scene.update_selection()
def get_scale(self):
return self.transform().m11()

View file

@ -128,7 +128,6 @@ class SQLiteIOWriteTestCase(BeeTestCase):
self.scene.addItem(item)
item.pixmap_to_bytes = MagicMock(return_value=b'abc')
self.io.write()
item.setScale(0.7)
item.setPos(20, 30)
item.filename = 'new.png'

View file

@ -1,3 +1,5 @@
from unittest.mock import MagicMock
from PyQt6 import QtGui
from beeref import commands
@ -14,6 +16,7 @@ class InsertItemsTestCase(BeeTestCase):
scene.items()))
scene = BeeGraphicsScene(None)
scene.update_selection = MagicMock()
item1 = BeePixmapItem(QtGui.QImage())
scene.addItem(item1)
item2 = BeePixmapItem(QtGui.QImage())
@ -37,6 +40,7 @@ class DeleteItemsTestCase(BeeTestCase):
scene.items()))
scene = BeeGraphicsScene(None)
scene.update_selection = MagicMock()
item1 = BeePixmapItem(QtGui.QImage())
scene.addItem(item1)
item2 = BeePixmapItem(QtGui.QImage())