Source code for pySimBlocks.gui.graphics.group_item

# ******************************************************************************
#                                  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 QPainterPath
from PySide6.QtGui import QBrush, QFont, QPainter, QPen
from PySide6.QtWidgets import QGraphicsItem, QGraphicsRectItem, QGraphicsTextItem, QStyle

from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup

if TYPE_CHECKING:
    from pySimBlocks.gui.widgets.diagram_view import DiagramView


[docs] class GroupBoundaryPortItem(QGraphicsItem): """Visual port on a group rectangle border.""" R = 6 L = 15 H = 10 MARGIN = 4 def __init__(self, boundary: BoundaryPort, parent_group: "GroupItem"): super().__init__(parent_group) self.boundary = boundary self.parent_group = parent_group self.setAcceptedMouseButtons(Qt.LeftButton) t = parent_group.view.theme self.label = QGraphicsTextItem(self) self.label.setDefaultTextColor(t.text) self.label.setFont(QFont("Sans Serif", 8)) self.update_port_label() @property def is_input(self) -> bool: return self.boundary.direction == "input" @property def is_on_left_side(self) -> bool: return self.pos().x() < (self.parent_group.rect().width() * 0.5)
[docs] def connection_anchor(self) -> QPointF: if self.is_input: x = -self.R if self.is_on_left_side else self.R local = QPointF(x, 0) else: x = self.L if not self.is_on_left_side else -self.L local = QPointF(x, 0) return self.mapToScene(local)
[docs] def update_port_label(self) -> None: """Refresh the external port label shown next to the boundary port.""" controller = self.parent_group.view.project_controller text = "" if controller is not None: text = controller.boundary_port_flow_label( self.parent_group.group, self.boundary, ) self.label.setPlainText(text) self.label.setVisible(bool(text)) self._layout_port_label()
def _layout_port_label(self) -> None: label_rect = self.label.boundingRect() if self.is_on_left_side: self.label.setPos( self.R + self.MARGIN, -label_rect.height() / 2, ) else: self.label.setPos( -label_rect.width() - self.R - self.MARGIN, -label_rect.height() / 2, )
[docs] def boundingRect(self) -> QRectF: return QRectF(-10, -10, 20, 20)
[docs] def itemChange(self, change, value): if change == QGraphicsItem.ItemScenePositionHasChanged: self._layout_port_label() return super().itemChange(change, value)
[docs] def mousePressEvent(self, event): if event.button() == Qt.LeftButton: self.parent_group.view.create_connection_event(self) event.accept() return super().mousePressEvent(event)
[docs] def shape(self): path = QPainterPath() if self.is_input: path.addEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R) else: 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() return path
[docs] def paint(self, painter, option, widget=None): t = self.parent_group.view.theme painter.setRenderHint(QPainter.Antialiasing) fill = t.port_in if self.is_input else t.port_out painter.setBrush(QBrush(fill)) painter.setPen(QPen(t.block_border, 1)) if self.is_input: painter.drawEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R) else: from PySide6.QtGui import QPainterPath 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)
[docs] class GroupItem(QGraphicsRectItem): """Render a visual group container on the diagram.""" MIN_WIDTH = 80.0 MIN_HEIGHT = 50.0 GRID_DX = 5 GRID_DY = 5 SELECTION_HANDLE_SIZE = 8 SELECTION_HANDLE_HIT_SIZE = 16 def __init__(self, group: VisualGroup, view: "DiagramView"): layout = group.layout or {} width = max(float(layout.get("width", 160.0)), self.MIN_WIDTH) height = max(float(layout.get("height", 100.0)), self.MIN_HEIGHT) super().__init__(0, 0, width, height) self.group = group self.view = view self.orientation = layout.get("orientation", "normal") if self.orientation not in {"normal", "flipped"}: self.orientation = "normal" self.boundary_port_items: dict[str, GroupBoundaryPortItem] = {} self._resize_handle: str | None = None self._resize_start_mouse: QPointF | None = None self._resize_start_pos: QPointF | None = None self._resize_start_width = width self._resize_start_height = height self._interaction_start_pos: QPointF | None = None self._interaction_start_rect: QRectF | None = None self._syncing_geometry = False x = float(layout.get("x", 0.0)) y = float(layout.get("y", 0.0)) self.setPos(QPointF(x, y)) self.setFlag(QGraphicsRectItem.ItemIsMovable) self.setFlag(QGraphicsRectItem.ItemIsSelectable) self.setFlag(QGraphicsRectItem.ItemSendsScenePositionChanges) self.setZValue(-1) self.sync_boundary_ports() def _border_boundary_ports(self) -> list[BoundaryPort]: """Return boundary ports that should appear on the group rectangle.""" return list(self.group.boundary_ports)
[docs] def boundary_anchor_for(self, boundary: BoundaryPort) -> QPointF: """Return the scene anchor for a boundary port on this group.""" port_item = self.boundary_port_items.get(boundary.uid) if port_item is not None: return port_item.connection_anchor() return self._boundary_anchor_from_geometry(boundary)
def _boundaries_for_direction(self, direction: str) -> list[BoundaryPort]: """Return border ports for one side in stable display order.""" previous_y = { uid: float(item.pos().y()) for uid, item in self.boundary_port_items.items() } return sorted( [ port for port in self._border_boundary_ports() if port.direction == direction ], key=lambda boundary: self._border_sort_key(boundary, previous_y), ) def _boundary_anchor_from_geometry(self, boundary: BoundaryPort) -> QPointF: """Compute a boundary anchor when no border port item is shown.""" rect = self.rect() same_direction = self._boundaries_for_direction(boundary.direction) if boundary not in same_direction: same_direction = [ port for port in self.group.boundary_ports if port.direction == boundary.direction ] index = same_direction.index(boundary) if boundary in same_direction else 0 total = len(same_direction) y = rect.height() * (index + 1) / (total + 1) flipped = self.orientation == "flipped" if boundary.direction == "input": local = ( QPointF(rect.width() + GroupBoundaryPortItem.R, y) if flipped else QPointF(-GroupBoundaryPortItem.R, y) ) else: local = ( QPointF(-GroupBoundaryPortItem.L, y) if flipped else QPointF(rect.width() + GroupBoundaryPortItem.L, y) ) return self.mapToScene(local) def _border_sort_key( self, boundary: BoundaryPort, previous_y: dict[str, float], ) -> tuple[float, str]: """Keep border ports in a stable vertical order across rebuilds.""" if boundary.uid in previous_y: return (previous_y[boundary.uid], boundary.uid) if boundary.proxy_layout: return (float(boundary.proxy_layout.get("y", 0.0)), boundary.uid) return (1e9, boundary.linked_port_uid or boundary.uid)
[docs] def sync_boundary_ports(self) -> None: """Rebuild boundary port items from group metadata.""" previous_y = { uid: float(item.pos().y()) for uid, item in self.boundary_port_items.items() } for item in list(self.boundary_port_items.values()): item.setParentItem(None) self.boundary_port_items.clear() border_ports = self._border_boundary_ports() inputs = sorted( [p for p in border_ports if p.direction == "input"], key=lambda boundary: self._border_sort_key(boundary, previous_y), ) outputs = sorted( [p for p in border_ports if p.direction == "output"], key=lambda boundary: self._border_sort_key(boundary, previous_y), ) rect = self.rect() flipped = self.orientation == "flipped" width = rect.width() for index, boundary in enumerate(inputs): item = GroupBoundaryPortItem(boundary, self) y = rect.height() * (index + 1) / (len(inputs) + 1) item.setPos(width if flipped else 0, y) item.update_port_label() self.boundary_port_items[boundary.uid] = item for index, boundary in enumerate(outputs): item = GroupBoundaryPortItem(boundary, self) y = rect.height() * (index + 1) / (len(outputs) + 1) item.setPos(0 if flipped else width, y) item.update_port_label() self.boundary_port_items[boundary.uid] = item
[docs] def set_orientation(self, orientation: str) -> None: if orientation not in {"normal", "flipped"}: return self.orientation = orientation self.sync_boundary_ports() layout = dict(self.group.layout or {}) layout["orientation"] = orientation self.group.layout = layout members = set( self.view.project_controller._group_content_uids_for_group(self.group) ) for conn_inst, conn_item in self.view.connections.items(): if ( conn_inst.src_block().uid in members or conn_inst.dst_block().uid in members ) and conn_item.is_manual: conn_item.invalidate_manual_route() self.view.on_group_moved(self) self.update()
[docs] def refresh_boundary_port_labels(self) -> None: """Update external port labels after diagram connections change.""" for item in self.boundary_port_items.values(): item.update_port_label()
[docs] def get_boundary_anchor(self, boundary_uid: str) -> QPointF | None: boundary = next( (port for port in self.group.boundary_ports if port.uid == boundary_uid), None, ) if boundary is None: return None return self.boundary_anchor_for(boundary)
[docs] def find_boundary_for_member_port(self, block_uid: str, port_name: str) -> str | None: key = f"{block_uid}:{port_name}" for boundary in self.group.boundary_ports: if boundary.linked_port_uid == key: return boundary.uid return None
[docs] def paint(self, painter, option, widget=None): t = self.view.theme selected = bool(option.state & QStyle.State_Selected) painter.setRenderHint(QPainter.Antialiasing) if selected: painter.setBrush(QBrush(t.block_bg_selected)) painter.setPen(QPen(t.block_border_selected, 3)) else: painter.setBrush(QBrush(t.block_bg)) painter.setPen(QPen(t.block_border, 2, Qt.DashLine)) painter.drawRect(self.rect()) painter.setPen(QPen(t.text_selected if selected else t.text)) painter.setFont(QFont("Sans Serif", 9, QFont.Bold)) painter.drawText( self.rect().adjusted(4, 4, -4, -4), int(Qt.AlignTop | Qt.AlignHCenter), self.group.name, ) if selected: half = self.SELECTION_HANDLE_SIZE / 2 r = self.rect() corners = [ (r.left(), r.top()), (r.right(), r.top()), (r.left(), r.bottom()), (r.right(), r.bottom()), ] painter.setPen(QPen(t.block_border_selected, 1)) painter.setBrush(t.text_selected) for x, y in corners: painter.drawRect( x - half, y - half, self.SELECTION_HANDLE_SIZE, self.SELECTION_HANDLE_SIZE )
[docs] def mousePressEvent(self, event): self._interaction_start_pos = QPointF(self.pos()) self._interaction_start_rect = QRectF(self.rect()) if self.isSelected(): handle = self._handle_at(event.pos()) if handle is not None: self._resize_handle = handle self._resize_start_mouse = event.scenePos() self._resize_start_pos = self.pos() self._resize_start_width = self.rect().width() self._resize_start_height = self.rect().height() event.accept() return super().mousePressEvent(event)
[docs] def mouseMoveEvent(self, event): if self._resize_handle and self._resize_start_mouse and self._resize_start_pos: delta = event.scenePos() - self._resize_start_mouse dx = round(delta.x() / self.GRID_DX) * self.GRID_DX dy = round(delta.y() / self.GRID_DY) * self.GRID_DY start_x = self._resize_start_pos.x() start_y = self._resize_start_pos.y() start_w = self._resize_start_width start_h = self._resize_start_height if self._resize_handle in ("tl", "bl"): new_x = min(start_x + dx, start_x + start_w - self.MIN_WIDTH) new_w = max(self.MIN_WIDTH, (start_x + start_w) - new_x) else: new_x = start_x new_w = max(self.MIN_WIDTH, start_w + dx) if self._resize_handle in ("tl", "tr"): new_y = min(start_y + dy, start_y + start_h - self.MIN_HEIGHT) new_h = max(self.MIN_HEIGHT, (start_y + start_h) - new_y) else: new_y = start_y new_h = max(self.MIN_HEIGHT, start_h + dy) self.setPos(QPointF(new_x, new_y)) self.setRect(0, 0, new_w, new_h) self.sync_boundary_ports() self.view.on_group_moved(self) self.update() event.accept() return super().mouseMoveEvent(event)
[docs] def mouseReleaseEvent(self, event): start_pos = self._interaction_start_pos start_rect = self._interaction_start_rect end_pos = QPointF(self.pos()) end_rect = QRectF(self.rect()) self._resize_handle = None self._resize_start_mouse = None self._resize_start_pos = None self._interaction_start_pos = None self._interaction_start_rect = None super().mouseReleaseEvent(event) if start_pos is None or start_rect is None: return if start_pos != end_pos or start_rect != end_rect: if self.view.project_controller: self.view.project_controller.execute_move_resize_group( self.group.uid, start_pos, start_rect, end_pos, end_rect, )
[docs] def itemChange(self, change, value): if change == QGraphicsItem.ItemPositionChange and self.scene(): if self._syncing_geometry: return value 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 not self._syncing_geometry and self._resize_handle is None ): self.view.on_group_moved(self) return super().itemChange(change, value)
[docs] def apply_geometry(self, pos: QPointF, rect: QRectF) -> None: """Apply position and size without triggering layout side effects.""" self._syncing_geometry = True try: self.setRect(0, 0, rect.width(), rect.height()) self.setPos(QPointF(pos)) self.sync_boundary_ports() finally: self._syncing_geometry = False self.group.layout = { "x": float(pos.x()), "y": float(pos.y()), "width": float(rect.width()), "height": float(rect.height()), "orientation": self.orientation, }
[docs] def mouseDoubleClickEvent(self, event): self.view.enter_group(self.group.uid) event.accept()
def _handle_hit_rects(self) -> dict[str, QRectF]: half = self.SELECTION_HANDLE_HIT_SIZE / 2 r = self.rect() return { "tl": QRectF( r.left() - half, r.top() - half, self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE, ), "tr": QRectF( r.right() - half, r.top() - half, self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE, ), "bl": QRectF( r.left() - half, r.bottom() - half, self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE, ), "br": QRectF( r.right() - half, r.bottom() - half, self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE, ), } def _handle_at(self, local_pos: QPointF) -> str | None: for name, rect in self._handle_hit_rects().items(): if rect.contains(local_pos): return name return None