Add ability to change opacity

This commit is contained in:
Rebecca Breu 2023-12-17 12:20:19 +01:00
parent 9c506aadaa
commit 3769531493
15 changed files with 254 additions and 32 deletions

View file

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

View file

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

View file

@ -103,6 +103,12 @@ menu_structure = [
'arrange_vertical',
],
},
{
'menu': '&Images',
'items': [
'change_opacity',
],
},
{
'menu': '&Settings',
'items': [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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