diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..720a829 Binary files /dev/null and b/docs/screenshot.png differ diff --git a/pipeline_editor/controller.py b/pipeline_editor/controller.py index c152e78..0a6a869 100644 --- a/pipeline_editor/controller.py +++ b/pipeline_editor/controller.py @@ -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 diff --git a/pipeline_editor/main_window.py b/pipeline_editor/main_window.py index 8244bfd..529ac0d 100644 --- a/pipeline_editor/main_window.py +++ b/pipeline_editor/main_window.py @@ -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 diff --git a/tests/test_app.py b/tests/test_app.py index 9f46910..6adfead 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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"