feat(edit): clipboard copy/paste of nodes

Add an internal clipboard on the controller with undoable macro paste, and
Copy/Paste menu actions and shortcuts in the main window. Covered by a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilya 2026-07-02 23:26:52 +02:00
parent ae3f752b6f
commit 35a465f088
4 changed files with 52 additions and 1 deletions

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -169,6 +169,7 @@ class EditorController:
self.document = document
self.library = library
self.undo_stack = QUndoStack()
self._clipboard: list[dict] = []
def add_node(self, template_key: str, x: float, y: float) -> Optional[NodeModel]:
tmpl = self.library.get(template_key)
@ -211,6 +212,31 @@ class EditorController:
self.undo_stack.endMacro()
return clones
def copy(self, node_ids) -> int:
"""Snapshot the given nodes into the internal clipboard."""
self._clipboard = [self.document.node(nid).to_dict()
for nid in node_ids if self.document.node(nid)]
return len(self._clipboard)
def paste(self, offset=(20, 20)) -> list:
"""Recreate clipboard nodes with fresh ids as one undoable step."""
pasted = []
if not self._clipboard:
return pasted
self.undo_stack.beginMacro("Paste")
for data in self._clipboard:
node = NodeModel.from_dict(data)
fresh = node.clone(self.document.next_id("n"))
fresh.x += offset[0]
fresh.y += offset[1]
self.undo_stack.push(AddNodeCommand(self.document, fresh))
pasted.append(fresh)
self.undo_stack.endMacro()
return pasted
def can_paste(self) -> bool:
return bool(self._clipboard)
def record_move(self, node_id: str, old, new) -> None:
if old == new:
return

View File

@ -97,6 +97,10 @@ class MainWindow(QMainWindow):
triggered=self.delete_selection)
self.act_dup = QAction("Du&plicate", self, shortcut="Ctrl+D",
triggered=self.duplicate_selection)
self.act_copy = QAction("&Copy", self, shortcut=QKeySequence.StandardKey.Copy,
triggered=self.copy_selection)
self.act_paste = QAction("&Paste", self, shortcut=QKeySequence.StandardKey.Paste,
triggered=self.paste_clipboard)
self.act_zoom_in = QAction("Zoom In", self, shortcut=QKeySequence.StandardKey.ZoomIn,
triggered=lambda: self.view.scale(1.2, 1.2))
@ -128,7 +132,7 @@ class MainWindow(QMainWindow):
em = m.addMenu("&Edit")
em.addActions([self.act_undo, self.act_redo])
em.addSeparator()
em.addActions([self.act_delete, self.act_dup])
em.addActions([self.act_copy, self.act_paste, self.act_dup, self.act_delete])
vm = m.addMenu("&View")
vm.addActions([self.act_zoom_in, self.act_zoom_out, self.act_fit])
@ -191,6 +195,15 @@ class MainWindow(QMainWindow):
node_ids, _ = self._selected_ids()
self.controller.duplicate(node_ids, offset=(self.config.grid_size, self.config.grid_size))
def copy_selection(self) -> None:
node_ids, _ = self._selected_ids()
n = self.controller.copy(node_ids)
self.statusBar().showMessage(f"Copied {n} node(s)")
def paste_clipboard(self) -> None:
g = self.config.grid_size
self.controller.paste(offset=(g, g))
# -- view toggles -----------------------------------------------------
def _toggle_grid(self, checked) -> None:
self.config.show_grid = checked

View File

@ -59,6 +59,18 @@ def test_duplicate_selection(win):
assert len(list(win.document.nodes())) == n_before + 1
def test_copy_paste(win):
win.load_sample()
node = next(win.document.nodes())
win.scene.node_items[node.node_id].setSelected(True)
n_before = len(list(win.document.nodes()))
win.copy_selection()
win.paste_clipboard()
assert len(list(win.document.nodes())) == n_before + 1
win.controller.undo() # paste is one macro step
assert len(list(win.document.nodes())) == n_before
def test_save_and_open_roundtrip(win, tmp_path):
win.load_sample()
path = tmp_path / "diagram.pipe"