"""DiagramScene: binds the document to graphics items and handles interaction.""" from __future__ import annotations from typing import Optional from PyQt6.QtCore import QPointF, Qt, pyqtSignal from PyQt6.QtGui import QColor, QPainter, QPen from PyQt6.QtWidgets import QGraphicsPathItem, QGraphicsScene from PyQt6.QtGui import QPainterPath from ..config import AppConfig from ..model.document import DiagramDocument from ..model.edge import EdgeModel from .edge_item import EdgeItem from .node_item import NodeItem from .port_item import PortItem from . import routing class DiagramScene(QGraphicsScene): """Keeps :class:`NodeItem`/:class:`EdgeItem` in sync with a document.""" # Emitted with the selected NodeModel / EdgeModel, or None. selection_changed = pyqtSignal(object) def __init__(self, document: DiagramDocument, config: AppConfig, controller=None, parent=None) -> None: super().__init__(parent) self.document = document self.config = config self.controller = controller self.node_items: dict[str, NodeItem] = {} self.edge_items: dict[str, EdgeItem] = {} self.setSceneRect(-2000, -2000, 4000, 4000) self._drag_port: Optional[PortItem] = None self._temp_edge: Optional[QGraphicsPathItem] = None self._connect_document() self._connect_config() self.selectionChanged.connect(self._on_selection_changed) self.rebuild() # -- document wiring -------------------------------------------------- def _connect_document(self) -> None: d = self.document d.node_added.connect(self._on_node_added) d.node_removed.connect(self._on_node_removed) d.node_changed.connect(self._on_node_changed) d.edge_added.connect(self._on_edge_added) d.edge_removed.connect(self._on_edge_removed) d.edge_changed.connect(self._on_edge_changed) d.cleared.connect(self.rebuild) def _connect_config(self) -> None: self.config.changed.connect(self._on_config_changed) def rebuild(self) -> None: for it in list(self.node_items.values()) + list(self.edge_items.values()): self.removeItem(it) self.node_items.clear() self.edge_items.clear() for node in self.document.nodes(): self._on_node_added(node.node_id) for edge in self.document.edges(): self._on_edge_added(edge.edge_id) self.update() def _on_node_added(self, node_id: str) -> None: node = self.document.node(node_id) if node is None or node_id in self.node_items: return item = NodeItem(self, node) self.node_items[node_id] = item self.addItem(item) def _on_node_removed(self, node_id: str) -> None: item = self.node_items.pop(node_id, None) if item is not None: self.removeItem(item) def _on_node_changed(self, node_id: str) -> None: item = self.node_items.get(node_id) if item is None: return if item.pos() != QPointF(item.node.x, item.node.y): item.setPos(item.node.x, item.node.y) item.refresh_ports() item.update() self.reroute_edges_for_node(node_id) def _on_edge_added(self, edge_id: str) -> None: edge = self.document.edge(edge_id) if edge is None or edge_id in self.edge_items: return item = EdgeItem(self, edge) self.edge_items[edge_id] = item self.addItem(item) def _on_edge_removed(self, edge_id: str) -> None: item = self.edge_items.pop(edge_id, None) if item is not None: self.removeItem(item) def _on_edge_changed(self, edge_id: str) -> None: item = self.edge_items.get(edge_id) if item is not None: item.reroute() item.update() def _on_config_changed(self) -> None: for item in self.edge_items.values(): item.reroute() self.update() # -- geometry services for items ------------------------------------- def port_scene_point(self, node_id: str, port_id: str) -> Optional[QPointF]: item = self.node_items.get(node_id) if item is not None: pi = item.port_item(port_id) if pi is not None: return pi.scene_center() node = self.document.node(node_id) if node is not None: pt = node.port_scene_point(port_id) if pt is not None: return QPointF(*pt) return None def edge_endpoints(self, edge: EdgeModel): s = self.port_scene_point(edge.source_node, edge.source_port) t = self.port_scene_point(edge.target_node, edge.target_port) if s is None or t is None: return None sn = self.document.node(edge.source_node) tn = self.document.node(edge.target_node) snorm = sn.port(edge.source_port).normal() if sn else (1, 0) tnorm = tn.port(edge.target_port).normal() if tn else (-1, 0) return ((s.x(), s.y()), snorm), ((t.x(), t.y()), tnorm) def other_edge_segments(self, edge_id: str): segs = [] for eid, item in self.edge_items.items(): if eid == edge_id: continue segs.extend(routing.polyline_segments(item.path_points())) return segs def reroute_edges_for_node(self, node_id: str) -> None: for edge in self.document.edges_for_node(node_id): item = self.edge_items.get(edge.edge_id) if item is not None: item.reroute() def on_node_move_finished(self, node_id: str, old, new) -> None: if self.controller is not None: self.controller.record_move(node_id, old, new) # -- selection -------------------------------------------------------- def _on_selection_changed(self) -> None: model = None items = self.selectedItems() if len(items) == 1: it = items[0] if isinstance(it, NodeItem): model = it.node elif isinstance(it, EdgeItem): model = it.edge self.selection_changed.emit(model) # -- node creation from a template (drop / programmatic) -------------- def create_node(self, template_key: str, scene_x: float, scene_y: float): x, y = self.config.snap_point(scene_x, scene_y) if self.controller is not None: return self.controller.add_node(template_key, x, y) return None # -- edge drawing interaction ---------------------------------------- def _port_at(self, scene_pos: QPointF) -> Optional[PortItem]: for it in self.items(scene_pos): if isinstance(it, PortItem): return it return None def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: port = self._port_at(event.scenePos()) if port is not None: self._begin_edge(port) event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event): if self._drag_port is not None: self._update_temp_edge(event.scenePos()) event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): if self._drag_port is not None: target = self._port_at(event.scenePos()) self._finish_edge(target) event.accept() return super().mouseReleaseEvent(event) def _begin_edge(self, port: PortItem) -> None: self._drag_port = port self._temp_edge = QGraphicsPathItem() pen = QPen(QColor("#1976d2"), 2, Qt.PenStyle.DashLine) self._temp_edge.setPen(pen) self._temp_edge.setZValue(10) self.addItem(self._temp_edge) self._highlight_compatible(port, True) def _update_temp_edge(self, scene_pos: QPointF) -> None: if self._temp_edge is None or self._drag_port is None: return start = self._drag_port.scene_center() path = QPainterPath(start) # simple elbow preview mid = QPointF(scene_pos.x(), start.y()) path.lineTo(mid) path.lineTo(scene_pos) self._temp_edge.setPath(path) def _finish_edge(self, target: Optional[PortItem]) -> None: source = self._drag_port self._highlight_compatible(source, False) if self._temp_edge is not None: self.removeItem(self._temp_edge) self._temp_edge = None self._drag_port = None if source is None or target is None or target is source: return s_node = source.node_item.node.node_id s_port = source.port.port_id t_node = target.node_item.node.node_id t_port = target.port.port_id if not self.document.can_connect(s_node, s_port, t_node, t_port): return if self.controller is not None: self.controller.connect(s_node, s_port, t_node, t_port) def _highlight_compatible(self, source: PortItem, on: bool) -> None: if not on: for ni in self.node_items.values(): for pi in ni.port_items(): pi.set_compatible_hint(False) return s_node = source.node_item.node.node_id s_port = source.port.port_id for ni in self.node_items.values(): for pi in ni.port_items(): if pi is source: continue ok = self.document.can_connect( s_node, s_port, ni.node.node_id, pi.port.port_id) pi.set_compatible_hint(ok) # -- grid background -------------------------------------------------- def drawBackground(self, painter: QPainter, rect) -> None: painter.fillRect(rect, QColor(self.config.background)) if not self.config.show_grid or self.config.grid_size <= 0: return g = self.config.grid_size left = int(rect.left()) - (int(rect.left()) % g) top = int(rect.top()) - (int(rect.top()) % g) pen = QPen(QColor(self.config.grid_color), 0) painter.setPen(pen) x = left while x < rect.right(): painter.drawLine(int(x), int(rect.top()), int(x), int(rect.bottom())) x += g y = top while y < rect.bottom(): painter.drawLine(int(rect.left()), int(y), int(rect.right()), int(y)) y += g