diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py index 697d4d4..43f3327 100644 --- a/beeref/actions/actions.py +++ b/beeref/actions/actions.py @@ -98,6 +98,14 @@ actions = [ 'group': 'active_when_selection', 'enabled': False, }, + { + 'id': 'arrange_optimal', + 'text': '&Optimal', + 'shortcuts': ['Shift+O'], + 'callback': 'on_action_arrange_optimal', + 'group': 'active_when_selection', + 'enabled': False, + }, { 'id': 'flip_horizontally', 'text': 'Flip &Horizontally', diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py index ba843e1..701e667 100644 --- a/beeref/actions/menu_structure.py +++ b/beeref/actions/menu_structure.py @@ -73,6 +73,12 @@ menu_structure = [ 'normalize_size', ], }, + { + 'menu': '&Arrange', + 'items': [ + 'arrange_optimal', + ], + }, { 'menu': '&Help', 'items': [ diff --git a/beeref/commands.py b/beeref/commands.py index fb763b8..d355a36 100644 --- a/beeref/commands.py +++ b/beeref/commands.py @@ -241,3 +241,25 @@ class ResetTransforms(QtGui.QUndoCommand): item.setRotation(old['rotation'], anchor=item.center) if old['flip'] == -1: item.do_flip(anchor=item.center) + + +class ArrangeItems(QtGui.QUndoCommand): + + def __init__(self, scene, items, positions): + super().__init__('Arrange items') + self.scene = scene + self.items = items + self.positions = positions + + def redo(self): + self.old_positions = [] + for item, pos in zip(self.items, self.positions): + self.old_positions.append(item.pos()) + orig_topleft = item.corners_scene_coords[0] + rect_topleft = self.scene.itemsBoundingRect( + items=[item]).topLeft() + item.setPos(pos + orig_topleft - rect_topleft) + + def undo(self): + for item, pos in zip(self.items, self.old_positions): + item.setPos(pos) diff --git a/beeref/scene.py b/beeref/scene.py index 934808b..0194345 100644 --- a/beeref/scene.py +++ b/beeref/scene.py @@ -20,6 +20,8 @@ import math from PyQt6 import QtCore, QtWidgets from PyQt6.QtCore import Qt +import rpack + from beeref import commands from beeref.selection import MultiSelectItem, RubberbandItem @@ -99,6 +101,44 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): commands.NormalizeItems( self.selectedItems(user_only=True), scale_factors)) + def arrange_optimal(self): + items = self.selectedItems(user_only=True) + sizes = [] + for item in items: + rect = self.itemsBoundingRect(items=[item]) + sizes.append((round(rect.width()), round(rect.height()))) + + if not sizes: + return + + 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) + + if rpack.overlapping(sizes, positions): + # Bug in rpack: + # https://github.com/Penlect/rectangle-packer/issues/4#issuecomment-822411097 + positions = [(p[1], p[0]) for p in positions] + + # 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.undo_stack.push( diff --git a/beeref/view.py b/beeref/view.py index dc86402..cb5a1aa 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -196,6 +196,9 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin): def on_action_normalize_size(self): self.scene.normalize_size() + def on_action_arrange_optimal(self): + self.scene.arrange_optimal() + def on_action_flip_horizontally(self): self.scene.flip_items(vertical=False) diff --git a/setup.py b/setup.py index 6ad7fa5..b77ad92 100644 --- a/setup.py +++ b/setup.py @@ -10,6 +10,7 @@ setup( description='A simple reference image viewer', install_requires=[ 'pyQt6', + 'rectangle-packer=>2.0.0', ], packages=['beeref'], entry_points={ diff --git a/tests/test_commands.py b/tests/test_commands.py index 416cb58..9b51c72 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -395,3 +395,30 @@ class ResetTransformsTestCase(BeeTestCase): assert item2.flip() == 1 assert item2.pos().x() == 0 assert item2.pos().y() == 0 + + +class ArrangeItemsTestCase(BeeTestCase): + + def test_redo_undo(self): + scene = BeeGraphicsScene(None) + item1 = BeePixmapItem(QtGui.QImage()) + item1.do_flip() + scene.addItem(item1) + item2 = BeePixmapItem(QtGui.QImage()) + item2.setRotation(90) + scene.addItem(item2) + with patch('beeref.items.BeePixmapItem.width', + new_callable=PropertyMock, return_value=100): + with patch('beeref.items.BeePixmapItem.height', + new_callable=PropertyMock, return_value=80): + command = commands.ArrangeItems( + scene, + [item1, item2], + [QtCore.QPointF(1, 2), QtCore.QPointF(203, 204)]) + + command.redo() + assert item1.pos() == QtCore.QPointF(101, 2) + assert item2.pos() == QtCore.QPointF(283, 204) + command.undo() + assert item1.pos() == QtCore.QPointF(0, 0) + assert item2.pos() == QtCore.QPointF(0, 0) diff --git a/tests/test_scene.py b/tests/test_scene.py index a6e4729..333f24a 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -144,6 +144,44 @@ class BeeGraphicsSceneTestCase(BeeTestCase): def test_normalize_size_when_no_items(self): self.scene.normalize_size() + def test_arrange_optimal(self): + for i in range(4): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + item.setSelected(True) + with patch('beeref.items.BeePixmapItem.width', + new_callable=PropertyMock, return_value=100): + with patch('beeref.items.BeePixmapItem.height', + new_callable=PropertyMock, return_value=80): + self.scene.arrange_optimal() + + expected_positions = {(-50, -40), (50, -40), (-50, 40), (50, 40)} + actual_positions = { + (i.pos().x(), i.pos().y()) + for i in self.scene.selectedItems(user_only=True)} + assert expected_positions == actual_positions + + def test_arrange_optimal_when_rotated(self): + for i in range(4): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + item.setRotation(90) + item.setSelected(True) + with patch('beeref.items.BeePixmapItem.width', + new_callable=PropertyMock, return_value=100): + with patch('beeref.items.BeePixmapItem.height', + new_callable=PropertyMock, return_value=80): + self.scene.arrange_optimal() + + expected_positions = {(-40, -50), (40, -50), (-40, 50), (40, 50)} + actual_positions = { + (i.pos().x(), i.pos().y()) + for i in self.scene.selectedItems(user_only=True)} + assert expected_positions == actual_positions + + def test_arrange_optimal_when_no_items(self): + self.scene.arrange_optimal() + def test_flip_items(self): item = BeePixmapItem(QtGui.QImage()) self.scene.addItem(item)