Skip to content

Commit

Permalink
Add action 'Arrange by Filename'
Browse files Browse the repository at this point in the history
  • Loading branch information
rbreu committed May 25, 2024
1 parent aac2d0e commit b22056c
Show file tree
Hide file tree
Showing 9 changed files with 227 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions beeref/actions/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,12 @@ def get_default_shortcut(self, index):
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...',
Expand Down
1 change: 1 addition & 0 deletions beeref/actions/menu_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
'arrange_optimal',
'arrange_horizontal',
'arrange_vertical',
'arrange_by_filename',
],
},
{
Expand Down
26 changes: 26 additions & 0 deletions beeref/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
49 changes: 47 additions & 2 deletions beeref/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

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


Expand Down Expand Up @@ -229,7 +229,6 @@ def arrange_optimal(self):
return

gap = self.settings.valueOrDefault('Items/arrange_gap')
center = self.get_selection_center()

sizes = []
for item in items:
Expand All @@ -252,12 +251,58 @@ def arrange_optimal(self):

# 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()
Expand Down
3 changes: 3 additions & 0 deletions beeref/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,9 @@ def on_action_arrange_vertical(self):
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,
Expand Down
40 changes: 40 additions & 0 deletions tests/items/test_items.py
Original file line number Diff line number Diff line change
@@ -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]
79 changes: 79 additions & 0 deletions tests/test_scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions tests/test_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down

0 comments on commit b22056c

Please sign in to comment.