From 3769531493774c85bc4401210904223991f6c513 Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Sun, 17 Dec 2023 12:20:19 +0100 Subject: [PATCH] Add ability to change opacity --- CHANGELOG.rst | 4 ++- beeref/actions/actions.py | 8 ++++- beeref/actions/menu_structure.py | 6 ++++ beeref/commands.py | 31 +++++++++++++++--- beeref/items.py | 7 +++-- beeref/scene.py | 14 ++++----- beeref/view.py | 10 ++++-- beeref/widgets/__init__.py | 54 +++++++++++++++++++++++++++++++- tests/fileio/test_sql.py | 7 +++++ tests/items/test_pixmapitem.py | 22 +++++++++++-- tests/items/test_textitem.py | 1 + tests/test_commands.py | 33 ++++++++++++++++++- tests/test_scene.py | 18 +++++------ tests/test_view.py | 19 +++++++++++ tests/widgets/test_widgets.py | 52 ++++++++++++++++++++++++++++-- 15 files changed, 254 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 25355a3..b72ab64 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,12 +6,14 @@ Added * For arranging, a gap between images can now be configured in the settings. +* The opacity of images can now be changed (Images -> Change Opacity). + Fixed: ------ * Scene Export: Fix output image size and margins when scene had been - scaled or moved + scaled or moved. * Scene Export: Selecting filename without file extension now automatically appends the extension from the selected filter istead of resulting in a confusing error message. diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py index e49b059..020d01f 100644 --- a/beeref/actions/actions.py +++ b/beeref/actions/actions.py @@ -154,12 +154,18 @@ actions = [ 'callback': 'on_action_arrange_vertical', 'group': 'active_when_selection', }, + { + 'id': 'change_opacity', + 'text': 'Change &Opacity...', + 'callback': 'on_action_change_opacity', + 'group': 'active_when_selection', + }, { 'id': 'crop', 'text': '&Crop', 'shortcuts': ['Shift+C'], 'callback': 'on_action_crop', - 'group': 'active_when_croppable', + 'group': 'active_when_single_image', }, { 'id': 'flip_horizontally', diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py index 99c2ab6..1ebbc89 100644 --- a/beeref/actions/menu_structure.py +++ b/beeref/actions/menu_structure.py @@ -103,6 +103,12 @@ menu_structure = [ 'arrange_vertical', ], }, + { + 'menu': '&Images', + 'items': [ + 'change_opacity', + ], + }, { 'menu': '&Settings', 'items': [ diff --git a/beeref/commands.py b/beeref/commands.py index 1f7cd9f..d231e8d 100644 --- a/beeref/commands.py +++ b/beeref/commands.py @@ -23,7 +23,6 @@ class InsertItems(QtGui.QUndoCommand): self.scene = scene self.items = items self.position = position - self.old_positions = [] self.ignore_first_redo = ignore_first_redo def redo(self): @@ -31,6 +30,7 @@ class InsertItems(QtGui.QUndoCommand): self.ignore_first_redo = False return if self.position: + self.old_positions = [] rect = self.scene.itemsBoundingRect(items=self.items) for item in self.items: self.old_positions.append(item.pos()) @@ -226,7 +226,7 @@ class ResetCrop(QtGui.QUndoCommand): def __init__(self, items): super().__init__('Reset Crop') - self.items = [item for item in items if item.is_croppable] + self.items = [item for item in items if item.is_image] def redo(self): self.old_crops = [] @@ -253,7 +253,7 @@ class ResetTransforms(QtGui.QUndoCommand): 'rotation': item.rotation(), 'flip': item.flip(), } - if item.is_croppable: + if item.is_image: values['crop'] = item.crop item.reset_crop() self.old_values.append(values) @@ -269,7 +269,7 @@ class ResetTransforms(QtGui.QUndoCommand): item.setRotation(old['rotation'], anchor=item.center) if old['flip'] == -1: item.do_flip(anchor=item.center) - if item.is_croppable: + if item.is_image: item.crop = old['crop'] @@ -322,3 +322,26 @@ class ChangeText(QtGui.QUndoCommand): def undo(self): self.item.setPlainText(self.old_text) + + +class ChangeOpacity(QtGui.QUndoCommand): + """Change Opacity.""" + + def __init__(self, items, opacity, ignore_first_redo=False): + super().__init__('Change Opacity') + self.ignore_first_redo = ignore_first_redo + self.items = list(filter(lambda item: item.is_image, items)) + self.opacity = opacity + self.old_opacities = [item.opacity() for item in items] + + def redo(self): + if self.ignore_first_redo: + self.ignore_first_redo = False + return + + for item in self.items: + item.setOpacity(self.opacity) + + def undo(self): + for item, opacity in zip(self.items, self.old_opacities): + item.setOpacity(opacity) diff --git a/beeref/items.py b/beeref/items.py index 3a3bda5..e6744b0 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -89,7 +89,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): self.filename = filename self.reset_crop() logger.debug(f'Initialized {self}') - self.is_croppable = True + self.is_image = True self.crop_mode = False self.init_selectable() self.settings = BeeSettings() @@ -101,6 +101,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): item.filename = item.filename or data.get('filename') if 'crop' in data: item.crop = QtCore.QRectF(*data['crop']) + item.setOpacity(data.get('opacity', 1)) return item def __str__(self): @@ -126,6 +127,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): def get_extra_save_data(self): return {'filename': self.filename, + 'opacity': self.opacity(), 'crop': [self.crop.topLeft().x(), self.crop.topLeft().y(), self.crop.width(), @@ -174,6 +176,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): item.setZValue(self.zValue()) item.setScale(self.scale()) item.setRotation(self.rotation()) + item.setOpacity(self.opacity()) if self.flip() == -1: item.do_flip() item.crop = self.crop @@ -494,7 +497,7 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): super().__init__(text or "Text") self.save_id = None logger.debug(f'Initialized {self}') - self.is_croppable = False + self.is_image = False self.init_selectable() self.is_editable = True self.edit_mode = False diff --git a/beeref/scene.py b/beeref/scene.py index 2e2f92f..538a604 100644 --- a/beeref/scene.py +++ b/beeref/scene.py @@ -252,9 +252,9 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): if self.crop_item: return - if self.has_croppable_selection(): + if self.has_single_image_selection(): item = self.selectedItems(user_only=True)[0] - if item.is_croppable: + if item.is_image: item.enter_crop_mode() def set_selected_all_items(self, value): @@ -278,12 +278,11 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): return len(self.selectedItems(user_only=True)) > 1 - def has_croppable_selection(self): - """Checks whether the current selection is croppable, i.e. a - single selection whose item is croppable.""" + def has_single_image_selection(self): + """Checks whether the current selection is a single image.""" if self.has_single_selection(): - return self.selectedItems(user_only=True)[0].is_croppable + return self.selectedItems(user_only=True)[0].is_image return False def mousePressEvent(self, event): @@ -455,7 +454,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): self.items_to_add.put((itemdata, selected)) def add_queued_items(self): - """Adds items added via ``add_items_later``""" + """Adds items added via ``add_item_later``""" while not self.items_to_add.empty(): data, selected = self.items_to_add.get() @@ -467,6 +466,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): cls = item_registry.get('text') data['data'] = {'text': f'Item of unknown type: {typ}'} item = cls.create_from_data(**data) + # Set the values common to all item types: item.update_from_data(**data) self.addItem(item) # Force recalculation of min/max z values: diff --git a/beeref/view.py b/beeref/view.py index c29e026..34a5593 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -281,6 +281,12 @@ class BeeGraphicsView(MainControlsMixin, def on_action_arrange_optimal(self): self.scene.arrange_optimal() + def on_action_change_opacity(self): + images = list(filter( + lambda item: item.is_image, + self.scene.selectedItems(user_only=True))) + widgets.ChangeOpacityDialog(self, images, self.undo_stack) + def on_action_crop(self): self.scene.crop_items() @@ -575,8 +581,8 @@ class BeeGraphicsView(MainControlsMixin, len(self.scene.selectedItems(user_only=True))) self.actiongroup_set_enabled('active_when_selection', self.scene.has_selection()) - self.actiongroup_set_enabled('active_when_croppable', - self.scene.has_croppable_selection()) + self.actiongroup_set_enabled('active_when_single_image', + self.scene.has_single_image_selection()) self.viewport().repaint() def recalc_scene_rect(self): diff --git a/beeref/widgets/__init__.py b/beeref/widgets/__init__.py index 0aba20b..1f98497 100644 --- a/beeref/widgets/__init__.py +++ b/beeref/widgets/__init__.py @@ -19,7 +19,7 @@ import os.path from PyQt6 import QtCore, QtWidgets from PyQt6.QtCore import Qt -from beeref import constants +from beeref import constants, commands from beeref.config import logfile_name from beeref.widgets import settings, welcome_overlay # noqa: F401 @@ -187,3 +187,55 @@ class SceneToPixmapExporterDialog(QtWidgets.QDialog): def value(self): return QtCore.QSize(self.width_input.value(), self.height_input.value()) + + +class ChangeOpacityDialog(QtWidgets.QDialog): + + def __init__(self, parent, images, undo_stack): + super().__init__(parent) + self.undo_stack = undo_stack + self.images = images + self.command = commands.ChangeOpacity(images, opacity=1) + + value = int(images[0].opacity() * 100) if images else 100 + + self.setWindowTitle('Change Opacity:') + self.setWindowModality(Qt.WindowModality.WindowModal) + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + + self.label = QtWidgets.QLabel('Opacity:') + layout.addWidget(self.label) + + self.input = QtWidgets.QSlider(Qt.Orientation.Horizontal) + self.input.valueChanged.connect(self.on_value_changed) + self.input.setRange(0, 100) + self.input.setValue(value) + layout.addWidget(self.input) + + # Bottom row of buttons + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok | + QtWidgets.QDialogButtonBox.StandardButton.Cancel) + + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + self.show() + + def on_value_changed(self, value): + self.label.setText(f'Opacity: {value}%') + self.command.opacity = value / 100 + self.command.redo() + + def accept(self): + if self.images: + logger.debug(f'Setting opacity to {self.command.opacity}') + self.command.ignore_first_redo = True + self.undo_stack.push(self.command) + return super().accept() + + def reject(self): + self.command.undo() + return super().reject() diff --git a/tests/fileio/test_sql.py b/tests/fileio/test_sql.py index 9d43e1b..8dd2fb4 100644 --- a/tests/fileio/test_sql.py +++ b/tests/fileio/test_sql.py @@ -228,6 +228,7 @@ def test_sqliteio_write_inserts_new_text_item(tmpfile, view): def test_sqliteio_write_inserts_new_pixmap_item_png(tmpfile, view): item = BeePixmapItem(QtGui.QImage(), filename='bee.jpg') view.scene.addItem(item) + item.setOpacity(0.66) item.setScale(1.3) item.setPos(44, 55) item.setZValue(0.22) @@ -253,6 +254,7 @@ def test_sqliteio_write_inserts_new_pixmap_item_png(tmpfile, view): assert json.loads(result[6]) == { 'filename': 'bee.jpg', 'crop': [5, 5, 100, 80], + 'opacity': 0.66, } assert result[7] == 'pixmap' assert result[8] == b'abc' @@ -331,6 +333,7 @@ def test_sqliteio_write_updates_existing_pixmap_item(tmpfile, view): item.setPos(44, 55) item.setZValue(0.22) item.setRotation(33) + item.setOpacity(0.2) item.save_id = 1 item.crop = QtCore.QRectF(5, 5, 80, 100) item.pixmap_to_bytes = MagicMock(return_value=(b'abc', 'png')) @@ -340,6 +343,7 @@ def test_sqliteio_write_updates_existing_pixmap_item(tmpfile, view): item.setPos(20, 30) item.setZValue(0.33) item.setRotation(100) + item.setOpacity(0.75) item.do_flip() item.crop = QtCore.QRectF(1, 2, 30, 40) item.filename = 'new.png' @@ -361,6 +365,7 @@ def test_sqliteio_write_updates_existing_pixmap_item(tmpfile, view): assert json.loads(result[6]) == { 'filename': 'new.png', 'crop': [1, 2, 30, 40], + 'opacity': 0.75, } assert result[7] == b'abc' @@ -495,6 +500,8 @@ def test_sqliteio_read_reads_readonly_pixmap_item(tmpfile, view, imgdata3x3): assert item.filename == 'bee.png' assert item.width == 3 assert item.height == 3 + assert item.crop == QtCore.QRectF(0, 0, 3, 3) + assert item.opacity() == 1 assert view.scene.items_to_add.empty() is True diff --git a/tests/items/test_pixmapitem.py b/tests/items/test_pixmapitem.py index 3e6ff54..3ffeeff 100644 --- a/tests/items/test_pixmapitem.py +++ b/tests/items/test_pixmapitem.py @@ -20,7 +20,7 @@ def test_init(selectable_mock, qapp, imgfilename3x3): assert item.scale() == 1 assert item.filename == imgfilename3x3 assert item.crop == QtCore.QRectF(0, 0, 3, 3) - assert item.is_croppable is True + assert item.is_image is True assert item.crop_mode is False selectable_mock.assert_called_once() @@ -75,9 +75,11 @@ def test_bounding_rect_unselected_in_crop_mode(qapp, imgfilename3x3): def test_get_extra_save_data(item): item.filename = 'foobar.png' item.crop = QtCore.QRectF(10, 20, 30, 40) + item.setOpacity(0.75) assert item.get_extra_save_data() == { 'filename': 'foobar.png', 'crop': [10, 20, 30, 40], + 'opacity': 0.75, } @@ -242,11 +244,17 @@ def test_update_from_data_keeps_unset_values(item): assert item.flip() == 1 -def test_create_from_data(item): +def test_create_from_minimal_data(qapp, item, imgfilename3x3): + with open(imgfilename3x3, 'rb') as f: + imgdata = f.read() + item.pixmap_from_bytes(imgdata) + new_item = BeePixmapItem.create_from_data( item=item, data={'filename': 'foobar.png'}) assert new_item is item assert item.filename == 'foobar.png' + assert item.crop == QtCore.QRectF(0, 0, 3, 3) + assert item.opacity() == 1 def test_create_from_data_with_crop(item): @@ -257,6 +265,14 @@ def test_create_from_data_with_crop(item): assert item.crop == QtCore.QRectF(10, 20, 30, 40) +def test_create_from_data_with_opacity(item): + new_item = BeePixmapItem.create_from_data( + item=item, data={'filename': 'foobar.png', 'opacity': 0.7}) + assert new_item is item + assert item.filename == 'foobar.png' + assert item.opacity() == 0.7 + + def test_create_copy(qapp, imgfilename3x3): item = BeePixmapItem(QtGui.QImage(imgfilename3x3), 'foo.png') item.setPos(20, 30) @@ -265,6 +281,7 @@ def test_create_copy(qapp, imgfilename3x3): item.setZValue(0.5) item.setScale(2.2) item.crop = QtCore.QRectF(10, 20, 30, 40) + item.setOpacity(0.7) copy = item.create_copy() assert copy.pixmap_to_bytes() == item.pixmap_to_bytes() @@ -275,6 +292,7 @@ def test_create_copy(qapp, imgfilename3x3): assert copy.zValue() == 0.5 assert copy.scale() == 2.2 assert copy.crop == QtCore.QRectF(10, 20, 30, 40) + assert copy.opacity() == 0.7 def test_copy_to_clipboard(qapp, imgfilename3x3): diff --git a/tests/items/test_textitem.py b/tests/items/test_textitem.py index ba125ab..9e94e20 100644 --- a/tests/items/test_textitem.py +++ b/tests/items/test_textitem.py @@ -20,6 +20,7 @@ def test_init(selectable_mock, qapp): assert item.toPlainText() == 'foo bar' assert item.is_editable is True assert item.edit_mode is False + assert item.is_image is False selectable_mock.assert_called_once() diff --git a/tests/test_commands.py b/tests/test_commands.py index 7f059d3..08d9e52 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -378,7 +378,7 @@ def test_reset_crop(qapp): assert item2.pos() == QtCore.QPointF(0, 0) -def test_reset_crop_ignores_uncroppable(qapp): +def test_reset_crop_ignores_non_images(qapp): item = BeeTextItem('foo') brect = item.boundingRect() command = commands.ResetCrop([item]) @@ -483,3 +483,34 @@ def test_change_text(): assert item.toPlainText() == 'bar' command.undo() assert item.toPlainText() == 'foo' + + +def test_change_opacity(view): + item1 = BeePixmapItem(QtGui.QImage()) + item1.setOpacity(0.5) + view.scene.addItem(item1) + item2 = BeePixmapItem(QtGui.QImage()) + item2.setOpacity(1) + command = commands.ChangeOpacity([item1, item2], 0.7) + command.redo() + assert item1.opacity() == 0.7 + assert item2.opacity() == 0.7 + command.undo() + assert item1.opacity() == 0.5 + assert item2.opacity() == 1 + + +def test_change_opacity_ignore_first_redo(view): + item1 = BeePixmapItem(QtGui.QImage()) + item1.setOpacity(0.5) + view.scene.addItem(item1) + item2 = BeePixmapItem(QtGui.QImage()) + item2.setOpacity(1) + command = commands.ChangeOpacity( + [item1, item2], 0.7, ignore_first_redo=True) + command.redo() + assert item1.opacity() == 0.5 + assert item2.opacity() == 1 + command.redo() + assert item1.opacity() == 0.7 + assert item2.opacity() == 0.7 diff --git a/tests/test_scene.py b/tests/test_scene.py index d4fc8a5..3d6e921 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -486,7 +486,7 @@ def test_crop_item_no_selection(view, item): item.enter_crop_mode.assert_not_called() -def test_crop_item_when_not_croppable(view): +def test_crop_item_when_not_image(view): item = BeeTextItem('foo') item.setSelected(True) item.enter_crop_mode = MagicMock() @@ -584,32 +584,32 @@ def test_has_multi_selection_when_multi_selection(view): assert view.scene.has_multi_selection() is True -def test_has_croppable_selection(view, item): +def test_has_single_image_selection(view, item): view.scene.addItem(item) item.setSelected(True) - assert view.scene.has_croppable_selection() is True + assert view.scene.has_single_image_selection() is True -def test_has_croppable_selection_when_item_not_croppable(view): +def test_has_single_image_selection_when_item_not_image(view): item = BeeTextItem('foo') view.scene.addItem(item) item.setSelected(True) - assert view.scene.has_croppable_selection() is False + assert view.scene.has_single_image_selection() is False -def test_has_croppable_selection_when_no_selection(view, item): +def test_has_single_image_selection_when_no_selection(view, item): view.scene.addItem(item) item.setSelected(False) - assert view.scene.has_croppable_selection() is False + assert view.scene.has_single_image_selection() is False -def test_has_croppable_selection_when_multi_selection(view, item): +def test_has_single_image_selection_when_multi_selection(view, item): view.scene.addItem(item) item.setSelected(True) item2 = BeePixmapItem(QtGui.QImage()) view.scene.addItem(item2) item2.setSelected(True) - assert view.scene.has_croppable_selection() is False + assert view.scene.has_single_image_selection() is False @patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent') diff --git a/tests/test_view.py b/tests/test_view.py index 814864f..bb2b16d 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -716,6 +716,25 @@ def test_on_action_delete_items(view, item): view.scene.cancel_crop_mode.assert_called_once() +@patch('beeref.widgets.ChangeOpacityDialog.__init__', + return_value=None) +def test_on_action_change_opacity(dialog_mock, view): + pixmapitem1 = BeePixmapItem(QtGui.QImage()) + view.scene.addItem(pixmapitem1) + pixmapitem1.setSelected(True) + + pixmapitem2 = BeePixmapItem(QtGui.QImage()) + view.scene.addItem(pixmapitem2) + pixmapitem2.setSelected(False) + + textitem = BeeTextItem('foo') + view.scene.addItem(textitem) + textitem.setSelected(True) + + view.on_action_change_opacity() + dialog_mock.assert_called_once_with(view, [pixmapitem1], view.undo_stack) + + @patch('PyQt6.QtGui.QUndoStack.isClean', return_value=True) def test_update_window_title_no_changes_no_filename(clear_mock, view): view.filename = None diff --git a/tests/widgets/test_widgets.py b/tests/widgets/test_widgets.py index 26382a1..bb85d3b 100644 --- a/tests/widgets/test_widgets.py +++ b/tests/widgets/test_widgets.py @@ -1,10 +1,12 @@ -from PyQt6 import QtCore, QtWidgets +from PyQt6 import QtCore, QtWidgets, QtGui from PyQt6.QtCore import Qt from beeref.config import logfile_name from beeref.widgets import ( + ChangeOpacityDialog, DebugLogDialog, - SceneToPixmapExporterDialog) + SceneToPixmapExporterDialog, +) def test_debug_log_dialog(qtbot, settings, view): @@ -46,3 +48,49 @@ def test_scene_to_pixmap_exporter_dialog_updates_width(view): dlg.height_input.setValue(160) assert dlg.width_input.value() == 120 assert dlg.value() == QtCore.QSize(120, 160) + + +def test_change_opacity_dialog_init(view, item): + item.setOpacity(0.6) + stack = QtGui.QUndoStack() + dlg = ChangeOpacityDialog(view, [item], stack) + assert dlg.input.value() == 60 + assert dlg.label.text() == 'Opacity: 60%' + + +def test_change_opacity_dialog_live_update(view, item): + item.setOpacity(0.6) + stack = QtGui.QUndoStack() + dlg = ChangeOpacityDialog(view, [item], stack) + dlg.input.setValue(30) + assert dlg.label.text() == 'Opacity: 30%' + assert item.opacity() == 0.3 + + +def test_change_opacity_dialog_accept(view, item): + item.setOpacity(0.6) + stack = QtGui.QUndoStack() + dlg = ChangeOpacityDialog(view, [item], stack) + dlg.input.setValue(30) + dlg.accept() + assert item.opacity() == 0.3 + assert len(stack) == 1 + + +def test_change_opacity_dialog_accept_when_no_items(view): + stack = QtGui.QUndoStack() + dlg = ChangeOpacityDialog(view, [], stack) + assert dlg.input.value() == 100 + dlg.input.setValue(30) + dlg.accept() + assert len(stack) == 0 + + +def test_change_opacity_dialog_reject(view, item): + item.setOpacity(0.6) + stack = QtGui.QUndoStack() + dlg = ChangeOpacityDialog(view, [item], stack) + dlg.input.setValue(30) + dlg.reject() + assert item.opacity() == 0.6 + assert len(stack) == 0