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>
124 lines
4.7 KiB
Python
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)
|