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>
273 lines
8.9 KiB
Python
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()
|