diff --git a/pySimBlocks/gui/graphics/block_item.py b/pySimBlocks/gui/graphics/block_item.py
index e0a58d8..722c0d1 100644
--- a/pySimBlocks/gui/graphics/block_item.py
+++ b/pySimBlocks/gui/graphics/block_item.py
@@ -85,6 +85,8 @@ def __init__(self,
self._resize_start_pos: QPointF | None = None
self._resize_start_width = self.WIDTH
self._resize_start_height = self.HEIGHT
+ self._interaction_start_pos: QPointF | None = None
+ self._interaction_start_rect: QRectF | None = None
self.setPos(pos)
self.setFlag(QGraphicsRectItem.ItemIsMovable)
@@ -143,6 +145,14 @@ def toggle_orientation(self):
self.view.on_block_moved(self)
self.update()
+ def set_orientation(self, orientation: str) -> None:
+ if orientation not in {"normal", "flipped"}:
+ return
+ self.orientation = orientation
+ self._layout_ports()
+ self.view.on_block_moved(self)
+ self.update()
+
def boundingRect(self) -> QRectF:
"""Return the item bounds including resize-handle hit areas.
@@ -225,6 +235,8 @@ def mousePressEvent(self, event):
Args:
event: Qt mouse-press 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:
@@ -284,11 +296,30 @@ def mouseReleaseEvent(self, event):
Args:
event: Qt mouse-release 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:
+ self.view.project_controller.execute_move_resize_block(
+ self.instance,
+ start_pos,
+ start_rect,
+ end_pos,
+ end_rect,
+ )
+
def mouseDoubleClickEvent(self, event):
"""Open the block configuration dialog on double click.
diff --git a/pySimBlocks/gui/main_window.py b/pySimBlocks/gui/main_window.py
index 21acdcd..e326999 100644
--- a/pySimBlocks/gui/main_window.py
+++ b/pySimBlocks/gui/main_window.py
@@ -35,6 +35,7 @@
from pySimBlocks.gui.services.project_saver import ProjectSaverYaml
from pySimBlocks.gui.services.simulation_runner import SimulationRunner
from pySimBlocks.gui.services.yaml_tools import cleanup_runtime_project_yaml
+from pySimBlocks.gui.undo_redo.undo_redo_manager import UndoManager
from pySimBlocks.gui.widgets.block_list import BlockList
from pySimBlocks.gui.widgets.diagram_view import DiagramView
from pySimBlocks.gui.widgets.toolbar_view import ToolBarView
@@ -73,10 +74,13 @@ def __init__(self, project_path: Path):
self.runner = SimulationRunner()
self.block_registry = load_block_registry()
+ self.undo_manager = UndoManager()
self.project_state = ProjectState(project_path)
self.view = DiagramView()
- self.project_controller = ProjectController(self.project_state, self.view, self.resolve_block_meta)
+ self.project_controller = ProjectController(
+ self.project_state, self.view, self.resolve_block_meta, self.undo_manager
+ )
self.view.project_controller = self.project_controller
self.blocks = BlockList(self.get_categories, self.get_blocks, self.resolve_block_meta)
self.toolbar = ToolBarView(self.saver, self.runner, self.project_controller)
@@ -94,6 +98,7 @@ def __init__(self, project_path: Path):
self.project_controller.load_project(self.loader)
self.project_controller.dirty_changed.connect(self.update_window_title)
+ self.undo_manager.stack.cleanChanged.connect(self._on_clean_changed)
self.update_window_title()
self.save_action = QAction("Save", self)
@@ -106,6 +111,16 @@ def __init__(self, project_path: Path):
self.quit_action.triggered.connect(self.close)
self.addAction(self.quit_action)
+ self.undo_action = self.undo_manager.create_undo_action(self)
+ self.undo_action.setShortcut(QKeySequence.Undo)
+ self.undo_action.setShortcutContext(Qt.ApplicationShortcut)
+ self.addAction(self.undo_action)
+
+ self.redo_action = self.undo_manager.create_redo_action(self)
+ self.redo_action.setShortcuts([QKeySequence("Ctrl+Y"), QKeySequence("Ctrl+Shift+Z")])
+ self.redo_action.setShortcutContext(Qt.ApplicationShortcut)
+ self.addAction(self.redo_action)
+
QTimer.singleShot(0, self.view.setFocus)
@@ -200,6 +215,10 @@ def closeEvent(self, event) -> None:
event: Qt close event.
"""
if self.confirm_discard_or_save("closing"):
+ try:
+ self.undo_manager.stack.cleanChanged.disconnect(self._on_clean_changed)
+ except (TypeError, RuntimeError):
+ pass
self.cleanup()
event.accept()
else:
@@ -235,4 +254,13 @@ def _on_save(self) -> None:
if not self.project_controller.is_dirty:
return
self.saver.save(self.project_controller.project_state, self.project_controller.view.block_items)
- self.project_controller.clear_dirty()
+ self.undo_manager.set_clean()
+
+ def _on_clean_changed(self, is_clean: bool) -> None:
+ try:
+ if is_clean:
+ self.project_controller.clear_dirty()
+ else:
+ self.project_controller.make_dirty()
+ except RuntimeError:
+ return
diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py
index a1cad85..44cde45 100644
--- a/pySimBlocks/gui/project_controller.py
+++ b/pySimBlocks/gui/project_controller.py
@@ -23,7 +23,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
-from PySide6.QtCore import QObject, Signal, QPointF
+from PySide6.QtCore import QObject, Signal, QPointF, QRectF
from pySimBlocks.gui.models import (
BlockInstance,
@@ -34,6 +34,17 @@
from pySimBlocks.gui.widgets.diagram_view import DiagramView
from pySimBlocks.gui.blocks.block_meta import BlockMeta
from pySimBlocks.gui.services.yaml_tools import cleanup_runtime_project_yaml
+from pySimBlocks.gui.undo_redo.undo_redo_manager import UndoManager
+from pySimBlocks.gui.undo_redo.commands import (
+ AddBlockCommand,
+ AddConnectionCommand,
+ EditBlockParamsCommand,
+ MoveResizeBlockCommand,
+ RemoveBlockCommand,
+ RemoveConnectionCommand,
+ ToggleOrientationCommand,
+ ConnectionSnapshot,
+)
if TYPE_CHECKING:
from pySimBlocks.gui.services.project_loader import ProjectLoader
@@ -62,6 +73,7 @@ def __init__(
project_state: ProjectState,
view: DiagramView,
resolve_block_meta: Callable[[str, str], BlockMeta],
+ undo_manager: UndoManager,
):
"""Initialize the ProjectController.
@@ -75,6 +87,7 @@ def __init__(
self.project_state = project_state
self.resolve_block_meta = resolve_block_meta
self.view = view
+ self.undo_manager = undo_manager
self.is_dirty: bool = False
@@ -101,7 +114,8 @@ def add_block(
"""
block_meta = self.resolve_block_meta(category, block_type)
block_instance = BlockInstance(block_meta)
- return self._add_block(block_instance, block_layout)
+ self.undo_manager.push(AddBlockCommand(self, block_instance, block_layout))
+ return block_instance
def add_copy_block(self, block_instance: BlockInstance) -> BlockInstance:
"""Add a copy of an existing block to the project.
@@ -113,7 +127,8 @@ def add_copy_block(self, block_instance: BlockInstance) -> BlockInstance:
The newly created copy as a :class:`BlockInstance`.
"""
copy = BlockInstance.copy(block_instance)
- return self._add_block(copy)
+ self.undo_manager.push(AddBlockCommand(self, copy))
+ return copy
def rename_block(self, block_instance: BlockInstance, new_name: str) -> None:
"""Rename a block and update all references in logging and plot signals.
@@ -156,16 +171,7 @@ def update_block_param(self, block_instance: BlockInstance, params: dict[str, An
params: New parameter dict. If a ``'name'`` key is present the
block is also renamed.
"""
- self.rename_block(block_instance, params.pop("name", block_instance.name))
-
- if params == block_instance.parameters:
- return
-
- block_instance.update_params(params)
- block_instance.resolve_ports()
- self._remove_connection_if_port_disapear(block_instance)
- self.view.refresh_block_port(block_instance)
- self.make_dirty()
+ self.undo_manager.push(EditBlockParamsCommand(self, block_instance, params))
def remove_block(self, block_instance: BlockInstance) -> None:
"""Remove a block, its connections, and its signals from the project.
@@ -173,29 +179,7 @@ def remove_block(self, block_instance: BlockInstance) -> None:
Args:
block_instance: The block to remove.
"""
- self.make_dirty()
-
- for connection in self.project_state.get_connections_of_block(block_instance):
- self.remove_connection(connection)
-
- removed_signals = [
- f"{block_instance.name}.outputs.{p.name}"
- for p in block_instance.ports if p.direction == "output"
- ]
- remaining_signals = [
- s for s in self.project_state.logging
- if s not in removed_signals
- ]
- self.set_logged_signals(remaining_signals)
-
- for i in reversed(range(len(self.project_state.plots))):
- plot = self.project_state.plots[i]
- plot["signals"] = [s for s in plot["signals"] if s not in removed_signals]
- if not plot["signals"]:
- self.delete_plot(i)
-
- self.project_state.remove_block(block_instance)
- self.view.remove_block(block_instance)
+ self.undo_manager.push(RemoveBlockCommand(self, block_instance))
def make_unique_name(self, base_name: str) -> str:
"""Return ``base_name`` or a suffixed variant that is unique across all blocks.
@@ -260,21 +244,13 @@ def add_connection(
"""
if not port1.is_compatible(port2):
return
-
src_port, dst_port = (
(port1, port2) if port1.direction == "output" else (port2, port1)
)
-
port_dst_connections = self.project_state.get_connections_of_port(dst_port)
-
if not dst_port.can_accept_connection(port_dst_connections):
return
-
- connection_instance = ConnectionInstance(src_port, dst_port)
-
- self.project_state.add_connection(connection_instance)
- self.view.add_connection(connection_instance, points)
- self.make_dirty()
+ self.undo_manager.push(AddConnectionCommand(self, src_port, dst_port, points))
def remove_connection(self, connection: ConnectionInstance) -> None:
"""Remove a connection from both the model and the view.
@@ -282,9 +258,37 @@ def remove_connection(self, connection: ConnectionInstance) -> None:
Args:
connection: The :class:`ConnectionInstance` to remove.
"""
- self.project_state.remove_connection(connection)
- self.view.remove_connection(connection)
- self.make_dirty()
+ self.undo_manager.push(RemoveConnectionCommand(self, connection))
+
+ def execute_move_resize_block(
+ self,
+ block_instance: BlockInstance,
+ old_pos: QPointF,
+ old_rect: QRectF,
+ new_pos: QPointF,
+ new_rect: QRectF,
+ ) -> None:
+ self.undo_manager.push(
+ MoveResizeBlockCommand(
+ self, block_instance.uid, old_pos, old_rect, new_pos, new_rect
+ )
+ )
+
+ def execute_toggle_orientation(self, block_instance: BlockInstance) -> None:
+ block_item = self.view.get_block_item_from_instance(block_instance)
+ if block_item is None:
+ return
+ old_orientation = block_item.orientation
+ new_orientation = "flipped" if old_orientation == "normal" else "normal"
+ self.undo_manager.push(
+ ToggleOrientationCommand(self, block_instance.uid, old_orientation, new_orientation)
+ )
+
+ def begin_macro(self, text: str) -> None:
+ self.undo_manager.stack.beginMacro(text)
+
+ def end_macro(self) -> None:
+ self.undo_manager.stack.endMacro()
# --------------------------------------------------------------------------
@@ -307,6 +311,8 @@ def clear(self) -> None:
"""Reset the project state and diagram view to an empty state."""
self.project_state.clear()
self.view.clear_scene()
+ self.undo_manager.clear()
+ self.clear_dirty()
def update_project_param(self, new_path: Path, ext: str) -> None:
"""Update the project directory path and external module reference.
@@ -422,16 +428,156 @@ def _add_block(
return block_instance
- def _remove_connection_if_port_disapear(self, block_instance: BlockInstance) -> None:
+ def _remove_connection_if_port_disapear(self, block_instance: BlockInstance) -> list[ConnectionSnapshot]:
"""Remove any connection whose source or destination port no longer exists."""
+ removed: list[ConnectionSnapshot] = []
for connection in self.project_state.get_connections_of_block(block_instance):
src_exists = connection.src_port in connection.src_block().ports
dst_exists = connection.dst_port in connection.dst_block().ports
if not (src_exists and dst_exists):
- self.remove_connection(connection)
+ removed.append(self._capture_connection_snapshot(connection))
+ self._remove_connection(connection)
+ return removed
def _ensure_logged(self, signals: list[str]) -> None:
"""Append any signal not yet in the logging list."""
for sig in signals:
if sig not in self.project_state.logging:
self.project_state.logging.append(sig)
+
+ def _remove_block(self, block_instance: BlockInstance) -> None:
+ for connection in list(self.project_state.get_connections_of_block(block_instance)):
+ self._remove_connection(connection)
+
+ removed_signals = [
+ f"{block_instance.name}.outputs.{p.name}"
+ for p in block_instance.ports if p.direction == "output"
+ ]
+ self.project_state.logging = [s for s in self.project_state.logging if s not in removed_signals]
+
+ for i in reversed(range(len(self.project_state.plots))):
+ plot = self.project_state.plots[i]
+ plot["signals"] = [s for s in plot["signals"] if s not in removed_signals]
+ if not plot["signals"]:
+ del self.project_state.plots[i]
+
+ self.project_state.remove_block(block_instance)
+ self.view.remove_block(block_instance)
+
+ def _remove_connection(self, connection: ConnectionInstance) -> None:
+ self.project_state.remove_connection(connection)
+ self.view.remove_connection(connection)
+
+ def _find_block_by_uid(self, block_uid: str) -> BlockInstance | None:
+ for block in self.project_state.blocks:
+ if block.uid == block_uid:
+ return block
+ return None
+
+ def _find_port(self, block_uid: str, port_name: str) -> PortInstance | None:
+ block = self._find_block_by_uid(block_uid)
+ if block is None:
+ return None
+ for port in block.ports:
+ if port.name == port_name:
+ return port
+ return None
+
+ def _capture_block_layout(self, block_instance: BlockInstance) -> dict:
+ block_item = self.view.get_block_item_from_instance(block_instance)
+ if block_item is None:
+ return {}
+ pos = block_item.pos()
+ rect = block_item.rect()
+ return {
+ "x": float(pos.x()),
+ "y": float(pos.y()),
+ "orientation": block_item.orientation,
+ "width": float(rect.width()),
+ "height": float(rect.height()),
+ }
+
+ def _capture_connection_snapshot(self, connection: ConnectionInstance) -> ConnectionSnapshot:
+ points: list[QPointF] | None = None
+ connection_item = self.view.connections.get(connection)
+ if (
+ connection_item is not None
+ and connection_item.is_manual
+ and connection_item.route is not None
+ ):
+ points = [QPointF(p) for p in connection_item.route.points]
+ return ConnectionSnapshot(
+ src_block_uid=connection.src_block().uid,
+ src_port_name=connection.src_port.name,
+ dst_block_uid=connection.dst_block().uid,
+ dst_port_name=connection.dst_port.name,
+ points=points,
+ )
+
+ def _add_connection_from_snapshot(self, snapshot: ConnectionSnapshot) -> ConnectionInstance | None:
+ src_port = self._find_port(snapshot.src_block_uid, snapshot.src_port_name)
+ dst_port = self._find_port(snapshot.dst_block_uid, snapshot.dst_port_name)
+ if src_port is None or dst_port is None:
+ return None
+ if not src_port.is_compatible(dst_port):
+ return None
+ if not dst_port.can_accept_connection(self.project_state.get_connections_of_port(dst_port)):
+ return None
+ connection_instance = ConnectionInstance(src_port, dst_port)
+ self.project_state.add_connection(connection_instance)
+ self.view.add_connection(connection_instance, snapshot.points)
+ return connection_instance
+
+ def _set_block_geometry(self, block_uid: str, pos: QPointF, rect: QRectF) -> None:
+ block = self._find_block_by_uid(block_uid)
+ if block is None:
+ return
+ block_item = self.view.get_block_item_from_instance(block)
+ if block_item is None:
+ return
+ block_item.setPos(QPointF(pos))
+ block_item.setRect(0, 0, rect.width(), rect.height())
+ block_item._layout_ports()
+ self.view.on_block_moved(block_item)
+
+ def _set_block_orientation(self, block_uid: str, orientation: str) -> None:
+ block = self._find_block_by_uid(block_uid)
+ if block is None:
+ return
+ block_item = self.view.get_block_item_from_instance(block)
+ if block_item is None:
+ return
+ block_item.set_orientation(orientation)
+ self.view.on_block_moved(block_item)
+
+ def _apply_block_update(
+ self,
+ block_instance: BlockInstance,
+ new_name: str,
+ params: dict[str, Any],
+ ) -> list[ConnectionSnapshot]:
+ old_name = block_instance.name
+ if old_name != new_name:
+ new_name = self.make_unique_name(new_name)
+ block_instance.name = new_name
+ prefix_old = f"{old_name}.outputs."
+ prefix_new = f"{new_name}.outputs."
+ self.project_state.logging = [
+ s.replace(prefix_old, prefix_new)
+ if s.startswith(prefix_old) else s
+ for s in self.project_state.logging
+ ]
+ for plot in self.project_state.plots:
+ plot["signals"] = [
+ s.replace(prefix_old, prefix_new)
+ if s.startswith(prefix_old) else s
+ for s in plot["signals"]
+ ]
+
+ if params != block_instance.parameters:
+ block_instance.update_params(params)
+ block_instance.resolve_ports()
+ removed = self._remove_connection_if_port_disapear(block_instance)
+ self.view.refresh_block_port(block_instance)
+ return removed
+ return []
diff --git a/pySimBlocks/gui/services/project_loader.py b/pySimBlocks/gui/services/project_loader.py
index 77e13ed..1865bd2 100644
--- a/pySimBlocks/gui/services/project_loader.py
+++ b/pySimBlocks/gui/services/project_loader.py
@@ -23,8 +23,10 @@
from PySide6.QtCore import QPointF
+from pySimBlocks.gui.models import BlockInstance
from pySimBlocks.gui.project_controller import ProjectController
from pySimBlocks.gui.services.yaml_tools import load_yaml_file
+from pySimBlocks.gui.undo_redo.commands import ConnectionSnapshot
class ProjectLoader(ABC):
@@ -133,7 +135,8 @@ def _load_blocks(
block_layout = self._sanitize_block_layout((layout_blocks or {}).get(name, {}))
controller.view.drop_event_pos = positions.get(name, QPointF(0, 0))
- block = controller.add_block(category, block_type, block_layout)
+ block_meta = controller.resolve_block_meta(category, block_type)
+ block = controller._add_block(BlockInstance(block_meta), block_layout)
controller.rename_block(block, name)
raw_params = desc.get("parameters", {})
@@ -230,7 +233,15 @@ def _load_connections(
continue
points = routes.get(conn_name, None) if isinstance(conn_name, str) else None
- controller.add_connection(src_port, dst_port, points)
+ controller._add_connection_from_snapshot(
+ ConnectionSnapshot(
+ src_block_uid=src_block.uid,
+ src_port_name=src_port.name,
+ dst_block_uid=dst_block.uid,
+ dst_port_name=dst_port.name,
+ points=points,
+ )
+ )
def _load_logging(self, controller: ProjectController, sim_data: dict):
"""Load the configured logging signal list."""
diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py
new file mode 100644
index 0000000..cd57b56
--- /dev/null
+++ b/pySimBlocks/gui/undo_redo/commands.py
@@ -0,0 +1,186 @@
+# ******************************************************************************
+# 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 .
+# ******************************************************************************
+# Authors: see Authors.txt
+# ******************************************************************************
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from PySide6.QtCore import QPointF, QRectF
+from PySide6.QtGui import QUndoCommand
+
+from pySimBlocks.gui.models import BlockInstance, PortInstance
+
+
+@dataclass
+class ConnectionSnapshot:
+ src_block_uid: str
+ src_port_name: str
+ dst_block_uid: str
+ dst_port_name: str
+ points: list[QPointF] | None = None
+
+
+class AddBlockCommand(QUndoCommand):
+ def __init__(self, controller, block_instance: BlockInstance, block_layout: dict | None = None):
+ super().__init__("Add Block")
+ self._controller = controller
+ self._block_instance = block_instance
+ self._block_layout = dict(block_layout or {})
+
+ def redo(self) -> None:
+ self._controller._add_block(self._block_instance, self._block_layout)
+ self._controller.make_dirty()
+
+ def undo(self) -> None:
+ self._controller._remove_block(self._block_instance)
+ self._controller.make_dirty()
+
+
+class AddConnectionCommand(QUndoCommand):
+ def __init__(self, controller, src_port: PortInstance, dst_port: PortInstance, points: list[QPointF] | None = None):
+ super().__init__("Add Connection")
+ self._controller = controller
+ self._snapshot = ConnectionSnapshot(
+ src_block_uid=src_port.block.uid,
+ src_port_name=src_port.name,
+ dst_block_uid=dst_port.block.uid,
+ dst_port_name=dst_port.name,
+ points=list(points) if points else None,
+ )
+ self._connection_instance = None
+
+ def redo(self) -> None:
+ self._connection_instance = self._controller._add_connection_from_snapshot(self._snapshot)
+ self._controller.make_dirty()
+
+ def undo(self) -> None:
+ if self._connection_instance is not None:
+ self._controller._remove_connection(self._connection_instance)
+ self._controller.make_dirty()
+
+
+class RemoveConnectionCommand(QUndoCommand):
+ def __init__(self, controller, connection_instance):
+ super().__init__("Delete Connection")
+ self._controller = controller
+ self._snapshot = controller._capture_connection_snapshot(connection_instance)
+ self._connection_instance = connection_instance
+
+ def redo(self) -> None:
+ if self._connection_instance is not None:
+ self._controller._remove_connection(self._connection_instance)
+ self._controller.make_dirty()
+
+ def undo(self) -> None:
+ self._connection_instance = self._controller._add_connection_from_snapshot(self._snapshot)
+ self._controller.make_dirty()
+
+
+class RemoveBlockCommand(QUndoCommand):
+ def __init__(self, controller, block_instance: BlockInstance):
+ super().__init__("Delete Block")
+ self._controller = controller
+ self._block_instance = block_instance
+ self._layout = controller._capture_block_layout(block_instance)
+ self._connections = [
+ controller._capture_connection_snapshot(connection)
+ for connection in controller.project_state.get_connections_of_block(block_instance)
+ ]
+ self._logging_before = list(controller.project_state.logging)
+ self._plots_before = [dict(title=p["title"], signals=list(p["signals"])) for p in controller.project_state.plots]
+
+ def redo(self) -> None:
+ self._controller._remove_block(self._block_instance)
+ self._controller.make_dirty()
+
+ def undo(self) -> None:
+ self._controller._add_block(self._block_instance, self._layout)
+ for snapshot in self._connections:
+ self._controller._add_connection_from_snapshot(snapshot)
+ self._controller.project_state.logging = list(self._logging_before)
+ self._controller.project_state.plots = [dict(title=p["title"], signals=list(p["signals"])) for p in self._plots_before]
+ self._controller.make_dirty()
+
+
+class MoveResizeBlockCommand(QUndoCommand):
+ def __init__(self, controller, block_uid: str, old_pos: QPointF, old_rect: QRectF, new_pos: QPointF, new_rect: QRectF):
+ super().__init__("Move/Resize Block")
+ self._controller = controller
+ self._block_uid = block_uid
+ self._old_pos = QPointF(old_pos)
+ self._old_rect = QRectF(old_rect)
+ self._new_pos = QPointF(new_pos)
+ self._new_rect = QRectF(new_rect)
+
+ def redo(self) -> None:
+ self._controller._set_block_geometry(self._block_uid, self._new_pos, self._new_rect)
+ self._controller.make_dirty()
+
+ def undo(self) -> None:
+ self._controller._set_block_geometry(self._block_uid, self._old_pos, self._old_rect)
+ self._controller.make_dirty()
+
+
+class ToggleOrientationCommand(QUndoCommand):
+ def __init__(self, controller, block_uid: str, old_orientation: str, new_orientation: str):
+ super().__init__("Flip Block")
+ self._controller = controller
+ self._block_uid = block_uid
+ self._old_orientation = old_orientation
+ self._new_orientation = new_orientation
+
+ def redo(self) -> None:
+ self._controller._set_block_orientation(self._block_uid, self._new_orientation)
+ self._controller.make_dirty()
+
+ def undo(self) -> None:
+ self._controller._set_block_orientation(self._block_uid, self._old_orientation)
+ self._controller.make_dirty()
+
+
+class EditBlockParamsCommand(QUndoCommand):
+ def __init__(self, controller, block_instance: BlockInstance, new_params: dict[str, Any]):
+ super().__init__("Edit Block Parameters")
+ self._controller = controller
+ self._block_instance = block_instance
+ self._old_name = block_instance.name
+ self._old_params = dict(block_instance.parameters)
+ self._new_name = new_params.get("name", block_instance.name)
+ self._new_params = {k: v for k, v in new_params.items() if k != "name"}
+ self._removed_connections: list[ConnectionSnapshot] = []
+
+ def redo(self) -> None:
+ self._removed_connections = self._controller._apply_block_update(
+ self._block_instance,
+ self._new_name,
+ self._new_params,
+ )
+ self._controller.make_dirty()
+
+ def undo(self) -> None:
+ self._controller._apply_block_update(
+ self._block_instance,
+ self._old_name,
+ self._old_params,
+ )
+ for snapshot in self._removed_connections:
+ self._controller._add_connection_from_snapshot(snapshot)
+ self._controller.make_dirty()
diff --git a/pySimBlocks/gui/undo_redo/undo_redo_manager.py b/pySimBlocks/gui/undo_redo/undo_redo_manager.py
new file mode 100644
index 0000000..38c0876
--- /dev/null
+++ b/pySimBlocks/gui/undo_redo/undo_redo_manager.py
@@ -0,0 +1,60 @@
+# ******************************************************************************
+# 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 .
+# ******************************************************************************
+# Authors: see Authors.txt
+# ******************************************************************************
+
+from __future__ import annotations
+
+from PySide6.QtGui import QAction, QUndoCommand, QUndoStack
+from PySide6.QtWidgets import QWidget
+
+
+class UndoManager:
+ """Small facade around QUndoStack used by the GUI."""
+
+ def __init__(self, undo_limit: int = 1000):
+ self._stack = QUndoStack()
+ self._stack.setUndoLimit(undo_limit)
+
+ @property
+ def stack(self) -> QUndoStack:
+ return self._stack
+
+ def push(self, command: QUndoCommand) -> None:
+ self._stack.push(command)
+
+ def clear(self) -> None:
+ self._stack.clear()
+
+ def undo(self) -> None:
+ self._stack.undo()
+
+ def redo(self) -> None:
+ self._stack.redo()
+
+ def set_clean(self) -> None:
+ self._stack.setClean()
+
+ def is_clean(self) -> bool:
+ return self._stack.isClean()
+
+ def create_undo_action(self, parent: QWidget) -> QAction:
+ return self._stack.createUndoAction(parent, "Undo")
+
+ def create_redo_action(self, parent: QWidget) -> QAction:
+ return self._stack.createRedoAction(parent, "Redo")
diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py
index cf232df..0ca0408 100644
--- a/pySimBlocks/gui/widgets/diagram_view.py
+++ b/pySimBlocks/gui/widgets/diagram_view.py
@@ -23,7 +23,7 @@
from typing import TYPE_CHECKING, Any
from PySide6.QtCore import QPointF, Qt, QTimer
-from PySide6.QtGui import QGuiApplication, QPainter, QPen
+from PySide6.QtGui import QGuiApplication, QKeySequence, QPainter, QPen
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView
from pySimBlocks.gui.graphics.block_item import BlockItem
@@ -81,6 +81,7 @@ def __init__(self):
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] = {}
@@ -195,7 +196,6 @@ def on_block_moved(self, block_item: BlockItem) -> None:
Args:
block_item: The block item that was repositioned.
"""
- self.project_controller.make_dirty()
for conn_inst, conn_item in self.connections.items():
if conn_inst.is_block_involved(block_item.instance):
conn_item.invalidate_manual_route()
@@ -245,6 +245,23 @@ def keyPressEvent(self, event) -> None:
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)]
@@ -278,7 +295,7 @@ def keyPressEvent(self, event) -> None:
selected = [i for i in self.diagram_scene.selectedItems()
if isinstance(i, BlockItem)]
for item in selected:
- item.toggle_orientation()
+ self.project_controller.execute_toggle_orientation(item.instance)
return
# CENTER VIEW
@@ -338,21 +355,25 @@ def mouseReleaseEvent(self, event) -> None:
def delete_selected(self) -> None:
"""Remove all selected blocks and connections from the project."""
- for item in self.diagram_scene.selectedItems():
- if isinstance(item, BlockItem):
- self.project_controller.remove_block(item.instance)
-
- elif isinstance(item, ConnectionItem):
- self.project_controller.remove_connection(item.instance)
+ 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()
def clear_scene(self) -> None:
"""Remove all blocks and connections from the scene and reset state."""
- for block in list(self.block_items.values()):
- self.project_controller.remove_block(block.instance)
-
- for connection in list(self.connections.values()):
- self.project_controller.remove_connection(connection.instance)
-
+ self.diagram_scene.clear()
+ self.block_items.clear()
+ self.connections.clear()
+ self.temp_connection = None
self.pending_port = None
def scale_view(self, factor: float) -> None:
diff --git a/pySimBlocks/gui/widgets/toolbar_view.py b/pySimBlocks/gui/widgets/toolbar_view.py
index 6fa9b90..0e7758d 100644
--- a/pySimBlocks/gui/widgets/toolbar_view.py
+++ b/pySimBlocks/gui/widgets/toolbar_view.py
@@ -20,7 +20,7 @@
from __future__ import annotations
-from PySide6.QtWidgets import QToolBar, QMessageBox, QProgressDialog, QApplication
+from PySide6.QtWidgets import QToolBar, QMessageBox, QProgressDialog, QApplication, QToolButton
from PySide6.QtGui import QAction
from PySide6.QtCore import Qt
@@ -35,7 +35,6 @@
from pySimBlocks.gui.addons.sofa.sofa_dialog import SofaDialog
from pySimBlocks.gui.addons.sofa.sofa_service import SofaService
-
class ToolBarView(QToolBar):
"""Application toolbar providing save, run, plot, and add-on actions.
@@ -64,6 +63,7 @@ def __init__(
None.
"""
super().__init__()
+ self.setToolButtonStyle(Qt.ToolButtonTextOnly)
self.saver = saver
self.runner = runner
@@ -73,6 +73,33 @@ def __init__(
save_action.triggered.connect(self.on_save)
self.addAction(save_action)
+ self.addSeparator()
+
+ self.undo_button = QToolButton(self)
+ self.undo_button.setText("Undo")
+ self.undo_button.setToolButtonStyle(Qt.ToolButtonTextOnly)
+ self.undo_button.clicked.connect(self.project_controller.undo_manager.undo)
+ self.undo_button.clicked.connect(self._focus_view_after_history_action)
+ self.addWidget(self.undo_button)
+
+ self.redo_button = QToolButton(self)
+ self.redo_button.setText("Redo")
+ self.redo_button.setToolButtonStyle(Qt.ToolButtonTextOnly)
+ self.redo_button.clicked.connect(self.project_controller.undo_manager.redo)
+ self.redo_button.clicked.connect(self._focus_view_after_history_action)
+ self.addWidget(self.redo_button)
+
+ self.project_controller.undo_manager.stack.canUndoChanged.connect(
+ self.undo_button.setEnabled
+ )
+ self.project_controller.undo_manager.stack.canRedoChanged.connect(
+ self.redo_button.setEnabled
+ )
+ self.undo_button.setEnabled(self.project_controller.undo_manager.stack.canUndo())
+ self.redo_button.setEnabled(self.project_controller.undo_manager.stack.canRedo())
+
+ self.addSeparator()
+
export_action = QAction("Export", self)
export_action.triggered.connect(self.on_export_project)
self.addAction(export_action)
@@ -184,6 +211,10 @@ def refresh_sofa_button(self) -> None:
if self.sofa_action in self.actions():
self.removeAction(self.sofa_action)
+ def _focus_view_after_history_action(self) -> None:
+ """Return keyboard focus to the canvas after undo/redo from toolbar."""
+ self.project_controller.view.setFocus()
+
def on_open_sofa_dialog(self) -> None:
"""Open the SOFA dialog if SOFA prerequisites are satisfied."""
ok, msg, details = self.sofa_service.can_use_sofa()
diff --git a/tests/gui/test_undo_redo.py b/tests/gui/test_undo_redo.py
new file mode 100644
index 0000000..b953aee
--- /dev/null
+++ b/tests/gui/test_undo_redo.py
@@ -0,0 +1,237 @@
+# Tests for the undo/redo feature
+# to be executed with
+# QT_QPA_PLATFORM=offscreen python -m pytest tests/gui/test_undo_redo.py -q
+# otherwise the gui window will be opened by a test
+
+import copy
+
+from PySide6.QtCore import QPointF, QRectF, Qt
+
+from pySimBlocks.gui.main_window import MainWindow
+
+
+def _create_window(qtbot, tmp_path):
+ window = MainWindow(tmp_path)
+ window.confirm_discard_or_save = lambda _action_name: True
+ qtbot.addWidget(window)
+ window.show()
+ qtbot.waitUntil(lambda: window.isVisible())
+ return window
+
+
+def _get_first_port(block, direction: str):
+ for port in block.ports:
+ if port.direction == direction:
+ return port
+ raise AssertionError(f"No port with direction={direction} for block {block.name}")
+
+
+def _connection_count(window) -> int:
+ return len(window.project_controller.project_state.connections)
+
+
+def test_undo_redo_add_block(qtbot, tmp_path):
+ window = _create_window(qtbot, tmp_path)
+ controller = window.project_controller
+ stack = window.undo_manager.stack
+
+ initial_count = len(controller.project_state.blocks)
+ block = controller.add_block("sources", "constant")
+
+ assert len(controller.project_state.blocks) == initial_count + 1
+ assert block in controller.project_state.blocks
+
+ stack.undo()
+ assert len(controller.project_state.blocks) == initial_count
+ assert block not in controller.project_state.blocks
+
+ stack.redo()
+ assert len(controller.project_state.blocks) == initial_count + 1
+ assert block in controller.project_state.blocks
+
+
+def test_undo_redo_edit_block_name(qtbot, tmp_path):
+ window = _create_window(qtbot, tmp_path)
+ controller = window.project_controller
+ stack = window.undo_manager.stack
+
+ block = controller.add_block("sources", "constant")
+ original_name = block.name
+ new_name = f"{original_name}_edited"
+
+ controller.update_block_param(block, {"name": new_name})
+ assert block.name == new_name
+
+ stack.undo()
+ assert block.name == original_name
+
+ stack.redo()
+ assert block.name == new_name
+
+
+def test_undo_redo_add_connection(qtbot, tmp_path):
+ window = _create_window(qtbot, tmp_path)
+ controller = window.project_controller
+ stack = window.undo_manager.stack
+
+ src_block = controller.add_block("sources", "constant")
+ dst_block = controller.add_block("operators", "sum")
+ src_port = _get_first_port(src_block, "output")
+ dst_port = _get_first_port(dst_block, "input")
+
+ controller.add_connection(src_port, dst_port)
+ assert _connection_count(window) == 1
+
+ stack.undo()
+ assert _connection_count(window) == 0
+
+ stack.redo()
+ assert _connection_count(window) == 1
+
+
+def test_undo_redo_remove_block_restores_connections(qtbot, tmp_path):
+ window = _create_window(qtbot, tmp_path)
+ controller = window.project_controller
+ stack = window.undo_manager.stack
+
+ src_block = controller.add_block("sources", "constant")
+ dst_block = controller.add_block("operators", "sum")
+ src_port = _get_first_port(src_block, "output")
+ dst_port = _get_first_port(dst_block, "input")
+ controller.add_connection(src_port, dst_port)
+
+ assert _connection_count(window) == 1
+ blocks_before_delete = len(controller.project_state.blocks)
+
+ controller.remove_block(src_block)
+ assert len(controller.project_state.blocks) == blocks_before_delete - 1
+ assert _connection_count(window) == 0
+
+ stack.undo()
+ assert len(controller.project_state.blocks) == blocks_before_delete
+ assert _connection_count(window) == 1
+
+ stack.redo()
+ assert len(controller.project_state.blocks) == blocks_before_delete - 1
+ assert _connection_count(window) == 0
+
+
+def test_undo_redo_move_resize_block(qtbot, tmp_path):
+ window = _create_window(qtbot, tmp_path)
+ controller = window.project_controller
+ stack = window.undo_manager.stack
+
+ block = controller.add_block("sources", "constant")
+ block_item = window.view.get_block_item_from_instance(block)
+
+ old_pos = QPointF(block_item.pos())
+ old_rect = QRectF(block_item.rect())
+ new_pos = QPointF(old_pos.x() + 25.0, old_pos.y() + 10.0)
+ new_rect = QRectF(0.0, 0.0, old_rect.width() + 20.0, old_rect.height() + 10.0)
+
+ controller.execute_move_resize_block(block, old_pos, old_rect, new_pos, new_rect)
+
+ assert block_item.pos() == new_pos
+ assert block_item.rect().width() == new_rect.width()
+ assert block_item.rect().height() == new_rect.height()
+
+ stack.undo()
+ assert block_item.pos() == old_pos
+ assert block_item.rect().width() == old_rect.width()
+ assert block_item.rect().height() == old_rect.height()
+
+ stack.redo()
+ assert block_item.pos() == new_pos
+ assert block_item.rect().width() == new_rect.width()
+ assert block_item.rect().height() == new_rect.height()
+
+
+def test_undo_redo_orientation_toggle(qtbot, tmp_path):
+ window = _create_window(qtbot, tmp_path)
+ controller = window.project_controller
+ stack = window.undo_manager.stack
+
+ block = controller.add_block("sources", "constant")
+ block_item = window.view.get_block_item_from_instance(block)
+ old_orientation = block_item.orientation
+
+ controller.execute_toggle_orientation(block)
+ assert block_item.orientation != old_orientation
+ toggled_orientation = block_item.orientation
+
+ stack.undo()
+ assert block_item.orientation == old_orientation
+
+ stack.redo()
+ assert block_item.orientation == toggled_orientation
+
+
+def test_redo_cleared_after_new_action(qtbot, tmp_path):
+ window = _create_window(qtbot, tmp_path)
+ controller = window.project_controller
+ stack = window.undo_manager.stack
+
+ block = controller.add_block("sources", "constant")
+ assert block in controller.project_state.blocks
+
+ stack.undo()
+ assert stack.canRedo()
+
+ controller.add_block("sources", "constant")
+ assert not stack.canRedo()
+
+
+def test_multistep_undo_redo_chain(qtbot, tmp_path):
+ window = _create_window(qtbot, tmp_path)
+ controller = window.project_controller
+ stack = window.undo_manager.stack
+
+ src_block = controller.add_block("sources", "constant")
+ dst_block = controller.add_block("operators", "sum")
+ controller.add_connection(_get_first_port(src_block, "output"), _get_first_port(dst_block, "input"))
+
+ block_item = window.view.get_block_item_from_instance(src_block)
+ old_pos = QPointF(block_item.pos())
+ old_rect = QRectF(block_item.rect())
+ new_pos = QPointF(old_pos.x() + 15.0, old_pos.y() + 15.0)
+ new_rect = QRectF(0.0, 0.0, old_rect.width() + 10.0, old_rect.height() + 10.0)
+ controller.execute_move_resize_block(src_block, old_pos, old_rect, new_pos, new_rect)
+
+ old_params = copy.deepcopy(src_block.parameters)
+ controller.update_block_param(src_block, {"name": f"{src_block.name}_v2"})
+
+ assert len(controller.project_state.blocks) == 2
+ assert _connection_count(window) == 1
+ assert src_block.parameters == old_params
+
+ for _ in range(5):
+ stack.undo()
+
+ assert len(controller.project_state.blocks) == 0
+ assert _connection_count(window) == 0
+
+ for _ in range(5):
+ stack.redo()
+
+ assert len(controller.project_state.blocks) == 2
+ assert _connection_count(window) == 1
+
+
+def test_keyboard_shortcuts_undo_redo(qtbot, tmp_path):
+ window = _create_window(qtbot, tmp_path)
+ controller = window.project_controller
+ stack = window.undo_manager.stack
+
+ controller.add_block("sources", "constant")
+ assert len(controller.project_state.blocks) == 1
+
+ window.view.setFocus()
+ qtbot.waitUntil(lambda: window.view.hasFocus())
+
+ qtbot.keyClick(window.view.viewport(), Qt.Key_Z, Qt.ControlModifier)
+ assert len(controller.project_state.blocks) == 0
+
+ qtbot.keyClick(window.view.viewport(), Qt.Key_Z, Qt.ControlModifier | Qt.ShiftModifier)
+ assert len(controller.project_state.blocks) == 1
+ assert stack.canUndo()
+