From af5aa5e87a19440447b1baeef2f1045563b9e6b7 Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Mon, 29 Mar 2021 18:22:45 +0200 Subject: [PATCH] Rework selection code part 2 --- beeref/items.py | 156 ++++++++++++++++++++++++++++++-- beeref/scene.py | 8 ++ beeref/selection.py | 191 --------------------------------------- beeref/view.py | 5 +- tests/fileio/test_sql.py | 1 - tests/test_commands.py | 4 + 6 files changed, 162 insertions(+), 203 deletions(-) delete mode 100644 beeref/selection.py diff --git a/beeref/items.py b/beeref/items.py index d594f76..578b201 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -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) diff --git a/beeref/scene.py b/beeref/scene.py index decc107..e911274 100644 --- a/beeref/scene.py +++ b/beeref/scene.py @@ -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() diff --git a/beeref/selection.py b/beeref/selection.py deleted file mode 100644 index 9d1aa67..0000000 --- a/beeref/selection.py +++ /dev/null @@ -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 . - -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() diff --git a/beeref/view.py b/beeref/view.py index 86ef7b3..429d77a 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -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() diff --git a/tests/fileio/test_sql.py b/tests/fileio/test_sql.py index 083f2ac..928f847 100644 --- a/tests/fileio/test_sql.py +++ b/tests/fileio/test_sql.py @@ -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' diff --git a/tests/test_commands.py b/tests/test_commands.py index 4530aad..c07a6aa 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -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())