From 371e2e19a1ddddb84f91b625e6d115fa3fca820f Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Sun, 17 Dec 2023 15:03:50 +0100 Subject: [PATCH] Add ability to toggle grayscale --- CHANGELOG.rst | 3 ++- beeref/actions/actions.py | 8 ++++++++ beeref/actions/menu_structure.py | 1 + beeref/commands.py | 20 +++++++++++++++++++- beeref/items.py | 24 +++++++++++++++++++++++- beeref/view.py | 8 ++++++++ tests/fileio/test_sql.py | 5 +++++ tests/items/test_pixmapitem.py | 26 ++++++++++++++++++++++++++ tests/test_commands.py | 15 +++++++++++++++ tests/test_view.py | 19 +++++++++++++++++++ 10 files changed, 126 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b72ab64..3075794 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,7 +6,8 @@ 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). +* The opacity of images can be changed (Images -> Change Opacity). +* Images can be set to display as grayscale (Images -> Grayscale). Fixed: diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py index 020d01f..b430962 100644 --- a/beeref/actions/actions.py +++ b/beeref/actions/actions.py @@ -160,6 +160,14 @@ actions = [ 'callback': 'on_action_change_opacity', 'group': 'active_when_selection', }, + { + 'id': 'grayscale', + 'text': '&Grayscale', + 'shortcuts': ['G'], + 'checkable': True, + 'callback': 'on_action_grayscale', + 'group': 'active_when_selection', + }, { 'id': 'crop', 'text': '&Crop', diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py index 1ebbc89..942993c 100644 --- a/beeref/actions/menu_structure.py +++ b/beeref/actions/menu_structure.py @@ -107,6 +107,7 @@ menu_structure = [ 'menu': '&Images', 'items': [ 'change_opacity', + 'grayscale', ], }, { diff --git a/beeref/commands.py b/beeref/commands.py index d231e8d..1c9acb7 100644 --- a/beeref/commands.py +++ b/beeref/commands.py @@ -325,7 +325,7 @@ class ChangeText(QtGui.QUndoCommand): class ChangeOpacity(QtGui.QUndoCommand): - """Change Opacity.""" + """Change opacity on images.""" def __init__(self, items, opacity, ignore_first_redo=False): super().__init__('Change Opacity') @@ -345,3 +345,21 @@ class ChangeOpacity(QtGui.QUndoCommand): def undo(self): for item, opacity in zip(self.items, self.old_opacities): item.setOpacity(opacity) + + +class ToggleGrayscale(QtGui.QUndoCommand): + """Toggle grayscale mode on images.""" + + def __init__(self, items, grayscale): + super().__init__('Toggle Grayscale') + self.items = list(filter(lambda item: item.is_image, items)) + self.grayscale = grayscale + self.old_grayscales = [item.grayscale for item in items] + + def redo(self): + for item in self.items: + item.grayscale = self.grayscale + + def undo(self): + for item, grayscale in zip(self.items, self.old_grayscales): + item.grayscale = grayscale diff --git a/beeref/items.py b/beeref/items.py index e6744b0..2858ca9 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -93,6 +93,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): self.crop_mode = False self.init_selectable() self.settings = BeeSettings() + self.grayscale = False @classmethod def create_from_data(self, **kwargs): @@ -102,6 +103,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): if 'crop' in data: item.crop = QtCore.QRectF(*data['crop']) item.setOpacity(data.get('opacity', 1)) + item.grayscale = data.get('grayscale', False) return item def __str__(self): @@ -119,6 +121,23 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): self._crop = value self.update() + @property + def grayscale(self): + return self._grayscale + + @grayscale.setter + def grayscale(self, value): + logger.debug('Setting grayscale for {self} to {value}') + self._grayscale = value + if value is True: + img = self.pixmap().toImage() + img = img.convertToFormat(QtGui.QImage.Format.Format_Grayscale8) + self._grayscale_pixmap = QtGui.QPixmap.fromImage(img) + else: + self._grayscale_pixmap = None + + self.update() + def bounding_rect_unselected(self): if self.crop_mode: return QtWidgets.QGraphicsPixmapItem.boundingRect(self) @@ -128,6 +147,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): def get_extra_save_data(self): return {'filename': self.filename, 'opacity': self.opacity(), + 'grayscale': self.grayscale, 'crop': [self.crop.topLeft().x(), self.crop.topLeft().y(), self.crop.width(), @@ -177,6 +197,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): item.setScale(self.scale()) item.setRotation(self.rotation()) item.setOpacity(self.opacity()) + item.grayscale = self.grayscale if self.flip() == -1: item.do_flip() item.crop = self.crop @@ -326,7 +347,8 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): self.draw_crop_rect(painter, handle()) self.draw_crop_rect(painter, self.crop_temp) else: - painter.drawPixmap(self.crop, self.pixmap(), self.crop) + pm = self._grayscale_pixmap if self.grayscale else self.pixmap() + painter.drawPixmap(self.crop, pm, self.crop) self.paint_selectable(painter, option, widget) def enter_crop_mode(self): diff --git a/beeref/view.py b/beeref/view.py index 34a5593..adeb975 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -287,6 +287,14 @@ class BeeGraphicsView(MainControlsMixin, self.scene.selectedItems(user_only=True))) widgets.ChangeOpacityDialog(self, images, self.undo_stack) + def on_action_grayscale(self, checked): + images = list(filter( + lambda item: item.is_image, + self.scene.selectedItems(user_only=True))) + if images: + self.undo_stack.push( + commands.ToggleGrayscale(images, checked)) + def on_action_crop(self): self.scene.crop_items() diff --git a/tests/fileio/test_sql.py b/tests/fileio/test_sql.py index 8dd2fb4..fa2ef31 100644 --- a/tests/fileio/test_sql.py +++ b/tests/fileio/test_sql.py @@ -235,6 +235,7 @@ def test_sqliteio_write_inserts_new_pixmap_item_png(tmpfile, view): item.setRotation(33) item.do_flip() item.crop = QtCore.QRectF(5, 5, 100, 80) + item.grayscale = True item.pixmap_to_bytes = MagicMock(return_value=(b'abc', 'png')) io = SQLiteIO(tmpfile, view.scene, create_new=True) io.write() @@ -255,6 +256,7 @@ def test_sqliteio_write_inserts_new_pixmap_item_png(tmpfile, view): 'filename': 'bee.jpg', 'crop': [5, 5, 100, 80], 'opacity': 0.66, + 'grayscale': True, } assert result[7] == 'pixmap' assert result[8] == b'abc' @@ -346,6 +348,7 @@ def test_sqliteio_write_updates_existing_pixmap_item(tmpfile, view): item.setOpacity(0.75) item.do_flip() item.crop = QtCore.QRectF(1, 2, 30, 40) + item.grayscale = True item.filename = 'new.png' item.pixmap_to_bytes.return_value = b'updated' io.create_new = False @@ -366,6 +369,7 @@ def test_sqliteio_write_updates_existing_pixmap_item(tmpfile, view): 'filename': 'new.png', 'crop': [1, 2, 30, 40], 'opacity': 0.75, + 'grayscale': True, } assert result[7] == b'abc' @@ -502,6 +506,7 @@ def test_sqliteio_read_reads_readonly_pixmap_item(tmpfile, view, imgdata3x3): assert item.height == 3 assert item.crop == QtCore.QRectF(0, 0, 3, 3) assert item.opacity() == 1 + assert item.grayscale is False assert view.scene.items_to_add.empty() is True diff --git a/tests/items/test_pixmapitem.py b/tests/items/test_pixmapitem.py index 3ffeeff..4b49923 100644 --- a/tests/items/test_pixmapitem.py +++ b/tests/items/test_pixmapitem.py @@ -59,6 +59,19 @@ def test_set_crop(qapp, item): item.prepareGeometryChange.assert_called_once_with() +def test_set_grayscale_true(qapp, item): + item.grayscale = True + assert item.grayscale is True + assert item._grayscale_pixmap is not None + + +def test_set_grayscale_false(qapp, item): + item._grayscale_pixmap = QtGui.QPixmap() + item.grayscale = False + assert item.grayscale is False + assert item._grayscale_pixmap is None + + def test_bounding_rect_unselected(qapp, imgfilename3x3): item = BeePixmapItem(QtGui.QImage(imgfilename3x3)) item.crop = QtCore.QRectF(1, 1, 2, 2) @@ -76,10 +89,12 @@ def test_get_extra_save_data(item): item.filename = 'foobar.png' item.crop = QtCore.QRectF(10, 20, 30, 40) item.setOpacity(0.75) + item.grayscale = True assert item.get_extra_save_data() == { 'filename': 'foobar.png', 'crop': [10, 20, 30, 40], 'opacity': 0.75, + 'grayscale': True } @@ -255,6 +270,7 @@ def test_create_from_minimal_data(qapp, item, imgfilename3x3): assert item.filename == 'foobar.png' assert item.crop == QtCore.QRectF(0, 0, 3, 3) assert item.opacity() == 1 + assert item.grayscale is False def test_create_from_data_with_crop(item): @@ -273,6 +289,14 @@ def test_create_from_data_with_opacity(item): assert item.opacity() == 0.7 +def test_create_from_data_with_grayscale(item): + new_item = BeePixmapItem.create_from_data( + item=item, data={'filename': 'foobar.png', 'grayscale': True}) + assert new_item is item + assert item.filename == 'foobar.png' + assert item.grayscale is True + + def test_create_copy(qapp, imgfilename3x3): item = BeePixmapItem(QtGui.QImage(imgfilename3x3), 'foo.png') item.setPos(20, 30) @@ -282,6 +306,7 @@ def test_create_copy(qapp, imgfilename3x3): item.setScale(2.2) item.crop = QtCore.QRectF(10, 20, 30, 40) item.setOpacity(0.7) + item.grayscale = True copy = item.create_copy() assert copy.pixmap_to_bytes() == item.pixmap_to_bytes() @@ -293,6 +318,7 @@ def test_create_copy(qapp, imgfilename3x3): assert copy.scale() == 2.2 assert copy.crop == QtCore.QRectF(10, 20, 30, 40) assert copy.opacity() == 0.7 + assert copy.grayscale is True def test_copy_to_clipboard(qapp, imgfilename3x3): diff --git a/tests/test_commands.py b/tests/test_commands.py index 08d9e52..fd28602 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -514,3 +514,18 @@ def test_change_opacity_ignore_first_redo(view): command.redo() assert item1.opacity() == 0.7 assert item2.opacity() == 0.7 + + +def test_toggle_grayscale(view): + item1 = BeePixmapItem(QtGui.QImage()) + item1.grayscale = True + view.scene.addItem(item1) + item2 = BeePixmapItem(QtGui.QImage()) + item2.grayscale = False + command = commands.ToggleGrayscale([item1, item2], True) + command.redo() + assert item1.grayscale is True + assert item2.grayscale is True + command.undo() + assert item1.grayscale is True + assert item2.grayscale is False diff --git a/tests/test_view.py b/tests/test_view.py index bb2b16d..e13c43b 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -735,6 +735,25 @@ def test_on_action_change_opacity(dialog_mock, view): dialog_mock.assert_called_once_with(view, [pixmapitem1], view.undo_stack) +def test_on_action_grayscale(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_grayscale(True) + assert len(view.undo_stack) == 1 + assert pixmapitem1.grayscale is True + assert pixmapitem2.grayscale is False + + @patch('PyQt6.QtGui.QUndoStack.isClean', return_value=True) def test_update_window_title_no_changes_no_filename(clear_mock, view): view.filename = None