Source code for pySimBlocks.gui.widgets.diagram_view

# ******************************************************************************
#                                  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 __future__ import annotations

from typing import TYPE_CHECKING, Any

from PySide6.QtCore import QPointF, Qt, QTimer
from PySide6.QtGui import QGuiApplication, QKeySequence, QPainter, QPen
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView

from pySimBlocks.gui.graphics.block_item import BlockItem
from pySimBlocks.gui.graphics.connection_item import ConnectionItem, OrthogonalRoute
from pySimBlocks.gui.graphics.port_item import PortItem
from pySimBlocks.gui.graphics.theme import make_theme
from pySimBlocks.gui.models.block_instance import BlockInstance
from pySimBlocks.gui.models.connection_instance import ConnectionInstance

if TYPE_CHECKING:
    from pySimBlocks.gui.project_controller import ProjectController


[docs] class DiagramView(QGraphicsView): """Interactive Qt graphics view for the block diagram canvas. Handles block/connection rendering, drag-and-drop, keyboard shortcuts, zoom, and mouse-driven wire creation. Attributes: diagram_scene: The underlying QGraphicsScene. theme: Current visual theme (colours, brushes). pending_port: Port item waiting for a connection to be completed. temp_connection: Temporary wire shown while dragging from a port. copied_block: Most recently copied block, used for paste. project_controller: Controller coordinating model mutations. block_items: Mapping from block UID to its visual BlockItem. connections: Mapping from ConnectionInstance to its visual ConnectionItem. """ def __init__(self): """Initialize the diagram view and configure scene behavior. Args: None. Raises: None. """ super().__init__() self.diagram_scene = QGraphicsScene(self) self.setScene(self.diagram_scene) self.setAcceptDrops(True) self.setRenderHint(QPainter.Antialiasing) self.theme = make_theme() self.diagram_scene.setBackgroundBrush(self.theme.scene_bg) hints = QGuiApplication.styleHints() hints.colorSchemeChanged.connect(self._on_color_scheme_changed) app = QGuiApplication.instance() if hasattr(app, "paletteChanged"): app.paletteChanged.connect(lambda *_: QTimer.singleShot(0, self._apply_theme_from_system)) self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate) self.pending_port: PortItem | None = None self.temp_connection: ConnectionItem | None = None self.copied_block: BlockItem | None = None self.drop_event_pos: QPointF = QPointF(0, 0) self.project_controller: ProjectController | None self.block_items: dict[str, BlockItem] = {} self.connections: dict[ConnectionInstance, ConnectionItem] = {} self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setResizeAnchor(QGraphicsView.AnchorUnderMouse) self.setDragMode(QGraphicsView.RubberBandDrag) # -------------------------------------------------------------------------- # Public Methods # --------------------------------------------------------------------------
[docs] def add_block( self, block_instance: BlockInstance, block_layout: dict[str, Any] | None = None, ) -> None: """Add a visual block item to the scene for the given block instance. Args: block_instance: The block model to represent visually. block_layout: Optional dict with position/size hints. """ block_item = BlockItem(block_instance, self.drop_event_pos, self, block_layout) self.diagram_scene.addItem(block_item) self.block_items[block_instance.uid] = block_item
[docs] def refresh_block_port(self, block_instance: BlockInstance) -> None: """Refresh the port visuals of the block item for the given instance. Args: block_instance: The block whose port items should be refreshed. """ block_item = self.get_block_item_from_instance(block_instance) if block_item: block_item.refresh_ports()
[docs] def remove_block(self, block_instance: BlockInstance) -> None: """Remove the visual block item for the given instance from the scene. Args: block_instance: The block whose visual item should be removed. """ block_item = self.block_items[block_instance.uid] self.diagram_scene.removeItem(block_item) self.block_items.pop(block_instance.uid, None)
[docs] def add_connection( self, connection_instance: ConnectionInstance, points: list[QPointF] | None = None, ) -> None: """Add a visual wire to the scene for the given connection instance. Args: connection_instance: The connection model to represent visually. points: Optional list of intermediate waypoints for the wire. """ src_port_item = self.get_block_item_from_instance(connection_instance.src_block()).get_port_item(connection_instance.src_port.name) dst_port_item = self.get_block_item_from_instance(connection_instance.dst_block()).get_port_item(connection_instance.dst_port.name) connection_item = ConnectionItem( src_port_item, dst_port_item, connection_instance, points ) self.connections[connection_instance] = connection_item self.diagram_scene.addItem(connection_item)
[docs] def remove_connection(self, connection_instance: ConnectionInstance) -> None: """Remove the visual wire for the given connection instance from the scene. Args: connection_instance: The connection whose visual item should be removed. """ connection_item = self.connections.pop(connection_instance, None) if connection_item: self.diagram_scene.removeItem(connection_item)
[docs] def get_block_item_from_instance(self, block_instance: BlockInstance) -> BlockItem | None: """Return the visual BlockItem for the given block instance, or None. Args: block_instance: The block model to look up. Returns: The corresponding :class:`BlockItem`, or ``None`` if not found. """ return self.block_items.get(block_instance.uid)
[docs] def create_connection_event(self, port: PortItem) -> None: """Begin a wire-drag interaction from the given port item. Args: port: The port item from which the connection is being drawn. """ if not self.pending_port: self.pending_port = port self.temp_connection = ConnectionItem(self.pending_port, None, None) self.diagram_scene.addItem(self.temp_connection) return
[docs] def update_block_param_event(self, block_instance: BlockInstance, params: dict[str, Any]) -> None: """Delegate a parameter update for the given block to the project controller. Args: block_instance: The block to update. params: New parameter dict to apply. """ self.project_controller.update_block_param(block_instance, params)
[docs] def on_block_moved(self, block_item: BlockItem) -> None: """Mark the project dirty and refresh all wires connected to the moved block. Args: block_item: The block item that was repositioned. """ for conn_inst, conn_item in self.connections.items(): if conn_inst.is_block_involved(block_item.instance): conn_item.invalidate_manual_route() conn_item.update_position()
[docs] def on_block_ports_refreshed(self, block_item: BlockItem) -> None: """Refresh all wire positions after the ports of a block have been updated. Args: block_item: The block item whose ports were refreshed. """ for conn_inst, conn_item in self.connections.items(): if conn_inst.is_block_involved(block_item.instance): conn_item.update_position()
[docs] def dragEnterEvent(self, event) -> None: """Accept drag events that carry text MIME data. Args: event: Qt drag-enter event. """ if event.mimeData().hasText(): event.acceptProposedAction()
[docs] def dragMoveEvent(self, event) -> None: """Accept proposed drag-move actions unconditionally. Args: event: Qt drag-move event. """ event.acceptProposedAction()
[docs] def dropEvent(self, event) -> None: """Handle a block drop by adding the corresponding block to the project. Args: event: Qt drop event carrying ``"category:block_type"`` text. """ self.drop_event_pos = self.mapToScene(event.position().toPoint()) category, block_type = event.mimeData().text().split(":") self.project_controller.add_block(category, block_type) event.acceptProposedAction()
[docs] def keyPressEvent(self, event) -> None: """Handle keyboard shortcuts for copy, paste, delete, zoom, rotate, and center. Args: event: Qt key-press event. """ # UNDO / REDO if event.matches(QKeySequence.Undo): self.project_controller.undo_manager.undo() event.accept() return if event.matches(QKeySequence.Redo): self.project_controller.undo_manager.redo() event.accept() return if ( event.key() == Qt.Key_Z and event.modifiers() == (Qt.ControlModifier | Qt.ShiftModifier) ): self.project_controller.undo_manager.redo() event.accept() return # COPY if event.key() == Qt.Key_C and event.modifiers() & Qt.ControlModifier: selected = [i for i in self.diagram_scene.selectedItems() if isinstance(i, BlockItem)] if selected: self.copied_block = selected[0] return # PASTE if event.key() == Qt.Key_V and event.modifiers() & Qt.ControlModifier: if self.copied_block: self.drop_event_pos = self.copied_block.pos() + QPointF(30, 30) self.project_controller.add_copy_block(self.copied_block.instance) return # DELETE if event.key() in (Qt.Key_Delete, Qt.Key_Backspace): self.delete_selected() return # ZOOM IN / OUT if event.key() in (Qt.Key_Plus, Qt.Key_Equal) and event.modifiers() & Qt.ControlModifier: self.scale_view(1.15) return if event.key() == Qt.Key_Minus and event.modifiers() & Qt.ControlModifier: self.scale_view(1 / 1.15) return # ROTATE BLOCK if event.key() == Qt.Key_R and event.modifiers() & Qt.ControlModifier: selected = [i for i in self.diagram_scene.selectedItems() if isinstance(i, BlockItem)] for item in selected: self.project_controller.execute_toggle_orientation(item.instance) return # CENTER VIEW if event.key() == Qt.Key_Space and not event.modifiers(): self._center_on_diagram() event.accept() return super().keyPressEvent(event)
[docs] def wheelEvent(self, event) -> None: """Zoom the view when Ctrl is held, otherwise scroll normally. Args: event: Qt wheel event. """ if event.modifiers() & Qt.ControlModifier: zoom_factor = 1.15 if event.angleDelta().y() > 0: self.scale_view(zoom_factor) else: self.scale_view(1 / zoom_factor) event.accept() else: super().wheelEvent(event)
[docs] def mouseMoveEvent(self, event) -> None: """Update the temporary wire endpoint while dragging from a port. Args: event: Qt mouse-move event. """ if self.temp_connection: pos = self.mapToScene(event.position().toPoint()) self.temp_connection.update_temp_position(pos) return super().mouseMoveEvent(event)
[docs] def mouseReleaseEvent(self, event) -> None: """Complete or cancel a wire drag on mouse release. Args: event: Qt mouse-release event. """ if not self.pending_port: super().mouseReleaseEvent(event) return pos = self.mapToScene(event.position().toPoint()) items = self.diagram_scene.items(pos) port = next((i for i in items if isinstance(i, PortItem)), None) if not port: self._cancel_temp_connection() return self.project_controller.add_connection(self.pending_port.instance, port.instance) self._cancel_temp_connection()
[docs] def delete_selected(self) -> None: """Remove all selected blocks and connections from the project.""" selected_items = list(self.diagram_scene.selectedItems()) if not selected_items: return self.project_controller.begin_macro("Delete Selection") try: for item in selected_items: if isinstance(item, BlockItem): self.project_controller.remove_block(item.instance) elif isinstance(item, ConnectionItem): self.project_controller.remove_connection(item.instance) finally: self.project_controller.end_macro()
[docs] def clear_scene(self) -> None: """Remove all blocks and connections from the scene and reset state.""" self.diagram_scene.clear() self.block_items.clear() self.connections.clear() self.temp_connection = None self.pending_port = None
[docs] def scale_view(self, factor: float) -> None: """Scale the view by ``factor``, clamped to the allowed zoom range. Args: factor: Multiplicative zoom factor to apply. """ current_scale = self.transform().m11() min_scale, max_scale = 0.2, 5.0 new_scale = current_scale * factor if min_scale <= new_scale <= max_scale: self.scale(factor, factor)
# -------------------------------------------------------------------------- # Private Methods # -------------------------------------------------------------------------- def _cancel_temp_connection(self) -> None: """Remove the temporary wire and reset the pending-port state.""" self.diagram_scene.removeItem(self.temp_connection) self.temp_connection = None self.pending_port = None def _on_color_scheme_changed(self, *_) -> None: """Schedule a theme refresh after the system colour scheme changes.""" QTimer.singleShot(0, self._apply_theme_from_system) def _apply_theme_from_system(self) -> None: """Reload the theme and repaint all scene items to match the system palette.""" self.theme = make_theme() self.diagram_scene.setBackgroundBrush(self.theme.scene_bg) self._refresh_theme_items() self.viewport().update() self.diagram_scene.update() def _refresh_theme_items(self) -> None: """Update colours on all block and connection items to match the current theme.""" for block in self.block_items.values(): block.update() for port in block.port_items: port.label.setDefaultTextColor(self.theme.text) port.update() for conn in self.connections.values(): conn.setPen(QPen(self.theme.wire, 2)) conn.update_position() conn.update() def _center_on_diagram(self) -> None: """Fit the view to the bounding rect of all scene items with a small margin.""" scene = self.diagram_scene items_rect = scene.itemsBoundingRect() if items_rect.isNull(): return # Un peu de marge pour éviter que ça colle aux bords margin = 40 items_rect.adjust(-margin, -margin, margin, margin) scene.setSceneRect(items_rect) self.fitInView(items_rect, Qt.KeepAspectRatio)