Add ability to toggle grayscale

This commit is contained in:
Rebecca Breu 2023-12-17 15:03:50 +01:00
parent 3769531493
commit 371e2e19a1
10 changed files with 126 additions and 3 deletions

View file

@ -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:

View file

@ -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',

View file

@ -107,6 +107,7 @@ menu_structure = [
'menu': '&Images',
'items': [
'change_opacity',
'grayscale',
],
},
{

View file

@ -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

View file

@ -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):

View file

@ -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()

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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