mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
Rework selection code part 2
This commit is contained in:
parent
667239b8b6
commit
af5aa5e87a
6 changed files with 162 additions and 203 deletions
156
beeref/items.py
156
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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Reference in a new issue