mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
Copy/Cut + internal pasting
This commit is contained in:
parent
780b9f414d
commit
8425f9bbc1
10 changed files with 216 additions and 12 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ menu_structure = [
|
|||
'select_all',
|
||||
'deselect_all',
|
||||
MENU_SEPARATOR,
|
||||
'cut',
|
||||
'copy',
|
||||
'paste',
|
||||
'delete',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue