1488 lines
60 KiB
Python
1488 lines
60 KiB
Python
import json
|
|
|
|
from PySide6.QtCore import QMimeData, QPointF, QRectF, Qt, Signal
|
|
from PySide6.QtGui import (
|
|
QColor,
|
|
QDragEnterEvent,
|
|
QDropEvent,
|
|
QMouseEvent,
|
|
QWheelEvent,
|
|
QPainter,
|
|
QPainterPath,
|
|
QPen,
|
|
QPolygonF,
|
|
QTransform,
|
|
)
|
|
from PySide6.QtWidgets import (
|
|
QGraphicsEllipseItem,
|
|
QGraphicsItem,
|
|
QGraphicsObject,
|
|
QGraphicsPathItem,
|
|
QGraphicsScene,
|
|
QGraphicsSceneContextMenuEvent,
|
|
QGraphicsSceneMouseEvent,
|
|
QGraphicsView,
|
|
QApplication,
|
|
QMenu,
|
|
QInputDialog,
|
|
QStyleOptionGraphicsItem,
|
|
QToolTip,
|
|
QWidget,
|
|
)
|
|
|
|
from bedit.core.model import Annotation, Component, Connection, Endpoint, Port
|
|
from bedit.gui.controllers.document import DocumentController
|
|
from bedit.gui.models.library_tree import COMPONENT_MIME_TYPE
|
|
from bedit.gui.graphics.icon_renderer import icon_bounds, paint_icon
|
|
from bedit.gui.graphics.icon_editor import ShapeOptionsDialog
|
|
from bedit.gui.graphics.icon_renderer import shape_pen
|
|
from bedit.gui.graphics.connection_styles import ConnectionStyle, connection_style
|
|
from bedit.gui.preferences import application_settings
|
|
|
|
|
|
SELECTION_MIME_TYPE = "application/x-bedit-selection"
|
|
|
|
|
|
def _graph_snap_size() -> int:
|
|
return application_settings().value("grid/graphSnapSize", 8, type=int)
|
|
|
|
|
|
def _graph_grid_size() -> int:
|
|
return application_settings().value("grid/graphSize", 64, type=int)
|
|
|
|
|
|
def _snapped(position: QPointF) -> QPointF:
|
|
grid = _graph_snap_size()
|
|
return QPointF(round(position.x() / grid) * grid, round(position.y() / grid) * grid)
|
|
|
|
|
|
class ConnectionPortItem(QGraphicsEllipseItem):
|
|
def __init__(self, endpoint: Endpoint, role: str, label: str, parent: QGraphicsItem) -> None:
|
|
super().__init__(-6, -6, 12, 12, parent)
|
|
self.endpoint = endpoint
|
|
self.role = role
|
|
self.setBrush(QColor("#ffffff"))
|
|
self.setPen(QPen(QColor("#303030"), 1.5))
|
|
self.setZValue(2)
|
|
self.setToolTip(label)
|
|
|
|
|
|
class ComponentGraphicsItem(QGraphicsObject):
|
|
WIDTH = 128.0
|
|
HEIGHT = 128.0
|
|
|
|
def __init__(self, component: Component, controller: DocumentController) -> None:
|
|
super().__init__()
|
|
self.component_id = component.id
|
|
self.component = component
|
|
self.controller = controller
|
|
self.drag_start = QPointF()
|
|
self.hitbox = icon_bounds(component.icon)
|
|
self.setFlags(
|
|
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
|
|
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
|
|
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
|
)
|
|
self.input_ports = self._create_ports(component.inputs, "target", 0.0)
|
|
self.output_ports = self._create_ports(component.outputs, "source", self.WIDTH)
|
|
self.setTransformOriginPoint(self.hitbox.center())
|
|
self.setRotation(component.rotation)
|
|
|
|
def _create_ports(self, ports, role: str, x: float) -> dict[str, ConnectionPortItem]:
|
|
result = {}
|
|
spacing = self.HEIGHT / (len(ports) + 1)
|
|
for index, port in enumerate(ports, start=1):
|
|
endpoint = Endpoint(block=self.component_id, port=port.id)
|
|
item = ConnectionPortItem(endpoint, role, port.name, self)
|
|
position = port.properties.get("iconPosition", {})
|
|
item.setPos(
|
|
float(position.get("x", x)) * self.WIDTH / self.component.icon.width,
|
|
float(position.get("y", spacing * index))
|
|
* self.HEIGHT
|
|
/ self.component.icon.height,
|
|
)
|
|
result[port.id] = item
|
|
return result
|
|
|
|
def boundingRect(self) -> QRectF: # noqa: N802
|
|
return self.hitbox
|
|
|
|
def paint(
|
|
self,
|
|
painter: QPainter,
|
|
option: QStyleOptionGraphicsItem,
|
|
widget: QWidget | None = None,
|
|
) -> None:
|
|
del option, widget
|
|
paint_icon(
|
|
painter,
|
|
self.component.icon,
|
|
QRectF(0, 0, self.WIDTH, self.HEIGHT),
|
|
)
|
|
if self.isSelected():
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.setPen(QPen(QColor("#2563eb"), 2, Qt.PenStyle.DashLine))
|
|
painter.drawRect(self.boundingRect())
|
|
|
|
def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
self.controller.activate_component(self.component_id)
|
|
event.accept()
|
|
|
|
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
|
|
if not self.isSelected():
|
|
scene = self.scene()
|
|
if scene is not None:
|
|
scene.clearSelection()
|
|
self.setSelected(True)
|
|
menu = QMenu()
|
|
options_action = menu.addAction("Component Options…")
|
|
ports_action = menu.addAction("Port Options…")
|
|
selected = menu.exec(event.screenPos())
|
|
if selected is options_action:
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
scene.componentOptionsRequested.emit(self.component_id)
|
|
elif selected is ports_action:
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
scene.componentPortOptionsRequested.emit(self.component_id)
|
|
event.accept()
|
|
|
|
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
self.drag_start = self.pos()
|
|
super().mousePressEvent(event)
|
|
|
|
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
super().mouseReleaseEvent(event)
|
|
snapped = _snapped(self.pos())
|
|
self.setPos(snapped)
|
|
self.controller.move_component(self.component_id, self.drag_start, snapped)
|
|
|
|
def itemChange(self, change, value): # noqa: N802
|
|
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
|
value = _snapped(value)
|
|
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
scene.update_connections_for_block(self.component_id)
|
|
return super().itemChange(change, value)
|
|
|
|
|
|
class InterfaceTerminalItem(QGraphicsObject):
|
|
WIDTH = 110.0
|
|
HEIGHT = 36.0
|
|
|
|
def __init__(self, port: Port, direction: str, controller: DocumentController) -> None:
|
|
super().__init__()
|
|
self.port = port
|
|
self.direction = direction
|
|
self.controller = controller
|
|
self.drag_start = QPointF()
|
|
role = "source" if direction == "input" else "target"
|
|
self.connection_port = ConnectionPortItem(
|
|
Endpoint(interface=port.id), role, port.name, self
|
|
)
|
|
connection_x = self.WIDTH if direction == "input" else 0.0
|
|
self.connection_port.setPos(connection_x, self.HEIGHT / 2)
|
|
self.setFlags(
|
|
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
|
|
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
|
|
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
|
)
|
|
self.setToolTip(f"Component {direction}: {port.name}")
|
|
|
|
def boundingRect(self) -> QRectF: # noqa: N802
|
|
return QRectF(0, 0, self.WIDTH, self.HEIGHT)
|
|
|
|
def paint(
|
|
self,
|
|
painter: QPainter,
|
|
option: QStyleOptionGraphicsItem,
|
|
widget: QWidget | None = None,
|
|
) -> None:
|
|
del option, widget
|
|
painter.setBrush(QColor("#e5e7eb"))
|
|
painter.setPen(QPen(QColor("#4b5563"), 1.5))
|
|
painter.drawRoundedRect(self.boundingRect(), 4, 4)
|
|
painter.setPen(QColor("#202020"))
|
|
marker = "IN" if self.direction == "input" else "OUT"
|
|
painter.drawText(
|
|
self.boundingRect().adjusted(8, 0, -8, 0),
|
|
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
|
|
f"{marker} {self.port.name}",
|
|
)
|
|
|
|
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
self.drag_start = self.pos()
|
|
super().mousePressEvent(event)
|
|
|
|
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
|
|
menu = QMenu()
|
|
options_action = menu.addAction(f"{self.direction.title()} Options…")
|
|
if menu.exec(event.screenPos()) is options_action:
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
scene.portOptionsRequested.emit(self.port.id, self.direction)
|
|
event.accept()
|
|
|
|
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
super().mouseReleaseEvent(event)
|
|
snapped = _snapped(self.pos())
|
|
self.setPos(snapped)
|
|
self.controller.move_interface_port(self.port.id, self.drag_start, snapped)
|
|
|
|
def itemChange(self, change, value): # noqa: N802
|
|
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
|
value = _snapped(value)
|
|
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
scene.update_connections_for_interface(self.port.id)
|
|
return super().itemChange(change, value)
|
|
|
|
|
|
class ConnectionGraphicsItem(QGraphicsPathItem):
|
|
def __init__(
|
|
self,
|
|
connection_id: str,
|
|
name: str = "",
|
|
style: ConnectionStyle | None = None,
|
|
) -> None:
|
|
super().__init__()
|
|
self.connection_id = connection_id
|
|
self.name = name
|
|
self.style = style or ConnectionStyle()
|
|
self.start = QPointF()
|
|
self.end = QPointF()
|
|
self.start_direction = QPointF(1, 0)
|
|
self.end_direction = QPointF(1, 0)
|
|
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
|
self._update_pen()
|
|
self.setZValue(0)
|
|
self.setToolTip(name or "Connection")
|
|
self.handles: list[WaypointHandle] = []
|
|
|
|
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
|
|
if not self.isSelected():
|
|
self.scene().clearSelection()
|
|
self.setSelected(True)
|
|
menu = QMenu()
|
|
add_node_action = menu.addAction("Add Node")
|
|
menu.addSeparator()
|
|
options_action = menu.addAction("Connection Options…")
|
|
selected = menu.exec(event.screenPos())
|
|
if selected is add_node_action:
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
scene.add_route_node("connection", self.connection_id, event.scenePos())
|
|
elif selected is options_action:
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
scene.connectionOptionsRequested.emit(self.connection_id)
|
|
event.accept()
|
|
|
|
def itemChange(self, change, value): # noqa: N802
|
|
result = super().itemChange(change, value)
|
|
if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
|
|
self._update_pen()
|
|
self._update_handles()
|
|
return result
|
|
|
|
def _update_pen(self) -> None:
|
|
self.setPen(
|
|
QPen(
|
|
QColor(self.style.selected_color if self.isSelected() else self.style.color),
|
|
self.style.selected_width if self.isSelected() else self.style.width,
|
|
self.style.line_style,
|
|
)
|
|
)
|
|
|
|
def set_connection_path(
|
|
self,
|
|
path: QPainterPath,
|
|
points: list[QPointF],
|
|
) -> None:
|
|
self.setPath(path)
|
|
self.start, self.end = points[0], points[-1]
|
|
if len(points) > 1:
|
|
self.start_direction = points[1] - points[0]
|
|
self.end_direction = points[-1] - points[-2]
|
|
|
|
def set_waypoints(self, points: list[QPointF]) -> None:
|
|
for handle in self.handles:
|
|
if handle.scene() is not None:
|
|
handle.scene().removeItem(handle)
|
|
self.handles = [
|
|
WaypointHandle("connection", self.connection_id, index, point)
|
|
for index, point in enumerate(points)
|
|
]
|
|
scene = self.scene()
|
|
if scene is not None:
|
|
for handle in self.handles:
|
|
scene.addItem(handle)
|
|
self._update_handles()
|
|
|
|
def _update_handles(self) -> None:
|
|
for handle in self.handles:
|
|
handle.setVisible(self.isSelected())
|
|
|
|
@staticmethod
|
|
def _arrow(end: QPointF, direction: QPointF, size: float) -> QPolygonF:
|
|
length = max(0.001, (direction.x() ** 2 + direction.y() ** 2) ** 0.5)
|
|
unit = QPointF(direction.x() / length, direction.y() / length)
|
|
normal = QPointF(-unit.y(), unit.x())
|
|
base = end - unit * size
|
|
return QPolygonF([end, base + normal * size * 0.45, base - normal * size * 0.45])
|
|
|
|
def paint(self, painter: QPainter, option, widget=None) -> None:
|
|
super().paint(painter, option, widget)
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(self.pen().color())
|
|
if self.style.arrow_at_target:
|
|
painter.drawPolygon(self._arrow(self.end, self.end_direction, self.style.arrow_size))
|
|
if self.style.arrow_at_source:
|
|
painter.drawPolygon(
|
|
self._arrow(self.start, -self.start_direction, self.style.arrow_size)
|
|
)
|
|
|
|
|
|
class WaypointHandle(QGraphicsEllipseItem):
|
|
def __init__(self, item_kind: str, item_id: str, index: int, position: QPointF) -> None:
|
|
super().__init__(-5, -5, 10, 10)
|
|
self.item_kind, self.item_id, self.index = item_kind, item_id, index
|
|
self.setPos(position)
|
|
self.setBrush(QColor("#ffffff"))
|
|
self.setPen(QPen(QColor("#2563eb"), 1.5))
|
|
self.setZValue(1000)
|
|
self.setFlags(
|
|
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
|
|
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
|
)
|
|
self.setCursor(Qt.CursorShape.SizeAllCursor)
|
|
self.drag_offset = QPointF()
|
|
|
|
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
owner = scene.route_graphics_item(self.item_kind, self.item_id)
|
|
if owner is not None:
|
|
owner.setSelected(True)
|
|
self.drag_offset = self.pos() - event.scenePos()
|
|
event.accept()
|
|
|
|
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
self.setPos(_snapped(event.scenePos() + self.drag_offset))
|
|
event.accept()
|
|
|
|
def itemChange(self, change, value): # noqa: N802
|
|
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
|
snapped = _snapped(value)
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
scene.preview_route_node(self.item_kind, self.item_id, self.index, snapped)
|
|
return snapped
|
|
return super().itemChange(change, value)
|
|
|
|
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
scene.move_route_node(self.item_kind, self.item_id, self.index, self.pos())
|
|
event.accept()
|
|
|
|
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
|
|
if self.index < 0:
|
|
event.accept()
|
|
return
|
|
menu = QMenu()
|
|
delete_action = menu.addAction("Delete Node")
|
|
if menu.exec(event.screenPos()) is delete_action:
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
scene.delete_route_node(self.item_kind, self.item_id, self.index)
|
|
event.accept()
|
|
|
|
|
|
class AnnotationResizeHandle(QGraphicsEllipseItem):
|
|
CURSORS = {
|
|
"n": Qt.CursorShape.SizeVerCursor,
|
|
"s": Qt.CursorShape.SizeVerCursor,
|
|
"e": Qt.CursorShape.SizeHorCursor,
|
|
"w": Qt.CursorShape.SizeHorCursor,
|
|
"nw": Qt.CursorShape.SizeFDiagCursor,
|
|
"se": Qt.CursorShape.SizeFDiagCursor,
|
|
"ne": Qt.CursorShape.SizeBDiagCursor,
|
|
"sw": Qt.CursorShape.SizeBDiagCursor,
|
|
}
|
|
|
|
def __init__(self, owner: "AnnotationGraphicsItem", role: str) -> None:
|
|
super().__init__(-4, -4, 8, 8, owner)
|
|
self.owner, self.role = owner, role
|
|
self.setBrush(QColor("#ffffff"))
|
|
self.setPen(QPen(QColor("#2563eb"), 1.5))
|
|
self.setCursor(self.CURSORS[role])
|
|
self.setZValue(1000)
|
|
|
|
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
self.owner.setSelected(True)
|
|
self.owner.begin_resize()
|
|
event.accept()
|
|
|
|
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
self.owner.resize_from_handle(self.role, event.scenePos())
|
|
event.accept()
|
|
|
|
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
self.owner.finish_resize()
|
|
event.accept()
|
|
|
|
|
|
class AnnotationGraphicsItem(QGraphicsObject):
|
|
def __init__(self, annotation: Annotation, controller: DocumentController) -> None:
|
|
super().__init__()
|
|
self.annotation = annotation
|
|
self.annotation_id = annotation.id
|
|
self.controller = controller
|
|
self.drag_start = QPointF(annotation.x, annotation.y)
|
|
self.resize_start: dict[str, float] | None = None
|
|
self.setPos(annotation.x, annotation.y)
|
|
self.setZValue(annotation.layer)
|
|
self.setFlags(
|
|
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
|
|
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
|
|
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
|
)
|
|
roles = ("n", "ne", "e", "se", "s", "sw", "w", "nw")
|
|
self.resize_handles = (
|
|
[AnnotationResizeHandle(self, role) for role in roles]
|
|
if annotation.kind == "box"
|
|
else []
|
|
)
|
|
self._position_resize_handles()
|
|
for handle in self.resize_handles:
|
|
handle.hide()
|
|
|
|
def boundingRect(self) -> QRectF: # noqa: N802
|
|
width, height = self.annotation.width, self.annotation.height
|
|
return QRectF(min(0, width), min(0, height), abs(width), abs(height)).adjusted(-4, -4, 4, 4)
|
|
|
|
def paint(self, painter: QPainter, option, widget=None) -> None:
|
|
del option, widget
|
|
rect = QRectF(0, 0, self.annotation.width, self.annotation.height).normalized()
|
|
painter.setPen(shape_pen(self.annotation.properties))
|
|
fill = self.annotation.properties.get("fill", "none")
|
|
painter.setBrush(
|
|
Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill)
|
|
)
|
|
if self.annotation.kind == "box":
|
|
radius = float(self.annotation.properties.get("cornerRadius", 0))
|
|
painter.drawRoundedRect(rect, radius, radius)
|
|
else:
|
|
painter.setPen(
|
|
QColor(
|
|
self.annotation.properties.get(
|
|
"color", self.annotation.properties.get("stroke", "#202020")
|
|
)
|
|
)
|
|
)
|
|
font = painter.font()
|
|
font.setPointSizeF(float(self.annotation.properties.get("fontSize", 12)))
|
|
painter.setFont(font)
|
|
painter.drawText(
|
|
rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop, self.annotation.text
|
|
)
|
|
if self.isSelected():
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.setPen(QPen(QColor("#2563eb"), 1, Qt.PenStyle.DashLine))
|
|
painter.drawRect(self.boundingRect())
|
|
|
|
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
self.drag_start = self.pos()
|
|
super().mousePressEvent(event)
|
|
|
|
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
super().mouseReleaseEvent(event)
|
|
new_pos = _snapped(self.pos())
|
|
self.setPos(new_pos)
|
|
old = {
|
|
"x": self.drag_start.x(),
|
|
"y": self.drag_start.y(),
|
|
"width": self.annotation.width,
|
|
"height": self.annotation.height,
|
|
}
|
|
new = {**old, "x": new_pos.x(), "y": new_pos.y()}
|
|
self.controller.set_annotation_geometry(self.annotation_id, old, new)
|
|
|
|
def itemChange(self, change, value): # noqa: N802
|
|
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
|
return _snapped(value)
|
|
if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
|
|
for handle in self.resize_handles:
|
|
handle.setVisible(bool(value))
|
|
return super().itemChange(change, value)
|
|
|
|
def _position_resize_handles(self) -> None:
|
|
width, height = self.annotation.width, self.annotation.height
|
|
positions = {
|
|
"n": QPointF(width / 2, 0),
|
|
"ne": QPointF(width, 0),
|
|
"e": QPointF(width, height / 2),
|
|
"se": QPointF(width, height),
|
|
"s": QPointF(width / 2, height),
|
|
"sw": QPointF(0, height),
|
|
"w": QPointF(0, height / 2),
|
|
"nw": QPointF(0, 0),
|
|
}
|
|
for handle in self.resize_handles:
|
|
handle.setPos(positions[handle.role])
|
|
|
|
def begin_resize(self) -> None:
|
|
self.resize_start = {
|
|
"x": self.annotation.x,
|
|
"y": self.annotation.y,
|
|
"width": self.annotation.width,
|
|
"height": self.annotation.height,
|
|
}
|
|
|
|
def resize_from_handle(self, role: str, scene_position: QPointF) -> None:
|
|
if self.resize_start is None:
|
|
self.begin_resize()
|
|
start = self.resize_start
|
|
edges = {
|
|
"left": start["x"],
|
|
"top": start["y"],
|
|
"right": start["x"] + start["width"],
|
|
"bottom": start["y"] + start["height"],
|
|
}
|
|
point = _snapped(scene_position)
|
|
minimum = _graph_snap_size()
|
|
if "w" in role:
|
|
edges["left"] = min(point.x(), edges["right"] - minimum)
|
|
if "e" in role:
|
|
edges["right"] = max(point.x(), edges["left"] + minimum)
|
|
if "n" in role:
|
|
edges["top"] = min(point.y(), edges["bottom"] - minimum)
|
|
if "s" in role:
|
|
edges["bottom"] = max(point.y(), edges["top"] + minimum)
|
|
self.prepareGeometryChange()
|
|
self.annotation.x, self.annotation.y = edges["left"], edges["top"]
|
|
self.annotation.width = edges["right"] - edges["left"]
|
|
self.annotation.height = edges["bottom"] - edges["top"]
|
|
self.setPos(self.annotation.x, self.annotation.y)
|
|
self._position_resize_handles()
|
|
self.update()
|
|
|
|
def finish_resize(self) -> None:
|
|
if self.resize_start is None:
|
|
return
|
|
old = self.resize_start
|
|
new = {
|
|
"x": self.annotation.x,
|
|
"y": self.annotation.y,
|
|
"width": self.annotation.width,
|
|
"height": self.annotation.height,
|
|
}
|
|
self.resize_start = None
|
|
self.controller.set_annotation_geometry(self.annotation_id, old, new)
|
|
|
|
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
|
|
scene = self.scene()
|
|
if not isinstance(scene, GraphScene):
|
|
return
|
|
if not self.isSelected():
|
|
scene.clearSelection()
|
|
self.setSelected(True)
|
|
menu = QMenu()
|
|
options_action = menu.addAction("Shape Options…")
|
|
menu.addSeparator()
|
|
actions = {
|
|
menu.addAction("Bring Forward"): "forward",
|
|
menu.addAction("Send Backward"): "backward",
|
|
menu.addAction("Bring to Front"): "front",
|
|
menu.addAction("Send to Back"): "back",
|
|
}
|
|
selected = menu.exec(event.screenPos())
|
|
if selected is options_action:
|
|
scene.edit_annotation_options(self.annotation_id)
|
|
elif selected in actions:
|
|
scene.reorder_selected_annotations(actions[selected])
|
|
event.accept()
|
|
|
|
|
|
class LineAnnotationGraphicsItem(QGraphicsPathItem):
|
|
def __init__(self, annotation: Annotation) -> None:
|
|
super().__init__()
|
|
self.annotation = annotation
|
|
self.annotation_id = annotation.id
|
|
self.handles: list[WaypointHandle] = []
|
|
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
|
self.setZValue(annotation.layer)
|
|
self._update_pen()
|
|
|
|
def _update_pen(self) -> None:
|
|
pen = shape_pen(self.annotation.properties)
|
|
if self.isSelected():
|
|
pen.setColor(QColor("#2563eb"))
|
|
pen.setWidthF(max(3.0, pen.widthF()))
|
|
self.setPen(pen)
|
|
|
|
def set_waypoints(self, points: list[QPointF], start: QPointF, end: QPointF) -> None:
|
|
for handle in self.handles:
|
|
if handle.scene() is not None:
|
|
handle.scene().removeItem(handle)
|
|
self.handles = [WaypointHandle("annotation", self.annotation_id, -1, start)]
|
|
self.handles.extend(
|
|
WaypointHandle("annotation", self.annotation_id, index, point)
|
|
for index, point in enumerate(points)
|
|
)
|
|
self.handles.append(WaypointHandle("annotation", self.annotation_id, -2, end))
|
|
scene = self.scene()
|
|
if scene is not None:
|
|
for handle in self.handles:
|
|
scene.addItem(handle)
|
|
for handle in self.handles:
|
|
handle.setVisible(self.isSelected())
|
|
|
|
def itemChange(self, change, value): # noqa: N802
|
|
result = super().itemChange(change, value)
|
|
if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
|
|
for handle in self.handles:
|
|
handle.setVisible(self.isSelected())
|
|
self._update_pen()
|
|
return result
|
|
|
|
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
|
|
if not self.isSelected():
|
|
self.scene().clearSelection()
|
|
self.setSelected(True)
|
|
menu = QMenu()
|
|
add_action = menu.addAction("Add Node")
|
|
options_action = menu.addAction("Shape Options…")
|
|
menu.addSeparator()
|
|
actions = {
|
|
menu.addAction("Bring Forward"): "forward",
|
|
menu.addAction("Send Backward"): "backward",
|
|
menu.addAction("Bring to Front"): "front",
|
|
menu.addAction("Send to Back"): "back",
|
|
}
|
|
selected = menu.exec(event.screenPos())
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
if selected is add_action:
|
|
scene.add_route_node("annotation", self.annotation_id, event.scenePos())
|
|
elif selected is options_action:
|
|
scene.edit_annotation_options(self.annotation_id)
|
|
elif selected in actions:
|
|
scene.reorder_selected_annotations(actions[selected])
|
|
event.accept()
|
|
|
|
|
|
class GraphScene(QGraphicsScene):
|
|
componentOptionsRequested = Signal(str)
|
|
componentPortOptionsRequested = Signal(str)
|
|
portOptionsRequested = Signal(str, str)
|
|
connectionOptionsRequested = Signal(str)
|
|
|
|
def __init__(self, controller: DocumentController, parent=None) -> None:
|
|
super().__init__(parent)
|
|
self.controller = controller
|
|
self.component_items: dict[str, ComponentGraphicsItem] = {}
|
|
self.input_items: dict[str, InterfaceTerminalItem] = {}
|
|
self.output_items: dict[str, InterfaceTerminalItem] = {}
|
|
self.connection_items: dict[str, ConnectionGraphicsItem] = {}
|
|
self.annotation_items: dict[str, QGraphicsItem] = {}
|
|
self.pending_source: ConnectionPortItem | None = None
|
|
self.pending_waypoints: list[QPointF] = []
|
|
self.pending_preview: QGraphicsPathItem | None = None
|
|
self.interaction_mode = "pointer"
|
|
self.connection_routing = "angled"
|
|
self.drawing_start: QPointF | None = None
|
|
self.drawing_waypoints: list[QPointF] = []
|
|
self.setSceneRect(-2000, -2000, 4000, 4000)
|
|
|
|
controller.documentReset.connect(self.rebuild)
|
|
controller.activeGraphChanged.connect(self.rebuild)
|
|
controller.componentMoved.connect(self.set_component_position)
|
|
controller.componentRotated.connect(self.set_component_rotation)
|
|
controller.graphItemChanged.connect(self.refresh_graph_item)
|
|
self.rebuild()
|
|
|
|
def rebuild(self) -> None:
|
|
self.clear()
|
|
self.component_items.clear()
|
|
self.input_items.clear()
|
|
self.output_items.clear()
|
|
self.connection_items.clear()
|
|
self.annotation_items.clear()
|
|
self.pending_source = None
|
|
self.pending_waypoints.clear()
|
|
self.pending_preview = None
|
|
owner = self.controller.active_component
|
|
if owner is None or owner.implementation_kind != "graph":
|
|
return
|
|
for port in owner.inputs:
|
|
item = InterfaceTerminalItem(port, "input", self.controller)
|
|
self.addItem(item)
|
|
item.setPos(port.x, port.y)
|
|
self.input_items[port.id] = item
|
|
for port in owner.outputs:
|
|
item = InterfaceTerminalItem(port, "output", self.controller)
|
|
self.addItem(item)
|
|
item.setPos(port.x, port.y)
|
|
self.output_items[port.id] = item
|
|
for component in owner.graph.blocks.values():
|
|
item = ComponentGraphicsItem(component, self.controller)
|
|
self.addItem(item)
|
|
item.setPos(component.x, component.y)
|
|
self.component_items[component.id] = item
|
|
for connection in owner.graph.connections.values():
|
|
item = ConnectionGraphicsItem(
|
|
connection.id,
|
|
connection.name,
|
|
connection_style(self.controller.connection_port_type(connection)),
|
|
)
|
|
self.addItem(item)
|
|
self.connection_items[connection.id] = item
|
|
self.update_connection(connection.id)
|
|
for annotation in owner.graph.annotations.values():
|
|
if annotation.kind == "line":
|
|
item = LineAnnotationGraphicsItem(annotation)
|
|
self.addItem(item)
|
|
self.annotation_items[annotation.id] = item
|
|
self.update_annotation(annotation.id)
|
|
else:
|
|
item = AnnotationGraphicsItem(annotation, self.controller)
|
|
self.addItem(item)
|
|
self.annotation_items[annotation.id] = item
|
|
|
|
def set_component_position(self, component_id: str, position: QPointF) -> None:
|
|
item = self.component_items.get(component_id)
|
|
if item is not None and item.pos() != position:
|
|
item.setPos(position)
|
|
|
|
def set_component_rotation(self, component_id: str, rotation: float) -> None:
|
|
item = self.component_items.get(component_id)
|
|
if item is not None:
|
|
item.setRotation(rotation)
|
|
self.update_connections_for_block(component_id)
|
|
|
|
def refresh_graph_item(self, item_kind: str, item_id: str) -> None:
|
|
if item_kind == "connection":
|
|
self.update_connection(item_id)
|
|
return
|
|
annotation = self.controller.active_graph.annotations.get(item_id)
|
|
graphics = self.annotation_items.get(item_id)
|
|
if annotation is None or graphics is None:
|
|
return
|
|
graphics.setZValue(annotation.layer)
|
|
if isinstance(graphics, LineAnnotationGraphicsItem):
|
|
graphics._update_pen()
|
|
self.update_annotation(item_id)
|
|
elif isinstance(graphics, AnnotationGraphicsItem):
|
|
graphics.prepareGeometryChange()
|
|
graphics.setPos(annotation.x, annotation.y)
|
|
graphics._position_resize_handles()
|
|
graphics.update()
|
|
|
|
def edit_annotation_options(self, annotation_id: str) -> None:
|
|
annotation = self.controller.active_graph.annotations.get(annotation_id)
|
|
if annotation is None:
|
|
return
|
|
element = {
|
|
"type": {"box": "rectangle", "line": "line", "text": "text"}[annotation.kind],
|
|
"width": abs(annotation.width),
|
|
"height": abs(annotation.height),
|
|
"text": annotation.text,
|
|
**annotation.properties,
|
|
}
|
|
dialog = ShapeOptionsDialog(element)
|
|
if dialog.exec() != dialog.DialogCode.Accepted:
|
|
return
|
|
values = annotation.to_dict()
|
|
values["size"] = {
|
|
"width": dialog.element["width"] * (-1 if annotation.width < 0 else 1),
|
|
"height": dialog.element["height"] * (-1 if annotation.height < 0 else 1),
|
|
}
|
|
values["text"] = str(dialog.element.get("text", annotation.text))
|
|
values["properties"].update(
|
|
{
|
|
key: value
|
|
for key, value in dialog.element.items()
|
|
if key not in {"type", "x", "y", "width", "height", "text"}
|
|
}
|
|
)
|
|
self.controller.edit_annotation(annotation_id, values)
|
|
|
|
def update_connections_for_block(self, component_id: str) -> None:
|
|
for connection in self.controller.active_graph.connections.values():
|
|
if component_id in (connection.source.block, connection.target.block):
|
|
self.update_connection(connection.id)
|
|
|
|
def update_connections_for_interface(self, port_id: str) -> None:
|
|
for connection in self.controller.active_graph.connections.values():
|
|
if port_id in (connection.source.interface, connection.target.interface):
|
|
self.update_connection(connection.id)
|
|
|
|
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
|
|
# The view paints the grid so it always covers the complete viewport.
|
|
del painter, rect
|
|
|
|
def _endpoint_item(self, endpoint: Endpoint, role: str) -> ConnectionPortItem | None:
|
|
if endpoint.interface is not None:
|
|
terminals = self.input_items if role == "source" else self.output_items
|
|
terminal = terminals.get(endpoint.interface)
|
|
return terminal.connection_port if terminal else None
|
|
component = self.component_items.get(endpoint.block or "")
|
|
if component is None:
|
|
return None
|
|
ports = component.output_ports if role == "source" else component.input_ports
|
|
return ports.get(endpoint.port or "")
|
|
|
|
def update_connection(self, connection_id: str) -> None:
|
|
connection = self.controller.active_graph.connections.get(connection_id)
|
|
graphics = self.connection_items.get(connection_id)
|
|
if connection is None or graphics is None:
|
|
return
|
|
source = self._endpoint_item(connection.source, "source")
|
|
target = self._endpoint_item(connection.target, "target")
|
|
if source is None or target is None:
|
|
return
|
|
start, end = source.scenePos(), target.scenePos()
|
|
routing = str(connection.properties.get("routing", "angled"))
|
|
waypoints = [
|
|
QPointF(float(point["x"]), float(point["y"]))
|
|
for point in connection.properties.get("waypoints", [])
|
|
if isinstance(point, dict) and "x" in point and "y" in point
|
|
]
|
|
path, direction_points = self._route_path(start, end, routing, waypoints)
|
|
graphics.set_connection_path(path, direction_points)
|
|
graphics.set_waypoints(waypoints)
|
|
|
|
def update_annotation(self, annotation_id: str) -> None:
|
|
annotation = self.controller.active_graph.annotations.get(annotation_id)
|
|
graphics = self.annotation_items.get(annotation_id)
|
|
if annotation is None or not isinstance(graphics, LineAnnotationGraphicsItem):
|
|
return
|
|
start = QPointF(annotation.x, annotation.y)
|
|
end = start + QPointF(annotation.width, annotation.height)
|
|
waypoints = [
|
|
QPointF(float(p["x"]), float(p["y"]))
|
|
for p in annotation.properties.get("waypoints", [])
|
|
]
|
|
routing = str(annotation.properties.get("routing", "angled"))
|
|
path, points = self._route_path(start, end, routing, waypoints)
|
|
graphics.setPath(path)
|
|
graphics.set_waypoints(waypoints, start, end)
|
|
|
|
@staticmethod
|
|
def _orthogonal_points(start: QPointF, end: QPointF, waypoints: list[QPointF]) -> list[QPointF]:
|
|
points = [start]
|
|
for waypoint in [*waypoints, end]:
|
|
previous = points[-1]
|
|
if previous.x() != waypoint.x() and previous.y() != waypoint.y():
|
|
elbow = QPointF(waypoint.x(), previous.y())
|
|
points.append(elbow)
|
|
if waypoint != points[-1]:
|
|
points.append(waypoint)
|
|
return points
|
|
|
|
@staticmethod
|
|
def _route_path(
|
|
start: QPointF,
|
|
end: QPointF,
|
|
routing: str,
|
|
waypoints: list[QPointF] | None = None,
|
|
) -> tuple[QPainterPath, list[QPointF]]:
|
|
waypoints = waypoints or []
|
|
path = QPainterPath(start)
|
|
if routing == "angled":
|
|
points = GraphScene._orthogonal_points(start, end, waypoints)
|
|
for point in points[1:]:
|
|
path.lineTo(point)
|
|
return path, points
|
|
if routing == "direct":
|
|
path.lineTo(end)
|
|
return path, [start, end]
|
|
if waypoints:
|
|
points = [start, *waypoints, end]
|
|
for index, point in enumerate(points[1:-1], start=1):
|
|
following = points[index + 1]
|
|
midpoint = QPointF((point.x() + following.x()) / 2, (point.y() + following.y()) / 2)
|
|
path.quadTo(point, midpoint)
|
|
path.quadTo(points[-2], end)
|
|
return path, points
|
|
distance = max(50.0, abs(end.x() - start.x()) * 0.5)
|
|
first_control, second_control = start + QPointF(distance, 0), end - QPointF(distance, 0)
|
|
path.cubicTo(first_control, second_control, end)
|
|
return path, [start, first_control, second_control, end]
|
|
|
|
def set_interaction_mode(self, mode: str) -> None:
|
|
self.interaction_mode = mode
|
|
if mode != "connect":
|
|
self._clear_pending_source()
|
|
self._clear_drawing()
|
|
|
|
def set_connection_routing(self, routing: str) -> None:
|
|
self.connection_routing = routing
|
|
self._clear_pending_source()
|
|
|
|
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
if self.interaction_mode in {"box", "text", "line"}:
|
|
self._drawing_press(event)
|
|
return
|
|
if self.interaction_mode != "connect":
|
|
self._clear_pending_source()
|
|
super().mousePressEvent(event)
|
|
return
|
|
if event.button() == Qt.MouseButton.RightButton:
|
|
self._clear_pending_source()
|
|
event.accept()
|
|
return
|
|
item = self.itemAt(event.scenePos(), QTransform())
|
|
if isinstance(item, ConnectionPortItem):
|
|
if item.role == "source":
|
|
self._clear_pending_source()
|
|
self.pending_source = item
|
|
self.pending_waypoints = []
|
|
item.setBrush(QColor("#f5b642"))
|
|
self._create_preview()
|
|
elif self.pending_source is not None:
|
|
if self.pending_source.endpoint != item.endpoint:
|
|
try:
|
|
self.controller.connect(
|
|
self.pending_source.endpoint,
|
|
item.endpoint,
|
|
routing=self.connection_routing,
|
|
waypoints=(
|
|
self._completed_angled_waypoints(
|
|
self.pending_source.scenePos(),
|
|
item.scenePos(),
|
|
self.pending_waypoints,
|
|
)
|
|
if self.connection_routing == "angled"
|
|
else self.pending_waypoints
|
|
),
|
|
)
|
|
except ValueError as error:
|
|
QToolTip.showText(event.screenPos(), str(error))
|
|
self._clear_pending_source()
|
|
event.accept()
|
|
return
|
|
if self.pending_source is not None and self.connection_routing == "angled":
|
|
self.pending_waypoints.append(_snapped(event.scenePos()))
|
|
self._update_preview(event.scenePos())
|
|
event.accept()
|
|
|
|
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
if self.interaction_mode == "connect" and self.pending_source is not None:
|
|
self._update_preview(event.scenePos())
|
|
event.accept()
|
|
return
|
|
if self.interaction_mode in {"box", "line"} and self.drawing_start is not None:
|
|
self._update_drawing_preview(event.scenePos())
|
|
event.accept()
|
|
return
|
|
super().mouseMoveEvent(event)
|
|
|
|
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
if self.interaction_mode == "box" and self.drawing_start is not None:
|
|
end = _snapped(event.scenePos())
|
|
if end != self.drawing_start:
|
|
self.controller.add_annotation("box", self.drawing_start, end)
|
|
self._clear_drawing()
|
|
event.accept()
|
|
return
|
|
super().mouseReleaseEvent(event)
|
|
|
|
def _drawing_press(self, event: QGraphicsSceneMouseEvent) -> None:
|
|
if event.button() == Qt.MouseButton.RightButton:
|
|
self._clear_drawing()
|
|
event.accept()
|
|
return
|
|
point = _snapped(event.scenePos())
|
|
if self.interaction_mode == "text":
|
|
text_value, accepted = QInputDialog.getText(None, "Add Text", "Text:")
|
|
if accepted and text_value:
|
|
self.controller.add_annotation(
|
|
"text", point, point + QPointF(160, 48), text=text_value
|
|
)
|
|
event.accept()
|
|
return
|
|
if self.drawing_start is None:
|
|
self.drawing_start = point
|
|
self.drawing_waypoints = []
|
|
self._create_preview()
|
|
elif self.interaction_mode == "line" and self.connection_routing == "angled":
|
|
self.drawing_waypoints.append(point)
|
|
self._update_drawing_preview(point)
|
|
elif self.interaction_mode == "line":
|
|
self.controller.add_annotation(
|
|
"line", self.drawing_start, point, routing=self.connection_routing
|
|
)
|
|
self._clear_drawing()
|
|
event.accept()
|
|
|
|
def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
|
if self.interaction_mode == "line" and self.drawing_start is not None:
|
|
end = _snapped(event.scenePos())
|
|
waypoints = (
|
|
self.drawing_waypoints[:-1]
|
|
if self.drawing_waypoints and self.drawing_waypoints[-1] == end
|
|
else self.drawing_waypoints
|
|
)
|
|
completed = self._completed_angled_waypoints(self.drawing_start, end, waypoints)
|
|
self.controller.add_annotation(
|
|
"line", self.drawing_start, end, routing="angled", waypoints=completed
|
|
)
|
|
self._clear_drawing()
|
|
event.accept()
|
|
return
|
|
super().mouseDoubleClickEvent(event)
|
|
|
|
@staticmethod
|
|
def _completed_angled_waypoints(
|
|
start: QPointF, end: QPointF, waypoints: list[QPointF]
|
|
) -> list[QPointF]:
|
|
return GraphScene._orthogonal_points(start, end, waypoints)[1:-1]
|
|
|
|
def _update_drawing_preview(self, cursor: QPointF) -> None:
|
|
if self.drawing_start is None or self.pending_preview is None:
|
|
return
|
|
end = _snapped(cursor)
|
|
if self.interaction_mode == "box":
|
|
path = QPainterPath()
|
|
path.addRect(QRectF(self.drawing_start, end).normalized())
|
|
else:
|
|
path, _ = self._route_path(
|
|
self.drawing_start, end, self.connection_routing, self.drawing_waypoints
|
|
)
|
|
self.pending_preview.setPath(path)
|
|
|
|
def _clear_drawing(self) -> None:
|
|
if self.pending_preview is not None and self.pending_source is None:
|
|
self.removeItem(self.pending_preview)
|
|
self.pending_preview = None
|
|
self.drawing_start = None
|
|
self.drawing_waypoints.clear()
|
|
|
|
def _route_values(self, item_kind: str, item_id: str):
|
|
item = (
|
|
self.controller.active_graph.connections
|
|
if item_kind == "connection"
|
|
else self.controller.active_graph.annotations
|
|
).get(item_id)
|
|
if item is None:
|
|
return None, []
|
|
points = [
|
|
QPointF(float(p["x"]), float(p["y"])) for p in item.properties.get("waypoints", [])
|
|
]
|
|
return item, points
|
|
|
|
def route_graphics_item(self, item_kind: str, item_id: str) -> QGraphicsItem | None:
|
|
return (
|
|
self.connection_items.get(item_id)
|
|
if item_kind == "connection"
|
|
else self.annotation_items.get(item_id)
|
|
)
|
|
|
|
def add_route_node(self, item_kind: str, item_id: str, position: QPointF) -> None:
|
|
item, points = self._route_values(item_kind, item_id)
|
|
if item is None:
|
|
return
|
|
snapped = _snapped(position)
|
|
if item_kind == "annotation":
|
|
start = QPointF(item.x, item.y)
|
|
end = QPointF(item.x + item.width, item.y + item.height)
|
|
else:
|
|
graphics = self.connection_items[item_id]
|
|
start, end = graphics.start, graphics.end
|
|
anchors = [start, *points, end]
|
|
|
|
def segment_distance(point: QPointF, first: QPointF, second: QPointF) -> float:
|
|
delta = second - first
|
|
length_squared = delta.x() ** 2 + delta.y() ** 2
|
|
if length_squared == 0:
|
|
return (point - first).manhattanLength()
|
|
ratio = max(
|
|
0.0,
|
|
min(
|
|
1.0,
|
|
((point.x() - first.x()) * delta.x() + (point.y() - first.y()) * delta.y())
|
|
/ length_squared,
|
|
),
|
|
)
|
|
nearest = first + delta * ratio
|
|
return (point.x() - nearest.x()) ** 2 + (point.y() - nearest.y()) ** 2
|
|
|
|
insertion = min(
|
|
range(len(anchors) - 1),
|
|
key=lambda index: segment_distance(snapped, anchors[index], anchors[index + 1]),
|
|
)
|
|
points.insert(insertion, snapped)
|
|
routing = "angled" if item.properties.get("routing") == "direct" else None
|
|
if routing == "angled" or item.properties.get("routing") == "angled":
|
|
points = self._orthogonal_points(start, end, points)[1:-1]
|
|
self.controller.set_route_waypoints(item_kind, item_id, points, routing)
|
|
|
|
def move_route_node(self, item_kind: str, item_id: str, index: int, position: QPointF) -> None:
|
|
item, points = self._route_values(item_kind, item_id)
|
|
if item is None:
|
|
return
|
|
position = _snapped(position)
|
|
if item_kind == "annotation" and index in {-1, -2}:
|
|
old = {
|
|
"x": item.x,
|
|
"y": item.y,
|
|
"width": item.width,
|
|
"height": item.height,
|
|
}
|
|
start = position if index == -1 else QPointF(item.x, item.y)
|
|
end = position if index == -2 else QPointF(item.x + item.width, item.y + item.height)
|
|
new = {
|
|
"x": start.x(),
|
|
"y": start.y(),
|
|
"width": end.x() - start.x(),
|
|
"height": end.y() - start.y(),
|
|
}
|
|
self.controller.set_annotation_geometry(item_id, old, new)
|
|
return
|
|
if index < len(points):
|
|
points[index] = position
|
|
if item.properties.get("routing") == "angled":
|
|
if item_kind == "annotation":
|
|
start = QPointF(item.x, item.y)
|
|
end = QPointF(item.x + item.width, item.y + item.height)
|
|
else:
|
|
graphics = self.connection_items[item_id]
|
|
start, end = graphics.start, graphics.end
|
|
points = self._orthogonal_points(start, end, points)[1:-1]
|
|
self.controller.set_route_waypoints(item_kind, item_id, points)
|
|
|
|
def preview_route_node(
|
|
self, item_kind: str, item_id: str, index: int, position: QPointF
|
|
) -> None:
|
|
item, points = self._route_values(item_kind, item_id)
|
|
if item is None:
|
|
return
|
|
if item_kind == "annotation" and index in {-1, -2}:
|
|
graphics = self.annotation_items.get(item_id)
|
|
if not isinstance(graphics, LineAnnotationGraphicsItem):
|
|
return
|
|
start = position if index == -1 else QPointF(item.x, item.y)
|
|
end = position if index == -2 else QPointF(item.x + item.width, item.y + item.height)
|
|
path, _direction_points = self._route_path(
|
|
start,
|
|
end,
|
|
str(item.properties.get("routing", "angled")),
|
|
points,
|
|
)
|
|
graphics.setPath(path)
|
|
return
|
|
if index >= len(points):
|
|
return
|
|
points[index] = position
|
|
routing = str(item.properties.get("routing", "angled"))
|
|
if item_kind == "connection":
|
|
graphics = self.connection_items.get(item_id)
|
|
if graphics is None:
|
|
return
|
|
path, direction_points = self._route_path(graphics.start, graphics.end, routing, points)
|
|
graphics.set_connection_path(path, direction_points)
|
|
return
|
|
graphics = self.annotation_items.get(item_id)
|
|
if not isinstance(graphics, LineAnnotationGraphicsItem):
|
|
return
|
|
start = QPointF(item.x, item.y)
|
|
end = QPointF(item.x + item.width, item.y + item.height)
|
|
path, _direction_points = self._route_path(start, end, routing, points)
|
|
graphics.setPath(path)
|
|
|
|
def delete_route_node(self, item_kind: str, item_id: str, index: int) -> None:
|
|
_item, points = self._route_values(item_kind, item_id)
|
|
if 0 <= index < len(points):
|
|
points.pop(index)
|
|
self.controller.set_route_waypoints(item_kind, item_id, points)
|
|
|
|
def reorder_selected_annotations(self, operation: str) -> None:
|
|
ids = {
|
|
item.annotation_id
|
|
for item in self.selectedItems()
|
|
if isinstance(item, (AnnotationGraphicsItem, LineAnnotationGraphicsItem))
|
|
}
|
|
self.controller.reorder_annotations(ids, operation)
|
|
|
|
def _create_preview(self) -> None:
|
|
self.pending_preview = QGraphicsPathItem()
|
|
self.pending_preview.setPen(QPen(QColor("#64748b"), 1.5, Qt.PenStyle.DashLine))
|
|
self.pending_preview.setZValue(-0.5)
|
|
self.addItem(self.pending_preview)
|
|
|
|
def _update_preview(self, cursor: QPointF) -> None:
|
|
if self.pending_source is None or self.pending_preview is None:
|
|
return
|
|
preview_cursor = _snapped(cursor) if self.connection_routing == "angled" else cursor
|
|
path, _points = self._route_path(
|
|
self.pending_source.scenePos(),
|
|
preview_cursor,
|
|
self.connection_routing,
|
|
self.pending_waypoints,
|
|
)
|
|
self.pending_preview.setPath(path)
|
|
|
|
def _clear_pending_source(self) -> None:
|
|
if self.pending_source is not None:
|
|
self.pending_source.setBrush(QColor("#ffffff"))
|
|
if self.pending_preview is not None:
|
|
self.removeItem(self.pending_preview)
|
|
self.pending_source = None
|
|
self.pending_waypoints.clear()
|
|
self.pending_preview = None
|
|
|
|
|
|
class GraphWorkspaceView(QGraphicsView):
|
|
toolUsed = Signal()
|
|
componentOptionsRequested = Signal(str)
|
|
componentPortOptionsRequested = Signal(str)
|
|
portOptionsRequested = Signal(str, str)
|
|
connectionOptionsRequested = Signal(str)
|
|
selectionAvailabilityChanged = Signal(bool)
|
|
|
|
def __init__(self, parent=None) -> None:
|
|
super().__init__(parent)
|
|
self.controller: DocumentController | None = None
|
|
self.tool_mode = "pointer"
|
|
self.paste_count = 0
|
|
self.setAcceptDrops(True)
|
|
self.setMouseTracking(True)
|
|
self.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
|
|
self.setBackgroundBrush(QColor("#f7f7f7"))
|
|
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
|
|
|
|
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
|
|
"""Paint the visible graph viewport in scene coordinates."""
|
|
painter.fillRect(rect, QColor("#f7f7f7"))
|
|
if (
|
|
self.controller is None
|
|
or self.controller.document is None
|
|
or self.controller.active_component is None
|
|
):
|
|
return
|
|
spacing = _graph_grid_size()
|
|
left = int(rect.left()) - (int(rect.left()) % spacing)
|
|
top = int(rect.top()) - (int(rect.top()) % spacing)
|
|
painter.setPen(QPen(QColor("#c5cbd1"), 0))
|
|
for x in range(left, int(rect.right()) + spacing, spacing):
|
|
painter.drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom()))
|
|
for y in range(top, int(rect.bottom()) + spacing, spacing):
|
|
painter.drawLine(QPointF(rect.left(), y), QPointF(rect.right(), y))
|
|
|
|
def _zoom(self, factor: float) -> None:
|
|
current = self.transform().m11()
|
|
target = current * factor
|
|
if 0.1 <= target <= 8.0:
|
|
self.scale(factor, factor)
|
|
|
|
def zoom_in(self) -> None:
|
|
self._zoom(1.2)
|
|
|
|
def zoom_out(self) -> None:
|
|
self._zoom(1 / 1.2)
|
|
|
|
def center_workspace(self) -> None:
|
|
scene = self.scene()
|
|
if scene is None:
|
|
return
|
|
bounds = scene.itemsBoundingRect()
|
|
if bounds.isEmpty():
|
|
self.resetTransform()
|
|
self.centerOn(0, 0)
|
|
else:
|
|
self.fitInView(bounds.adjusted(-80, -80, 80, 80), Qt.AspectRatioMode.KeepAspectRatio)
|
|
|
|
def wheelEvent(self, event: QWheelEvent) -> None: # noqa: N802
|
|
self._zoom(1.2 if event.angleDelta().y() > 0 else 1 / 1.2)
|
|
event.accept()
|
|
|
|
def set_model(self, controller: DocumentController) -> None:
|
|
self.controller = controller
|
|
scene = GraphScene(controller, self)
|
|
scene.componentOptionsRequested.connect(self.componentOptionsRequested)
|
|
scene.componentPortOptionsRequested.connect(self.componentPortOptionsRequested)
|
|
scene.portOptionsRequested.connect(self.portOptionsRequested)
|
|
scene.connectionOptionsRequested.connect(self.connectionOptionsRequested)
|
|
scene.selectionChanged.connect(
|
|
lambda: self.selectionAvailabilityChanged.emit(bool(scene.selectedItems()))
|
|
)
|
|
self.setScene(scene)
|
|
|
|
def select_all(self) -> None:
|
|
scene = self.scene()
|
|
if scene is None:
|
|
return
|
|
for item in scene.items():
|
|
if item.flags() & QGraphicsItem.GraphicsItemFlag.ItemIsSelectable:
|
|
item.setSelected(True)
|
|
|
|
def delete_selected(self) -> None:
|
|
if self.controller is None or not isinstance(self.scene(), GraphScene):
|
|
return
|
|
blocks: set[str] = set()
|
|
connections: set[str] = set()
|
|
inputs: set[str] = set()
|
|
outputs: set[str] = set()
|
|
annotations: set[str] = set()
|
|
for item in self.scene().selectedItems():
|
|
if isinstance(item, ComponentGraphicsItem):
|
|
blocks.add(item.component_id)
|
|
elif isinstance(item, ConnectionGraphicsItem):
|
|
connections.add(item.connection_id)
|
|
elif isinstance(item, InterfaceTerminalItem):
|
|
(inputs if item.direction == "input" else outputs).add(item.port.id)
|
|
elif isinstance(item, (AnnotationGraphicsItem, LineAnnotationGraphicsItem)):
|
|
annotations.add(item.annotation_id)
|
|
self.controller.delete_selection(blocks, connections, inputs, outputs)
|
|
self.controller.delete_annotations(annotations)
|
|
|
|
def has_selected_components(self) -> bool:
|
|
scene = self.scene()
|
|
return bool(
|
|
scene and any(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems())
|
|
)
|
|
|
|
def has_single_selected_component(self) -> bool:
|
|
scene = self.scene()
|
|
return bool(
|
|
scene
|
|
and sum(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems()) == 1
|
|
)
|
|
|
|
def open_selected_component(self) -> bool:
|
|
if self.controller is None or self.scene() is None:
|
|
return False
|
|
selected = [
|
|
item for item in self.scene().selectedItems() if isinstance(item, ComponentGraphicsItem)
|
|
]
|
|
if len(selected) != 1:
|
|
return False
|
|
self.controller.activate_component(selected[0].component_id)
|
|
return True
|
|
|
|
def rotate_selected(self) -> None:
|
|
if self.controller is None or self.scene() is None:
|
|
return
|
|
component_ids = {
|
|
item.component_id
|
|
for item in self.scene().selectedItems()
|
|
if isinstance(item, ComponentGraphicsItem)
|
|
}
|
|
self.controller.rotate_components(component_ids)
|
|
|
|
def copy_selection(self) -> bool:
|
|
if self.controller is None or not isinstance(self.scene(), GraphScene):
|
|
return False
|
|
selected_ids = {
|
|
item.component_id
|
|
for item in self.scene().selectedItems()
|
|
if isinstance(item, ComponentGraphicsItem)
|
|
}
|
|
if not selected_ids:
|
|
return False
|
|
graph = self.controller.active_graph
|
|
components = [graph.blocks[component_id].to_dict() for component_id in selected_ids]
|
|
connections = [
|
|
connection.to_dict()
|
|
for connection in graph.connections.values()
|
|
if connection.source.block in selected_ids and connection.target.block in selected_ids
|
|
]
|
|
mime_data = QMimeData()
|
|
mime_data.setData(
|
|
SELECTION_MIME_TYPE,
|
|
json.dumps({"components": components, "connections": connections}).encode("utf-8"),
|
|
)
|
|
QApplication.clipboard().setMimeData(mime_data)
|
|
self.paste_count = 0
|
|
return True
|
|
|
|
def cut_selection(self) -> None:
|
|
if self.copy_selection():
|
|
self.delete_selected()
|
|
|
|
def paste_selection(self) -> None:
|
|
if self.controller is None:
|
|
return
|
|
mime_data = QApplication.clipboard().mimeData()
|
|
if not mime_data.hasFormat(SELECTION_MIME_TYPE):
|
|
return
|
|
try:
|
|
payload = json.loads(bytes(mime_data.data(SELECTION_MIME_TYPE)).decode("utf-8"))
|
|
components = [Component.from_dict(item) for item in payload.get("components", [])]
|
|
connections = [Connection.from_dict(item) for item in payload.get("connections", [])]
|
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
return
|
|
self.paste_count += 1
|
|
new_ids = self.controller.paste_selection(
|
|
components,
|
|
connections,
|
|
QPointF(32 * self.paste_count, 32 * self.paste_count),
|
|
)
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
scene.clearSelection()
|
|
for component_id in new_ids:
|
|
item = scene.component_items.get(component_id)
|
|
if item is not None:
|
|
item.setSelected(True)
|
|
|
|
def set_tool_mode(self, mode: str) -> None:
|
|
self.tool_mode = mode
|
|
self.setDragMode(
|
|
QGraphicsView.DragMode.RubberBandDrag
|
|
if mode == "pointer"
|
|
else QGraphicsView.DragMode.NoDrag
|
|
)
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
scene.set_interaction_mode(mode)
|
|
|
|
def set_connection_routing(self, routing: str) -> None:
|
|
scene = self.scene()
|
|
if isinstance(scene, GraphScene):
|
|
scene.set_connection_routing(routing)
|
|
|
|
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
|
|
super().mousePressEvent(event)
|
|
|
|
def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802
|
|
if (
|
|
event.mimeData().hasFormat(COMPONENT_MIME_TYPE)
|
|
and self.controller is not None
|
|
and self.controller.active_component is not None
|
|
and self.controller.active_component.implementation_kind == "graph"
|
|
):
|
|
event.acceptProposedAction()
|
|
return
|
|
super().dragEnterEvent(event)
|
|
|
|
def dragMoveEvent(self, event) -> None: # noqa: N802
|
|
if (
|
|
event.mimeData().hasFormat(COMPONENT_MIME_TYPE)
|
|
and self.controller is not None
|
|
and self.controller.active_component is not None
|
|
and self.controller.active_component.implementation_kind == "graph"
|
|
):
|
|
event.acceptProposedAction()
|
|
return
|
|
super().dragMoveEvent(event)
|
|
|
|
def dropEvent(self, event: QDropEvent) -> None: # noqa: N802
|
|
if self.controller is None or not event.mimeData().hasFormat(COMPONENT_MIME_TYPE):
|
|
super().dropEvent(event)
|
|
return
|
|
data = json.loads(bytes(event.mimeData().data(COMPONENT_MIME_TYPE)).decode("utf-8"))
|
|
source = Component.from_dict(data)
|
|
self.controller.add_component_copy(
|
|
source, _snapped(self.mapToScene(event.position().toPoint()))
|
|
)
|
|
event.acceptProposedAction()
|