From b22056cefa4d92cda19a90c5381091c893a801c9 Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Sat, 25 May 2024 18:21:23 +0200 Subject: [PATCH] Add action 'Arrange by Filename' --- CHANGELOG.rst | 1 + beeref/actions/actions.py | 6 +++ beeref/actions/menu_structure.py | 1 + beeref/items.py | 26 +++++++++++ beeref/scene.py | 49 +++++++++++++++++++- beeref/view.py | 3 ++ tests/items/test_items.py | 40 ++++++++++++++++ tests/test_scene.py | 79 ++++++++++++++++++++++++++++++++ tests/test_view.py | 24 ++++++++++ 9 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 tests/items/test_items.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 06aa006..dd5384e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,7 @@ Added * Added a confirmation dialog when attempting to close unsaved files. The confirmation dialog can be disalbed in: Settings -> Miscellaneous -> Confirm when closing an unsaved file +* Add option to arrange by filename (Arrange -> By Filename) Fixed diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py index c45fb64..1abf3e2 100644 --- a/beeref/actions/actions.py +++ b/beeref/actions/actions.py @@ -260,6 +260,12 @@ actions = ActionList([ callback='on_action_arrange_vertical', group='active_when_selection', ), + Action( + id='arrange_by_filename', + text='By &Filename', + callback='on_action_arrange_by_filename', + group='active_when_selection', + ), Action( id='change_opacity', text='Change &Opacity...', diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py index 4428e77..2f06c61 100644 --- a/beeref/actions/menu_structure.py +++ b/beeref/actions/menu_structure.py @@ -102,6 +102,7 @@ menu_structure = [ 'arrange_optimal', 'arrange_horizontal', 'arrange_vertical', + 'arrange_by_filename', ], }, { diff --git a/beeref/items.py b/beeref/items.py index 7a2c45b..238b9ef 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -41,6 +41,32 @@ def register_item(cls): return cls +def sort_by_filename(items): + """Order items by filename. + + Items with a filename (ordered by filename) first, then items + without a filename but with a save_id follow (ordered by + save_id), then remaining items in the order that they have + been inserted into the scene. + """ + + items_by_filename = [] + items_by_save_id = [] + items_remaining = [] + + for item in items: + if getattr(item, 'filename', None): + items_by_filename.append(item) + elif getattr(item, 'save_id', None): + items_by_save_id.append(item) + else: + items_remaining.append(item) + + items_by_filename.sort(key=lambda x: x.filename) + items_by_save_id.sort(key=lambda x: x.save_id) + return items_by_filename + items_by_save_id + items_remaining + + class BeeItemMixin(SelectableMixin): """Base for all items added by the user.""" diff --git a/beeref/scene.py b/beeref/scene.py index 0299a85..95b2065 100644 --- a/beeref/scene.py +++ b/beeref/scene.py @@ -24,7 +24,7 @@ import rpack from beeref import commands from beeref.config import BeeSettings -from beeref.items import item_registry, BeeErrorItem +from beeref.items import item_registry, BeeErrorItem, sort_by_filename from beeref.selection import MultiSelectItem, RubberbandItem @@ -229,7 +229,6 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): return gap = self.settings.valueOrDefault('Items/arrange_gap') - center = self.get_selection_center() sizes = [] for item in items: @@ -252,12 +251,58 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): # We want the items to center around the selection's center, # not (0, 0) + center = self.get_selection_center() 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 arrange_by_filename(self): + """Order items by filename. + + Items with a filename (ordered by filename) first, then items + without a filename but with a save_id follow (ordered by + save_id), then remaining items in the order that they have + been inserted into the scene. + """ + + self.cancel_active_modes() + max_width = 0 + max_height = 0 + gap = self.settings.valueOrDefault('Items/arrange_gap') + items = sort_by_filename(self.selectedItems(user_only=True)) + + if len(items) < 2: + return + + for item in items: + rect = self.itemsBoundingRect(items=[item]) + max_width = max(max_width, rect.width() + gap) + max_height = max(max_height, rect.height() + gap) + + # We want the items to center around the selection's center, + # not (0, 0) + num_rows = math.ceil(math.sqrt(len(items))) + center = self.get_selection_center() + diff = center - num_rows/2 * QtCore.QPointF(max_width, max_height) + + iter_items = iter(items) + positions = [] + for j in range(num_rows): + for i in range(num_rows): + try: + item = next(iter_items) + rect = self.itemsBoundingRect(items=[item]) + point = QtCore.QPointF( + i * max_width + (max_width - rect.width())/2, + j * max_height + (max_height - rect.height())/2) + positions.append(point + diff) + except StopIteration: + break + + self.undo_stack.push(commands.ArrangeItems(self, items, positions)) + def flip_items(self, vertical=False): """Flip selected items.""" self.cancel_active_modes() diff --git a/beeref/view.py b/beeref/view.py index 63cb38b..bd2d31b 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -324,6 +324,9 @@ class BeeGraphicsView(MainControlsMixin, def on_action_arrange_optimal(self): self.scene.arrange_optimal() + def on_action_arrange_by_filename(self): + self.scene.arrange_by_filename() + def on_action_change_opacity(self): images = list(filter( lambda item: item.is_image, diff --git a/tests/items/test_items.py b/tests/items/test_items.py new file mode 100644 index 0000000..8e12bb1 --- /dev/null +++ b/tests/items/test_items.py @@ -0,0 +1,40 @@ +from PyQt6 import QtGui + +from beeref.items import sort_by_filename, BeePixmapItem + + +def test_sort_by_filename(view): + item1 = BeePixmapItem(QtGui.QImage()) + + item2 = BeePixmapItem(QtGui.QImage()) + item2.filename = 'foo.png' + item2.save_id = 66 + + item3 = BeePixmapItem(QtGui.QImage()) + item3.save_id = 33 + + item4 = BeePixmapItem(QtGui.QImage()) + item4.filename = 'bar.png' + item4.save_id = 77 + + item5 = BeePixmapItem(QtGui.QImage()) + item5.save_id = 22 + + result = sort_by_filename([item1, item2, item3, item4, item5]) + assert result == [item4, item2, item5, item3, item1] + + +def test_sort_by_filename_when_only_by_filename(view): + item1 = BeePixmapItem(QtGui.QImage()) + item1.filename = 'foo.png' + item2 = BeePixmapItem(QtGui.QImage()) + item2.filename = 'bar.png' + assert sort_by_filename([item1, item2]) == [item2, item1] + + +def test_sort_by_filename_when_only_by_save_id(view): + item1 = BeePixmapItem(QtGui.QImage()) + item1.save_id = 66 + item2 = BeePixmapItem(QtGui.QImage()) + item2.save_id = 33 + assert sort_by_filename([item1, item2]) == [item2, item1] diff --git a/tests/test_scene.py b/tests/test_scene.py index 56a42b2..c3f5e9a 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -429,6 +429,85 @@ def test_arrange_optimal_when_no_items(view): view.scene.cancel_crop_mode.assert_called_once_with() +def test_arrange_by_filename(view): + item1 = BeePixmapItem(QtGui.QImage()) + view.scene.addItem(item1) + item1.setSelected(True) + item1.crop = QtCore.QRectF(0, 0, 100, 80) + + item2 = BeePixmapItem(QtGui.QImage()) + item2.filename = 'foo.png' + item2.save_id = 66 + view.scene.addItem(item2) + item2.setSelected(True) + item2.crop = QtCore.QRectF(0, 0, 80, 60) + + item3 = BeePixmapItem(QtGui.QImage()) + item3.save_id = 33 + view.scene.addItem(item3) + item3.setSelected(True) + item3.crop = QtCore.QRectF(0, 0, 100, 80) + + item4 = BeePixmapItem(QtGui.QImage()) + item4.filename = 'bar.png' + item4.save_id = 77 + view.scene.addItem(item4) + item4.setSelected(True) + item4.crop = QtCore.QRectF(0, 0, 100, 80) + + view.scene.cancel_crop_mode = MagicMock() + view.scene.arrange_by_filename() + + assert item4.pos() == QtCore.QPointF(-50, -40) + assert item2.pos() == QtCore.QPointF(60, -30) + assert item3.pos() == QtCore.QPointF(-50, 40) + assert item1.pos() == QtCore.QPointF(50, 40) + view.scene.cancel_crop_mode.assert_called_once_with() + + +def test_arrange_by_filename_with_gap(view, settings): + settings.setValue('Items/arrange_gap', 6) + item1 = BeePixmapItem(QtGui.QImage()) + view.scene.addItem(item1) + item1.setSelected(True) + item1.crop = QtCore.QRectF(0, 0, 100, 80) + + item2 = BeePixmapItem(QtGui.QImage()) + item2.filename = 'foo.png' + item2.save_id = 66 + view.scene.addItem(item2) + item2.setSelected(True) + item2.crop = QtCore.QRectF(0, 0, 80, 60) + + item3 = BeePixmapItem(QtGui.QImage()) + item3.save_id = 33 + view.scene.addItem(item3) + item3.setSelected(True) + item3.crop = QtCore.QRectF(0, 0, 100, 80) + + item4 = BeePixmapItem(QtGui.QImage()) + item4.filename = 'bar.png' + item4.save_id = 77 + view.scene.addItem(item4) + item4.setSelected(True) + item4.crop = QtCore.QRectF(0, 0, 100, 80) + + view.scene.cancel_crop_mode = MagicMock() + view.scene.arrange_by_filename() + + assert item4.pos() == QtCore.QPointF(-53, -43) + assert item2.pos() == QtCore.QPointF(63, -33) + assert item3.pos() == QtCore.QPointF(-53, 43) + assert item1.pos() == QtCore.QPointF(53, 43) + view.scene.cancel_crop_mode.assert_called_once_with() + + +def test_arrange_by_filename_when_no_items(view): + view.scene.cancel_crop_mode = MagicMock() + view.scene.arrange_by_filename() + view.scene.cancel_crop_mode.assert_called_once_with() + + def test_flip_items(view, item): view.scene.addItem(item) item.setSelected(True) diff --git a/tests/test_view.py b/tests/test_view.py index 913ed9b..2dbdd57 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -993,6 +993,30 @@ def test_on_action_delete_items(view, item): view.cancel_active_modes.assert_called_once() +@patch('beeref.scene.BeeGraphicsScene.arrange') +def test_on_action_arrange_horizontal(arrange_mock, view): + view.on_action_arrange_horizontal() + arrange_mock.assert_called_once_with() + + +@patch('beeref.scene.BeeGraphicsScene.arrange') +def test_on_action_arrange_vertical(arrange_mock, view): + view.on_action_arrange_vertical() + arrange_mock.assert_called_once_with(vertical=True) + + +@patch('beeref.scene.BeeGraphicsScene.arrange_optimal') +def test_on_action_arrange_optimal(arrange_mock, view): + view.on_action_arrange_optimal() + arrange_mock.assert_called_once_with() + + +@patch('beeref.scene.BeeGraphicsScene.arrange_by_filename') +def test_on_action_arrange_by_filename(arrange_mock, view): + view.on_action_arrange_by_filename() + arrange_mock.assert_called_once_with() + + @patch('beeref.widgets.ChangeOpacityDialog.__init__', return_value=None) def test_on_action_change_opacity(dialog_mock, view):