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:
parent
ae3f752b6f
commit
35a465f088
BIN
docs/screenshot.png
Normal file
BIN
docs/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user