Copy/Cut + internal pasting

This commit is contained in:
Rebecca Breu 2021-05-15 19:34:20 +02:00
parent 780b9f414d
commit 8425f9bbc1
10 changed files with 216 additions and 12 deletions

View file

@ -60,6 +60,20 @@ actions = [
'group': 'active_when_can_redo',
'enabled': False,
},
{
'id': 'copy',
'text': '&Copy',
'shortcuts': ['Ctrl+C'],
'callback': 'on_action_copy',
'group': 'active_when_selection',
},
{
'id': 'cut',
'text': 'Cu&t',
'shortcuts': ['Ctrl+X'],
'callback': 'on_action_cut',
'group': 'active_when_selection',
},
{
'id': 'paste',
'text': '&Paste',

View file

@ -41,6 +41,8 @@ menu_structure = [
'select_all',
'deselect_all',
MENU_SEPARATOR,
'cut',
'copy',
'paste',
'delete',
],

View file

@ -18,16 +18,24 @@ from PyQt6 import QtCore, QtGui
class InsertItems(QtGui.QUndoCommand):
def __init__(self, scene, items, ignore_first_redo=False):
def __init__(self, scene, items, position=None, ignore_first_redo=False):
super().__init__('Insert items')
self.scene = scene
self.items = items
self.position = position
self.old_positions = []
self.ignore_first_redo = ignore_first_redo
def redo(self):
if self.ignore_first_redo:
self.ignore_first_redo = False
return
if self.position:
rect = self.scene.itemsBoundingRect(items=self.items)
center = (rect.topLeft() + rect.bottomRight()) / 2
for item in self.items:
self.old_positions.append(item.pos())
item.setPos(item.pos() + self.position - center)
self.scene.clearSelection()
for item in self.items:
self.scene.addItem(item)
@ -37,6 +45,9 @@ class InsertItems(QtGui.QUndoCommand):
self.scene.clearSelection()
for item in self.items:
self.scene.removeItem(item)
if self.position:
for item, pos in zip(self.items, self.old_positions):
item.setPos(pos)
class DeleteItems(QtGui.QUndoCommand):

View file

@ -88,3 +88,14 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem):
and not self.scene().has_selection()
and not self.scene().rubberband_active):
self.bring_to_front()
def create_copy(self):
item = BeePixmapItem(QtGui.QImage(), self.filename)
item.setPixmap(self.pixmap())
item.setPos(self.pos())
item.setZValue(self.zValue())
item.setScale(self.scale())
item.setRotation(self.rotation())
if self.flip() == -1:
item.do_flip()
return item

View file

@ -42,6 +42,20 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
self.selectionChanged.connect(self.on_selection_change)
self.changed.connect(self.on_change)
self.items_to_add = Queue()
self.internal_clipboard = []
def copy_selection_to_internal_clipboard(self):
self.internal_clipboard = []
for item in self.selectedItems(user_only=True):
self.internal_clipboard.append(item)
def paste_from_internal_clipboard(self, position):
self.set_selected_all_items(False)
copies = []
for item in self.internal_clipboard:
copy = item.create_copy()
copies.append(copy)
self.undo_stack.push(commands.InsertItems(self, copies, position))
def normalize_width_or_height(self, mode):
"""Scale the selected images to have the same width or height, as

View file

@ -213,6 +213,13 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
commands.DeleteItems(
self.scene, self.scene.selectedItems(user_only=True)))
def on_action_cut(self):
logger.debug('Cutting items...')
self.on_action_copy()
self.undo_stack.push(
commands.DeleteItems(
self.scene, self.scene.selectedItems(user_only=True)))
def on_action_normalize_height(self):
self.scene.normalize_height()
@ -392,17 +399,42 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
filter=f'Images ({formats})')
self.do_insert_images(filenames)
def on_action_paste(self):
logger.info('Pasting from clipboard...')
def on_action_copy(self):
logger.debug('Copying to clipboard...')
clipboard = QtWidgets.QApplication.clipboard()
items = self.scene.selectedItems(user_only=True)
# At the moment, we can only copy one image to the global
# clipboard. (Later, we might create an image of the whole
# selection for external copying.)
clipboard.setPixmap(items[0].pixmap())
# However, we can copy all items to the internal clipboard:
self.scene.copy_selection_to_internal_clipboard()
# We set a marker for ourselves in the global clipboard so
# that we know to look up the internal clipboard when pasting:
clipboard.mimeData().setData(
'beeref/items', QtCore.QByteArray.number(len(items)))
def on_action_paste(self):
logger.debug('Pasting from clipboard...')
clipboard = QtWidgets.QApplication.clipboard()
pos = self.mapToScene(self.mapFromGlobal(self.cursor().pos()))
# See if we need to look up the internal clipboard:
data = clipboard.mimeData().data('beeref/items')
logger.debug(f'Custom data in clipboard: {data}')
if data:
self.scene.paste_from_internal_clipboard(pos)
return
img = clipboard.image()
if img.isNull():
logger.info('No image data in clipboard')
else:
if not img.isNull():
item = BeePixmapItem(img)
pos = self.mapToScene(self.mapFromGlobal(self.cursor().pos()))
item.set_pos_center(pos)
self.undo_stack.push(commands.InsertItems(self.scene, [item]))
self.undo_stack.push(commands.InsertItems(self.scene, [item], pos))
return
logger.info('No image data in clipboard')
def on_selection_changed(self):
logger.debug('Currently selected items: %s',
@ -546,6 +578,5 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
img = QtGui.QImage(mimedata.imageData())
item = BeePixmapItem(img)
pos = self.mapToScene(pos)
item.set_pos_center(pos)
self.undo_stack.push(commands.InsertItems(self.scene, [item]))
self.undo_stack.push(commands.InsertItems(self.scene, [item], pos))
logger.info('Drop not an image')

View file

@ -20,15 +20,47 @@ class InsertItemsTestCase(BeeTestCase):
item1 = BeePixmapItem(QtGui.QImage())
scene.addItem(item1)
item2 = BeePixmapItem(QtGui.QImage())
item2.setPos(50, 40)
command = commands.InsertItems(scene, [item2])
command.redo()
assert list(scene.items_for_save()) == [item1, item2]
assert item1.isSelected() is False
assert item2.isSelected() is True
assert item2.pos() == QtCore.QPointF(50, 40)
item2.zValue() > 5
command.undo()
assert list(scene.items_for_save()) == [item1]
assert item1.isSelected() is False
assert item2.pos() == QtCore.QPointF(50, 40)
@patch('beeref.scene.BeeGraphicsScene.views')
def test_redo_undo_with_position(self, views_mock):
scene = BeeGraphicsScene(None)
view = MagicMock(get_scale=MagicMock(return_value=1))
views_mock.return_value = [view]
scene.update_selection = MagicMock()
item1 = BeePixmapItem(QtGui.QImage())
item1.setPos(10, 20)
scene.addItem(item1)
item2 = BeePixmapItem(QtGui.QImage())
item2.setPos(50, 40)
scene.addItem(item2)
with patch('beeref.items.BeePixmapItem.width',
new_callable=PropertyMock, return_value=100):
with patch('beeref.items.BeePixmapItem.height',
new_callable=PropertyMock, return_value=80):
command = commands.InsertItems(
scene, [item1, item2], QtCore.QPointF(100, 200))
command.redo()
assert set(scene.items_for_save()) == {item1, item2}
assert item1.pos() == QtCore.QPointF(30, 150)
assert item2.pos() == QtCore.QPointF(70, 170)
command.undo()
assert list(scene.items_for_save()) == []
assert item1.pos() == QtCore.QPointF(10, 20)
assert item2.pos() == QtCore.QPointF(50, 40)
@patch('beeref.scene.BeeGraphicsScene.views')
def test_ignore_first_redo(self, views_mock):

View file

@ -121,3 +121,20 @@ class BeePixmapItemTestCase(BeeTestCase):
def test_selection_action_items(self):
item = BeePixmapItem(QtGui.QImage())
assert item.selection_action_items() == [item]
def test_create_copy(self):
item = BeePixmapItem(QtGui.QImage(self.imgfilename3x3), 'foo.png')
item.setPos(20, 30)
item.setRotation(33)
item.do_flip()
item.setZValue(0.5)
item.setScale(2.2)
copy = item.create_copy()
assert copy.pixmap_to_bytes() == item.pixmap_to_bytes()
assert copy.filename == 'foo.png'
assert copy.pos() == QtCore.QPointF(20, 30)
assert copy.rotation() == 33
assert item.flip() == -1
assert item.zValue() == 0.5
assert item.scale() == 2.2

View file

@ -828,3 +828,32 @@ class BeeGraphicsSceneTestCase(BeeTestCase):
def test_add_queued_items_when_no_items(self):
self.scene.add_queued_items()
assert self.scene.items() == []
def test_copy_selection_to_internal_clipboard(self):
item1 = BeePixmapItem(QtGui.QImage())
self.scene.addItem(item1)
item1.setSelected(True)
item2 = BeePixmapItem(QtGui.QImage())
self.scene.addItem(item2)
item2.setSelected(True)
item3 = BeePixmapItem(QtGui.QImage())
self.scene.addItem(item3)
self.scene.copy_selection_to_internal_clipboard()
assert set(self.scene.internal_clipboard) == {item1, item2}
assert set(self.scene.items_for_save()) == {item1, item2, item3}
def test_paste_from_internal_clipboard(self):
item1 = BeePixmapItem(QtGui.QImage())
self.scene.addItem(item1)
item1.setSelected(True)
item2 = BeePixmapItem(QtGui.QImage())
item2.setScale(3.3)
self.scene.internal_clipboard = [item2]
self.scene.paste_from_internal_clipboard(None)
assert len(list(self.scene.items_for_save())) == 2
assert item1.isSelected() is False
new_item = self.scene.selectedItems(user_only=True)[0]
assert new_item.scale() == 3.3
assert new_item is not item2

View file

@ -321,13 +321,38 @@ class BeeGraphicsViewTestCase(ViewBaseTestCase):
assert items[0][1] is True
clear_mock.assert_called_once_with()
@patch('PyQt6.QtWidgets.QApplication.clipboard')
def test_on_action_copy(self, clipboard_mock):
item = BeePixmapItem(QtGui.QImage(self.imgfilename3x3))
self.view.scene.addItem(item)
item.setSelected(True)
mimedata = QtCore.QMimeData()
clipboard_mock.return_value.mimeData.return_value = mimedata
self.view.on_action_copy()
clipboard_mock.return_value.setPixmap.assert_called_once()
self.view.scene.internal_clipboard == [item]
assert mimedata.data('beeref/items') == b'1'
@patch('beeref.scene.BeeGraphicsScene.clearSelection')
@patch('PyQt6.QtGui.QClipboard.image')
def test_on_action_paste(self, clipboard_mock, clear_mock):
def test_on_action_paste_external(self, clipboard_mock, clear_mock):
clipboard_mock.return_value = QtGui.QImage(self.imgfilename3x3)
self.view.on_action_paste()
assert len(self.view.scene.items()) == 1
assert self.view.scene.items()[0].isSelected() is True
@patch('beeref.scene.BeeGraphicsScene.clearSelection')
@patch('PyQt6.QtGui.QClipboard.mimeData')
def test_on_action_paste_internal(self, mimedata_mock, clear_mock):
mimedata = QtCore.QMimeData()
mimedata.setData('beeref/items', QtCore.QByteArray.number(1))
mimedata_mock.return_value = mimedata
item = BeePixmapItem(QtGui.QImage())
self.view.scene.internal_clipboard = [item]
self.view.on_action_paste()
assert len(self.view.scene.items()) == 1
assert self.view.scene.items()[0].isSelected() is True
clear_mock.assert_called_once_with()
@patch('beeref.scene.BeeGraphicsScene.clearSelection')
@ -338,6 +363,24 @@ class BeeGraphicsViewTestCase(ViewBaseTestCase):
assert len(self.view.scene.items()) == 0
clear_mock.assert_not_called()
@patch('beeref.view.BeeGraphicsView.on_action_copy')
def test_on_action_cut(self, copy_mock):
item = BeePixmapItem(QtGui.QImage())
self.view.scene.addItem(item)
item.setSelected(True)
self.view.on_action_cut()
copy_mock.assert_called_once_with()
assert self.view.scene.items() == []
assert self.view.undo_stack.isClean() is False
def test_on_action_delete_items(self):
item = BeePixmapItem(QtGui.QImage())
self.view.scene.addItem(item)
item.setSelected(True)
self.view.on_action_delete_items()
assert self.view.scene.items() == []
assert self.view.undo_stack.isClean() is False
class UpdateWindowTitleTestCase(ViewBaseTestCase):