diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 71961ea..25355a3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 diff --git a/beeref/config.py b/beeref/config.py index 88e91b8..01f4288 100644 --- a/beeref/config.py +++ b/beeref/config.py @@ -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() diff --git a/beeref/items.py b/beeref/items.py index 4d93ba5..3a3bda5 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -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 diff --git a/beeref/scene.py b/beeref/scene.py index d4fa436..2e2f92f 100644 --- a/beeref/scene.py +++ b/beeref/scene.py @@ -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 diff --git a/beeref/widgets/settings.py b/beeref/widgets/settings.py index 2037ced..3e754bb 100644 --- a/beeref/widgets/settings.py +++ b/beeref/widgets/settings.py @@ -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() diff --git a/tests/test_config.py b/tests/test_config.py index 175d39b..c836fcd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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() diff --git a/tests/test_scene.py b/tests/test_scene.py index 4265957..d4fc8a5 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -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()) diff --git a/tests/widgets/test_settings.py b/tests/widgets/test_settings.py index 5999ac8..2b604f9 100644 --- a/tests/widgets/test_settings.py +++ b/tests/widgets/test_settings.py @@ -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