diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py index f806f65..af6a59e 100644 --- a/beeref/actions/actions.py +++ b/beeref/actions/actions.py @@ -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', diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py index 57af80b..8bfd76b 100644 --- a/beeref/actions/menu_structure.py +++ b/beeref/actions/menu_structure.py @@ -41,6 +41,8 @@ menu_structure = [ 'select_all', 'deselect_all', MENU_SEPARATOR, + 'cut', + 'copy', 'paste', 'delete', ], diff --git a/beeref/commands.py b/beeref/commands.py index d355a36..6b01b73 100644 --- a/beeref/commands.py +++ b/beeref/commands.py @@ -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): diff --git a/beeref/items.py b/beeref/items.py index 862279b..81c26d5 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -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 diff --git a/beeref/scene.py b/beeref/scene.py index 479bfe1..642690e 100644 --- a/beeref/scene.py +++ b/beeref/scene.py @@ -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 diff --git a/beeref/view.py b/beeref/view.py index 5991082..6853f12 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -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') diff --git a/tests/test_commands.py b/tests/test_commands.py index 9b51c72..a6ad7bf 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -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): diff --git a/tests/test_items.py b/tests/test_items.py index 7f8d4d4..9ab02c3 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -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 diff --git a/tests/test_scene.py b/tests/test_scene.py index 4926d16..d29927b 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -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 diff --git a/tests/test_view.py b/tests/test_view.py index 7167740..835ea5e 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -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):