Ilya af66debcde feat(view): graphics scene, node/port/edge items, undoable controller
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>
2026-07-02 23:20:09 +02:00

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