# ******************************************************************************
# pySimBlocks
# Copyright (c) 2026 Université de Lille & INRIA
# ******************************************************************************
from __future__ import annotations
from typing import TYPE_CHECKING
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QBrush, QFont, QPainter, QPen, QPainterPath
from PySide6.QtWidgets import QGraphicsItem, QGraphicsRectItem, QStyle
from pySimBlocks.gui.models.visual_group import BoundaryPort
if TYPE_CHECKING:
from pySimBlocks.gui.widgets.diagram_view import DiagramView
[docs]
class GroupProxyPortItem(QGraphicsItem):
"""Single visible port on a GroupIn or GroupOut proxy block."""
R = 6
L = 15
H = 10
def __init__(self, is_output: bool, parent_proxy: "GroupProxyItem"):
super().__init__(parent_proxy)
self.is_output = is_output
self.parent_proxy = parent_proxy
@property
def is_on_left_side(self) -> bool:
return self.pos().x() < (self.parent_proxy.rect().width() * 0.5)
[docs]
def connection_anchor(self) -> QPointF:
if self.is_output:
x = self.L if not self.is_on_left_side else -self.L
local = QPointF(x, 0)
else:
x = -self.R if self.is_on_left_side else self.R
local = QPointF(x, 0)
return self.mapToScene(local)
[docs]
def boundingRect(self) -> QRectF:
return QRectF(-12, -12, 24, 24)
[docs]
def shape(self):
path = QPainterPath()
if self.is_output:
tip_x = self.L if not self.is_on_left_side else -self.L
path.moveTo(0, -self.H)
path.lineTo(0, self.H)
path.lineTo(tip_x, 0)
path.closeSubpath()
else:
path.addEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R)
return path
[docs]
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self.parent_proxy.view.create_connection_event(self)
event.accept()
return
super().mousePressEvent(event)
[docs]
def paint(self, painter, option, widget=None):
t = self.parent_proxy.view.theme
painter.setRenderHint(QPainter.Antialiasing)
fill = t.port_out if self.is_output else t.port_in
painter.setBrush(QBrush(fill))
painter.setPen(QPen(t.block_border, 1))
if self.is_output:
path = QPainterPath()
path.moveTo(0, -self.H)
path.lineTo(0, self.H)
tip_x = self.L if not self.is_on_left_side else -self.L
path.lineTo(tip_x, 0)
path.closeSubpath()
painter.drawPath(path)
else:
painter.drawEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R)
[docs]
class GroupProxyItem(QGraphicsRectItem):
"""Render a GroupIn or GroupOut proxy inside a visual group.
GroupIn exposes only an output toward group members; its external input
lives on the parent GroupItem border. GroupOut exposes only an input from
members; its external output lives on the parent GroupItem border.
"""
WIDTH = 56.0
HEIGHT = 45.0
TYPE_LABEL_MIN_HEIGHT = 40.0
GRID_DX = 5
GRID_DY = 5
def __init__(self, boundary: BoundaryPort, view: "DiagramView"):
super().__init__(0, 0, self.WIDTH, self.HEIGHT)
self.boundary = boundary
self.view = view
layout = boundary.proxy_layout or {}
self.orientation = layout.get("orientation", "normal")
if self.orientation not in {"normal", "flipped"}:
self.orientation = "normal"
if self.is_group_in:
self.port_item = GroupProxyPortItem(is_output=True, parent_proxy=self)
else:
self.port_item = GroupProxyPortItem(is_output=False, parent_proxy=self)
self._layout_port()
self.setPos(QPointF(float(layout.get("x", 0.0)), float(layout.get("y", 0.0))))
self.setFlag(QGraphicsRectItem.ItemIsMovable)
self.setFlag(QGraphicsRectItem.ItemIsSelectable)
self.setFlag(QGraphicsRectItem.ItemSendsScenePositionChanges)
self.setZValue(0)
self._interaction_start_pos: QPointF | None = None
@property
def is_group_in(self) -> bool:
return self.boundary.direction == "input"
@property
def is_group_out(self) -> bool:
return self.boundary.direction == "output"
@property
def kind_label(self) -> str:
return "In" if self.is_group_in else "Out"
[docs]
def center_label(self) -> str:
"""Return proxy label from member port or manual override."""
controller = self.view.project_controller
if controller is None:
return self.kind_label
text = controller.boundary_proxy_label(self.boundary)
return text if text else self.kind_label
[docs]
def member_anchor(self) -> QPointF:
"""Anchor used for wires between the proxy and group members."""
return self.port_item.connection_anchor()
def _layout_port(self) -> None:
rect = self.rect()
mid_y = rect.height() / 2
flipped = self.orientation == "flipped"
if self.is_group_in:
self.port_item.setPos(0 if flipped else rect.width(), mid_y)
else:
self.port_item.setPos(rect.width() if flipped else 0, mid_y)
[docs]
def set_orientation(self, orientation: str) -> None:
if orientation not in {"normal", "flipped"}:
return
self.orientation = orientation
self._layout_port()
layout = dict(self.boundary.proxy_layout or {})
layout["orientation"] = orientation
self.boundary.proxy_layout = layout
for conn_item in self.view.connections.values():
if conn_item.is_manual:
conn_item.invalidate_manual_route()
self.view.on_proxy_moved(self)
self.update()
[docs]
def paint(self, painter, option, widget=None):
t = self.view.theme
selected = bool(option.state & QStyle.State_Selected)
rect = self.rect()
painter.setRenderHint(QPainter.Antialiasing)
painter.setBrush(QBrush(t.block_bg_selected if selected else t.block_bg))
painter.setPen(QPen(t.block_border_selected if selected else t.block_border, 2))
painter.drawRect(rect)
name_font = QFont("Sans Serif", 9)
painter.setFont(name_font)
painter.setPen(QPen(t.text_selected if selected else t.text))
name_rect = QRectF(rect.x(), rect.y(), rect.width(), rect.height() * 0.60)
painter.drawText(name_rect, int(Qt.AlignCenter | Qt.AlignBottom), self.center_label())
if rect.height() >= self.TYPE_LABEL_MIN_HEIGHT:
type_font = QFont("Sans Serif", 8)
type_font.setItalic(True)
painter.setFont(type_font)
painter.setPen(QPen(t.text_type_selected if selected else t.text_type))
type_rect = QRectF(
rect.x(),
rect.y() + rect.height() * 0.58,
rect.width(),
rect.height() * 0.38,
)
painter.drawText(type_rect, int(Qt.AlignCenter | Qt.AlignTop), self.kind_label)
[docs]
def mousePressEvent(self, event):
self._interaction_start_pos = QPointF(self.pos())
super().mousePressEvent(event)
[docs]
def mouseReleaseEvent(self, event):
start_pos = self._interaction_start_pos
end_pos = QPointF(self.pos())
self._interaction_start_pos = None
super().mouseReleaseEvent(event)
if (
start_pos is not None
and start_pos != end_pos
and self.view.project_controller is not None
and self.view.current_view_group_uid is not None
):
self.view.project_controller.execute_move_proxy_layout(
self.view.current_view_group_uid,
self.boundary.uid,
start_pos,
end_pos,
)
[docs]
def itemChange(self, change, value):
if change == QGraphicsItem.ItemPositionChange and self.scene():
x = round(value.x() / self.GRID_DX) * self.GRID_DX
y = round(value.y() / self.GRID_DY) * self.GRID_DY
return QPointF(x, y)
if change == QGraphicsItem.ItemPositionHasChanged and self.view.project_controller:
pos = self.pos()
layout = dict(self.boundary.proxy_layout or {})
layout["x"] = float(pos.x())
layout["y"] = float(pos.y())
layout["orientation"] = self.orientation
self.boundary.proxy_layout = layout
self.view.on_proxy_moved(self)
return super().itemChange(change, value)