Source code for pySimBlocks.gui.graphics.block_item

# ******************************************************************************
#                                  pySimBlocks
#                     Copyright (c) 2026 Université de Lille & INRIA
# ******************************************************************************
#  This program is free software: you can redistribute it and/or modify it
#  under the terms of the GNU Lesser General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or (at your
#  option) any later version.
#
#  This program is distributed in the hope that it will be useful, but WITHOUT
#  ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
#  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License
#  for more details.
#
#  You should have received a copy of the GNU Lesser General Public License
#  along with this program.  If not, see <https://www.gnu.org/licenses/>.
# ******************************************************************************
#  Authors: see Authors.txt
# ******************************************************************************

from typing import TYPE_CHECKING

from PySide6.QtCore import QPoint, QPointF, QRectF, Qt
from PySide6.QtGui import QPainterPath, QPen
from PySide6.QtWidgets import QGraphicsItem, QGraphicsRectItem, QStyle

from pySimBlocks.gui.dialogs.block_dialog import BlockDialog
from pySimBlocks.gui.graphics.port_item import PortItem

if TYPE_CHECKING:
    from pySimBlocks.gui.models.block_instance import BlockInstance
    from pySimBlocks.gui.widgets.diagram_view import DiagramView


[docs] class BlockItem(QGraphicsRectItem): """Render and interact with a block instance on the diagram scene. Attributes: view: Diagram view owning this graphics item. instance: Block instance represented by the item. orientation: Display orientation of the block. port_items: Visual port items attached to the block. """ WIDTH = 120 HEIGHT = 60 MIN_WIDTH = 40 MIN_HEIGHT = 30 GRID_DX = 5 GRID_DY = 5 SELECTION_HANDLE_SIZE = 8 SELECTION_HANDLE_HIT_SIZE = 16 def __init__(self, instance: "BlockInstance", pos: QPointF | QPoint, view: "DiagramView", layout: dict | None = None, ): """Initialize a block item. Args: instance: Block instance represented by this item. pos: Initial scene position. view: Diagram view owning the item. layout: Optional persisted layout properties. Raises: None. """ layout = layout or {} width = layout.get("width", self.WIDTH) height = layout.get("height", self.HEIGHT) width = float(width) if isinstance(width, (int, float)) else float(self.WIDTH) height = float(height) if isinstance(height, (int, float)) else float(self.HEIGHT) width = max(float(self.MIN_WIDTH), width) height = max(float(self.MIN_HEIGHT), height) super().__init__(0, 0, width, height) self.view = view self.instance = instance self.orientation = layout.get("orientation", "normal") self._resize_handle: str | None = None self._resize_start_mouse: QPointF | None = None self._resize_start_pos: QPointF | None = None self._resize_start_width = self.WIDTH self._resize_start_height = self.HEIGHT self.setPos(pos) self.setFlag(QGraphicsRectItem.ItemIsMovable) self.setFlag(QGraphicsRectItem.ItemIsSelectable) self.setFlag(QGraphicsRectItem.ItemSendsScenePositionChanges) # Ports self.port_items: list[PortItem] = [] for port in self.instance.ports: item = PortItem(port, self) self.port_items.append(item) self._layout_ports() # -------------------------------------------------------------------------- # Public Methods # --------------------------------------------------------------------------
[docs] def get_port_item(self, name:str) -> PortItem | None: """Return the visual port item matching the given port name. Args: name: Port name to look up. Returns: Matching port item, or None if not found. """ for port in self.port_items: if port.instance.name == name: return port
[docs] def refresh_ports(self): """Synchronize visual ports with the current block instance ports.""" for item in list(self.port_items): if item.instance not in self.instance.ports: self.scene().removeItem(item) self.port_items.remove(item) displayed_ports = {item.instance for item in self.port_items} for port in self.instance.ports: if port not in displayed_ports: item = PortItem(port, self) self.port_items.append(item) self._layout_ports() for item in self.port_items: item.update_display_as() self.view.on_block_ports_refreshed(self)
[docs] def toggle_orientation(self): """Flip the block orientation and relayout its ports.""" self.orientation = "flipped" if self.orientation == "normal" else "normal" self._layout_ports() self.view.on_block_moved(self) self.update()
[docs] def boundingRect(self) -> QRectF: """Return the item bounds including resize-handle hit areas. Returns: Bounding rectangle used for painting and interaction. """ half = self.SELECTION_HANDLE_HIT_SIZE / 2 return self.rect().adjusted(-half, -half, half, half)
[docs] def shape(self) -> QPainterPath: """Return the selectable shape including resize handles. Returns: Painter path used for hit testing. """ path = QPainterPath() path.addRect(self.rect()) for rect in self._handle_hit_rects().values(): path.addRect(rect) return path
[docs] def paint(self, painter, option, widget=None): """Paint the block body, label, and resize handles when selected. Args: painter: Painter used to render the item. option: Style option describing the current paint state. widget: Optional target widget. """ t = self.view.theme selected = bool(option.state & QStyle.State_Selected) if selected: painter.setBrush(t.block_bg_selected) painter.setPen(QPen(t.block_border_selected, 3)) else: painter.setBrush(t.block_bg) painter.setPen(QPen(t.block_border, 3)) painter.drawRect(self.rect()) if selected: painter.setPen(t.text_selected) else: painter.setPen(t.text) painter.drawText(self.rect(), Qt.AlignCenter, self.instance.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): """Start resize interaction when a selected handle is pressed. Args: event: Qt mouse-press event. """ 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): """Resize or move the block in response to mouse movement. Args: event: Qt mouse-move 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"): # drag left edge 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: # drag right edge new_x = start_x new_w = max(self.MIN_WIDTH, start_w + dx) if self._resize_handle in ("tl", "tr"): # drag top edge 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: # drag bottom edge 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._layout_ports() self.view.on_block_moved(self) self.update() event.accept() return super().mouseMoveEvent(event)
[docs] def mouseReleaseEvent(self, event): """End any active resize interaction. Args: event: Qt mouse-release event. """ self._resize_handle = None self._resize_start_mouse = None self._resize_start_pos = None super().mouseReleaseEvent(event)
[docs] def mouseDoubleClickEvent(self, event): """Open the block configuration dialog on double click. Args: event: Qt mouse double-click event. """ dialog = BlockDialog(self, readonly=False) dialog.exec() self.update() event.accept()
[docs] def itemChange(self, change, value): """Snap movement to the grid and notify the view when position changes. Args: change: Item change identifier. value: Proposed new value for the change. Returns: Adjusted change value when snapping is needed, otherwise the base implementation result. """ 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: self.view.on_block_moved(self) return super().itemChange(change, value)
# -------------------------------------------------------------------------- # Private Methods # -------------------------------------------------------------------------- def _handle_hit_rects(self) -> dict[str, QRectF]: """Return enlarged hit rectangles for the resize handles.""" 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: """Return the resize handle name located under the given position.""" for name, rect in self._handle_hit_rects().items(): if rect.contains(local_pos): return name return None def _layout_ports(self): """Place input and output ports on the correct block sides.""" inputs = [p for p in self.port_items if p.is_input] outputs = [p for p in self.port_items if not p.is_input] flipped = self.orientation == "flipped" width = self.rect().width() if not flipped: self._layout_side(inputs, x=0) self._layout_side(outputs, x=width) else: self._layout_side(inputs, x=width) self._layout_side(outputs, x=0) def _layout_side(self, ports, x): """Evenly distribute a list of ports along one block side.""" if not ports: return step = self.rect().height() / (len(ports) + 1) for i, port in enumerate(ports, start=1): port.setPos(x, i * step) port.update_label_position()