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()