# ******************************************************************************
# 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)