mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
467 lines
16 KiB
Python
467 lines
16 KiB
Python
# 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 queue import Queue
|
|
import logging
|
|
import math
|
|
|
|
from PyQt6 import QtCore, QtWidgets
|
|
from PyQt6.QtCore import Qt
|
|
|
|
import rpack
|
|
|
|
from beeref import commands
|
|
from beeref.items import item_registry
|
|
from beeref.selection import MultiSelectItem, RubberbandItem
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
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.min_z = 0
|
|
self.Z_STEP = 0.001
|
|
self.multi_select_item = MultiSelectItem()
|
|
self.rubberband_item = RubberbandItem()
|
|
self.selectionChanged.connect(self.on_selection_change)
|
|
self.changed.connect(self.on_change)
|
|
self.items_to_add = Queue()
|
|
self.internal_clipboard = []
|
|
self.edit_item = None
|
|
self.crop_item = None
|
|
|
|
def addItem(self, item):
|
|
logger.debug(f'Adding item {item}')
|
|
super().addItem(item)
|
|
|
|
def removeItem(self, item):
|
|
logger.debug(f'Removing item {item}')
|
|
super().removeItem(item)
|
|
|
|
def cancel_crop_mode(self):
|
|
"""Cancels an ongoing crop mode, if there is any."""
|
|
if self.crop_item:
|
|
self.crop_item.exit_crop_mode(confirm=False)
|
|
|
|
def copy_selection_to_internal_clipboard(self):
|
|
self.internal_clipboard = []
|
|
for item in self.selectedItems(user_only=True):
|
|
self.internal_clipboard.append(item)
|
|
|
|
def paste_from_internal_clipboard(self, position):
|
|
self.set_selected_all_items(False)
|
|
copies = []
|
|
for item in self.internal_clipboard:
|
|
copy = item.create_copy()
|
|
copies.append(copy)
|
|
self.undo_stack.push(commands.InsertItems(self, copies, position))
|
|
|
|
def raise_to_top(self):
|
|
self.cancel_crop_mode()
|
|
items = self.selectedItems(user_only=True)
|
|
z_values = map(lambda i: i.zValue(), items)
|
|
delta = self.max_z + self.Z_STEP - min(z_values)
|
|
logger.debug(f'Raise to top, delta: {delta}')
|
|
for item in items:
|
|
item.setZValue(item.zValue() + delta)
|
|
|
|
def lower_to_bottom(self):
|
|
self.cancel_crop_mode()
|
|
items = self.selectedItems(user_only=True)
|
|
z_values = map(lambda i: i.zValue(), items)
|
|
delta = self.min_z - self.Z_STEP - max(z_values)
|
|
logger.debug(f'Lower to bottom, delta: {delta}')
|
|
|
|
for item in items:
|
|
item.setZValue(item.zValue() + delta)
|
|
|
|
def normalize_width_or_height(self, mode):
|
|
"""Scale the selected images to have the same width or height, as
|
|
specified by ``mode``.
|
|
|
|
:param mode: "width" or "height".
|
|
"""
|
|
|
|
self.cancel_crop_mode()
|
|
values = []
|
|
items = self.selectedItems(user_only=True)
|
|
for item in items:
|
|
rect = self.itemsBoundingRect(items=[item])
|
|
values.append(getattr(rect, mode)())
|
|
if len(values) < 2:
|
|
return
|
|
avg = sum(values) / len(values)
|
|
logger.debug(f'Calculated average {mode} {avg}')
|
|
|
|
scale_factors = []
|
|
for item in items:
|
|
rect = self.itemsBoundingRect(items=[item])
|
|
scale_factors.append(avg / getattr(rect, mode)())
|
|
self.undo_stack.push(
|
|
commands.NormalizeItems(items, scale_factors))
|
|
|
|
def normalize_height(self):
|
|
"""Scale selected images to the same height."""
|
|
return self.normalize_width_or_height('height')
|
|
|
|
def normalize_width(self):
|
|
"""Scale selected images to the same width."""
|
|
return self.normalize_width_or_height('width')
|
|
|
|
def normalize_size(self):
|
|
"""Scale selected images to the same size.
|
|
|
|
Size meaning the area = widh * height.
|
|
"""
|
|
|
|
self.cancel_crop_mode()
|
|
sizes = []
|
|
items = self.selectedItems(user_only=True)
|
|
for item in items:
|
|
rect = self.itemsBoundingRect(items=[item])
|
|
sizes.append(rect.width() * rect.height())
|
|
|
|
if len(sizes) < 2:
|
|
return
|
|
|
|
avg = sum(sizes) / len(sizes)
|
|
logger.debug(f'Calculated average size {avg}')
|
|
|
|
scale_factors = []
|
|
for item in items:
|
|
rect = self.itemsBoundingRect(items=[item])
|
|
scale_factors.append(math.sqrt(avg / rect.width() / rect.height()))
|
|
self.undo_stack.push(
|
|
commands.NormalizeItems(items, scale_factors))
|
|
|
|
def arrange(self, vertical=False):
|
|
"""Arrange items in a line (horizontally or vertically)."""
|
|
|
|
self.cancel_crop_mode()
|
|
|
|
items = self.selectedItems(user_only=True)
|
|
if len(items) < 2:
|
|
return
|
|
|
|
center = self.get_selection_center()
|
|
positions = []
|
|
rects = []
|
|
for item in items:
|
|
rects.append({
|
|
'rect': self.itemsBoundingRect(items=[item]),
|
|
'item': item})
|
|
|
|
if vertical:
|
|
rects.sort(key=lambda r: r['rect'].topLeft().y())
|
|
sum_height = sum(map(lambda r: r['rect'].height(), rects))
|
|
y = round(center.y() - sum_height/2)
|
|
for rect in rects:
|
|
positions.append(
|
|
QtCore.QPointF(
|
|
round(center.x() - rect['rect'].width()/2), y))
|
|
y += rect['rect'].height()
|
|
|
|
else:
|
|
rects.sort(key=lambda r: r['rect'].topLeft().x())
|
|
sum_width = sum(map(lambda r: r['rect'].width(), rects))
|
|
x = round(center.x() - sum_width/2)
|
|
for rect in rects:
|
|
positions.append(
|
|
QtCore.QPointF(
|
|
x, round(center.y() - rect['rect'].height()/2)))
|
|
x += rect['rect'].width()
|
|
|
|
self.undo_stack.push(
|
|
commands.ArrangeItems(self,
|
|
[r['item'] for r in rects],
|
|
positions))
|
|
|
|
def arrange_optimal(self):
|
|
self.cancel_crop_mode()
|
|
|
|
items = self.selectedItems(user_only=True)
|
|
if len(items) < 2:
|
|
return
|
|
|
|
sizes = []
|
|
for item in items:
|
|
rect = self.itemsBoundingRect(items=[item])
|
|
sizes.append((round(rect.width()), round(rect.height())))
|
|
|
|
center = self.get_selection_center()
|
|
|
|
# The minimal area the items need if they could be packed optimally;
|
|
# we use this as a starting shape for the packing algorithm
|
|
min_area = sum(map(lambda s: s[0] * s[1], sizes))
|
|
width = math.ceil(math.sqrt(min_area))
|
|
|
|
positions = None
|
|
while not positions:
|
|
try:
|
|
positions = rpack.pack(
|
|
sizes, max_width=width, max_height=width)
|
|
except rpack.PackingImpossibleError:
|
|
width = math.ceil(width * 1.2)
|
|
|
|
# We want the items to center around the selection's center,
|
|
# not (0, 0)
|
|
bounds = rpack.bbox_size(sizes, positions)
|
|
diff = center - QtCore.QPointF(bounds[0]/2, bounds[1]/2)
|
|
positions = [QtCore.QPointF(*pos) + diff for pos in positions]
|
|
|
|
self.undo_stack.push(commands.ArrangeItems(self, items, positions))
|
|
|
|
def flip_items(self, vertical=False):
|
|
"""Flip selected items."""
|
|
self.cancel_crop_mode()
|
|
self.undo_stack.push(
|
|
commands.FlipItems(self.selectedItems(user_only=True),
|
|
self.get_selection_center(),
|
|
vertical=vertical))
|
|
|
|
def crop_items(self):
|
|
"""Crop selected item."""
|
|
|
|
if self.crop_item:
|
|
return
|
|
if self.has_croppable_selection():
|
|
item = self.selectedItems(user_only=True)[0]
|
|
if item.is_croppable:
|
|
item.enter_crop_mode()
|
|
|
|
def set_selected_all_items(self, value):
|
|
"""Sets the selection mode of all items to ``value``."""
|
|
self.cancel_crop_mode()
|
|
for item in self.items():
|
|
item.setSelected(value)
|
|
|
|
def has_selection(self):
|
|
"""Checks whether there are currently items selected."""
|
|
|
|
return bool(self.selectedItems(user_only=True))
|
|
|
|
def has_single_selection(self):
|
|
"""Checks whether there's currently exactly one item selected."""
|
|
|
|
return len(self.selectedItems(user_only=True)) == 1
|
|
|
|
def has_multi_selection(self):
|
|
"""Checks whether there are currently more than one items selected."""
|
|
|
|
return len(self.selectedItems(user_only=True)) > 1
|
|
|
|
def has_croppable_selection(self):
|
|
"""Checks whether the current selection is croppable, i.e. a
|
|
single selection whose item is croppable."""
|
|
|
|
if self.has_single_selection():
|
|
return self.selectedItems(user_only=True)[0].is_croppable
|
|
return False
|
|
|
|
def mousePressEvent(self, event):
|
|
if event.button() == Qt.MouseButton.RightButton:
|
|
# Right-click invokes the context menu on the
|
|
# GraphicsView. We don't need it here.
|
|
return
|
|
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
self.event_start = event.scenePos()
|
|
item_at_pos = self.itemAt(
|
|
event.scenePos(), self.views()[0].transform())
|
|
|
|
if self.edit_item:
|
|
if item_at_pos != self.edit_item:
|
|
self.edit_item.exit_edit_mode()
|
|
else:
|
|
super().mousePressEvent(event)
|
|
return
|
|
if self.crop_item:
|
|
if item_at_pos != self.crop_item:
|
|
self.cancel_crop_mode()
|
|
else:
|
|
super().mousePressEvent(event)
|
|
return
|
|
if item_at_pos:
|
|
self.move_active = True
|
|
elif self.items():
|
|
self.rubberband_active = True
|
|
|
|
super().mousePressEvent(event)
|
|
|
|
def mouseDoubleClickEvent(self, event):
|
|
item = self.itemAt(event.scenePos(), self.views()[0].transform())
|
|
if item:
|
|
self.move_active = False
|
|
if not item.isSelected():
|
|
item.setSelected(True)
|
|
if item.is_editable:
|
|
item.enter_edit_mode()
|
|
self.mousePressEvent(event)
|
|
else:
|
|
self.views()[0].fit_rect(
|
|
self.itemsBoundingRect(items=[item]),
|
|
toggle_item=item)
|
|
return
|
|
super().mouseDoubleClickEvent(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())
|
|
self.views()[0].reset_previous_transform()
|
|
super().mouseMoveEvent(event)
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
if self.rubberband_active:
|
|
if self.rubberband_item.scene():
|
|
logger.debug('Ending rubberband selection')
|
|
self.removeItem(self.rubberband_item)
|
|
self.rubberband_active = False
|
|
if (self.move_active
|
|
and self.has_selection()
|
|
and not self.multi_select_item.is_action_active()
|
|
and not self.selectedItems()[0].is_action_active()):
|
|
delta = event.scenePos() - self.event_start
|
|
if not delta.isNull():
|
|
self.undo_stack.push(
|
|
commands.MoveItemsBy(self.selectedItems(),
|
|
delta,
|
|
ignore_first_redo=True))
|
|
self.move_active = False
|
|
super().mouseReleaseEvent(event)
|
|
|
|
def selectedItems(self, user_only=False):
|
|
"""If ``user_only`` is set to ``True``, only return items added
|
|
by the user (i.e. no multi select outlines and other UI items).
|
|
|
|
User items are items that have a ``save_id`` attribute.
|
|
"""
|
|
|
|
items = super().selectedItems()
|
|
if user_only:
|
|
return list(filter(lambda i: hasattr(i, 'save_id'), items))
|
|
return items
|
|
|
|
def items_for_save(self):
|
|
|
|
"""Returns the items that are to be saved.
|
|
|
|
Items to be saved are items that have a save_id attribute.
|
|
"""
|
|
|
|
return filter(lambda i: hasattr(i, 'save_id'),
|
|
self.items(order=Qt.SortOrder.AscendingOrder))
|
|
|
|
def clear_save_ids(self):
|
|
for item in self.items_for_save():
|
|
item.save_id = None
|
|
|
|
def on_view_scale_change(self):
|
|
for item in self.selectedItems():
|
|
item.on_view_scale_change()
|
|
|
|
def itemsBoundingRect(self, selection_only=False, items=None):
|
|
"""Returns the bounding rect of the scene's items; either all of them
|
|
or only selected ones, or the items givin in ``items``.
|
|
|
|
Re-implemented to not include the items's selection handles.
|
|
"""
|
|
|
|
def filter_user_items(ilist):
|
|
return list(filter(lambda i: hasattr(i, 'save_id'), ilist))
|
|
|
|
if selection_only:
|
|
base = filter_user_items(self.selectedItems())
|
|
elif items:
|
|
base = items
|
|
else:
|
|
base = filter_user_items(self.items())
|
|
|
|
if not base:
|
|
return QtCore.QRectF(0, 0, 0, 0)
|
|
|
|
x = []
|
|
y = []
|
|
|
|
for item in base:
|
|
for corner in item.corners_scene_coords:
|
|
x.append(corner.x())
|
|
y.append(corner.y())
|
|
|
|
return QtCore.QRectF(
|
|
QtCore.QPointF(min(x), min(y)),
|
|
QtCore.QPointF(max(x), max(y)))
|
|
|
|
def get_selection_center(self):
|
|
rect = self.itemsBoundingRect(selection_only=True)
|
|
return (rect.topLeft() + rect.bottomRight()) / 2
|
|
|
|
def on_selection_change(self):
|
|
if self.has_multi_selection():
|
|
self.multi_select_item.fit_selection_area(
|
|
self.itemsBoundingRect(selection_only=True))
|
|
if self.has_multi_selection() and not self.multi_select_item.scene():
|
|
self.addItem(self.multi_select_item)
|
|
self.multi_select_item.bring_to_front()
|
|
if not self.has_multi_selection() and self.multi_select_item.scene():
|
|
self.removeItem(self.multi_select_item)
|
|
|
|
def on_change(self, region):
|
|
if (self.multi_select_item.scene()
|
|
and not self.multi_select_item.scale_active
|
|
and not self.multi_select_item.rotate_active):
|
|
self.multi_select_item.fit_selection_area(
|
|
self.itemsBoundingRect(selection_only=True))
|
|
|
|
def add_item_later(self, itemdata, selected=False):
|
|
"""Keep an item for adding later via ``add_queued_items``
|
|
|
|
:param dict itemdata: Defines the item's data
|
|
:param bool selected: Whether the item is initialised as selected
|
|
"""
|
|
|
|
self.items_to_add.put((itemdata, selected))
|
|
|
|
def add_queued_items(self):
|
|
"""Adds items added via ``add_items_later``"""
|
|
|
|
while not self.items_to_add.empty():
|
|
data, selected = self.items_to_add.get()
|
|
typ = data.pop('type')
|
|
cls = item_registry.get(typ)
|
|
if not cls:
|
|
# Just in case we add new item types in future versions
|
|
logger.warning(f'Encountered item of unknown type: {typ}')
|
|
cls = item_registry.get('text')
|
|
data['data'] = {'text': f'Item of unknown type: {typ}'}
|
|
item = cls.create_from_data(**data)
|
|
item.update_from_data(**data)
|
|
self.addItem(item)
|
|
# Force recalculation of min/max z values:
|
|
item.setZValue(item.zValue())
|
|
if selected:
|
|
item.setSelected(True)
|
|
item.bring_to_front()
|