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

124 lines
4.7 KiB
Python

"""NodeItem: renders a node's SVG symbol, title and ports; draggable/selectable."""
from __future__ import annotations
from PyQt6.QtCore import QRectF, Qt, QPointF
from PyQt6.QtGui import QColor, QFont, QPen
from PyQt6.QtWidgets import QGraphicsObject, QGraphicsItem, QStyle
from ..model.node import NodeModel
from . import svg_cache
from .port_item import PortItem
LABEL_HEIGHT = 18.0
class NodeItem(QGraphicsObject):
"""Graphics item bound to a :class:`NodeModel`."""
def __init__(self, scene_ref, node: NodeModel) -> None:
super().__init__()
self._scene = scene_ref
self.node = node
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.setAcceptHoverEvents(True)
self.setZValue(1)
self.setPos(node.x, node.y)
self._renderer = svg_cache.renderer(node.svg_name)
self._port_items: dict[str, PortItem] = {}
self._press_pos: QPointF | None = None
self._build_ports()
# -- helpers used by children ----------------------------------------
def scene_relations(self):
return getattr(self._scene, "document", None) and self._scene.document.relations
def port_item(self, port_id: str) -> PortItem | None:
return self._port_items.get(port_id)
def port_items(self):
return self._port_items.values()
def _build_ports(self) -> None:
for p in self.node.ports:
self._port_items[p.port_id] = PortItem(self, p)
def refresh_ports(self) -> None:
for pi in self._port_items.values():
pi.update_position()
# -- geometry ---------------------------------------------------------
def body_rect(self) -> QRectF:
return QRectF(0, 0, self.node.width, self.node.height)
def boundingRect(self) -> QRectF:
m = 6
return QRectF(-m, -m, self.node.width + 2 * m,
self.node.height + LABEL_HEIGHT + 2 * m)
def set_model_size(self, w: float, h: float) -> None:
self.prepareGeometryChange()
self.node.width = w
self.node.height = h
self.refresh_ports()
self.update()
# -- interaction ------------------------------------------------------
def mousePressEvent(self, event):
self._press_pos = self.pos()
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
if self._press_pos is not None and self.pos() != self._press_pos:
old = (self._press_pos.x(), self._press_pos.y())
new = (self.pos().x(), self.pos().y())
self._scene.on_node_move_finished(self.node.node_id, old, new)
self._press_pos = None
def itemChange(self, change, value):
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.scene():
cfg = getattr(self._scene, "config", None)
if cfg is not None and cfg.snap_to_grid:
value = QPointF(cfg.snap(value.x()), cfg.snap(value.y()))
return value
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.node.x = self.pos().x()
self.node.y = self.pos().y()
self._scene.reroute_edges_for_node(self.node.node_id)
if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
self.update()
return super().itemChange(change, value)
# -- painting ---------------------------------------------------------
def paint(self, painter, option, widget=None):
painter.setRenderHint(painter.RenderHint.Antialiasing, True)
body = self.body_rect()
selected = bool(option.state & QStyle.StateFlag.State_Selected)
if selected:
pen = QPen(QColor("#1976d2"), 1.5, Qt.PenStyle.DashLine)
painter.setPen(pen)
painter.setBrush(QColor(25, 118, 210, 30))
painter.drawRoundedRect(body.adjusted(-3, -3, 3, 3), 4, 4)
if self._renderer is not None:
self._renderer.render(painter, body)
else:
painter.setPen(QPen(QColor("#555"), 2))
painter.setBrush(QColor("#eceff1"))
painter.drawRoundedRect(body, 6, 6)
# title label
title = self.node.title
if title:
painter.setPen(QColor("#263238"))
f = QFont()
f.setPointSizeF(8.5)
painter.setFont(f)
rect = QRectF(-10, self.node.height + 2, self.node.width + 20, LABEL_HEIGHT)
painter.drawText(rect, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, title)