Added configurable gap between images when arranging

This commit is contained in:
Rebecca Breu 2023-12-15 22:32:57 +01:00
parent 781c49fddd
commit 9c506aadaa
8 changed files with 212 additions and 14 deletions

View file

@ -1,7 +1,14 @@
0.3.2 - (unreleased)
====================
Added
-----
* For arranging, a gap between images can now be configured in the
settings.
Fixed:
------
* Scene Export: Fix output image size and margins when scene had been
scaled or moved

View file

@ -106,8 +106,16 @@ settings_events = BeeSettingsEvents()
class BeeSettings(QtCore.QSettings):
DEFAULTS = {
'Items/image_storage_format': 'best',
FIELDS = {
'Items/image_storage_format': {
'default': 'best',
'validate': lambda x: x in ('png', 'jpg', 'best'),
},
'Items/arrange_gap': {
'default': 0,
'cast': int,
'validate': lambda x: 0 <= x <= 200,
},
}
def __init__(self):
@ -123,15 +131,40 @@ class BeeSettings(QtCore.QSettings):
constants.APPNAME,
constants.APPNAME)
def valueOrDefault(self, key, type=None):
val = self.value(key, type)
def valueOrDefault(self, key):
"""Get the value for key, or the default value specified in FIELDS.
This is the method to be used for configurable settings (as
opposed to settings that BeeRef stores on its own.)
This will validate and type cast the given value if 'cast' and
'validate' are specified in the FIELDS entry for the given
key. The default value will be returned if validation or type
casting fails.
"""
val = self.value(key)
conf = self.FIELDS[key]
if val is None:
val = self.DEFAULTS.get(key)
val = conf['default']
if 'cast' in conf:
try:
val = conf['cast'](val)
except (ValueError, TypeError):
val = conf['default']
if 'validate' in conf:
if not conf['validate'](val):
val = conf['default']
return val
def restore_defaults(self):
"""Restore all the values specified in FILEDS to their default values
by removing them from the settings file.
"""
logger.debug('Restoring settings to defaults')
for key in self.DEFAULTS.keys():
for key in self.FIELDS.keys():
self.remove(key)
settings_events.restore_defaults.emit()

View file

@ -135,8 +135,6 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
"""Determines the format for storing this image."""
formt = self.settings.valueOrDefault('Items/image_storage_format')
if formt not in ('png', 'jpg', 'best'):
formt = 'best'
if formt == 'best':
# Images with alpha channel and small images are stored as png

View file

@ -23,6 +23,7 @@ from PyQt6.QtCore import Qt
import rpack
from beeref import commands
from beeref.config import BeeSettings
from beeref.items import item_registry
from beeref.selection import MultiSelectItem, RubberbandItem
@ -48,6 +49,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
self.internal_clipboard = []
self.edit_item = None
self.crop_item = None
self.settings = BeeSettings()
def clear(self):
super().clear()
@ -166,6 +168,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
if len(items) < 2:
return
gap = self.settings.valueOrDefault('Items/arrange_gap')
center = self.get_selection_center()
positions = []
rects = []
@ -182,7 +185,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
positions.append(
QtCore.QPointF(
round(center.x() - rect['rect'].width()/2), y))
y += rect['rect'].height()
y += rect['rect'].height() + gap
else:
rects.sort(key=lambda r: r['rect'].topLeft().x())
@ -192,7 +195,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
positions.append(
QtCore.QPointF(
x, round(center.y() - rect['rect'].height()/2)))
x += rect['rect'].width()
x += rect['rect'].width() + gap
self.undo_stack.push(
commands.ArrangeItems(self,
@ -206,12 +209,14 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
if len(items) < 2:
return
gap = self.settings.valueOrDefault('Items/arrange_gap')
center = self.get_selection_center()
sizes = []
for item in items:
rect = self.itemsBoundingRect(items=[item])
sizes.append((round(rect.width()), round(rect.height())))
center = self.get_selection_center()
sizes.append((round(rect.width() + gap),
round(rect.height() + gap)))
# The minimal area the items need if they could be packed optimally;
# we use this as a starting shape for the packing algorithm

View file

@ -56,6 +56,7 @@ class RadioGroup(QtWidgets.QGroupBox):
layout.addWidget(btn)
self.ignore_values_changed = False
layout.addStretch(100)
def on_values_changed(self, value, button):
if self.ignore_values_changed:
@ -73,6 +74,48 @@ class RadioGroup(QtWidgets.QGroupBox):
self.ignore_values_changed = False
class IntegerGroup(QtWidgets.QGroupBox):
TITLE = None
HELPTEXT = None
KEY = None
MIN = None
MAX = None
def __init__(self):
super().__init__(self.TITLE)
self.settings = BeeSettings()
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)
settings_events.restore_defaults.connect(self.on_restore_defaults)
if self.HELPTEXT:
helptxt = QtWidgets.QLabel(self.HELPTEXT)
helptxt.setWordWrap(True)
layout.addWidget(helptxt)
self.input = QtWidgets.QSpinBox()
self.input.setValue(self.settings.valueOrDefault(self.KEY))
self.input.setRange(self.MIN, self.MAX)
self.input.valueChanged.connect(self.on_value_changed)
layout.addWidget(self.input)
layout.addStretch(100)
self.ignore_values_changed = False
def on_value_changed(self, value):
if self.ignore_values_changed:
return
if value != self.settings.valueOrDefault(self.KEY):
logger.debug(f'Setting {self.KEY} changed to: {value}')
self.settings.setValue(self.KEY, value)
def on_restore_defaults(self):
new_value = self.settings.valueOrDefault(self.KEY)
self.ignore_values_changed = True
self.input.setValue(new_value)
self.ignore_values_changed = False
class ImageStorageFormatWidget(RadioGroup):
TITLE = 'Image Storage Format:'
HELPTEXT = ('How images are stored inside bee files.'
@ -87,6 +130,14 @@ class ImageStorageFormatWidget(RadioGroup):
'Small bee file, but lossy and no transparency support'))
class ArrangeGapWidget(IntegerGroup):
TITLE = 'Arrange Gap:'
HELPTEXT = ('The gap between images when using arrange actions.')
KEY = 'Items/arrange_gap'
MIN = 0
MAX = 200
class SettingsDialog(QtWidgets.QDialog):
def __init__(self, parent):
super().__init__(parent)
@ -98,6 +149,7 @@ class SettingsDialog(QtWidgets.QDialog):
misc_layout = QtWidgets.QGridLayout()
misc.setLayout(misc_layout)
misc_layout.addWidget(ImageStorageFormatWidget(), 0, 0)
misc_layout.addWidget(ArrangeGapWidget(), 0, 1)
tabs.addTab(misc, '&Miscellaneous')
layout = QtWidgets.QVBoxLayout()

View file

@ -44,6 +44,21 @@ def test_settings_value_or_default_gets_overriden_value(settings):
assert settings.valueOrDefault('Items/image_storage_format') == 'png'
def test_settings_value_or_default_gets_default_when_invalid(settings):
settings.setValue('Items/image_storage_format', 'foo')
assert settings.valueOrDefault('Items/image_storage_format') == 'best'
def test_settings_value_or_default_casts_value(settings):
settings.setValue('Items/arrange_gap', '5')
assert settings.valueOrDefault('Items/arrange_gap') == 5
def test_settings_value_or_default_gets_default_when_cast_error(settings):
settings.setValue('Items/arrange_gap', 'foo')
assert settings.valueOrDefault('Items/arrange_gap') == 0
def test_restore_defaults_restores(settings):
settings.setValue('Items/image_storage_format', 'png')
settings.restore_defaults()

View file

@ -271,6 +271,29 @@ def test_arrange_horizontal(view):
view.scene.cancel_crop_mode.assert_called_once_with()
def test_arrange_horizontal_with_gap(view, settings):
settings.setValue('Items/arrange_gap', 6)
item1 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item1)
item1.setSelected(True)
item1.setPos(10, -100)
item2 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item2)
item2.setSelected(True)
item2.setPos(-10, 40)
view.scene.cancel_crop_mode = MagicMock()
with patch.object(item1, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
with patch.object(item2, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
view.scene.arrange()
assert item2.pos() == QtCore.QPointF(-50, -30)
assert item1.pos() == QtCore.QPointF(56, -30)
view.scene.cancel_crop_mode.assert_called_once_with()
def test_arrange_vertical(view):
item1 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item1)
@ -293,6 +316,29 @@ def test_arrange_vertical(view):
view.scene.cancel_crop_mode = MagicMock()
def test_arrange_vertical_with_gap(view, settings):
settings.setValue('Items/arrange_gap', 6)
item1 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item1)
item1.setSelected(True)
item1.setPos(10, -100)
item2 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item2)
item2.setSelected(True)
item2.setPos(-10, 40)
view.scene.cancel_crop_mode = MagicMock()
with patch.object(item1, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
with patch.object(item2, 'bounding_rect_unselected',
return_value=QtCore.QRectF(0, 0, 100, 80)):
view.scene.arrange(vertical=True)
assert item1.pos() == QtCore.QPointF(0, -70)
assert item2.pos() == QtCore.QPointF(0, 16)
view.scene.cancel_crop_mode = MagicMock()
def test_arrange_when_rotated(view):
item1 = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item1)
@ -340,6 +386,24 @@ def test_arrange_optimal(view):
view.scene.cancel_crop_mode.assert_called_once_with()
def test_arrange_optimal_with_gap(view, settings):
settings.setValue('Items/arrange_gap', 6)
for i in range(4):
item = BeePixmapItem(QtGui.QImage())
view.scene.addItem(item)
item.setSelected(True)
item.crop = QtCore.QRectF(0, 0, 100, 80)
view.scene.cancel_crop_mode = MagicMock()
view.scene.arrange_optimal()
expected_positions = {(-56, -46), (50, -46), (-56, 40), (50, 40)}
actual_positions = {
(i.pos().x(), i.pos().y())
for i in view.scene.selectedItems(user_only=True)}
assert expected_positions == actual_positions
view.scene.cancel_crop_mode.assert_called_once_with()
def test_arrange_optimal_when_rotated(view):
for i in range(4):
item = BeePixmapItem(QtGui.QImage())

View file

@ -2,6 +2,7 @@ from unittest.mock import patch
from PyQt6 import QtWidgets
from beeref.widgets.settings import (
ArrangeGapWidget,
ImageStorageFormatWidget,
SettingsDialog,
)
@ -22,7 +23,7 @@ def test_image_storage_format_saves_change(settings, view):
assert widget.buttons['best'].isChecked() is False
assert widget.buttons['png'].isChecked() is False
assert widget.buttons['jpg'].isChecked() is True
assert settings.valueOrDefault('Items/image_storage_format', 'jpg')
assert settings.valueOrDefault('Items/image_storage_format') == 'jpg'
def test_image_storage_format_on_restore_defaults(settings, view):
@ -35,11 +36,34 @@ def test_image_storage_format_on_restore_defaults(settings, view):
assert widget.buttons['jpg'].isChecked() is False
def test_arrange_gap_initialises_input_from_settings(settings, view):
settings.setValue('Items/arrange_gap', 6)
widget = ArrangeGapWidget()
assert widget.input.value() == 6
def test_arrange_gap_saves_change(settings, view):
settings.setValue('Items/arrange_gap', 6)
widget = ArrangeGapWidget()
widget.input.setValue(8)
assert settings.valueOrDefault('Items/arrange_gap') == 8
def test_arrange_gap_on_restore_defaults(settings, view):
widget = ArrangeGapWidget()
widget.input.setValue(7)
settings.setValue('Items/arrange_gap', 0)
widget.on_restore_defaults()
assert widget.input.value() == 0
@patch('PyQt6.QtWidgets.QMessageBox.question',
return_value=QtWidgets.QMessageBox.StandardButton.Yes)
def test_settings_dialog_on_restore_defaults(msg_mock, settings, view):
dialog = SettingsDialog(view)
settings.setValue('Items/image_storage_format', 'jpg')
settings.setValue('Items/arrange_gap', 10)
dialog.on_restore_defaults()
msg_mock.assert_called_once()
assert settings.valueOrDefault('Items/image_storage_format') == 'best'
assert settings.valueOrDefault('Items/arrange_gap') == 0