"""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() 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 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()