Ilya 35a465f088 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>
2026-07-02 23:26:52 +02:00

273 lines
8.9 KiB
Python

"""EditorController: high-level, undoable editing operations.
All user edits go through here so they are uniformly undoable and reusable from
both the UI and tests. The controller owns a ``QUndoStack``; the scene reacts to
the resulting document signals.
"""
from __future__ import annotations
from typing import Optional
from PyQt6.QtGui import QUndoCommand, QUndoStack
from .model.document import DiagramDocument
from .model.edge import EdgeModel
from .model.node import NodeModel
from .model.node_library import NodeLibrary
# -- commands -------------------------------------------------------------
class AddNodeCommand(QUndoCommand):
def __init__(self, doc: DiagramDocument, node: NodeModel):
super().__init__(f"Add {node.title or node.template_key}")
self._doc = doc
self._node = node
def redo(self):
if self._doc.node(self._node.node_id) is None:
self._doc.add_node(self._node)
def undo(self):
self._doc.remove_node(self._node.node_id)
class AddEdgeCommand(QUndoCommand):
def __init__(self, doc: DiagramDocument, edge: EdgeModel):
super().__init__("Connect")
self._doc = doc
self._edge = edge
def redo(self):
if self._doc.edge(self._edge.edge_id) is None:
self._doc.add_edge(self._edge)
def undo(self):
self._doc.remove_edge(self._edge.edge_id)
class DeleteCommand(QUndoCommand):
"""Delete nodes and/or edges, restoring them (and cascaded edges) on undo."""
def __init__(self, doc: DiagramDocument, node_ids, edge_ids):
super().__init__("Delete")
self._doc = doc
node_ids = set(node_ids)
# any edge touching a deleted node is implicitly deleted too
edge_ids = set(edge_ids)
for e in doc.edges():
if e.source_node in node_ids or e.target_node in node_ids:
edge_ids.add(e.edge_id)
self._nodes = [doc.node(nid).to_dict() for nid in node_ids if doc.node(nid)]
self._edges = [doc.edge(eid).to_dict() for eid in edge_ids if doc.edge(eid)]
def redo(self):
for e in self._edges:
self._doc.remove_edge(e["id"])
for n in self._nodes:
self._doc.remove_node(n["id"])
def undo(self):
for n in self._nodes:
if self._doc.node(n["id"]) is None:
self._doc.add_node(NodeModel.from_dict(n))
for e in self._edges:
if self._doc.edge(e["id"]) is None:
self._doc.add_edge(EdgeModel.from_dict(e))
class MoveNodeCommand(QUndoCommand):
def __init__(self, doc: DiagramDocument, node_id: str, old, new):
super().__init__("Move")
self._doc = doc
self._id = node_id
self._old = old
self._new = new
def _apply(self, pos):
node = self._doc.node(self._id)
if node is not None:
node.x, node.y = pos
self._doc.notify_node_changed(self._id)
def redo(self):
self._apply(self._new)
def undo(self):
self._apply(self._old)
def id(self): # enable merging of consecutive moves of the same node
return 0xC0FFEE
def mergeWith(self, other: "QUndoCommand") -> bool:
if isinstance(other, MoveNodeCommand) and other._id == self._id:
self._new = other._new
return True
return False
class SetGeometryCommand(QUndoCommand):
"""Undoable edit of a node geometry attribute (x/y/width/height/rotation)."""
_FIELDS = ("x", "y", "width", "height", "rotation")
def __init__(self, doc: DiagramDocument, node_id: str, field: str, old, new):
super().__init__(f"Edit {field}")
self._doc = doc
self._id = node_id
self._field = field
self._old = old
self._new = new
def _apply(self, value):
node = self._doc.node(self._id)
if node is not None and self._field in self._FIELDS:
setattr(node, self._field, float(value))
self._doc.notify_node_changed(self._id)
def redo(self):
self._apply(self._new)
def undo(self):
self._apply(self._old)
class SetPropertyCommand(QUndoCommand):
def __init__(self, doc: DiagramDocument, kind: str, owner_id: str,
key: str, old, new):
super().__init__(f"Edit {key}")
self._doc = doc
self._kind = kind
self._owner_id = owner_id
self._key = key
self._old = old
self._new = new
def _owner(self):
return (self._doc.node(self._owner_id) if self._kind == "node"
else self._doc.edge(self._owner_id))
def _apply(self, value):
owner = self._owner()
if owner is None:
return
owner.properties.set_value(self._key, value)
if self._kind == "node":
self._doc.notify_node_changed(self._owner_id)
else:
self._doc.notify_edge_changed(self._owner_id)
def redo(self):
self._apply(self._new)
def undo(self):
self._apply(self._old)
# -- controller -----------------------------------------------------------
class EditorController:
def __init__(self, document: DiagramDocument, library: NodeLibrary):
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)
if tmpl is None:
return None
node = tmpl.instantiate(self.document.next_id("n"), x, y)
self.undo_stack.push(AddNodeCommand(self.document, node))
return node
def connect(self, s_node, s_port, t_node, t_port) -> Optional[EdgeModel]:
if not self.document.can_connect(s_node, s_port, t_node, t_port):
return None
edge = EdgeModel(self.document.next_id("e"), s_node, s_port, t_node, t_port)
self.undo_stack.push(AddEdgeCommand(self.document, edge))
return edge
def delete(self, node_ids=(), edge_ids=()) -> None:
node_ids = list(node_ids)
edge_ids = list(edge_ids)
if not node_ids and not edge_ids:
return
self.undo_stack.push(DeleteCommand(self.document, node_ids, edge_ids))
def duplicate(self, node_ids, offset=(20, 20)) -> list:
"""Clone the given nodes (offset a little) as one undoable step."""
clones = []
ids = list(node_ids)
if not ids:
return clones
self.undo_stack.beginMacro("Duplicate")
for nid in ids:
node = self.document.node(nid)
if node is None:
continue
clone = node.clone(self.document.next_id("n"))
clone.x += offset[0]
clone.y += offset[1]
self.undo_stack.push(AddNodeCommand(self.document, clone))
clones.append(clone)
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
self.undo_stack.push(MoveNodeCommand(self.document, node_id, old, new))
def set_property(self, kind: str, owner_id: str, key: str, value) -> None:
owner = (self.document.node(owner_id) if kind == "node"
else self.document.edge(owner_id))
if owner is None:
return
prop = owner.properties.find(key)
if prop is None:
return
old = prop.value
if old == value:
return
self.undo_stack.push(SetPropertyCommand(self.document, kind, owner_id, key, old, value))
def set_geometry(self, node_id: str, field: str, value) -> None:
node = self.document.node(node_id)
if node is None or not hasattr(node, field):
return
old = getattr(node, field)
if float(old) == float(value):
return
self.undo_stack.push(SetGeometryCommand(self.document, node_id, field, old, value))
# convenience
def undo(self):
self.undo_stack.undo()
def redo(self):
self.undo_stack.redo()