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.document = document
|
||||||
self.library = library
|
self.library = library
|
||||||
self.undo_stack = QUndoStack()
|
self.undo_stack = QUndoStack()
|
||||||
|
self._clipboard: list[dict] = []
|
||||||
|
|
||||||
def add_node(self, template_key: str, x: float, y: float) -> Optional[NodeModel]:
|
def add_node(self, template_key: str, x: float, y: float) -> Optional[NodeModel]:
|
||||||
tmpl = self.library.get(template_key)
|
tmpl = self.library.get(template_key)
|
||||||
@ -211,6 +212,31 @@ class EditorController:
|
|||||||
self.undo_stack.endMacro()
|
self.undo_stack.endMacro()
|
||||||
return clones
|
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:
|
def record_move(self, node_id: str, old, new) -> None:
|
||||||
if old == new:
|
if old == new:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -97,6 +97,10 @@ class MainWindow(QMainWindow):
|
|||||||
triggered=self.delete_selection)
|
triggered=self.delete_selection)
|
||||||
self.act_dup = QAction("Du&plicate", self, shortcut="Ctrl+D",
|
self.act_dup = QAction("Du&plicate", self, shortcut="Ctrl+D",
|
||||||
triggered=self.duplicate_selection)
|
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,
|
self.act_zoom_in = QAction("Zoom In", self, shortcut=QKeySequence.StandardKey.ZoomIn,
|
||||||
triggered=lambda: self.view.scale(1.2, 1.2))
|
triggered=lambda: self.view.scale(1.2, 1.2))
|
||||||
@ -128,7 +132,7 @@ class MainWindow(QMainWindow):
|
|||||||
em = m.addMenu("&Edit")
|
em = m.addMenu("&Edit")
|
||||||
em.addActions([self.act_undo, self.act_redo])
|
em.addActions([self.act_undo, self.act_redo])
|
||||||
em.addSeparator()
|
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 = m.addMenu("&View")
|
||||||
vm.addActions([self.act_zoom_in, self.act_zoom_out, self.act_fit])
|
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()
|
node_ids, _ = self._selected_ids()
|
||||||
self.controller.duplicate(node_ids, offset=(self.config.grid_size, self.config.grid_size))
|
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 -----------------------------------------------------
|
# -- view toggles -----------------------------------------------------
|
||||||
def _toggle_grid(self, checked) -> None:
|
def _toggle_grid(self, checked) -> None:
|
||||||
self.config.show_grid = checked
|
self.config.show_grid = checked
|
||||||
|
|||||||
@ -59,6 +59,18 @@ def test_duplicate_selection(win):
|
|||||||
assert len(list(win.document.nodes())) == n_before + 1
|
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):
|
def test_save_and_open_roundtrip(win, tmp_path):
|
||||||
win.load_sample()
|
win.load_sample()
|
||||||
path = tmp_path / "diagram.pipe"
|
path = tmp_path / "diagram.pipe"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user