Add the QGraphicsView layer: NodeItem (SVG symbol + title + draggable, snap-to-grid), PortItem (relation-colored anchors with compatibility hints), EdgeItem (orthogonal routing, arc hops on crossings, line styles, head/tail decorations, animated flow dashes), and DiagramScene binding the document to items with edge-drawing interaction and a grid background. Add EditorController with a QUndoStack (add/connect/delete/move/edit) and scene interaction tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
281 lines
10 KiB
Python
281 lines
10 KiB
Python
"""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
|