From 3ba06238fc5a4c0dc76aef266c5faecb23372b43 Mon Sep 17 00:00:00 2001 From: Lukasik Maxence Date: Tue, 28 Apr 2026 15:15:10 +0200 Subject: [PATCH 1/6] adding command pattern classes for gui interactions --- pySimBlocks/gui/undo_redo/commands.py | 186 ++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 pySimBlocks/gui/undo_redo/commands.py 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() From 70bc8a22083dc9361c3ea54aad958d30fb686e76 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Tue, 28 Apr 2026 15:27:07 +0200 Subject: [PATCH 2/6] manager for undo/redo --- .../gui/undo_redo/undo_redo_manager.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 pySimBlocks/gui/undo_redo/undo_redo_manager.py 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") From 142b433a9d4b25d21494c03bc3abca461edf1d3b Mon Sep 17 00:00:00 2001 From: Lukasik Date: Wed, 29 Apr 2026 11:32:03 +0200 Subject: [PATCH 3/6] addingundo and redo buttons to toolbar and keyPressEvent --- pySimBlocks/gui/widgets/diagram_view.py | 51 +++++++++++++++++-------- pySimBlocks/gui/widgets/toolbar_view.py | 36 ++++++++++++++++- 2 files changed, 70 insertions(+), 17 deletions(-) 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..0e117a3 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 @@ -34,7 +34,7 @@ # Add ons from pySimBlocks.gui.addons.sofa.sofa_dialog import SofaDialog from pySimBlocks.gui.addons.sofa.sofa_service import SofaService - +print("TOOLBAR FILE:", __file__) class ToolBarView(QToolBar): """Application toolbar providing save, run, plot, and add-on actions. @@ -64,6 +64,7 @@ def __init__( None. """ super().__init__() + self.setToolButtonStyle(Qt.ToolButtonTextOnly) self.saver = saver self.runner = runner @@ -73,6 +74,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 +212,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() From 7928689fa09fbb83f3d1d74cfdd52b88936318a3 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Thu, 30 Apr 2026 10:04:01 +0200 Subject: [PATCH 4/6] adaptating project loader and block_item to new command pattern --- pySimBlocks/gui/graphics/block_item.py | 31 ++++++++++++++++++++++ pySimBlocks/gui/services/project_loader.py | 15 +++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) 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/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.""" From 88769508eee91352fa4597ac360b17e8d9d2bcb5 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Thu, 30 Apr 2026 14:00:54 +0200 Subject: [PATCH 5/6] adding undo redo management to main window and project controller --- pySimBlocks/gui/main_window.py | 32 +++- pySimBlocks/gui/project_controller.py | 246 ++++++++++++++++++++------ 2 files changed, 226 insertions(+), 52 deletions(-) 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 [] From 03b9141e5a6a7dc6be55f99067d1f756df49f7c5 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Mon, 4 May 2026 11:43:35 +0200 Subject: [PATCH 6/6] adding tests for undo redo --- pySimBlocks/gui/widgets/toolbar_view.py | 1 - tests/gui/test_undo_redo.py | 237 ++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 tests/gui/test_undo_redo.py diff --git a/pySimBlocks/gui/widgets/toolbar_view.py b/pySimBlocks/gui/widgets/toolbar_view.py index 0e117a3..0e7758d 100644 --- a/pySimBlocks/gui/widgets/toolbar_view.py +++ b/pySimBlocks/gui/widgets/toolbar_view.py @@ -34,7 +34,6 @@ # Add ons from pySimBlocks.gui.addons.sofa.sofa_dialog import SofaDialog from pySimBlocks.gui.addons.sofa.sofa_service import SofaService -print("TOOLBAR FILE:", __file__) class ToolBarView(QToolBar): """Application toolbar providing save, run, plot, and add-on actions. 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() +