From 91eeb57b535838d9f7650a4582f96e80ea98ab8d Mon Sep 17 00:00:00 2001 From: Lukasik Date: Tue, 2 Jun 2026 11:35:46 +0200 Subject: [PATCH 01/42] feat(gui): add visual group model and persist gui.groups in project yaml --- pySimBlocks/gui/models/__init__.py | 5 +- pySimBlocks/gui/models/project_state.py | 3 + pySimBlocks/gui/models/visual_group.py | 115 +++++++++++++++++++++ pySimBlocks/gui/services/project_loader.py | 27 ++++- pySimBlocks/gui/services/yaml_tools.py | 7 ++ 5 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 pySimBlocks/gui/models/visual_group.py diff --git a/pySimBlocks/gui/models/__init__.py b/pySimBlocks/gui/models/__init__.py index f89cebb..41442b8 100644 --- a/pySimBlocks/gui/models/__init__.py +++ b/pySimBlocks/gui/models/__init__.py @@ -22,10 +22,13 @@ from pySimBlocks.gui.models.connection_instance import ConnectionInstance from pySimBlocks.gui.models.port_instance import PortInstance from pySimBlocks.gui.models.project_state import ProjectState +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup __all__ = [ "BlockInstance", "ConnectionInstance", "PortInstance", - "ProjectState" + "ProjectState", + "BoundaryPort", + "VisualGroup", ] diff --git a/pySimBlocks/gui/models/project_state.py b/pySimBlocks/gui/models/project_state.py index b4e397b..6dc7464 100644 --- a/pySimBlocks/gui/models/project_state.py +++ b/pySimBlocks/gui/models/project_state.py @@ -23,6 +23,7 @@ from pySimBlocks.gui.models.block_instance import BlockInstance, PortInstance from pySimBlocks.gui.models.connection_instance import ConnectionInstance from pySimBlocks.gui.models.project_simulation_params import ProjectSimulationParams +from pySimBlocks.gui.models.visual_group import VisualGroup class ProjectState: """Store the editable state of a GUI project. @@ -55,6 +56,7 @@ def __init__(self, directory_path: Path): self.logging: list[str] = [] self.logs: dict = {} self.plots: list[dict[str, str | list[str]]] = [] + self.visual_groups: list[VisualGroup] = [] # --- Public methods --- @@ -67,6 +69,7 @@ def clear(self): self.logs.clear() self.logging.clear() self.plots.clear() + self.visual_groups.clear() self.simulation.clear() diff --git a/pySimBlocks/gui/models/visual_group.py b/pySimBlocks/gui/models/visual_group.py new file mode 100644 index 0000000..bbccbe4 --- /dev/null +++ b/pySimBlocks/gui/models/visual_group.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class BoundaryPort: + """Describe one visual group boundary port.""" + + uid: str + direction: str + linked_port_uid: str = "" + origin: str = "auto" + linked_connection_uid: str = "" + label: str = "" + proxy_uid: str = "" + proxy_layout: dict[str, float] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """Serialize the boundary port to a YAML-friendly mapping.""" + out: dict[str, Any] = { + "uid": self.uid, + "direction": self.direction, + "linked_port_uid": self.linked_port_uid, + "origin": self.origin, + "linked_connection_uid": self.linked_connection_uid, + } + if self.label: + out["label"] = self.label + if self.proxy_uid: + out["proxy_uid"] = self.proxy_uid + if self.proxy_layout: + out["proxy_layout"] = self.proxy_layout + return out + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "BoundaryPort": + """Build a boundary port from a raw mapping.""" + return cls( + uid=str(data.get("uid", "")), + direction=str(data.get("direction", "")), + linked_port_uid=str(data.get("linked_port_uid", "")), + origin=str(data.get("origin", "auto")), + linked_connection_uid=str(data.get("linked_connection_uid", "")), + label=str(data.get("label", "")), + proxy_uid=str(data.get("proxy_uid", "")), + proxy_layout=dict(data.get("proxy_layout", {})) + if isinstance(data.get("proxy_layout", {}), dict) + else {}, + ) + + +@dataclass +class VisualGroup: + """Store one visual-only group definition.""" + + uid: str + name: str + members: list[str] = field(default_factory=list) + parent_uid: str | None = None + layout: dict[str, float] = field(default_factory=dict) + boundary_ports: list[BoundaryPort] = field(default_factory=list) + child_group_uids: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + """Serialize the visual group to a YAML-friendly mapping.""" + out: dict[str, Any] = { + "uid": self.uid, + "name": self.name, + "parent_uid": self.parent_uid, + "members": list(self.members), + "layout": dict(self.layout), + "boundary_ports": [port.to_dict() for port in self.boundary_ports], + "child_group_uids": list(self.child_group_uids), + } + return out + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "VisualGroup": + """Build a visual group from a raw mapping.""" + boundary_ports_raw = data.get("boundary_ports", []) + boundary_ports = [] + if isinstance(boundary_ports_raw, list): + boundary_ports = [ + BoundaryPort.from_dict(item) + for item in boundary_ports_raw + if isinstance(item, dict) + ] + + members = data.get("members", []) + if not isinstance(members, list): + members = [] + + layout = data.get("layout", {}) + if not isinstance(layout, dict): + layout = {} + + children = data.get("child_group_uids", []) + if not isinstance(children, list): + children = [] + + parent_uid = data.get("parent_uid", None) + if parent_uid is not None: + parent_uid = str(parent_uid) + + return cls( + uid=str(data.get("uid", "")), + name=str(data.get("name", "")), + members=[str(m) for m in members], + parent_uid=parent_uid, + layout=dict(layout), + boundary_ports=boundary_ports, + child_group_uids=[str(c) for c in children], + ) diff --git a/pySimBlocks/gui/services/project_loader.py b/pySimBlocks/gui/services/project_loader.py index 1865bd2..3239afa 100644 --- a/pySimBlocks/gui/services/project_loader.py +++ b/pySimBlocks/gui/services/project_loader.py @@ -23,7 +23,7 @@ from PySide6.QtCore import QPointF -from pySimBlocks.gui.models import BlockInstance +from pySimBlocks.gui.models import BlockInstance, VisualGroup 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 @@ -87,6 +87,7 @@ def load(self, controller: ProjectController, directory: Path): self._load_connections(controller, diagram_data, layout_conns) self._load_logging(controller, sim_data) self._load_plots(controller, sim_data) + self._load_groups(controller, gui_data) controller.clear_dirty() @@ -253,6 +254,30 @@ def _load_plots(self, controller: ProjectController, sim_data: dict): plot_data = sim_data.get("plots", []) controller.project_state.plots = plot_data if isinstance(plot_data, list) else [] + def _load_groups(self, controller: ProjectController, gui_data: dict) -> None: + """Load visual groups from GUI-only project data.""" + if not isinstance(gui_data, dict): + controller.project_state.visual_groups = [] + return + + groups_data = gui_data.get("groups", []) + if not isinstance(groups_data, list): + print("[Groups warning] project.yaml gui.groups is invalid, ignored.") + controller.project_state.visual_groups = [] + return + + groups: list[VisualGroup] = [] + for idx, raw in enumerate(groups_data): + if not isinstance(raw, dict): + print(f"[Groups warning] Invalid group entry at index {idx}, ignored.") + continue + group = VisualGroup.from_dict(raw) + if not group.uid: + print(f"[Groups warning] Group at index {idx} has empty uid, ignored.") + continue + groups.append(group) + controller.project_state.visual_groups = groups + def _load_layout_data(self, gui_data: dict) -> tuple[dict, dict, list[str]]: """Extract block and connection layout data from GUI configuration.""" warnings = [] diff --git a/pySimBlocks/gui/services/yaml_tools.py b/pySimBlocks/gui/services/yaml_tools.py index fdc5d73..05a9cab 100644 --- a/pySimBlocks/gui/services/yaml_tools.py +++ b/pySimBlocks/gui/services/yaml_tools.py @@ -278,6 +278,11 @@ def _build_layout_section( return data +def _build_groups_section(project_state: ProjectState) -> list[dict]: + """Build serialized visual groups for the GUI section.""" + return [group.to_dict() for group in project_state.visual_groups] + + def build_project_yaml( project_state: ProjectState, block_items: dict[str, BlockItem] | None = None, @@ -301,6 +306,7 @@ def build_project_yaml( blocks = _build_blocks_section(project_state) connections, conn_name_map = _build_connections_section(project_state) layout = _build_layout_section(block_items, conn_name_map) + groups = _build_groups_section(project_state) return { "schema_version": 1, @@ -314,5 +320,6 @@ def build_project_yaml( }, "gui": { "layout": layout, + "groups": groups, }, } From d3bc9a3b4b501cd50ac0568ff4ef70f405146df9 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Wed, 3 Jun 2026 11:32:52 +0200 Subject: [PATCH 02/42] feat(gui): add group/ungroup API and boundary port derivation for visual groups --- pySimBlocks/gui/models/project_state.py | 27 ++++++++ pySimBlocks/gui/project_controller.py | 83 +++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/pySimBlocks/gui/models/project_state.py b/pySimBlocks/gui/models/project_state.py index 6dc7464..2b75007 100644 --- a/pySimBlocks/gui/models/project_state.py +++ b/pySimBlocks/gui/models/project_state.py @@ -116,6 +116,7 @@ def remove_block(self, block_instance: BlockInstance): """ if block_instance in self.blocks: self.blocks.remove(block_instance) + self.remove_block_from_groups(block_instance.uid) def add_connection(self, conn: ConnectionInstance): """Add a connection instance to the project. @@ -199,3 +200,29 @@ def can_plot(self) -> tuple[bool, str]: ) return True, "Plotting is available." + + def get_visual_group(self, group_uid: str) -> VisualGroup | None: + """Return a visual group by UID, if present.""" + for group in self.visual_groups: + if group.uid == group_uid: + return group + return None + + def remove_visual_group(self, group_uid: str) -> bool: + """Remove a visual group by UID.""" + group = self.get_visual_group(group_uid) + if group is None: + return False + self.visual_groups.remove(group) + return True + + def remove_block_from_groups(self, block_uid: str) -> None: + """Remove a block UID from all groups and drop empty groups.""" + to_remove: list[VisualGroup] = [] + for group in self.visual_groups: + if block_uid in group.members: + group.members = [uid for uid in group.members if uid != block_uid] + if not group.members: + to_remove.append(group) + for group in to_remove: + self.visual_groups.remove(group) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 44a1f1f..588b108 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -21,15 +21,18 @@ from __future__ import annotations from pathlib import Path +import uuid from typing import TYPE_CHECKING, Any, Callable from PySide6.QtCore import QObject, Signal, QPointF, QRectF from pySimBlocks.gui.models import ( BlockInstance, + BoundaryPort, ConnectionInstance, PortInstance, ProjectState, + VisualGroup, ) from pySimBlocks.gui.widgets.diagram_view import DiagramView from pySimBlocks.gui.blocks.block_meta import BlockMeta @@ -181,6 +184,41 @@ def remove_block(self, block_instance: BlockInstance) -> None: """ self.undo_manager.push(RemoveBlockCommand(self, block_instance)) + def group_blocks(self, blocks: list[BlockInstance], name: str | None = None) -> VisualGroup: + """Create a visual group from an explicit block selection.""" + unique_blocks: list[BlockInstance] = [] + seen = set() + for block in blocks: + if block.uid in seen: + continue + seen.add(block.uid) + unique_blocks.append(block) + + if len(unique_blocks) < 2: + raise ValueError("At least two blocks are required to create a group.") + + member_uids = [b.uid for b in unique_blocks] + default_name = self._make_unique_group_name(name or "Group") + group = VisualGroup( + uid=uuid.uuid4().hex, + name=default_name, + members=member_uids, + parent_uid=None, + layout={}, + boundary_ports=self._build_group_boundary_ports(member_uids), + child_group_uids=[], + ) + self.project_state.visual_groups.append(group) + self.make_dirty() + return group + + def ungroup(self, group_uid: str) -> bool: + """Remove a visual group by UID.""" + removed = self.project_state.remove_visual_group(group_uid) + if removed: + self.make_dirty() + return removed + def make_unique_name(self, base_name: str) -> str: """Return ``base_name`` or a suffixed variant that is unique across all blocks. @@ -675,3 +713,48 @@ def _apply_block_update( self.view.refresh_block_port(block_instance) return removed return [] + + def _build_group_boundary_ports(self, member_uids: list[str]) -> list[BoundaryPort]: + """Derive boundary ports from connections crossing group boundaries.""" + members = set(member_uids) + boundary_ports: list[BoundaryPort] = [] + by_internal_port_uid: dict[tuple[str, str], BoundaryPort] = {} + + for connection in self.project_state.connections: + src_in = connection.src_block().uid in members + dst_in = connection.dst_block().uid in members + if src_in == dst_in: + continue + + if dst_in: + direction = "input" + internal_port = connection.dst_port + else: + direction = "output" + internal_port = connection.src_port + + key = (internal_port.block.uid, internal_port.name) + if key in by_internal_port_uid: + continue + + boundary = BoundaryPort( + uid=uuid.uuid4().hex, + direction=direction, + linked_port_uid=f"{internal_port.block.uid}:{internal_port.name}", + origin="auto", + linked_connection_uid=f"{connection.src_block().uid}:{connection.src_port.name}->{connection.dst_block().uid}:{connection.dst_port.name}", + ) + by_internal_port_uid[key] = boundary + boundary_ports.append(boundary) + + return boundary_ports + + def _make_unique_group_name(self, base_name: str) -> str: + """Return a unique visual group name based on existing groups.""" + existing = {g.name for g in self.project_state.visual_groups} + if base_name not in existing: + return base_name + i = 1 + while f"{base_name}_{i}" in existing: + i += 1 + return f"{base_name}_{i}" From a06868fd08107120d778a7b08434183f58708793 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Thu, 4 Jun 2026 11:02:02 +0200 Subject: [PATCH 03/42] feat(gui): render visual groups with group/ungroup UI and group view --- pySimBlocks/gui/graphics/__init__.py | 5 +- pySimBlocks/gui/graphics/connection_item.py | 5 +- pySimBlocks/gui/graphics/group_item.py | 165 +++++++++++++++++ pySimBlocks/gui/project_controller.py | 110 +++++++++--- pySimBlocks/gui/undo_redo/commands.py | 48 ++++- pySimBlocks/gui/widgets/diagram_view.py | 188 +++++++++++++++++++- 6 files changed, 494 insertions(+), 27 deletions(-) create mode 100644 pySimBlocks/gui/graphics/group_item.py diff --git a/pySimBlocks/gui/graphics/__init__.py b/pySimBlocks/gui/graphics/__init__.py index 621efd5..6dada80 100644 --- a/pySimBlocks/gui/graphics/__init__.py +++ b/pySimBlocks/gui/graphics/__init__.py @@ -20,10 +20,13 @@ from pySimBlocks.gui.graphics.block_item import BlockItem from pySimBlocks.gui.graphics.connection_item import ConnectionItem +from pySimBlocks.gui.graphics.group_item import GroupItem, GroupBoundaryPortItem from pySimBlocks.gui.graphics.port_item import PortItem __all__ = [ "BlockItem", "ConnectionItem", - "PortItem" + "GroupItem", + "GroupBoundaryPortItem", + "PortItem", ] diff --git a/pySimBlocks/gui/graphics/connection_item.py b/pySimBlocks/gui/graphics/connection_item.py index 42c5f18..d19945b 100644 --- a/pySimBlocks/gui/graphics/connection_item.py +++ b/pySimBlocks/gui/graphics/connection_item.py @@ -122,8 +122,9 @@ def update_position(self): if self.is_temporary: return - p1 = self.src_port.connection_anchor() - p2 = self.dst_port.connection_anchor() + view = self.src_port.parent_block.view + p1 = view.connection_anchor_for_port_item(self.src_port) + p2 = view.connection_anchor_for_port_item(self.dst_port) if self.is_manual and self.route and len(self.route.points) >= 2: self.route.points[0] = p1 self.route.points[-1] = p2 diff --git a/pySimBlocks/gui/graphics/group_item.py b/pySimBlocks/gui/graphics/group_item.py new file mode 100644 index 0000000..ba114c4 --- /dev/null +++ b/pySimBlocks/gui/graphics/group_item.py @@ -0,0 +1,165 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtGui import QBrush, QFont, QPainter, QPen +from PySide6.QtWidgets import QGraphicsItem, QGraphicsRectItem, QStyle + +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup + +if TYPE_CHECKING: + from pySimBlocks.gui.widgets.diagram_view import DiagramView + + +class GroupBoundaryPortItem(QGraphicsItem): + """Visual port on a group rectangle border.""" + + R = 6 + L = 15 + H = 10 + + def __init__(self, boundary: BoundaryPort, parent_group: "GroupItem"): + super().__init__(parent_group) + self.boundary = boundary + self.parent_group = parent_group + self.setAcceptedMouseButtons(Qt.LeftButton) + + @property + def is_input(self) -> bool: + return self.boundary.direction == "input" + + def connection_anchor(self) -> QPointF: + if self.is_input: + local = QPointF(-self.R, 0) + else: + local = QPointF(self.L, 0) + return self.mapToScene(local) + + def boundingRect(self) -> QRectF: + return QRectF(-10, -10, 20, 20) + + def paint(self, painter, option, widget=None): + t = self.parent_group.view.theme + painter.setRenderHint(QPainter.Antialiasing) + fill = t.port_in if self.is_input else t.port_out + painter.setBrush(QBrush(fill)) + painter.setPen(QPen(t.block_border, 1)) + if self.is_input: + painter.drawEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R) + else: + from PySide6.QtGui import QPainterPath + + path = QPainterPath() + path.moveTo(0, -self.H) + path.lineTo(0, self.H) + path.lineTo(self.L, 0) + path.closeSubpath() + painter.drawPath(path) + + +class GroupItem(QGraphicsRectItem): + """Render a visual group container on the diagram.""" + + MIN_WIDTH = 80.0 + MIN_HEIGHT = 50.0 + MARGIN = 16.0 + + def __init__(self, group: VisualGroup, view: "DiagramView"): + layout = group.layout or {} + width = max(float(layout.get("width", 160.0)), self.MIN_WIDTH) + height = max(float(layout.get("height", 100.0)), self.MIN_HEIGHT) + super().__init__(0, 0, width, height) + self.group = group + self.view = view + self.boundary_port_items: dict[str, GroupBoundaryPortItem] = {} + + x = float(layout.get("x", 0.0)) + y = float(layout.get("y", 0.0)) + self.setPos(QPointF(x, y)) + self.setFlag(QGraphicsRectItem.ItemIsMovable) + self.setFlag(QGraphicsRectItem.ItemIsSelectable) + self.setFlag(QGraphicsRectItem.ItemSendsScenePositionChanges) + self.setZValue(-1) + self.sync_boundary_ports() + + def sync_boundary_ports(self) -> None: + """Rebuild boundary port items from group metadata.""" + for item in list(self.boundary_port_items.values()): + item.setParentItem(None) + self.boundary_port_items.clear() + + inputs = [p for p in self.group.boundary_ports if p.direction == "input"] + outputs = [p for p in self.group.boundary_ports if p.direction == "output"] + rect = self.rect() + + for index, boundary in enumerate(inputs): + item = GroupBoundaryPortItem(boundary, self) + y = rect.height() * (index + 1) / (len(inputs) + 1) + item.setPos(0, y) + self.boundary_port_items[boundary.uid] = item + + for index, boundary in enumerate(outputs): + item = GroupBoundaryPortItem(boundary, self) + y = rect.height() * (index + 1) / (len(outputs) + 1) + item.setPos(rect.width(), y) + self.boundary_port_items[boundary.uid] = item + + def get_boundary_anchor(self, boundary_uid: str) -> QPointF | None: + port_item = self.boundary_port_items.get(boundary_uid) + if port_item is None: + return None + return port_item.connection_anchor() + + def find_boundary_for_member_port(self, block_uid: str, port_name: str) -> str | None: + key = f"{block_uid}:{port_name}" + for boundary in self.group.boundary_ports: + if boundary.linked_port_uid == key: + return boundary.uid + return None + + def paint(self, painter, option, widget=None): + t = self.view.theme + painter.setRenderHint(QPainter.Antialiasing) + painter.setBrush(QBrush(t.block_bg)) + pen = QPen(t.block_border, 2, Qt.DashLine) + if option.state & QStyle.State_Selected: # type: ignore[name-defined] + pen.setColor(t.selection) + pen.setWidth(3) + painter.setPen(pen) + painter.drawRect(self.rect()) + + painter.setPen(QPen(t.text)) + painter.setFont(QFont("Sans Serif", 9, QFont.Bold)) + painter.drawText( + self.rect().adjusted(4, 4, -4, -4), + int(Qt.AlignTop | Qt.AlignHCenter), + self.group.name, + ) + + def itemChange(self, change, value): + if change == QGraphicsItem.ItemPositionHasChanged and self.view.project_controller: + self._persist_layout() + self.view.on_group_moved(self) + return super().itemChange(change, value) + + def _persist_layout(self) -> None: + pos = self.pos() + rect = self.rect() + self.group.layout = { + "x": float(pos.x()), + "y": float(pos.y()), + "width": float(rect.width()), + "height": float(rect.height()), + } + if self.view.project_controller: + self.view.project_controller.make_dirty() + + def mouseDoubleClickEvent(self, event): + self.view.enter_group(self.group.uid) + event.accept() diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 588b108..75aa150 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -42,10 +42,12 @@ AddBlockCommand, AddConnectionCommand, EditBlockParamsCommand, + GroupBlocksCommand, MoveResizeBlockCommand, RemoveBlockCommand, RemoveConnectionCommand, ToggleOrientationCommand, + UngroupCommand, ConnectionSnapshot, ) @@ -184,8 +186,8 @@ def remove_block(self, block_instance: BlockInstance) -> None: """ self.undo_manager.push(RemoveBlockCommand(self, block_instance)) - def group_blocks(self, blocks: list[BlockInstance], name: str | None = None) -> VisualGroup: - """Create a visual group from an explicit block selection.""" + def group_blocks(self, blocks: list[BlockInstance], name: str | None = None) -> VisualGroup | None: + """Create a visual group from an explicit block selection (undoable).""" unique_blocks: list[BlockInstance] = [] seen = set() for block in blocks: @@ -197,27 +199,33 @@ def group_blocks(self, blocks: list[BlockInstance], name: str | None = None) -> if len(unique_blocks) < 2: raise ValueError("At least two blocks are required to create a group.") - member_uids = [b.uid for b in unique_blocks] - default_name = self._make_unique_group_name(name or "Group") - group = VisualGroup( - uid=uuid.uuid4().hex, - name=default_name, - members=member_uids, - parent_uid=None, - layout={}, - boundary_ports=self._build_group_boundary_ports(member_uids), - child_group_uids=[], - ) - self.project_state.visual_groups.append(group) - self.make_dirty() - return group + self.undo_manager.push(GroupBlocksCommand(self, unique_blocks, name)) + member_uids = {b.uid for b in unique_blocks} + for group in reversed(self.project_state.visual_groups): + if set(group.members) == member_uids: + return group + return None def ungroup(self, group_uid: str) -> bool: - """Remove a visual group by UID.""" - removed = self.project_state.remove_visual_group(group_uid) - if removed: - self.make_dirty() - return removed + """Remove a visual group by UID (undoable).""" + if self.project_state.get_visual_group(group_uid) is None: + return False + self.undo_manager.push(UngroupCommand(self, group_uid)) + return True + + def group_selected_blocks(self) -> VisualGroup | None: + """Group currently selected blocks in the diagram view.""" + blocks = self.view.get_selected_block_instances() + if len(blocks) < 2: + return None + return self.group_blocks(blocks) + + def ungroup_selected_group(self) -> bool: + """Ungroup the currently selected visual group.""" + group_uid = self.view.get_selected_group_uid() + if group_uid is None: + return False + return self.ungroup(group_uid) def make_unique_name(self, base_name: str) -> str: """Return ``base_name`` or a suffixed variant that is unique across all blocks. @@ -349,6 +357,7 @@ def clear(self) -> None: """Reset the project state and diagram view to an empty state.""" self.project_state.clear() self.view.clear_scene() + self.view.current_view_group_uid = None self.undo_manager.clear() self.clear_dirty() @@ -376,6 +385,7 @@ def load_project(self, loader: "ProjectLoader") -> None: project files and populates this controller. """ loader.load(self, self.project_state.directory_path) + self.view.refresh_visual_groups() # -------------------------------------------------------------------------- @@ -522,6 +532,7 @@ def _add_block( block_instance.resolve_ports() self.project_state.add_block(block_instance) self.view.add_block(block_instance, block_layout) + self.view.refresh_visual_groups() return block_instance @@ -595,6 +606,7 @@ def _remove_block(self, block_instance: BlockInstance) -> None: self.project_state.remove_block(block_instance) self.view.remove_block(block_instance) + self.view.refresh_visual_groups() def _remove_connection(self, connection: ConnectionInstance) -> None: self.project_state.remove_connection(connection) @@ -714,6 +726,62 @@ def _apply_block_update( return removed return [] + def _create_visual_group( + self, + blocks: list[BlockInstance], + name: str | None = None, + ) -> VisualGroup: + """Create and register a visual group without pushing undo.""" + member_uids = [b.uid for b in blocks] + group = VisualGroup( + uid=uuid.uuid4().hex, + name=self._make_unique_group_name(name or "Group"), + members=member_uids, + parent_uid=None, + layout=self._compute_group_layout(member_uids), + boundary_ports=self._build_group_boundary_ports(member_uids), + child_group_uids=[], + ) + self.project_state.visual_groups.append(group) + return group + + def _remove_visual_group(self, group_uid: str) -> bool: + """Remove a visual group without pushing undo.""" + return self.project_state.remove_visual_group(group_uid) + + def _compute_group_layout(self, member_uids: list[str]) -> dict[str, float]: + """Compute a bounding layout for group members.""" + margin = 16.0 + min_x = float("inf") + min_y = float("inf") + max_x = float("-inf") + max_y = float("-inf") + found = False + + for uid in member_uids: + block = self._find_block_by_uid(uid) + if block is None: + continue + item = self.view.get_block_item_from_instance(block) + if item is None: + continue + found = True + rect = item.sceneBoundingRect() + min_x = min(min_x, rect.left()) + min_y = min(min_y, rect.top()) + max_x = max(max_x, rect.right()) + max_y = max(max_y, rect.bottom()) + + if not found: + return {"x": 0.0, "y": 0.0, "width": 160.0, "height": 100.0} + + return { + "x": float(min_x - margin), + "y": float(min_y - margin), + "width": float(max(max_x - min_x + 2 * margin, 80.0)), + "height": float(max(max_y - min_y + 2 * margin, 50.0)), + } + def _build_group_boundary_ports(self, member_uids: list[str]) -> list[BoundaryPort]: """Derive boundary ports from connections crossing group boundaries.""" members = set(member_uids) diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index cd57b56..cfacabc 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -26,7 +26,7 @@ from PySide6.QtCore import QPointF, QRectF from PySide6.QtGui import QUndoCommand -from pySimBlocks.gui.models import BlockInstance, PortInstance +from pySimBlocks.gui.models import BlockInstance, PortInstance, VisualGroup @dataclass @@ -156,6 +156,52 @@ def undo(self) -> None: self._controller.make_dirty() +class GroupBlocksCommand(QUndoCommand): + def __init__(self, controller, blocks: list[BlockInstance], name: str | None = None): + super().__init__("Group Blocks") + self._controller = controller + self._blocks = list(blocks) + self._name = name + self._group_uid: str | None = None + + def redo(self) -> None: + group = self._controller._create_visual_group(self._blocks, self._name) + self._group_uid = group.uid + self._controller.view.refresh_visual_groups() + self._controller.make_dirty() + + def undo(self) -> None: + if self._group_uid: + self._controller._remove_visual_group(self._group_uid) + self._controller.view.refresh_visual_groups() + self._controller.make_dirty() + + +class UngroupCommand(QUndoCommand): + def __init__(self, controller, group_uid: str): + super().__init__("Ungroup") + self._controller = controller + self._group_uid = group_uid + self._group_snapshot: dict | None = None + + def redo(self) -> None: + group = self._controller.project_state.get_visual_group(self._group_uid) + if group is not None: + self._group_snapshot = group.to_dict() + self._controller._remove_visual_group(self._group_uid) + if self._controller.view.current_view_group_uid == self._group_uid: + self._controller.view.exit_group_view() + self._controller.view.refresh_visual_groups() + self._controller.make_dirty() + + def undo(self) -> None: + if self._group_snapshot: + group = VisualGroup.from_dict(self._group_snapshot) + self._controller.project_state.visual_groups.append(group) + self._controller.view.refresh_visual_groups() + self._controller.make_dirty() + + class EditBlockParamsCommand(QUndoCommand): def __init__(self, controller, block_instance: BlockInstance, new_params: dict[str, Any]): super().__init__("Edit Block Parameters") diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 0ca0408..64d56c7 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -24,9 +24,10 @@ from PySide6.QtCore import QPointF, Qt, QTimer from PySide6.QtGui import QGuiApplication, QKeySequence, QPainter, QPen -from PySide6.QtWidgets import QGraphicsScene, QGraphicsView +from PySide6.QtWidgets import QGraphicsScene, QGraphicsView, QMenu from pySimBlocks.gui.graphics.block_item import BlockItem +from pySimBlocks.gui.graphics.group_item import GroupItem 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 @@ -84,7 +85,9 @@ def __init__(self): self.drop_event_pos: QPointF = QPointF(0, 0) self.project_controller: ProjectController | None self.block_items: dict[str, BlockItem] = {} + self.group_items: dict[str, GroupItem] = {} self.connections: dict[ConnectionInstance, ConnectionItem] = {} + self.current_view_group_uid: str | None = None self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setResizeAnchor(QGraphicsView.AnchorUnderMouse) @@ -158,6 +161,132 @@ def remove_connection(self, connection_instance: ConnectionInstance) -> None: if connection_item: self.diagram_scene.removeItem(connection_item) + def get_selected_block_instances(self) -> list[BlockInstance]: + """Return block instances currently selected on the diagram.""" + selected = [] + for item in self.diagram_scene.selectedItems(): + if isinstance(item, BlockItem): + selected.append(item.instance) + return selected + + def get_selected_group_uid(self) -> str | None: + """Return the UID of a selected group item, if any.""" + for item in self.diagram_scene.selectedItems(): + if isinstance(item, GroupItem): + return item.group.uid + return None + + def refresh_visual_groups(self) -> None: + """Sync group items, member visibility, and connection display.""" + if self.project_controller is None: + return + + state = self.project_controller.project_state + all_member_uids: set[str] = set() + for group in state.visual_groups: + all_member_uids.update(group.members) + + active_uid = self.current_view_group_uid + active_group = state.get_visual_group(active_uid) if active_uid else None + + known_group_uids = {g.uid for g in state.visual_groups} + for uid in list(self.group_items.keys()): + if uid not in known_group_uids: + item = self.group_items.pop(uid) + self.diagram_scene.removeItem(item) + + for group in state.visual_groups: + item = self.group_items.get(group.uid) + if item is None: + item = GroupItem(group, self) + self.diagram_scene.addItem(item) + self.group_items[group.uid] = item + else: + item.group = group + item.sync_boundary_ports() + if group.layout: + item.setPos( + QPointF( + float(group.layout.get("x", 0.0)), + float(group.layout.get("y", 0.0)), + ) + ) + item.setRect( + 0, + 0, + float(group.layout.get("width", 160.0)), + float(group.layout.get("height", 100.0)), + ) + + if active_group is None: + for block_uid, block_item in self.block_items.items(): + block_item.setVisible(block_uid not in all_member_uids) + for group_uid, group_item in self.group_items.items(): + group_item.setVisible(True) + else: + member_set = set(active_group.members) + for block_uid, block_item in self.block_items.items(): + block_item.setVisible(block_uid in member_set) + for group_uid, group_item in self.group_items.items(): + group_item.setVisible(group_uid != active_group.uid) + + for conn_inst, conn_item in self.connections.items(): + src_uid = conn_inst.src_block().uid + dst_uid = conn_inst.dst_block().uid + + if active_group is None: + src_in = src_uid in all_member_uids + dst_in = dst_uid in all_member_uids + visible = not (src_in and dst_in) + else: + members = set(active_group.members) + src_in = src_uid in members + dst_in = dst_uid in members + visible = src_in and dst_in or (src_in ^ dst_in) + + conn_item.setVisible(visible) + if visible: + conn_item.update_position() + + def connection_anchor_for_port_item(self, port_item: PortItem) -> QPointF: + """Return the scene anchor for a port, redirecting through group borders when collapsed.""" + block_uid = port_item.instance.block.uid + port_name = port_item.instance.name + + if self.current_view_group_uid is not None: + return port_item.connection_anchor() + + for group_item in self.group_items.values(): + if block_uid not in group_item.group.members: + continue + boundary_uid = group_item.find_boundary_for_member_port(block_uid, port_name) + if boundary_uid is None: + break + anchor = group_item.get_boundary_anchor(boundary_uid) + if anchor is not None: + return anchor + + return port_item.connection_anchor() + + def enter_group(self, group_uid: str) -> None: + """Open the internal view of a visual group.""" + if self.project_controller is None: + return + if self.project_controller.project_state.get_visual_group(group_uid) is None: + return + self.current_view_group_uid = group_uid + self.refresh_visual_groups() + + def exit_group_view(self) -> None: + """Return to the root diagram view.""" + self.current_view_group_uid = None + self.refresh_visual_groups() + + def on_group_moved(self, group_item: GroupItem) -> None: + """Refresh wires after a group container is moved.""" + for conn_inst, conn_item in self.connections.items(): + conn_item.update_position() + def get_block_item_from_instance(self, block_instance: BlockInstance) -> BlockItem | None: """Return the visual BlockItem for the given block instance, or None. @@ -290,6 +419,27 @@ def keyPressEvent(self, event) -> None: self.scale_view(1 / 1.15) return + # GROUP / UNGROUP + if ( + event.key() == Qt.Key_G + and event.modifiers() == (Qt.ControlModifier | Qt.ShiftModifier) + ): + self.project_controller.group_selected_blocks() + event.accept() + return + if ( + event.key() == Qt.Key_U + and event.modifiers() == (Qt.ControlModifier | Qt.ShiftModifier) + ): + self.project_controller.ungroup_selected_group() + event.accept() + return + + if event.key() == Qt.Key_Escape and self.current_view_group_uid is not None: + self.exit_group_view() + event.accept() + return + # ROTATE BLOCK if event.key() == Qt.Key_R and event.modifiers() & Qt.ControlModifier: selected = [i for i in self.diagram_scene.selectedItems() @@ -353,6 +503,33 @@ def mouseReleaseEvent(self, event) -> None: self.project_controller.add_connection(self.pending_port.instance, port.instance) self._cancel_temp_connection() + def contextMenuEvent(self, event) -> None: + """Show diagram context menu for grouping actions.""" + if self.project_controller is None: + super().contextMenuEvent(event) + return + + menu = QMenu(self) + selected_blocks = self.get_selected_block_instances() + selected_group_uid = self.get_selected_group_uid() + + group_action = menu.addAction("Grouper") + group_action.setEnabled(len(selected_blocks) >= 2) + group_action.triggered.connect(self.project_controller.group_selected_blocks) + + ungroup_action = menu.addAction("Dé-grouper") + ungroup_action.setEnabled(selected_group_uid is not None) + if selected_group_uid is not None: + ungroup_action.triggered.connect( + lambda: self.project_controller.ungroup(selected_group_uid) + ) + + if self.current_view_group_uid is not None: + exit_action = menu.addAction("Niveau supérieur") + exit_action.triggered.connect(self.exit_group_view) + + menu.exec(event.globalPos()) + def delete_selected(self) -> None: """Remove all selected blocks and connections from the project.""" selected_items = list(self.diagram_scene.selectedItems()) @@ -361,7 +538,9 @@ def delete_selected(self) -> None: self.project_controller.begin_macro("Delete Selection") try: for item in selected_items: - if isinstance(item, BlockItem): + if isinstance(item, GroupItem): + self.project_controller.ungroup(item.group.uid) + elif isinstance(item, BlockItem): self.project_controller.remove_block(item.instance) elif isinstance(item, ConnectionItem): self.project_controller.remove_connection(item.instance) @@ -372,7 +551,9 @@ 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.group_items.clear() self.connections.clear() + self.current_view_group_uid = None self.temp_connection = None self.pending_port = None @@ -425,6 +606,9 @@ def _refresh_theme_items(self) -> None: conn.update_position() conn.update() + for group in self.group_items.values(): + group.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 From 808ea725d92c81d2d9b2314240fea83197627314 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Thu, 4 Jun 2026 11:39:52 +0200 Subject: [PATCH 04/42] fix(gui): fix group paint on select and complete group resize undo --- pySimBlocks/gui/graphics/group_item.py | 158 +++++++++++++++++++++++-- pySimBlocks/gui/project_controller.py | 33 ++++++ pySimBlocks/gui/undo_redo/commands.py | 27 +++++ 3 files changed, 208 insertions(+), 10 deletions(-) diff --git a/pySimBlocks/gui/graphics/group_item.py b/pySimBlocks/gui/graphics/group_item.py index ba114c4..3edcf0d 100644 --- a/pySimBlocks/gui/graphics/group_item.py +++ b/pySimBlocks/gui/graphics/group_item.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtGui import QPainterPath from PySide6.QtGui import QBrush, QFont, QPainter, QPen from PySide6.QtWidgets import QGraphicsItem, QGraphicsRectItem, QStyle @@ -69,6 +70,10 @@ class GroupItem(QGraphicsRectItem): MIN_WIDTH = 80.0 MIN_HEIGHT = 50.0 MARGIN = 16.0 + GRID_DX = 5 + GRID_DY = 5 + SELECTION_HANDLE_SIZE = 8 + SELECTION_HANDLE_HIT_SIZE = 16 def __init__(self, group: VisualGroup, view: "DiagramView"): layout = group.layout or {} @@ -78,6 +83,13 @@ def __init__(self, group: VisualGroup, view: "DiagramView"): self.group = group self.view = view self.boundary_port_items: dict[str, GroupBoundaryPortItem] = {} + self._resize_handle: str | None = None + self._resize_start_mouse: QPointF | None = None + self._resize_start_pos: QPointF | None = None + self._resize_start_width = width + self._resize_start_height = height + self._interaction_start_pos: QPointF | None = None + self._interaction_start_rect: QRectF | None = None x = float(layout.get("x", 0.0)) y = float(layout.get("y", 0.0)) @@ -125,16 +137,19 @@ def find_boundary_for_member_port(self, block_uid: str, port_name: str) -> str | def paint(self, painter, option, widget=None): t = self.view.theme + selected = bool(option.state & QStyle.State_Selected) painter.setRenderHint(QPainter.Antialiasing) - painter.setBrush(QBrush(t.block_bg)) - pen = QPen(t.block_border, 2, Qt.DashLine) - if option.state & QStyle.State_Selected: # type: ignore[name-defined] - pen.setColor(t.selection) - pen.setWidth(3) - painter.setPen(pen) + + if selected: + painter.setBrush(QBrush(t.block_bg_selected)) + painter.setPen(QPen(t.block_border_selected, 3)) + else: + painter.setBrush(QBrush(t.block_bg)) + painter.setPen(QPen(t.block_border, 2, Qt.DashLine)) + painter.drawRect(self.rect()) - painter.setPen(QPen(t.text)) + painter.setPen(QPen(t.text_selected if selected else t.text)) painter.setFont(QFont("Sans Serif", 9, QFont.Bold)) painter.drawText( self.rect().adjusted(4, 4, -4, -4), @@ -142,10 +157,107 @@ def paint(self, painter, option, widget=None): self.group.name, ) + if selected: + half = self.SELECTION_HANDLE_SIZE / 2 + r = self.rect() + corners = [ + (r.left(), r.top()), + (r.right(), r.top()), + (r.left(), r.bottom()), + (r.right(), r.bottom()), + ] + painter.setPen(QPen(t.block_border_selected, 1)) + painter.setBrush(t.text_selected) + for x, y in corners: + painter.drawRect( + x - half, y - half, self.SELECTION_HANDLE_SIZE, self.SELECTION_HANDLE_SIZE + ) + + def mousePressEvent(self, 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: + self._resize_handle = handle + self._resize_start_mouse = event.scenePos() + self._resize_start_pos = self.pos() + self._resize_start_width = self.rect().width() + self._resize_start_height = self.rect().height() + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self._resize_handle and self._resize_start_mouse and self._resize_start_pos: + delta = event.scenePos() - self._resize_start_mouse + dx = round(delta.x() / self.GRID_DX) * self.GRID_DX + dy = round(delta.y() / self.GRID_DY) * self.GRID_DY + + start_x = self._resize_start_pos.x() + start_y = self._resize_start_pos.y() + start_w = self._resize_start_width + start_h = self._resize_start_height + + if self._resize_handle in ("tl", "bl"): + new_x = min(start_x + dx, start_x + start_w - self.MIN_WIDTH) + new_w = max(self.MIN_WIDTH, (start_x + start_w) - new_x) + else: + new_x = start_x + new_w = max(self.MIN_WIDTH, start_w + dx) + + if self._resize_handle in ("tl", "tr"): + new_y = min(start_y + dy, start_y + start_h - self.MIN_HEIGHT) + new_h = max(self.MIN_HEIGHT, (start_y + start_h) - new_y) + else: + new_y = start_y + new_h = max(self.MIN_HEIGHT, start_h + dy) + + self.setPos(QPointF(new_x, new_y)) + self.setRect(0, 0, new_w, new_h) + self.sync_boundary_ports() + self._persist_layout() + self.view.on_group_moved(self) + self.update() + event.accept() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, 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: + if self.view.project_controller: + self.view.project_controller.execute_move_resize_group( + self.group.uid, + start_pos, + start_rect, + end_pos, + end_rect, + ) + def itemChange(self, change, value): - if change == QGraphicsItem.ItemPositionHasChanged and self.view.project_controller: + if change == QGraphicsItem.ItemPositionChange and self.scene(): + x = round(value.x() / self.GRID_DX) * self.GRID_DX + y = round(value.y() / self.GRID_DY) * self.GRID_DY + return QPointF(x, y) + + if change == QGraphicsItem.ItemPositionHasChanged: self._persist_layout() self.view.on_group_moved(self) + return super().itemChange(change, value) def _persist_layout(self) -> None: @@ -157,9 +269,35 @@ def _persist_layout(self) -> None: "width": float(rect.width()), "height": float(rect.height()), } - if self.view.project_controller: - self.view.project_controller.make_dirty() def mouseDoubleClickEvent(self, event): self.view.enter_group(self.group.uid) event.accept() + + def _handle_hit_rects(self) -> dict[str, QRectF]: + half = self.SELECTION_HANDLE_HIT_SIZE / 2 + r = self.rect() + return { + "tl": QRectF( + r.left() - half, r.top() - half, + self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE, + ), + "tr": QRectF( + r.right() - half, r.top() - half, + self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE, + ), + "bl": QRectF( + r.left() - half, r.bottom() - half, + self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE, + ), + "br": QRectF( + r.right() - half, r.bottom() - half, + self.SELECTION_HANDLE_HIT_SIZE, self.SELECTION_HANDLE_HIT_SIZE, + ), + } + + def _handle_at(self, local_pos: QPointF) -> str | None: + for name, rect in self._handle_hit_rects().items(): + if rect.contains(local_pos): + return name + return None diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 75aa150..d49680f 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -44,6 +44,7 @@ EditBlockParamsCommand, GroupBlocksCommand, MoveResizeBlockCommand, + MoveResizeGroupCommand, RemoveBlockCommand, RemoveConnectionCommand, ToggleOrientationCommand, @@ -320,6 +321,20 @@ def execute_move_resize_block( ) ) + def execute_move_resize_group( + self, + group_uid: str, + old_pos: QPointF, + old_rect: QRectF, + new_pos: QPointF, + new_rect: QRectF, + ) -> None: + self.undo_manager.push( + MoveResizeGroupCommand( + self, group_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: @@ -672,6 +687,24 @@ def _add_connection_from_snapshot(self, snapshot: ConnectionSnapshot) -> Connect self.view.add_connection(connection_instance, snapshot.points) return connection_instance + def _set_group_geometry(self, group_uid: str, pos: QPointF, rect: QRectF) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + group.layout = { + "x": float(pos.x()), + "y": float(pos.y()), + "width": float(rect.width()), + "height": float(rect.height()), + } + group_item = self.view.group_items.get(group_uid) + if group_item is None: + return + group_item.setPos(QPointF(pos)) + group_item.setRect(0, 0, rect.width(), rect.height()) + group_item.sync_boundary_ports() + self.view.on_group_moved(group_item) + 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: diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index cfacabc..c1fa438 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -202,6 +202,33 @@ def undo(self) -> None: self._controller.make_dirty() +class MoveResizeGroupCommand(QUndoCommand): + def __init__( + self, + controller, + group_uid: str, + old_pos: QPointF, + old_rect: QRectF, + new_pos: QPointF, + new_rect: QRectF, + ): + super().__init__("Move/Resize Group") + self._controller = controller + self._group_uid = group_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_group_geometry(self._group_uid, self._new_pos, self._new_rect) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._set_group_geometry(self._group_uid, self._old_pos, self._old_rect) + self._controller.make_dirty() + + class EditBlockParamsCommand(QUndoCommand): def __init__(self, controller, block_instance: BlockInstance, new_params: dict[str, Any]): super().__init__("Edit Block Parameters") From b796cf89a90b2d77bcf554f7b9b10e1867d9d6ad Mon Sep 17 00:00:00 2001 From: Lukasik Date: Thu, 4 Jun 2026 12:11:34 +0200 Subject: [PATCH 05/42] fix(gui): restore group move/resize undo and drop layout sync side effect --- pySimBlocks/gui/graphics/group_item.py | 24 +++++++++++++++++------- pySimBlocks/gui/project_controller.py | 18 +++++++++--------- pySimBlocks/gui/widgets/diagram_view.py | 21 +++++++++++---------- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/pySimBlocks/gui/graphics/group_item.py b/pySimBlocks/gui/graphics/group_item.py index 3edcf0d..6264f65 100644 --- a/pySimBlocks/gui/graphics/group_item.py +++ b/pySimBlocks/gui/graphics/group_item.py @@ -69,7 +69,6 @@ class GroupItem(QGraphicsRectItem): MIN_WIDTH = 80.0 MIN_HEIGHT = 50.0 - MARGIN = 16.0 GRID_DX = 5 GRID_DY = 5 SELECTION_HANDLE_SIZE = 8 @@ -90,6 +89,7 @@ def __init__(self, group: VisualGroup, view: "DiagramView"): self._resize_start_height = height self._interaction_start_pos: QPointF | None = None self._interaction_start_rect: QRectF | None = None + self._syncing_geometry = False x = float(layout.get("x", 0.0)) y = float(layout.get("y", 0.0)) @@ -216,7 +216,6 @@ def mouseMoveEvent(self, event): self.setPos(QPointF(new_x, new_y)) self.setRect(0, 0, new_w, new_h) self.sync_boundary_ports() - self._persist_layout() self.view.on_group_moved(self) self.update() event.accept() @@ -250,19 +249,30 @@ def mouseReleaseEvent(self, event): def itemChange(self, change, value): if change == QGraphicsItem.ItemPositionChange and self.scene(): + if self._syncing_geometry: + return value x = round(value.x() / self.GRID_DX) * self.GRID_DX y = round(value.y() / self.GRID_DY) * self.GRID_DY return QPointF(x, y) - if change == QGraphicsItem.ItemPositionHasChanged: - self._persist_layout() + if ( + change == QGraphicsItem.ItemPositionHasChanged + and not self._syncing_geometry + and self._resize_handle is None + ): self.view.on_group_moved(self) return super().itemChange(change, value) - def _persist_layout(self) -> None: - pos = self.pos() - rect = self.rect() + def apply_geometry(self, pos: QPointF, rect: QRectF) -> None: + """Apply position and size without triggering layout side effects.""" + self._syncing_geometry = True + try: + self.setRect(0, 0, rect.width(), rect.height()) + self.setPos(QPointF(pos)) + self.sync_boundary_ports() + finally: + self._syncing_geometry = False self.group.layout = { "x": float(pos.x()), "y": float(pos.y()), diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index d49680f..272657d 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -329,6 +329,8 @@ def execute_move_resize_group( new_pos: QPointF, new_rect: QRectF, ) -> None: + if old_pos == new_pos and old_rect == new_rect: + return self.undo_manager.push( MoveResizeGroupCommand( self, group_uid, old_pos, old_rect, new_pos, new_rect @@ -691,18 +693,16 @@ def _set_group_geometry(self, group_uid: str, pos: QPointF, rect: QRectF) -> Non group = self.project_state.get_visual_group(group_uid) if group is None: return - group.layout = { - "x": float(pos.x()), - "y": float(pos.y()), - "width": float(rect.width()), - "height": float(rect.height()), - } group_item = self.view.group_items.get(group_uid) if group_item is None: + group.layout = { + "x": float(pos.x()), + "y": float(pos.y()), + "width": float(rect.width()), + "height": float(rect.height()), + } return - group_item.setPos(QPointF(pos)) - group_item.setRect(0, 0, rect.width(), rect.height()) - group_item.sync_boundary_ports() + group_item.apply_geometry(pos, rect) self.view.on_group_moved(group_item) def _set_block_geometry(self, block_uid: str, pos: QPointF, rect: QRectF) -> None: diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 64d56c7..076cf76 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any -from PySide6.QtCore import QPointF, Qt, QTimer +from PySide6.QtCore import QPointF, QRectF, Qt, QTimer from PySide6.QtGui import QGuiApplication, QKeySequence, QPainter, QPen from PySide6.QtWidgets import QGraphicsScene, QGraphicsView, QMenu @@ -203,20 +203,21 @@ def refresh_visual_groups(self) -> None: self.group_items[group.uid] = item else: item.group = group - item.sync_boundary_ports() if group.layout: - item.setPos( + item.apply_geometry( QPointF( float(group.layout.get("x", 0.0)), float(group.layout.get("y", 0.0)), - ) - ) - item.setRect( - 0, - 0, - float(group.layout.get("width", 160.0)), - float(group.layout.get("height", 100.0)), + ), + QRectF( + 0, + 0, + float(group.layout.get("width", 160.0)), + float(group.layout.get("height", 100.0)), + ), ) + else: + item.sync_boundary_ports() if active_group is None: for block_uid, block_item in self.block_items.items(): From 7aef7cd0fa644548c2727244a95f3e374e85aee9 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Fri, 5 Jun 2026 11:50:52 +0200 Subject: [PATCH 06/42] feat(gui): add member layouts for group internal view and fix group redo snapshot --- pySimBlocks/gui/models/visual_group.py | 12 +++++ pySimBlocks/gui/project_controller.py | 58 +++++++++++++++++++++++++ pySimBlocks/gui/services/yaml_tools.py | 9 +++- pySimBlocks/gui/undo_redo/commands.py | 19 ++++++-- pySimBlocks/gui/widgets/diagram_view.py | 29 ++++++++++++- 5 files changed, 122 insertions(+), 5 deletions(-) diff --git a/pySimBlocks/gui/models/visual_group.py b/pySimBlocks/gui/models/visual_group.py index bbccbe4..57c8ee2 100644 --- a/pySimBlocks/gui/models/visual_group.py +++ b/pySimBlocks/gui/models/visual_group.py @@ -62,6 +62,7 @@ class VisualGroup: layout: dict[str, float] = field(default_factory=dict) boundary_ports: list[BoundaryPort] = field(default_factory=list) child_group_uids: list[str] = field(default_factory=list) + member_layouts: dict[str, dict[str, Any]] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: """Serialize the visual group to a YAML-friendly mapping.""" @@ -73,6 +74,9 @@ def to_dict(self) -> dict[str, Any]: "layout": dict(self.layout), "boundary_ports": [port.to_dict() for port in self.boundary_ports], "child_group_uids": list(self.child_group_uids), + "member_layouts": { + uid: dict(layout) for uid, layout in self.member_layouts.items() + }, } return out @@ -104,6 +108,13 @@ def from_dict(cls, data: dict[str, Any]) -> "VisualGroup": if parent_uid is not None: parent_uid = str(parent_uid) + member_layouts_raw = data.get("member_layouts", {}) + member_layouts: dict[str, dict[str, Any]] = {} + if isinstance(member_layouts_raw, dict): + for uid, member_layout in member_layouts_raw.items(): + if isinstance(member_layout, dict): + member_layouts[str(uid)] = dict(member_layout) + return cls( uid=str(data.get("uid", "")), name=str(data.get("name", "")), @@ -112,4 +123,5 @@ def from_dict(cls, data: dict[str, Any]) -> "VisualGroup": layout=dict(layout), boundary_ports=boundary_ports, child_group_uids=[str(c) for c in children], + member_layouts=member_layouts, ) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 272657d..919cbb4 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -774,6 +774,7 @@ def _create_visual_group( layout=self._compute_group_layout(member_uids), boundary_ports=self._build_group_boundary_ports(member_uids), child_group_uids=[], + member_layouts=self._capture_member_layouts(member_uids), ) self.project_state.visual_groups.append(group) return group @@ -782,6 +783,63 @@ def _remove_visual_group(self, group_uid: str) -> bool: """Remove a visual group without pushing undo.""" return self.project_state.remove_visual_group(group_uid) + def _capture_member_layouts(self, member_uids: list[str]) -> dict[str, dict[str, Any]]: + """Snapshot block item geometry for internal group view.""" + layouts: dict[str, dict[str, Any]] = {} + for uid in member_uids: + block = self._find_block_by_uid(uid) + if block is None: + continue + item = self.view.get_block_item_from_instance(block) + if item is None: + continue + layouts[uid] = self._capture_block_layout(block) + return layouts + + def _apply_block_layout(self, block_item, layout: dict[str, Any]) -> None: + """Apply a stored layout snapshot to a block item.""" + block_item.setPos(QPointF(float(layout.get("x", 0.0)), float(layout.get("y", 0.0)))) + block_item.setRect( + 0, + 0, + float(layout.get("width", block_item.rect().width())), + float(layout.get("height", block_item.rect().height())), + ) + orientation = layout.get("orientation") + if isinstance(orientation, str): + block_item.orientation = orientation + block_item._layout_ports() + self.view.on_block_moved(block_item) + + def apply_member_layouts(self, group: VisualGroup) -> None: + """Apply stored member layouts to visible block items.""" + for uid in group.members: + layout = group.member_layouts.get(uid) + if not layout: + continue + block = self._find_block_by_uid(uid) + if block is None: + continue + item = self.view.get_block_item_from_instance(block) + if item is None: + continue + self._apply_block_layout(item, layout) + + def save_member_layouts(self, group: VisualGroup) -> None: + """Persist current block item geometry into the group model.""" + for uid in group.members: + block = self._find_block_by_uid(uid) + if block is None: + continue + item = self.view.get_block_item_from_instance(block) + if item is None: + continue + group.member_layouts[uid] = self._capture_block_layout(block) + + def restore_members_after_ungroup(self, group: VisualGroup) -> None: + """Place ungrouped members using their internal layouts.""" + self.apply_member_layouts(group) + def _compute_group_layout(self, member_uids: list[str]) -> dict[str, float]: """Compute a bounding layout for group members.""" margin = 16.0 diff --git a/pySimBlocks/gui/services/yaml_tools.py b/pySimBlocks/gui/services/yaml_tools.py index 05a9cab..3b49735 100644 --- a/pySimBlocks/gui/services/yaml_tools.py +++ b/pySimBlocks/gui/services/yaml_tools.py @@ -231,13 +231,17 @@ def _build_connections_section(project_state: ProjectState) -> tuple[list[dict], def _build_layout_section( block_items: dict[str, BlockItem], conn_name_map: dict[tuple[str, str], str], + hidden_member_uids: set[str] | None = None, ) -> dict: """Build the GUI layout section for a project YAML document.""" data: dict = {"blocks": {}} manual_connections = {} seen = set() + hidden_member_uids = hidden_member_uids or set() for block in block_items.values(): + if block.instance.uid in hidden_member_uids: + continue name = block.instance.name pos = block.pos() data["blocks"][name] = { @@ -305,7 +309,10 @@ def build_project_yaml( blocks = _build_blocks_section(project_state) connections, conn_name_map = _build_connections_section(project_state) - layout = _build_layout_section(block_items, conn_name_map) + hidden_member_uids: set[str] = set() + for group in project_state.visual_groups: + hidden_member_uids.update(group.members) + layout = _build_layout_section(block_items, conn_name_map, hidden_member_uids) groups = _build_groups_section(project_state) return { diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index c1fa438..0a98df0 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -163,15 +163,27 @@ def __init__(self, controller, blocks: list[BlockInstance], name: str | None = N self._blocks = list(blocks) self._name = name self._group_uid: str | None = None + self._group_snapshot: dict | None = None def redo(self) -> None: - group = self._controller._create_visual_group(self._blocks, self._name) - self._group_uid = group.uid + if self._group_snapshot is not None: + group = VisualGroup.from_dict(self._group_snapshot) + self._controller.project_state.visual_groups.append(group) + self._group_uid = group.uid + else: + group = self._controller._create_visual_group(self._blocks, self._name) + self._group_uid = group.uid + self._group_snapshot = group.to_dict() self._controller.view.refresh_visual_groups() self._controller.make_dirty() def undo(self) -> None: if self._group_uid: + group = self._controller.project_state.get_visual_group(self._group_uid) + if group is not None: + self._group_snapshot = group.to_dict() + if self._controller.view.current_view_group_uid == self._group_uid: + self._controller.view.exit_group_view() self._controller._remove_visual_group(self._group_uid) self._controller.view.refresh_visual_groups() self._controller.make_dirty() @@ -188,9 +200,10 @@ def redo(self) -> None: group = self._controller.project_state.get_visual_group(self._group_uid) if group is not None: self._group_snapshot = group.to_dict() - self._controller._remove_visual_group(self._group_uid) if self._controller.view.current_view_group_uid == self._group_uid: self._controller.view.exit_group_view() + self._controller.restore_members_after_ungroup(group) + self._controller._remove_visual_group(self._group_uid) self._controller.view.refresh_visual_groups() self._controller.make_dirty() diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 076cf76..88df665 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -273,16 +273,32 @@ def enter_group(self, group_uid: str) -> None: """Open the internal view of a visual group.""" if self.project_controller is None: return - if self.project_controller.project_state.get_visual_group(group_uid) is None: + group = self.project_controller.project_state.get_visual_group(group_uid) + if group is None: return + if self.current_view_group_uid and self.current_view_group_uid != group_uid: + self._save_active_group_member_layouts() self.current_view_group_uid = group_uid self.refresh_visual_groups() + self.project_controller.apply_member_layouts(group) def exit_group_view(self) -> None: """Return to the root diagram view.""" + self._save_active_group_member_layouts() self.current_view_group_uid = None self.refresh_visual_groups() + def _save_active_group_member_layouts(self) -> None: + """Persist block positions for the active internal group view.""" + if self.project_controller is None or self.current_view_group_uid is None: + return + group = self.project_controller.project_state.get_visual_group( + self.current_view_group_uid + ) + if group is None: + return + self.project_controller.save_member_layouts(group) + def on_group_moved(self, group_item: GroupItem) -> None: """Refresh wires after a group container is moved.""" for conn_inst, conn_item in self.connections.items(): @@ -326,6 +342,17 @@ def on_block_moved(self, block_item: BlockItem) -> None: Args: block_item: The block item that was repositioned. """ + if self.current_view_group_uid and self.project_controller is not None: + group = self.project_controller.project_state.get_visual_group( + self.current_view_group_uid + ) + if ( + group is not None + and block_item.instance.uid in group.members + ): + group.member_layouts[block_item.instance.uid] = ( + self.project_controller._capture_block_layout(block_item.instance) + ) for conn_inst, conn_item in self.connections.items(): if conn_inst.is_block_involved(block_item.instance): conn_item.invalidate_manual_route() From 5d7d6573b1ae6b9e58ec1968659de755015d7561 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Mon, 8 Jun 2026 10:59:13 +0200 Subject: [PATCH 07/42] feat(gui): add GroupIn/GroupOut proxy graphics for internal group view --- pySimBlocks/gui/group_ports.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 pySimBlocks/gui/group_ports.py diff --git a/pySimBlocks/gui/group_ports.py b/pySimBlocks/gui/group_ports.py new file mode 100644 index 0000000..032b7b3 --- /dev/null +++ b/pySimBlocks/gui/group_ports.py @@ -0,0 +1,10 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +"""Constants for visual-only group port palette entries.""" + +GROUP_PORTS_CATEGORY = "group_ports" +GROUP_IN_TYPE = "In" +GROUP_OUT_TYPE = "Out" From fafd03a28a41e200429daf375fa8b9148346499e Mon Sep 17 00:00:00 2001 From: Lukasik Date: Mon, 8 Jun 2026 10:59:35 +0200 Subject: [PATCH 08/42] feat(gui): add undo commands for group ports, proxy moves, and wire routes --- pySimBlocks/gui/undo_redo/commands.py | 99 ++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index 0a98df0..992b9e4 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -26,7 +26,30 @@ from PySide6.QtCore import QPointF, QRectF from PySide6.QtGui import QUndoCommand -from pySimBlocks.gui.models import BlockInstance, PortInstance, VisualGroup +from pySimBlocks.gui.models import BlockInstance, ConnectionInstance, PortInstance, VisualGroup +from pySimBlocks.gui.models.visual_group import BoundaryPort + + +def _clone_route_points(points: list[QPointF] | None) -> list[QPointF] | None: + if points is None: + return None + return [QPointF(point) for point in points] + + +def routes_equal( + left: list[QPointF] | None, + right: list[QPointF] | None, +) -> bool: + if left is None and right is None: + return True + if left is None or right is None: + return False + if len(left) != len(right): + return False + return all( + left_point.x() == right_point.x() and left_point.y() == right_point.y() + for left_point, right_point in zip(left, right) + ) @dataclass @@ -270,3 +293,77 @@ def undo(self) -> None: for snapshot in self._removed_connections: self._controller._add_connection_from_snapshot(snapshot) self._controller.make_dirty() + + +class EditConnectionRouteCommand(QUndoCommand): + def __init__( + self, + controller, + connection_instance: ConnectionInstance, + old_points: list[QPointF] | None, + new_points: list[QPointF] | None, + ): + super().__init__("Edit Connection Route") + self._controller = controller + self._connection_instance = connection_instance + self._old_points = _clone_route_points(old_points) + self._new_points = _clone_route_points(new_points) + + def redo(self) -> None: + self._controller._apply_connection_route( + self._connection_instance, self._new_points + ) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._apply_connection_route( + self._connection_instance, self._old_points + ) + self._controller.make_dirty() + + +class AddManualBoundaryCommand(QUndoCommand): + def __init__(self, controller, group_uid: str, boundary: BoundaryPort): + super().__init__("Add Group Port") + self._controller = controller + self._group_uid = group_uid + self._boundary = boundary + + def redo(self) -> None: + self._controller._add_manual_boundary_port(self._group_uid, self._boundary) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._remove_manual_boundary_port( + self._group_uid, self._boundary.uid + ) + self._controller.make_dirty() + + +class MoveProxyLayoutCommand(QUndoCommand): + def __init__( + self, + controller, + group_uid: str, + boundary_uid: str, + old_pos: QPointF, + new_pos: QPointF, + ): + super().__init__("Move Group Port") + self._controller = controller + self._group_uid = group_uid + self._boundary_uid = boundary_uid + self._old_pos = QPointF(old_pos) + self._new_pos = QPointF(new_pos) + + def redo(self) -> None: + self._controller._set_proxy_layout( + self._group_uid, self._boundary_uid, self._new_pos + ) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._set_proxy_layout( + self._group_uid, self._boundary_uid, self._old_pos + ) + self._controller.make_dirty() From 28a7a8336801029ae83dd5a5db36da32023e0e33 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Mon, 8 Jun 2026 11:00:24 +0200 Subject: [PATCH 09/42] feat(gui): wire group boundary proxies, port rebuild, and layout undo hooks --- pySimBlocks/gui/project_controller.py | 244 +++++++++++++++++++++++++- 1 file changed, 240 insertions(+), 4 deletions(-) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 919cbb4..51deb46 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -41,8 +41,11 @@ from pySimBlocks.gui.undo_redo.commands import ( AddBlockCommand, AddConnectionCommand, + AddManualBoundaryCommand, EditBlockParamsCommand, + EditConnectionRouteCommand, GroupBlocksCommand, + MoveProxyLayoutCommand, MoveResizeBlockCommand, MoveResizeGroupCommand, RemoveBlockCommand, @@ -347,6 +350,33 @@ def execute_toggle_orientation(self, block_instance: BlockInstance) -> None: ToggleOrientationCommand(self, block_instance.uid, old_orientation, new_orientation) ) + def execute_edit_connection_route( + self, + connection: ConnectionInstance, + old_points: list[QPointF] | None, + new_points: list[QPointF] | None, + ) -> None: + from pySimBlocks.gui.undo_redo.commands import routes_equal + + if routes_equal(old_points, new_points): + return + self.undo_manager.push( + EditConnectionRouteCommand(self, connection, old_points, new_points) + ) + + def execute_move_proxy_layout( + self, + group_uid: str, + boundary_uid: str, + old_pos: QPointF, + new_pos: QPointF, + ) -> None: + if old_pos == new_pos: + return + self.undo_manager.push( + MoveProxyLayoutCommand(self, group_uid, boundary_uid, old_pos, new_pos) + ) + def begin_macro(self, text: str) -> None: self.undo_manager.stack.beginMacro(text) @@ -364,6 +394,10 @@ def make_dirty(self) -> None: self.is_dirty = True self.dirty_changed.emit(True) + def mark_gui_layout_dirty(self) -> None: + """Mark GUI-only layout edits that stay outside the undo/redo stack.""" + self.make_dirty() + def clear_dirty(self) -> None: """Clear the unsaved-changes flag and emit :attr:`dirty_changed`.""" if self.is_dirty: @@ -402,6 +436,8 @@ def load_project(self, loader: "ProjectLoader") -> None: project files and populates this controller. """ loader.load(self, self.project_state.directory_path) + for group in self.project_state.visual_groups: + self.ensure_group_boundary_proxies(group) self.view.refresh_visual_groups() @@ -571,8 +607,9 @@ def _ensure_logged(self, signals: list[str]) -> None: self.project_state.logging.append(sig) def _remove_block(self, block_instance: BlockInstance) -> None: + block_uid = block_instance.uid for connection in list(self.project_state.get_connections_of_block(block_instance)): - self._remove_connection(connection) + self._remove_connection(connection, refresh_boundaries=False) removed_signals = [ f"{block_instance.name}.outputs.{p.name}" @@ -623,11 +660,19 @@ def _remove_block(self, block_instance: BlockInstance) -> None: self.project_state.remove_block(block_instance) self.view.remove_block(block_instance) - self.view.refresh_visual_groups() + self._refresh_boundaries_for_member_uids({block_uid}) - def _remove_connection(self, connection: ConnectionInstance) -> None: + def _remove_connection( + self, + connection: ConnectionInstance, + *, + refresh_boundaries: bool = True, + ) -> None: + block_uids = {connection.src_block().uid, connection.dst_block().uid} self.project_state.remove_connection(connection) self.view.remove_connection(connection) + if refresh_boundaries: + self._refresh_boundaries_for_member_uids(block_uids) def _find_block_by_uid(self, block_uid: str) -> BlockInstance | None: for block in self.project_state.blocks: @@ -687,6 +732,9 @@ def _add_connection_from_snapshot(self, snapshot: ConnectionSnapshot) -> Connect connection_instance = ConnectionInstance(src_port, dst_port) self.project_state.add_connection(connection_instance) self.view.add_connection(connection_instance, snapshot.points) + self._refresh_boundaries_for_member_uids( + {src_port.block.uid, dst_port.block.uid} + ) return connection_instance def _set_group_geometry(self, group_uid: str, pos: QPointF, rect: QRectF) -> None: @@ -776,6 +824,7 @@ def _create_visual_group( child_group_uids=[], member_layouts=self._capture_member_layouts(member_uids), ) + self.ensure_group_boundary_proxies(group) self.project_state.visual_groups.append(group) return group @@ -840,6 +889,136 @@ def restore_members_after_ungroup(self, group: VisualGroup) -> None: """Place ungrouped members using their internal layouts.""" self.apply_member_layouts(group) + def ensure_group_boundary_proxies(self, group: VisualGroup) -> None: + """Assign proxy ids and default layouts for each group boundary port.""" + inputs = [port for port in group.boundary_ports if port.direction == "input"] + outputs = [port for port in group.boundary_ports if port.direction == "output"] + for index, port in enumerate(inputs): + self._ensure_boundary_proxy(port, "input", index, len(inputs), group) + for index, port in enumerate(outputs): + self._ensure_boundary_proxy(port, "output", index, len(outputs), group) + + def _ensure_boundary_proxy( + self, + boundary: BoundaryPort, + direction: str, + index: int, + total: int, + group: VisualGroup, + ) -> None: + if not boundary.proxy_uid: + boundary.proxy_uid = uuid.uuid4().hex + if not boundary.proxy_layout: + boundary.proxy_layout = self._default_proxy_layout( + direction, index, total, group + ) + + def _default_proxy_layout( + self, + direction: str, + index: int, + total: int, + group: VisualGroup, + ) -> dict[str, float]: + layout = group.layout or {} + height = float(layout.get("height", 100.0)) + y = 20.0 + (index + 1) * max(40.0, height / (total + 1)) + if direction == "input": + return {"x": 10.0, "y": y} + width = float(layout.get("width", 160.0)) + return {"x": max(80.0, width - 66.0), "y": y} + + def _apply_connection_route( + self, + connection: ConnectionInstance, + points: list[QPointF] | None, + ) -> None: + connection_item = self.view.connections.get(connection) + if connection_item is None: + return + if points is None or len(points) < 2: + connection_item.invalidate_manual_route() + else: + connection_item.apply_manual_route(points) + connection_item.update_position() + + def save_proxy_layouts(self, group: VisualGroup) -> None: + """Persist current proxy item positions into the group model.""" + for boundary in group.boundary_ports: + item = self.view.proxy_items.get(boundary.uid) + if item is None: + continue + pos = item.pos() + boundary.proxy_layout = {"x": float(pos.x()), "y": float(pos.y())} + + def _add_manual_boundary_port( + self, + group_uid: str, + boundary: BoundaryPort, + ) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + if any(port.uid == boundary.uid for port in group.boundary_ports): + return + group.boundary_ports.append(boundary) + self.ensure_group_boundary_proxies(group) + self.view.refresh_visual_groups() + + def _remove_manual_boundary_port(self, group_uid: str, boundary_uid: str) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + group.boundary_ports = [ + port for port in group.boundary_ports if port.uid != boundary_uid + ] + proxy_item = self.view.proxy_items.pop(boundary_uid, None) + if proxy_item is not None: + self.view.diagram_scene.removeItem(proxy_item) + self.view.refresh_visual_groups() + + def _set_proxy_layout( + self, + group_uid: str, + boundary_uid: str, + pos: QPointF, + ) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + boundary = next( + (port for port in group.boundary_ports if port.uid == boundary_uid), + None, + ) + if boundary is None: + return + boundary.proxy_layout = {"x": float(pos.x()), "y": float(pos.y())} + proxy_item = self.view.proxy_items.get(boundary_uid) + if proxy_item is not None: + proxy_item.setPos(QPointF(pos)) + for conn_item in self.view.connections.values(): + conn_item.update_position() + + def add_manual_boundary_port( + self, + group_uid: str, + direction: str, + pos: QPointF, + ) -> BoundaryPort | None: + """Add a manual GroupIn/GroupOut boundary port to a visual group.""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return None + boundary = BoundaryPort( + uid=uuid.uuid4().hex, + direction=direction, + origin="manual", + proxy_uid=uuid.uuid4().hex, + proxy_layout={"x": float(pos.x()), "y": float(pos.y())}, + ) + self.undo_manager.push(AddManualBoundaryCommand(self, group_uid, boundary)) + return boundary + def _compute_group_layout(self, member_uids: list[str]) -> dict[str, float]: """Compute a bounding layout for group members.""" margin = 16.0 @@ -901,13 +1080,70 @@ def _build_group_boundary_ports(self, member_uids: list[str]) -> list[BoundaryPo direction=direction, linked_port_uid=f"{internal_port.block.uid}:{internal_port.name}", origin="auto", - linked_connection_uid=f"{connection.src_block().uid}:{connection.src_port.name}->{connection.dst_block().uid}:{connection.dst_port.name}", + linked_connection_uid=self._connection_key(connection), ) by_internal_port_uid[key] = boundary boundary_ports.append(boundary) return boundary_ports + def _connection_key(self, connection: ConnectionInstance) -> str: + """Build a stable key for one diagram connection.""" + return ( + f"{connection.src_block().uid}:{connection.src_port.name}->" + f"{connection.dst_block().uid}:{connection.dst_port.name}" + ) + + def _rebuild_group_boundary_ports(self, group: VisualGroup) -> None: + """Recompute auto boundary ports, keeping manual ports and stable auto ids.""" + manual_ports = [port for port in group.boundary_ports if port.origin == "manual"] + existing_auto = { + port.linked_port_uid: port + for port in group.boundary_ports + if port.origin == "auto" + } + + rebuilt_auto: list[BoundaryPort] = [] + for port in self._build_group_boundary_ports(group.members): + previous = existing_auto.get(port.linked_port_uid) + if previous is not None: + port.uid = previous.uid + port.proxy_uid = previous.proxy_uid + port.proxy_layout = dict(previous.proxy_layout) + rebuilt_auto.append(port) + + group.boundary_ports = manual_ports + rebuilt_auto + self.ensure_group_boundary_proxies(group) + + def _group_needs_boundary_refresh( + self, + group: VisualGroup, + block_uids: set[str], + ) -> bool: + """Return whether a group may need boundary ports recomputed.""" + members = set(group.members) + if members.intersection(block_uids): + return True + + for connection in self.project_state.connections: + src_uid = connection.src_block().uid + dst_uid = connection.dst_block().uid + src_in = src_uid in members + dst_in = dst_uid in members + if src_in != dst_in and (src_uid in block_uids or dst_uid in block_uids): + return True + return False + + def _refresh_boundaries_for_member_uids(self, block_uids: set[str]) -> None: + """Refresh boundary ports for groups affected by block or connection changes.""" + changed = False + for group in self.project_state.visual_groups: + if self._group_needs_boundary_refresh(group, block_uids): + self._rebuild_group_boundary_ports(group) + changed = True + if changed: + self.view.refresh_visual_groups() + def _make_unique_group_name(self, base_name: str) -> str: """Return a unique visual group name based on existing groups.""" existing = {g.name for g in self.project_state.visual_groups} From 824d6fedbeae603fb535d120dd304501b5ed3ca0 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Mon, 8 Jun 2026 11:00:49 +0200 Subject: [PATCH 10/42] feat(gui): show group proxies in internal view and refine diagram interactions --- pySimBlocks/gui/widgets/diagram_view.py | 176 ++++++++++++++++++++---- 1 file changed, 150 insertions(+), 26 deletions(-) diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 88df665..4f6ff78 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -22,15 +22,17 @@ from typing import TYPE_CHECKING, Any -from PySide6.QtCore import QPointF, QRectF, Qt, QTimer +from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal from PySide6.QtGui import QGuiApplication, QKeySequence, QPainter, QPen from PySide6.QtWidgets import QGraphicsScene, QGraphicsView, QMenu from pySimBlocks.gui.graphics.block_item import BlockItem from pySimBlocks.gui.graphics.group_item import GroupItem +from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyItem 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.group_ports import GROUP_IN_TYPE, GROUP_OUT_TYPE, GROUP_PORTS_CATEGORY from pySimBlocks.gui.models.block_instance import BlockInstance from pySimBlocks.gui.models.connection_instance import ConnectionInstance @@ -39,21 +41,9 @@ 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. - """ + """Interactive Qt graphics view for the block diagram canvas.""" + + group_view_changed = Signal() def __init__(self): """Initialize the diagram view and configure scene behavior. @@ -86,6 +76,7 @@ def __init__(self): self.project_controller: ProjectController | None self.block_items: dict[str, BlockItem] = {} self.group_items: dict[str, GroupItem] = {} + self.proxy_items: dict[str, GroupProxyItem] = {} self.connections: dict[ConnectionInstance, ConnectionItem] = {} self.current_view_group_uid: str | None = None @@ -249,12 +240,50 @@ def refresh_visual_groups(self) -> None: if visible: conn_item.update_position() + self._refresh_group_proxies(active_group) + + def _refresh_group_proxies(self, active_group) -> None: + """Create or hide GroupIn/GroupOut proxy items for the active internal view.""" + if active_group is None: + for item in self.proxy_items.values(): + item.setVisible(False) + return + + active_boundary_uids = {boundary.uid for boundary in active_group.boundary_ports} + for uid in list(self.proxy_items.keys()): + if uid not in active_boundary_uids: + item = self.proxy_items.pop(uid) + self.diagram_scene.removeItem(item) + + for boundary in active_group.boundary_ports: + item = self.proxy_items.get(boundary.uid) + if item is None: + item = GroupProxyItem(boundary, self) + self.diagram_scene.addItem(item) + self.proxy_items[boundary.uid] = item + else: + item.boundary = boundary + if boundary.proxy_layout: + item.setPos( + QPointF( + float(boundary.proxy_layout.get("x", 0.0)), + float(boundary.proxy_layout.get("y", 0.0)), + ) + ) + item.setVisible(True) + def connection_anchor_for_port_item(self, port_item: PortItem) -> QPointF: """Return the scene anchor for a port, redirecting through group borders when collapsed.""" block_uid = port_item.instance.block.uid port_name = port_item.instance.name - if self.current_view_group_uid is not None: + active_uid = self.current_view_group_uid + if active_uid and self.project_controller is not None: + group = self.project_controller.project_state.get_visual_group(active_uid) + if group is not None: + external_anchor = self._proxy_anchor_for_external_port(group, port_item) + if external_anchor is not None: + return external_anchor return port_item.connection_anchor() for group_item in self.group_items.values(): @@ -269,6 +298,64 @@ def connection_anchor_for_port_item(self, port_item: PortItem) -> QPointF: return port_item.connection_anchor() + def _proxy_anchor_for_external_port(self, group, port_item: PortItem) -> QPointF | None: + """In internal view, attach crossing wires to GroupIn/GroupOut proxies.""" + if self.project_controller is None: + return None + + members = set(group.members) + port_instance = port_item.instance + for connection in self.project_controller.project_state.connections: + if connection.src_port is not port_instance and connection.dst_port is not port_instance: + continue + + src_uid = connection.src_block().uid + dst_uid = connection.dst_block().uid + src_in = src_uid in members + dst_in = dst_uid in members + if src_in == dst_in: + continue + + if dst_in: + member_uid, member_port_name = dst_uid, connection.dst_port.name + external_port = connection.src_port + else: + member_uid, member_port_name = src_uid, connection.src_port.name + external_port = connection.dst_port + + if external_port is not port_instance: + continue + + linked_key = f"{member_uid}:{member_port_name}" + for boundary in group.boundary_ports: + if boundary.linked_port_uid != linked_key: + continue + proxy = self.proxy_items.get(boundary.uid) + if proxy is None: + return None + return proxy.external_anchor() + return None + + def on_proxy_moved(self, _proxy_item: GroupProxyItem) -> None: + """Refresh wires after a group proxy is moved.""" + for conn_item in self.connections.values(): + conn_item.update_position() + + def on_connection_route_edited( + self, + connection_item: ConnectionItem, + old_points: list[QPointF] | None, + new_points: list[QPointF] | None, + ) -> None: + """Record a manual wire route edit on the undo/redo stack.""" + if self.project_controller is not None: + self.project_controller.execute_edit_connection_route( + connection_item.instance, + old_points, + new_points, + ) + connection_item.update_position() + def enter_group(self, group_uid: str) -> None: """Open the internal view of a visual group.""" if self.project_controller is None: @@ -277,19 +364,22 @@ def enter_group(self, group_uid: str) -> None: if group is None: return if self.current_view_group_uid and self.current_view_group_uid != group_uid: - self._save_active_group_member_layouts() + self._save_active_group_view_state() + self.project_controller.ensure_group_boundary_proxies(group) self.current_view_group_uid = group_uid self.refresh_visual_groups() self.project_controller.apply_member_layouts(group) + self.group_view_changed.emit() def exit_group_view(self) -> None: """Return to the root diagram view.""" - self._save_active_group_member_layouts() + self._save_active_group_view_state() self.current_view_group_uid = None self.refresh_visual_groups() + self.group_view_changed.emit() - def _save_active_group_member_layouts(self) -> None: - """Persist block positions for the active internal group view.""" + def _save_active_group_view_state(self) -> None: + """Persist block and proxy positions for the active internal group view.""" if self.project_controller is None or self.current_view_group_uid is None: return group = self.project_controller.project_state.get_visual_group( @@ -298,6 +388,7 @@ def _save_active_group_member_layouts(self) -> None: if group is None: return self.project_controller.save_member_layouts(group) + self.project_controller.save_proxy_layouts(group) def on_group_moved(self, group_item: GroupItem) -> None: """Refresh wires after a group container is moved.""" @@ -355,7 +446,6 @@ def on_block_moved(self, block_item: BlockItem) -> None: ) 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() def on_block_ports_refreshed(self, block_item: BlockItem) -> None: @@ -393,7 +483,17 @@ def dropEvent(self, event) -> None: """ self.drop_event_pos = self.mapToScene(event.position().toPoint()) category, block_type = event.mimeData().text().split(":") - self.project_controller.add_block(category, block_type) + if category == GROUP_PORTS_CATEGORY: + if self.current_view_group_uid is None or self.project_controller is None: + return + direction = "input" if block_type == GROUP_IN_TYPE else "output" + self.project_controller.add_manual_boundary_port( + self.current_view_group_uid, + direction, + self.drop_event_pos, + ) + else: + self.project_controller.add_block(category, block_type) event.acceptProposedAction() def keyPressEvent(self, event) -> None: @@ -541,11 +641,11 @@ def contextMenuEvent(self, event) -> None: selected_blocks = self.get_selected_block_instances() selected_group_uid = self.get_selected_group_uid() - group_action = menu.addAction("Grouper") + group_action = menu.addAction("Group") group_action.setEnabled(len(selected_blocks) >= 2) group_action.triggered.connect(self.project_controller.group_selected_blocks) - ungroup_action = menu.addAction("Dé-grouper") + ungroup_action = menu.addAction("Ungroup") ungroup_action.setEnabled(selected_group_uid is not None) if selected_group_uid is not None: ungroup_action.triggered.connect( @@ -553,8 +653,12 @@ def contextMenuEvent(self, event) -> None: ) if self.current_view_group_uid is not None: - exit_action = menu.addAction("Niveau supérieur") + exit_action = menu.addAction("Go up") exit_action.triggered.connect(self.exit_group_view) + add_in = menu.addAction("Add input") + add_in.triggered.connect(self._add_manual_group_input) + add_out = menu.addAction("Add output") + add_out.triggered.connect(self._add_manual_group_output) menu.exec(event.globalPos()) @@ -580,6 +684,7 @@ def clear_scene(self) -> None: self.diagram_scene.clear() self.block_items.clear() self.group_items.clear() + self.proxy_items.clear() self.connections.clear() self.current_view_group_uid = None self.temp_connection = None @@ -637,6 +742,25 @@ def _refresh_theme_items(self) -> None: for group in self.group_items.values(): group.update() + for proxy in self.proxy_items.values(): + proxy.update() + + def _add_manual_group_input(self) -> None: + self._add_manual_group_boundary("input") + + def _add_manual_group_output(self) -> None: + self._add_manual_group_boundary("output") + + def _add_manual_group_boundary(self, direction: str) -> None: + if self.project_controller is None or self.current_view_group_uid is None: + return + origin = self.mapToScene(self.viewport().rect().center()) + self.project_controller.add_manual_boundary_port( + self.current_view_group_uid, + direction, + origin, + ) + 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 From c1dc2ec72928a9c5b7f01ce3db0f0e7978827546 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Mon, 8 Jun 2026 11:01:20 +0200 Subject: [PATCH 11/42] feat(gui): expose group In/Out palette entries in internal group view --- pySimBlocks/gui/main_window.py | 40 +++++++++++++++------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/pySimBlocks/gui/main_window.py b/pySimBlocks/gui/main_window.py index 9188a4e..cc27972 100644 --- a/pySimBlocks/gui/main_window.py +++ b/pySimBlocks/gui/main_window.py @@ -37,6 +37,11 @@ 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.group_ports import ( + GROUP_IN_TYPE, + GROUP_OUT_TYPE, + GROUP_PORTS_CATEGORY, +) from pySimBlocks.gui.widgets.diagram_view import DiagramView from pySimBlocks.gui.widgets.toolbar_view import ToolBarView from pySimBlocks.tools.blocks_registry import load_block_registry @@ -83,6 +88,7 @@ def __init__(self, project_path: Path): ) self.view.project_controller = self.project_controller self.blocks = BlockList(self.get_categories, self.get_blocks, self.resolve_block_meta) + self.view.group_view_changed.connect(self.blocks.rebuild) self.toolbar = ToolBarView(self.saver, self.runner, self.project_controller) splitter = QSplitter(Qt.Horizontal) @@ -130,34 +136,22 @@ def __init__(self, project_path: Path): # -------------------------------------------------------------------------- def get_categories(self) -> List[str]: - """Return the sorted list of block categories from the registry. - - Returns: - Sorted list of category name strings. - """ - return sorted(self.block_registry.keys()) + """Return the sorted list of block categories from the registry.""" + categories = list(self.block_registry.keys()) + if self.view.current_view_group_uid is not None: + categories.append(GROUP_PORTS_CATEGORY) + return sorted(categories) def get_blocks(self, category: str) -> List[str]: - """Return the sorted list of block type names within a category. - - Args: - category: Category name to look up. - - Returns: - Sorted list of block type name strings. - """ + """Return the sorted list of block type names within a category.""" + if category == GROUP_PORTS_CATEGORY: + return [GROUP_IN_TYPE, GROUP_OUT_TYPE] return sorted(self.block_registry.get(category, {}).keys()) def resolve_block_meta(self, category: str, block_type: str) -> BlockMeta: - """Return the :class:`BlockMeta` for a given category and block type. - - Args: - category: Category name of the block. - block_type: Type name of the block within the category. - - Returns: - The :class:`BlockMeta` descriptor for the requested block. - """ + """Return the :class:`BlockMeta` for a given category and block type.""" + if category == GROUP_PORTS_CATEGORY: + raise KeyError("Group port palette entries are visual-only.") return self.block_registry[category][block_type] From ce71ed32f37d9e530a7b41220ffaf15f4dca71b8 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Mon, 8 Jun 2026 11:04:00 +0200 Subject: [PATCH 12/42] fix(gui): improve wire segment picking and left-button route drag --- pySimBlocks/gui/graphics/connection_item.py | 81 +++++++++++++-------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/pySimBlocks/gui/graphics/connection_item.py b/pySimBlocks/gui/graphics/connection_item.py index d19945b..b5782ba 100644 --- a/pySimBlocks/gui/graphics/connection_item.py +++ b/pySimBlocks/gui/graphics/connection_item.py @@ -62,8 +62,9 @@ class ConnectionItem(QGraphicsPathItem): OFFSET = 8 MARGIN = 12 DETOUR = 8 - PICK_TOL = 6 + PICK_TOL = 10 GRID = 5 + AXIS_EPS = 0.5 def __init__(self, src_port: PortItem | None, @@ -93,6 +94,8 @@ def __init__(self, self._valid_port = src_port if src_port is not None else dst_port self.is_manual: bool = False self.route: OrthogonalRoute | None = None + self._route_drag_active = False + self._route_points_before_drag: list[QPointF] | None = None if points and len(points) >= 2: self.apply_manual_route(points) @@ -110,7 +113,7 @@ def __init__(self, pen = QPen(t.wire, 3, Qt.SolidLine) self.setPen(pen) - self.setZValue(1) + self.setZValue(2) self.update_position() @@ -176,14 +179,14 @@ def segment_at(self, scene_pos: QPointF) -> int | None: for i in range(len(pts) - 1): a, b = pts[i], pts[i + 1] - if a.x() == b.x(): # vertical + if abs(a.x() - b.x()) < self.AXIS_EPS: # vertical if abs(scene_pos.x() - a.x()) < self.PICK_TOL \ - and min(a.y(), b.y()) <= scene_pos.y() <= max(a.y(), b.y()): + and min(a.y(), b.y()) - self.PICK_TOL <= scene_pos.y() <= max(a.y(), b.y()) + self.PICK_TOL: return i - if a.y() == b.y(): # horizontal + elif abs(a.y() - b.y()) < self.AXIS_EPS: # horizontal if abs(scene_pos.y() - a.y()) < self.PICK_TOL \ - and min(a.x(), b.x()) <= scene_pos.x() <= max(a.x(), b.x()): + and min(a.x(), b.x()) - self.PICK_TOL <= scene_pos.x() <= max(a.x(), b.x()) + self.PICK_TOL: return i return None @@ -194,30 +197,37 @@ def shape(self): Stroke path used for hit testing. """ stroker = QPainterPathStroker() - stroker.setWidth(6) + stroker.setWidth(12) return stroker.createStroke(self.path()) def mousePressEvent(self, event): - """Start manual segment dragging when pressing a routed segment. - - Args: - event: Qt mouse-press event. - """ - idx = self.segment_at(event.scenePos()) - if idx is not None: - self.route.dragged_index = idx - self.is_manual = True - event.accept() - else: - super().mousePressEvent(event) + """Start manual segment dragging with the left mouse button.""" + if event.button() == Qt.LeftButton: + idx = self.segment_at(event.scenePos()) + if idx is not None: + if self.route is None: + self.update_position() + if self.route is None: + super().mousePressEvent(event) + return + self._route_points_before_drag = [ + QPointF(point) for point in self.route.points + ] + self.route.dragged_index = idx + self.is_manual = True + self._route_drag_active = True + self.grabMouse() + event.accept() + return + super().mousePressEvent(event) def mouseMoveEvent(self, event): - """Move the selected orthogonal segment during manual route editing. - - Args: - event: Qt mouse-move event. - """ + """Move the selected orthogonal segment during manual route editing.""" if not self.route or self.route.dragged_index is None: + super().mouseMoveEvent(event) + return + + if not (event.buttons() & Qt.LeftButton): return i = self.route.dragged_index @@ -225,12 +235,12 @@ def mouseMoveEvent(self, event): b = self.route.points[i + 1] pos = event.scenePos() - if a.x() == b.x(): # vertical segment + if abs(a.x() - b.x()) < self.AXIS_EPS: # vertical segment x = self._snap(pos.x()) self.route.points[i] = QPointF(x, a.y()) self.route.points[i + 1] = QPointF(x, b.y()) - elif a.y() == b.y(): # horizontal segment + elif abs(a.y() - b.y()) < self.AXIS_EPS: # horizontal segment y = self._snap(pos.y()) self.route.points[i] = QPointF(a.x(), y) self.route.points[i + 1] = QPointF(b.x(), y) @@ -238,14 +248,23 @@ def mouseMoveEvent(self, event): self._apply_route(self.route.points) def mouseReleaseEvent(self, event): - """Finish manual segment dragging. - - Args: - event: Qt mouse-release event. - """ + """Finish manual segment dragging.""" + was_dragging = self._route_drag_active if self.route: self.route.dragged_index = None + self._route_drag_active = False + if was_dragging: + self.ungrabMouse() super().mouseReleaseEvent(event) + if was_dragging and event.button() == Qt.LeftButton and self.route is not None: + view = self.src_port.parent_block.view + new_points = [QPointF(point) for point in self.route.points] + view.on_connection_route_edited( + self, + self._route_points_before_drag, + new_points, + ) + self._route_points_before_drag = None # -------------------------------------------------------------------------- From 33a467238969fc8a710995695778405fc138321a Mon Sep 17 00:00:00 2001 From: Lukasik Date: Mon, 8 Jun 2026 11:06:56 +0200 Subject: [PATCH 13/42] feat(gui): add GroupProxyItem graphics for internal group In/Out --- pySimBlocks/gui/graphics/group_proxy_item.py | 162 +++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 pySimBlocks/gui/graphics/group_proxy_item.py diff --git a/pySimBlocks/gui/graphics/group_proxy_item.py b/pySimBlocks/gui/graphics/group_proxy_item.py new file mode 100644 index 0000000..c358bcb --- /dev/null +++ b/pySimBlocks/gui/graphics/group_proxy_item.py @@ -0,0 +1,162 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtGui import QBrush, QFont, QPainter, QPen, QPainterPath +from PySide6.QtWidgets import QGraphicsItem, QGraphicsRectItem, QStyle + +from pySimBlocks.gui.models.visual_group import BoundaryPort + +if TYPE_CHECKING: + from pySimBlocks.gui.widgets.diagram_view import DiagramView + + +class GroupProxyPortItem(QGraphicsItem): + """Single connection anchor on a GroupIn / GroupOut proxy block.""" + + R = 6 + L = 15 + H = 10 + + def __init__(self, is_output: bool, parent_proxy: "GroupProxyItem"): + super().__init__(parent_proxy) + self.is_output = is_output + self.parent_proxy = parent_proxy + + def connection_anchor(self) -> QPointF: + if self.is_output: + local = QPointF(self.L, 0) + else: + local = QPointF(-self.R, 0) + return self.mapToScene(local) + + def boundingRect(self) -> QRectF: + return QRectF(-12, -12, 24, 24) + + def paint(self, painter, option, widget=None): + t = self.parent_proxy.view.theme + painter.setRenderHint(QPainter.Antialiasing) + fill = t.port_out if self.is_output else t.port_in + painter.setBrush(QBrush(fill)) + painter.setPen(QPen(t.block_border, 1)) + if self.is_output: + path = QPainterPath() + path.moveTo(0, -self.H) + path.lineTo(0, self.H) + path.lineTo(self.L, 0) + path.closeSubpath() + painter.drawPath(path) + else: + painter.drawEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R) + + +class GroupProxyItem(QGraphicsRectItem): + """Render a GroupIn or GroupOut proxy inside a visual group.""" + + WIDTH = 56.0 + HEIGHT = 36.0 + GRID_DX = 5 + GRID_DY = 5 + + def __init__(self, boundary: BoundaryPort, view: "DiagramView"): + super().__init__(0, 0, self.WIDTH, self.HEIGHT) + self.boundary = boundary + self.view = view + if self.is_group_in: + self.external_port = GroupProxyPortItem(is_output=False, parent_proxy=self) + self.member_port = GroupProxyPortItem(is_output=True, parent_proxy=self) + else: + self.member_port = GroupProxyPortItem(is_output=False, parent_proxy=self) + self.external_port = GroupProxyPortItem(is_output=True, parent_proxy=self) + + rect = self.rect() + mid_y = rect.height() / 2 + if self.is_group_in: + self.external_port.setPos(0, mid_y) + self.member_port.setPos(rect.width(), mid_y) + else: + self.member_port.setPos(0, mid_y) + self.external_port.setPos(rect.width(), mid_y) + + layout = boundary.proxy_layout or {} + self.setPos(QPointF(float(layout.get("x", 0.0)), float(layout.get("y", 0.0)))) + self.setFlag(QGraphicsRectItem.ItemIsMovable) + self.setFlag(QGraphicsRectItem.ItemIsSelectable) + self.setFlag(QGraphicsRectItem.ItemSendsScenePositionChanges) + self.setZValue(0) + self._interaction_start_pos: QPointF | None = None + + @property + def is_group_in(self) -> bool: + return self.boundary.direction == "input" + + @property + def is_group_out(self) -> bool: + return self.boundary.direction == "output" + + @property + def label(self) -> str: + return "In" if self.is_group_in else "Out" + + def member_anchor(self) -> QPointF: + """Anchor facing group members (GroupIn out, GroupOut in).""" + return self.member_port.connection_anchor() + + def external_anchor(self) -> QPointF: + """Anchor facing the parent diagram (GroupIn in, GroupOut out).""" + return self.external_port.connection_anchor() + + def paint(self, painter, option, widget=None): + t = self.view.theme + selected = bool(option.state & QStyle.State_Selected) + painter.setRenderHint(QPainter.Antialiasing) + painter.setBrush(QBrush(t.block_bg_selected if selected else t.block_bg)) + painter.setPen(QPen(t.block_border_selected if selected else t.block_border, 2)) + painter.drawRect(self.rect()) + painter.setPen(QPen(t.text_selected if selected else t.text)) + painter.setFont(QFont("Sans Serif", 8, QFont.Bold)) + painter.drawText(self.rect(), int(Qt.AlignCenter), self.label) + + def mousePressEvent(self, event): + self._interaction_start_pos = QPointF(self.pos()) + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event): + start_pos = self._interaction_start_pos + end_pos = QPointF(self.pos()) + self._interaction_start_pos = None + super().mouseReleaseEvent(event) + if ( + start_pos is not None + and start_pos != end_pos + and self.view.project_controller is not None + and self.view.current_view_group_uid is not None + ): + self.view.project_controller.execute_move_proxy_layout( + self.view.current_view_group_uid, + self.boundary.uid, + start_pos, + end_pos, + ) + + def itemChange(self, change, value): + if change == QGraphicsItem.ItemPositionChange and self.scene(): + x = round(value.x() / self.GRID_DX) * self.GRID_DX + y = round(value.y() / self.GRID_DY) * self.GRID_DY + return QPointF(x, y) + + if change == QGraphicsItem.ItemPositionHasChanged and self.view.project_controller: + pos = self.pos() + self.boundary.proxy_layout = { + "x": float(pos.x()), + "y": float(pos.y()), + } + self.view.on_proxy_moved(self) + + return super().itemChange(change, value) From 099779aa7082c3cd5547c8bfe64504f353900e18 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Mon, 8 Jun 2026 11:07:22 +0200 Subject: [PATCH 14/42] fix(gui): rebuild block list when entering or leaving group view --- pySimBlocks/gui/widgets/block_list.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pySimBlocks/gui/widgets/block_list.py b/pySimBlocks/gui/widgets/block_list.py index 1384713..b4f4747 100644 --- a/pySimBlocks/gui/widgets/block_list.py +++ b/pySimBlocks/gui/widgets/block_list.py @@ -102,6 +102,8 @@ def on_item_double_clicked(self, item, column): return category, block_type = data + if category == "group_ports": + return meta = self.resolve_block_meta(category, block_type) @@ -148,6 +150,21 @@ def __init__(self, self.search.textChanged.connect(self._apply_filter) + def rebuild(self) -> None: + """Rebuild the category tree after the diagram view context changes.""" + expanded = { + self.tree.topLevelItem(i).text(0): self.tree.topLevelItem(i).isExpanded() + for i in range(self.tree.topLevelItemCount()) + } + self.tree.clear() + for category in self.tree.get_categories(): + category_item = QTreeWidgetItem(self.tree, [category]) + category_item.setExpanded(expanded.get(category, False)) + for block_type in self.tree.get_blocks(category): + block_item = QTreeWidgetItem(category_item, [block_type]) + block_item.setData(0, Qt.UserRole, (category, block_type)) + self._apply_filter(self.search.text()) + # -------------------------------------------------------------------------- # Private Methods From ff1347233ecc2b31ff39105f5e2ce55c33671732 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Fri, 12 Jun 2026 12:48:43 +0200 Subject: [PATCH 15/42] update gui/graphics/__init__.py --- pySimBlocks/gui/graphics/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pySimBlocks/gui/graphics/__init__.py b/pySimBlocks/gui/graphics/__init__.py index 6dada80..180b5c9 100644 --- a/pySimBlocks/gui/graphics/__init__.py +++ b/pySimBlocks/gui/graphics/__init__.py @@ -21,6 +21,7 @@ from pySimBlocks.gui.graphics.block_item import BlockItem from pySimBlocks.gui.graphics.connection_item import ConnectionItem from pySimBlocks.gui.graphics.group_item import GroupItem, GroupBoundaryPortItem +from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyItem, GroupProxyPortItem from pySimBlocks.gui.graphics.port_item import PortItem __all__ = [ @@ -28,5 +29,7 @@ "ConnectionItem", "GroupItem", "GroupBoundaryPortItem", + "GroupProxyItem", + "GroupProxyPortItem", "PortItem", ] From 5aa56822d9ebc93a83a27fb5961d62ddfb54215c Mon Sep 17 00:00:00 2001 From: Lukasik Date: Fri, 12 Jun 2026 14:08:14 +0200 Subject: [PATCH 16/42] feat(gui): add view navigation and In/Out port labels for groups --- pySimBlocks/gui/graphics/group_item.py | 47 ++++- pySimBlocks/gui/graphics/group_proxy_item.py | 73 +++++--- pySimBlocks/gui/group_boundary_labels.py | 61 +++++++ pySimBlocks/gui/main_window.py | 23 ++- pySimBlocks/gui/project_controller.py | 39 +++- pySimBlocks/gui/undo_redo/commands.py | 35 +++- pySimBlocks/gui/widgets/diagram_view.py | 166 +++++++++++++++--- .../gui/widgets/view_navigation_bar.py | 79 +++++++++ 8 files changed, 463 insertions(+), 60 deletions(-) create mode 100644 pySimBlocks/gui/group_boundary_labels.py create mode 100644 pySimBlocks/gui/widgets/view_navigation_bar.py diff --git a/pySimBlocks/gui/graphics/group_item.py b/pySimBlocks/gui/graphics/group_item.py index 6264f65..3518383 100644 --- a/pySimBlocks/gui/graphics/group_item.py +++ b/pySimBlocks/gui/graphics/group_item.py @@ -10,7 +10,7 @@ from PySide6.QtCore import QPointF, QRectF, Qt from PySide6.QtGui import QPainterPath from PySide6.QtGui import QBrush, QFont, QPainter, QPen -from PySide6.QtWidgets import QGraphicsItem, QGraphicsRectItem, QStyle +from PySide6.QtWidgets import QGraphicsItem, QGraphicsRectItem, QGraphicsTextItem, QStyle from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup @@ -25,11 +25,18 @@ class GroupBoundaryPortItem(QGraphicsItem): L = 15 H = 10 + MARGIN = 4 + def __init__(self, boundary: BoundaryPort, parent_group: "GroupItem"): super().__init__(parent_group) self.boundary = boundary self.parent_group = parent_group self.setAcceptedMouseButtons(Qt.LeftButton) + t = parent_group.view.theme + self.label = QGraphicsTextItem(self) + self.label.setDefaultTextColor(t.text) + self.label.setFont(QFont("Sans Serif", 8)) + self.update_port_label() @property def is_input(self) -> bool: @@ -42,9 +49,40 @@ def connection_anchor(self) -> QPointF: local = QPointF(self.L, 0) return self.mapToScene(local) + def update_port_label(self) -> None: + """Refresh the external port label shown next to the boundary port.""" + controller = self.parent_group.view.project_controller + text = "" + if controller is not None: + text = controller.boundary_port_flow_label( + self.parent_group.group, + self.boundary, + ) + self.label.setPlainText(text) + self.label.setVisible(bool(text)) + self._layout_port_label() + + def _layout_port_label(self) -> None: + label_rect = self.label.boundingRect() + if self.is_input: + self.label.setPos( + self.R + self.MARGIN, + -label_rect.height() / 2, + ) + else: + self.label.setPos( + -label_rect.width() - self.R - self.MARGIN, + -label_rect.height() / 2, + ) + def boundingRect(self) -> QRectF: return QRectF(-10, -10, 20, 20) + def itemChange(self, change, value): + if change == QGraphicsItem.ItemScenePositionHasChanged: + self._layout_port_label() + return super().itemChange(change, value) + def paint(self, painter, option, widget=None): t = self.parent_group.view.theme painter.setRenderHint(QPainter.Antialiasing) @@ -114,14 +152,21 @@ def sync_boundary_ports(self) -> None: item = GroupBoundaryPortItem(boundary, self) y = rect.height() * (index + 1) / (len(inputs) + 1) item.setPos(0, y) + item.update_port_label() self.boundary_port_items[boundary.uid] = item for index, boundary in enumerate(outputs): item = GroupBoundaryPortItem(boundary, self) y = rect.height() * (index + 1) / (len(outputs) + 1) item.setPos(rect.width(), y) + item.update_port_label() self.boundary_port_items[boundary.uid] = item + def refresh_boundary_port_labels(self) -> None: + """Update external port labels after diagram connections change.""" + for item in self.boundary_port_items.values(): + item.update_port_label() + def get_boundary_anchor(self, boundary_uid: str) -> QPointF | None: port_item = self.boundary_port_items.get(boundary_uid) if port_item is None: diff --git a/pySimBlocks/gui/graphics/group_proxy_item.py b/pySimBlocks/gui/graphics/group_proxy_item.py index c358bcb..4f3737f 100644 --- a/pySimBlocks/gui/graphics/group_proxy_item.py +++ b/pySimBlocks/gui/graphics/group_proxy_item.py @@ -18,7 +18,7 @@ class GroupProxyPortItem(QGraphicsItem): - """Single connection anchor on a GroupIn / GroupOut proxy block.""" + """Single visible port on a GroupIn or GroupOut proxy block.""" R = 6 L = 15 @@ -57,10 +57,16 @@ def paint(self, painter, option, widget=None): class GroupProxyItem(QGraphicsRectItem): - """Render a GroupIn or GroupOut proxy inside a visual group.""" + """Render a GroupIn or GroupOut proxy inside a visual group. + + GroupIn exposes only an output toward group members; its external input + lives on the parent GroupItem border. GroupOut exposes only an input from + members; its external output lives on the parent GroupItem border. + """ WIDTH = 56.0 - HEIGHT = 36.0 + HEIGHT = 45.0 + TYPE_LABEL_MIN_HEIGHT = 40.0 GRID_DX = 5 GRID_DY = 5 @@ -68,21 +74,15 @@ def __init__(self, boundary: BoundaryPort, view: "DiagramView"): super().__init__(0, 0, self.WIDTH, self.HEIGHT) self.boundary = boundary self.view = view - if self.is_group_in: - self.external_port = GroupProxyPortItem(is_output=False, parent_proxy=self) - self.member_port = GroupProxyPortItem(is_output=True, parent_proxy=self) - else: - self.member_port = GroupProxyPortItem(is_output=False, parent_proxy=self) - self.external_port = GroupProxyPortItem(is_output=True, parent_proxy=self) - rect = self.rect() mid_y = rect.height() / 2 + if self.is_group_in: - self.external_port.setPos(0, mid_y) - self.member_port.setPos(rect.width(), mid_y) + self.port_item = GroupProxyPortItem(is_output=True, parent_proxy=self) + self.port_item.setPos(rect.width(), mid_y) else: - self.member_port.setPos(0, mid_y) - self.external_port.setPos(rect.width(), mid_y) + self.port_item = GroupProxyPortItem(is_output=False, parent_proxy=self) + self.port_item.setPos(0, mid_y) layout = boundary.proxy_layout or {} self.setPos(QPointF(float(layout.get("x", 0.0)), float(layout.get("y", 0.0)))) @@ -101,27 +101,52 @@ def is_group_out(self) -> bool: return self.boundary.direction == "output" @property - def label(self) -> str: + def kind_label(self) -> str: return "In" if self.is_group_in else "Out" - def member_anchor(self) -> QPointF: - """Anchor facing group members (GroupIn out, GroupOut in).""" - return self.member_port.connection_anchor() + def center_label(self) -> str: + """External flow label (source for In, destination for Out), or In/Out.""" + controller = self.view.project_controller + group_uid = self.view.current_view_group_uid + if controller is None or group_uid is None: + return self.kind_label + group = controller.project_state.get_visual_group(group_uid) + if group is None: + return self.kind_label + text = controller.boundary_port_flow_label(group, self.boundary) + return text if text else self.kind_label - def external_anchor(self) -> QPointF: - """Anchor facing the parent diagram (GroupIn in, GroupOut out).""" - return self.external_port.connection_anchor() + def member_anchor(self) -> QPointF: + """Anchor used for wires between the proxy and group members.""" + return self.port_item.connection_anchor() def paint(self, painter, option, widget=None): t = self.view.theme selected = bool(option.state & QStyle.State_Selected) + rect = self.rect() painter.setRenderHint(QPainter.Antialiasing) painter.setBrush(QBrush(t.block_bg_selected if selected else t.block_bg)) painter.setPen(QPen(t.block_border_selected if selected else t.block_border, 2)) - painter.drawRect(self.rect()) + painter.drawRect(rect) + + name_font = QFont("Sans Serif", 9) + painter.setFont(name_font) painter.setPen(QPen(t.text_selected if selected else t.text)) - painter.setFont(QFont("Sans Serif", 8, QFont.Bold)) - painter.drawText(self.rect(), int(Qt.AlignCenter), self.label) + name_rect = QRectF(rect.x(), rect.y(), rect.width(), rect.height() * 0.60) + painter.drawText(name_rect, int(Qt.AlignCenter | Qt.AlignBottom), self.center_label()) + + if rect.height() >= self.TYPE_LABEL_MIN_HEIGHT: + type_font = QFont("Sans Serif", 8) + type_font.setItalic(True) + painter.setFont(type_font) + painter.setPen(QPen(t.text_type_selected if selected else t.text_type)) + type_rect = QRectF( + rect.x(), + rect.y() + rect.height() * 0.58, + rect.width(), + rect.height() * 0.38, + ) + painter.drawText(type_rect, int(Qt.AlignCenter | Qt.AlignTop), self.kind_label) def mousePressEvent(self, event): self._interaction_start_pos = QPointF(self.pos()) diff --git a/pySimBlocks/gui/group_boundary_labels.py b/pySimBlocks/gui/group_boundary_labels.py new file mode 100644 index 0000000..cc7ab43 --- /dev/null +++ b/pySimBlocks/gui/group_boundary_labels.py @@ -0,0 +1,61 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +from pySimBlocks.gui.models.connection_instance import ConnectionInstance +from pySimBlocks.gui.models.project_state import ProjectState +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup + + +def _port_display(port) -> str: + return str(port.display_as or port.name) + + +def boundary_port_label( + state: ProjectState, + group: VisualGroup, + boundary: BoundaryPort, +) -> str: + """Return the flow label for a group boundary port. + + Input boundaries show the external source port (where the signal comes from). + Output boundaries show the external destination port (where the signal goes). + """ + linked = boundary.linked_port_uid + if not linked or ":" not in linked: + return "" + member_uid, member_port_name = linked.split(":", 1) + members = set(group.members) + for connection in state.connections: + external_port = _external_port_for_boundary( + connection, members, boundary.direction, member_uid, member_port_name + ) + if external_port is not None: + return _port_display(external_port) + return "" + + +def _external_port_for_boundary( + connection: ConnectionInstance, + members: set[str], + direction: str, + member_uid: str, + member_port_name: str, +): + if direction == "input": + if ( + connection.dst_block().uid == member_uid + and connection.dst_port.name == member_port_name + and connection.src_block().uid not in members + ): + return connection.src_port + elif ( + connection.src_block().uid == member_uid + and connection.src_port.name == member_port_name + and connection.dst_block().uid not in members + ): + return connection.dst_port + return None diff --git a/pySimBlocks/gui/main_window.py b/pySimBlocks/gui/main_window.py index cc27972..aa54bf5 100644 --- a/pySimBlocks/gui/main_window.py +++ b/pySimBlocks/gui/main_window.py @@ -25,7 +25,7 @@ from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QAction, QKeySequence -from PySide6.QtWidgets import QMainWindow, QSplitter +from PySide6.QtWidgets import QMainWindow, QSplitter, QVBoxLayout, QWidget from pySimBlocks.gui.blocks.block_meta import BlockMeta from pySimBlocks.gui.dialogs.unsaved_dialog import UnsavedChangesDialog @@ -44,6 +44,7 @@ ) from pySimBlocks.gui.widgets.diagram_view import DiagramView from pySimBlocks.gui.widgets.toolbar_view import ToolBarView +from pySimBlocks.gui.widgets.view_navigation_bar import ViewNavigationBar from pySimBlocks.tools.blocks_registry import load_block_registry @@ -89,12 +90,23 @@ def __init__(self, project_path: Path): self.view.project_controller = self.project_controller self.blocks = BlockList(self.get_categories, self.get_blocks, self.resolve_block_meta) self.view.group_view_changed.connect(self.blocks.rebuild) + self.nav_bar = ViewNavigationBar(self._resolve_group_name_for_nav) + self.view.view_stack_changed.connect(self._update_view_navigation_bar) + self.nav_bar.navigate_requested.connect(self.view.navigate_to_depth) self.toolbar = ToolBarView(self.saver, self.runner, self.project_controller) + diagram_panel = QWidget() + diagram_layout = QVBoxLayout(diagram_panel) + diagram_layout.setContentsMargins(0, 0, 0, 0) + diagram_layout.setSpacing(0) + diagram_layout.addWidget(self.nav_bar) + diagram_layout.addWidget(self.view, 1) + splitter = QSplitter(Qt.Horizontal) splitter.addWidget(self.blocks) - splitter.addWidget(self.view) + splitter.addWidget(diagram_panel) splitter.setSizes([180, 800]) + self._update_view_navigation_bar() self.setCentralWidget(splitter) self.addToolBar(self.toolbar) @@ -135,6 +147,13 @@ def __init__(self, project_path: Path): # Registry # -------------------------------------------------------------------------- + def _resolve_group_name_for_nav(self, group_uid: str) -> str | None: + group = self.project_state.get_visual_group(group_uid) + return group.name if group is not None else None + + def _update_view_navigation_bar(self) -> None: + self.nav_bar.set_view_stack(self.view.view_stack) + def get_categories(self) -> List[str]: """Return the sorted list of block categories from the registry.""" categories = list(self.block_registry.keys()) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 51deb46..2589603 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -34,6 +34,7 @@ ProjectState, VisualGroup, ) +from pySimBlocks.gui.group_boundary_labels import boundary_port_label 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 @@ -50,6 +51,7 @@ MoveResizeGroupCommand, RemoveBlockCommand, RemoveConnectionCommand, + RenameGroupCommand, ToggleOrientationCommand, UngroupCommand, ConnectionSnapshot, @@ -231,6 +233,28 @@ def ungroup_selected_group(self) -> bool: return False return self.ungroup(group_uid) + def boundary_port_flow_label( + self, + group: VisualGroup, + boundary: BoundaryPort, + ) -> str: + """Return the flow label for a boundary (source for In, destination for Out).""" + return boundary_port_label(self.project_state, group, boundary) + + def rename_visual_group(self, group_uid: str, new_name: str) -> bool: + """Rename a visual group (undoable).""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + trimmed = new_name.strip() + if not trimmed or trimmed == group.name: + return False + unique_name = self._make_unique_group_name(trimmed, exclude_uid=group_uid) + self.undo_manager.push( + RenameGroupCommand(self, group_uid, group.name, unique_name) + ) + return True + def make_unique_name(self, base_name: str) -> str: """Return ``base_name`` or a suffixed variant that is unique across all blocks. @@ -408,7 +432,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.view.current_view_group_uid = None + self.view.view_stack = [] + self.view.view_stack_changed.emit() self.undo_manager.clear() self.clear_dirty() @@ -1144,9 +1169,17 @@ def _refresh_boundaries_for_member_uids(self, block_uids: set[str]) -> None: if changed: self.view.refresh_visual_groups() - def _make_unique_group_name(self, base_name: str) -> str: + def _make_unique_group_name( + self, + base_name: str, + exclude_uid: str | None = None, + ) -> str: """Return a unique visual group name based on existing groups.""" - existing = {g.name for g in self.project_state.visual_groups} + existing = { + g.name + for g in self.project_state.visual_groups + if g.uid != exclude_uid + } if base_name not in existing: return base_name i = 1 diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index 992b9e4..ac78d50 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -205,8 +205,8 @@ def undo(self) -> None: group = self._controller.project_state.get_visual_group(self._group_uid) if group is not None: self._group_snapshot = group.to_dict() - if self._controller.view.current_view_group_uid == self._group_uid: - self._controller.view.exit_group_view() + if self._group_uid in self._controller.view.view_stack: + self._controller.view.navigate_out_of_group(self._group_uid) self._controller._remove_visual_group(self._group_uid) self._controller.view.refresh_visual_groups() self._controller.make_dirty() @@ -223,8 +223,8 @@ def redo(self) -> None: group = self._controller.project_state.get_visual_group(self._group_uid) if group is not None: self._group_snapshot = group.to_dict() - if self._controller.view.current_view_group_uid == self._group_uid: - self._controller.view.exit_group_view() + if self._group_uid in self._controller.view.view_stack: + self._controller.view.navigate_out_of_group(self._group_uid) self._controller.restore_members_after_ungroup(group) self._controller._remove_visual_group(self._group_uid) self._controller.view.refresh_visual_groups() @@ -367,3 +367,30 @@ def undo(self) -> None: self._group_uid, self._boundary_uid, self._old_pos ) self._controller.make_dirty() + + +class RenameGroupCommand(QUndoCommand): + def __init__(self, controller, group_uid: str, old_name: str, new_name: str): + super().__init__("Rename Group") + self._controller = controller + self._group_uid = group_uid + self._old_name = old_name + self._new_name = new_name + + def redo(self) -> None: + group = self._controller.project_state.get_visual_group(self._group_uid) + if group is None: + return + group.name = self._new_name + self._controller.view.refresh_visual_groups() + self._controller.view.view_stack_changed.emit() + self._controller.make_dirty() + + def undo(self) -> None: + group = self._controller.project_state.get_visual_group(self._group_uid) + if group is None: + return + group.name = self._old_name + self._controller.view.refresh_visual_groups() + self._controller.view.view_stack_changed.emit() + self._controller.make_dirty() diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 4f6ff78..e6ba950 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -24,7 +24,7 @@ from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal from PySide6.QtGui import QGuiApplication, QKeySequence, QPainter, QPen -from PySide6.QtWidgets import QGraphicsScene, QGraphicsView, QMenu +from PySide6.QtWidgets import QGraphicsScene, QGraphicsView, QInputDialog, QMenu from pySimBlocks.gui.graphics.block_item import BlockItem from pySimBlocks.gui.graphics.group_item import GroupItem @@ -44,6 +44,7 @@ class DiagramView(QGraphicsView): """Interactive Qt graphics view for the block diagram canvas.""" group_view_changed = Signal() + view_stack_changed = Signal() def __init__(self): """Initialize the diagram view and configure scene behavior. @@ -78,7 +79,7 @@ def __init__(self): self.group_items: dict[str, GroupItem] = {} self.proxy_items: dict[str, GroupProxyItem] = {} self.connections: dict[ConnectionInstance, ConnectionItem] = {} - self.current_view_group_uid: str | None = None + self.view_stack: list[str] = [] self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setResizeAnchor(QGraphicsView.AnchorUnderMouse) @@ -112,6 +113,7 @@ def refresh_block_port(self, block_instance: BlockInstance) -> None: block_item = self.get_block_item_from_instance(block_instance) if block_item: block_item.refresh_ports() + self._refresh_group_port_labels() def remove_block(self, block_instance: BlockInstance) -> None: """Remove the visual block item for the given instance from the scene. @@ -167,6 +169,68 @@ def get_selected_group_uid(self) -> str | None: return item.group.uid return None + @property + def current_view_group_uid(self) -> str | None: + """UID of the innermost group in the current view stack.""" + return self.view_stack[-1] if self.view_stack else None + + def _group_path_uids(self, group_uid: str) -> list[str]: + """Build the root-to-group UID path using parent_group_uid links.""" + if self.project_controller is None: + return [group_uid] + state = self.project_controller.project_state + path: list[str] = [] + uid: str | None = group_uid + while uid: + group = state.get_visual_group(uid) + if group is None: + return [group_uid] + path.append(uid) + uid = group.parent_uid + path.reverse() + return path + + def _activate_view_stack(self) -> None: + """Apply the current view stack to the scene.""" + if self.project_controller is None: + return + if self.view_stack: + group_uid = self.view_stack[-1] + group = self.project_controller.project_state.get_visual_group(group_uid) + if group is None: + self.view_stack = [] + else: + self.project_controller.ensure_group_boundary_proxies(group) + self.refresh_visual_groups() + self.project_controller.apply_member_layouts(group) + self.group_view_changed.emit() + self.view_stack_changed.emit() + return + self.refresh_visual_groups() + self.group_view_changed.emit() + self.view_stack_changed.emit() + + def navigate_to_depth(self, depth: int) -> None: + """Navigate to a breadcrumb depth (0 = root diagram).""" + depth = max(0, min(depth, len(self.view_stack))) + if depth == len(self.view_stack): + return + self._save_active_group_view_state() + self.view_stack = self.view_stack[:depth] + self._activate_view_stack() + + def navigate_out_of_group(self, group_uid: str) -> None: + """Leave a group and its nested views in the navigation stack.""" + if group_uid not in self.view_stack: + return + self.navigate_to_depth(self.view_stack.index(group_uid)) + + def pop_view_level(self) -> None: + """Go up one level in the navigation stack.""" + if not self.view_stack: + return + self.navigate_to_depth(len(self.view_stack) - 1) + def refresh_visual_groups(self) -> None: """Sync group items, member visibility, and connection display.""" if self.project_controller is None: @@ -217,10 +281,11 @@ def refresh_visual_groups(self) -> None: group_item.setVisible(True) else: member_set = set(active_group.members) + child_groups = set(active_group.child_group_uids) for block_uid, block_item in self.block_items.items(): block_item.setVisible(block_uid in member_set) for group_uid, group_item in self.group_items.items(): - group_item.setVisible(group_uid != active_group.uid) + group_item.setVisible(group_uid in child_groups) for conn_inst, conn_item in self.connections.items(): src_uid = conn_inst.src_block().uid @@ -241,6 +306,14 @@ def refresh_visual_groups(self) -> None: conn_item.update_position() self._refresh_group_proxies(active_group) + self._refresh_group_port_labels() + + def _refresh_group_port_labels(self) -> None: + """Refresh boundary and proxy labels after connection changes.""" + for group_item in self.group_items.values(): + group_item.refresh_boundary_port_labels() + for proxy_item in self.proxy_items.values(): + proxy_item.update() def _refresh_group_proxies(self, active_group) -> None: """Create or hide GroupIn/GroupOut proxy items for the active internal view.""" @@ -299,7 +372,7 @@ def connection_anchor_for_port_item(self, port_item: PortItem) -> QPointF: return port_item.connection_anchor() def _proxy_anchor_for_external_port(self, group, port_item: PortItem) -> QPointF | None: - """In internal view, attach crossing wires to GroupIn/GroupOut proxies.""" + """In internal view, attach crossing wires to the member-facing proxy port.""" if self.project_controller is None: return None @@ -333,7 +406,7 @@ def _proxy_anchor_for_external_port(self, group, port_item: PortItem) -> QPointF proxy = self.proxy_items.get(boundary.uid) if proxy is None: return None - return proxy.external_anchor() + return proxy.member_anchor() return None def on_proxy_moved(self, _proxy_item: GroupProxyItem) -> None: @@ -360,23 +433,18 @@ def enter_group(self, group_uid: str) -> None: """Open the internal view of a visual group.""" if self.project_controller is None: return - group = self.project_controller.project_state.get_visual_group(group_uid) - if group is None: + if self.project_controller.project_state.get_visual_group(group_uid) is None: return - if self.current_view_group_uid and self.current_view_group_uid != group_uid: - self._save_active_group_view_state() - self.project_controller.ensure_group_boundary_proxies(group) - self.current_view_group_uid = group_uid - self.refresh_visual_groups() - self.project_controller.apply_member_layouts(group) - self.group_view_changed.emit() + new_stack = self._group_path_uids(group_uid) + if new_stack == self.view_stack: + return + self._save_active_group_view_state() + self.view_stack = new_stack + self._activate_view_stack() def exit_group_view(self) -> None: - """Return to the root diagram view.""" - self._save_active_group_view_state() - self.current_view_group_uid = None - self.refresh_visual_groups() - self.group_view_changed.emit() + """Go up one level in the navigation stack.""" + self.pop_view_level() def _save_active_group_view_state(self) -> None: """Persist block and proxy positions for the active internal group view.""" @@ -563,7 +631,7 @@ def keyPressEvent(self, event) -> None: event.accept() return - if event.key() == Qt.Key_Escape and self.current_view_group_uid is not None: + if event.key() == Qt.Key_Escape and self.view_stack: self.exit_group_view() event.accept() return @@ -640,17 +708,33 @@ def contextMenuEvent(self, event) -> None: menu = QMenu(self) selected_blocks = self.get_selected_block_instances() selected_group_uid = self.get_selected_group_uid() + clicked_group = self._group_item_at(event) + target_group_uid = ( + clicked_group.group.uid if clicked_group is not None else selected_group_uid + ) group_action = menu.addAction("Group") group_action.setEnabled(len(selected_blocks) >= 2) - group_action.triggered.connect(self.project_controller.group_selected_blocks) + group_action.triggered.connect( + lambda *_args: self.project_controller.group_selected_blocks() + ) - ungroup_action = menu.addAction("Ungroup") - ungroup_action.setEnabled(selected_group_uid is not None) - if selected_group_uid is not None: + if target_group_uid is not None: + enter_action = menu.addAction("Enter") + enter_action.triggered.connect( + lambda *_args, uid=target_group_uid: self.enter_group(uid) + ) + rename_action = menu.addAction("Rename") + rename_action.triggered.connect( + lambda *_args, uid=target_group_uid: self._rename_group(uid) + ) + ungroup_action = menu.addAction("Ungroup") ungroup_action.triggered.connect( - lambda: self.project_controller.ungroup(selected_group_uid) + lambda *_args, uid=target_group_uid: self.project_controller.ungroup(uid) ) + else: + ungroup_action = menu.addAction("Ungroup") + ungroup_action.setEnabled(False) if self.current_view_group_uid is not None: exit_action = menu.addAction("Go up") @@ -686,9 +770,10 @@ def clear_scene(self) -> None: self.group_items.clear() self.proxy_items.clear() self.connections.clear() - self.current_view_group_uid = None + self.view_stack = [] self.temp_connection = None self.pending_port = None + self.view_stack_changed.emit() def scale_view(self, factor: float) -> None: """Scale the view by ``factor``, clamped to the allowed zoom range. @@ -741,6 +826,9 @@ def _refresh_theme_items(self) -> None: for group in self.group_items.values(): group.update() + group.refresh_boundary_port_labels() + for boundary_item in group.boundary_port_items.values(): + boundary_item.label.setDefaultTextColor(self.theme.text) for proxy in self.proxy_items.values(): proxy.update() @@ -751,6 +839,32 @@ def _add_manual_group_input(self) -> None: def _add_manual_group_output(self) -> None: self._add_manual_group_boundary("output") + def _group_item_at(self, event) -> GroupItem | None: + if hasattr(event, "position"): + view_pos = event.position().toPoint() + else: + view_pos = event.pos() + pos = self.mapToScene(view_pos) + for item in self.diagram_scene.items(pos): + if isinstance(item, GroupItem): + return item + return None + + def _rename_group(self, group_uid: str) -> None: + if self.project_controller is None: + return + group = self.project_controller.project_state.get_visual_group(group_uid) + if group is None: + return + new_name, accepted = QInputDialog.getText( + self, + "Rename Group", + "Group name:", + text=group.name, + ) + if accepted: + self.project_controller.rename_visual_group(group_uid, new_name) + def _add_manual_group_boundary(self, direction: str) -> None: if self.project_controller is None or self.current_view_group_uid is None: return diff --git a/pySimBlocks/gui/widgets/view_navigation_bar.py b/pySimBlocks/gui/widgets/view_navigation_bar.py new file mode 100644 index 0000000..38e7c9a --- /dev/null +++ b/pySimBlocks/gui/widgets/view_navigation_bar.py @@ -0,0 +1,79 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +from typing import Callable + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QWidget, +) + + +class ViewNavigationBar(QWidget): + """Breadcrumb bar for diagram / group view navigation.""" + + navigate_requested = Signal(int) + + ROOT_LABEL = "Diagram" + + def __init__( + self, + resolve_group_name: Callable[[str], str | None], + parent: QWidget | None = None, + ): + super().__init__(parent) + self._resolve_group_name = resolve_group_name + self._view_stack: list[str] = [] + + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(6, 4, 6, 4) + self._layout.setSpacing(4) + self._layout.addStretch(1) + + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + def set_view_stack(self, stack_uids: list[str]) -> None: + """Rebuild breadcrumb buttons from the current view stack.""" + self._view_stack = list(stack_uids) + + while self._layout.count(): + item = self._layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + self._add_segment_button(self.ROOT_LABEL, depth=0, enabled=len(stack_uids) > 0) + + for depth, group_uid in enumerate(stack_uids, start=1): + self._layout.addWidget(self._separator()) + name = self._resolve_group_name(group_uid) or "Group" + is_current = depth == len(stack_uids) + self._add_segment_button(name, depth=depth, enabled=not is_current) + + self._layout.addStretch(1) + + def _separator(self) -> QLabel: + label = QLabel("›", self) + label.setStyleSheet("color: palette(mid);") + return label + + def _add_segment_button(self, text: str, depth: int, enabled: bool) -> None: + button = QPushButton(text, self) + button.setFlat(True) + button.setCursor(Qt.PointingHandCursor if enabled else Qt.ArrowCursor) + button.setEnabled(enabled) + if not enabled: + font = button.font() + font.setBold(True) + button.setFont(font) + if enabled: + button.clicked.connect(lambda _checked=False, d=depth: self.navigate_requested.emit(d)) + self._layout.addWidget(button) From 70c43571697a75470d2ead5a86c13a155b900835 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Mon, 15 Jun 2026 14:01:24 +0200 Subject: [PATCH 17/42] feat(gui): add manual boundary wiring and fix group port/delete edge cases --- pySimBlocks/gui/graphics/connection_item.py | 19 +- pySimBlocks/gui/graphics/group_item.py | 18 ++ pySimBlocks/gui/graphics/group_proxy_item.py | 18 ++ .../gui/graphics/manual_boundary_wire_item.py | 34 +++ pySimBlocks/gui/group_boundary_labels.py | 8 +- pySimBlocks/gui/models/visual_group.py | 3 + pySimBlocks/gui/project_controller.py | 218 ++++++++++++++++++ .../gui/services/group_boundary_service.py | 149 ++++++++++++ pySimBlocks/gui/undo_redo/commands.py | 36 ++- pySimBlocks/gui/widgets/diagram_view.py | 131 ++++++++++- 10 files changed, 615 insertions(+), 19 deletions(-) create mode 100644 pySimBlocks/gui/graphics/manual_boundary_wire_item.py create mode 100644 pySimBlocks/gui/services/group_boundary_service.py diff --git a/pySimBlocks/gui/graphics/connection_item.py b/pySimBlocks/gui/graphics/connection_item.py index b5782ba..6a86e2e 100644 --- a/pySimBlocks/gui/graphics/connection_item.py +++ b/pySimBlocks/gui/graphics/connection_item.py @@ -26,6 +26,19 @@ from pySimBlocks.gui.models.connection_instance import ConnectionInstance +def _endpoint_view(endpoint): + if isinstance(endpoint, PortItem): + return endpoint.parent_block.view + from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyPortItem + from pySimBlocks.gui.graphics.group_item import GroupBoundaryPortItem + + if isinstance(endpoint, GroupProxyPortItem): + return endpoint.parent_proxy.view + if isinstance(endpoint, GroupBoundaryPortItem): + return endpoint.parent_group.view + raise TypeError(f"Unsupported wire endpoint: {type(endpoint)!r}") + + class OrthogonalRoute: """Store routed connection points and the segment being dragged. @@ -100,17 +113,17 @@ def __init__(self, if points and len(points) >= 2: self.apply_manual_route(points) - t = self._valid_port.parent_block.view.theme + t = _endpoint_view(self._valid_port) if self.is_temporary: self.setFlag(QGraphicsItem.ItemIsSelectable, False) self.setAcceptedMouseButtons(Qt.NoButton) - pen = QPen(t.wire, 3, Qt.DashLine) + pen = QPen(t.theme.wire, 3, Qt.DashLine) else: self.setFlag(QGraphicsItem.ItemIsSelectable, True) self.setAcceptedMouseButtons(Qt.LeftButton) - pen = QPen(t.wire, 3, Qt.SolidLine) + pen = QPen(t.theme.wire, 3, Qt.SolidLine) self.setPen(pen) self.setZValue(2) diff --git a/pySimBlocks/gui/graphics/group_item.py b/pySimBlocks/gui/graphics/group_item.py index 3518383..cd622ec 100644 --- a/pySimBlocks/gui/graphics/group_item.py +++ b/pySimBlocks/gui/graphics/group_item.py @@ -83,6 +83,24 @@ def itemChange(self, change, value): self._layout_port_label() return super().itemChange(change, value) + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self.parent_group.view.create_connection_event(self) + event.accept() + return + super().mousePressEvent(event) + + def shape(self): + path = QPainterPath() + if self.is_input: + path.addEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R) + else: + path.moveTo(0, -self.H) + path.lineTo(0, self.H) + path.lineTo(self.L, 0) + path.closeSubpath() + return path + def paint(self, painter, option, widget=None): t = self.parent_group.view.theme painter.setRenderHint(QPainter.Antialiasing) diff --git a/pySimBlocks/gui/graphics/group_proxy_item.py b/pySimBlocks/gui/graphics/group_proxy_item.py index 4f3737f..3715322 100644 --- a/pySimBlocks/gui/graphics/group_proxy_item.py +++ b/pySimBlocks/gui/graphics/group_proxy_item.py @@ -39,6 +39,24 @@ def connection_anchor(self) -> QPointF: def boundingRect(self) -> QRectF: return QRectF(-12, -12, 24, 24) + def shape(self): + path = QPainterPath() + if self.is_output: + path.moveTo(0, -self.H) + path.lineTo(0, self.H) + path.lineTo(self.L, 0) + path.closeSubpath() + else: + path.addEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R) + return path + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self.parent_proxy.view.create_connection_event(self) + event.accept() + return + super().mousePressEvent(event) + def paint(self, painter, option, widget=None): t = self.parent_proxy.view.theme painter.setRenderHint(QPainter.Antialiasing) diff --git a/pySimBlocks/gui/graphics/manual_boundary_wire_item.py b/pySimBlocks/gui/graphics/manual_boundary_wire_item.py new file mode 100644 index 0000000..66598bd --- /dev/null +++ b/pySimBlocks/gui/graphics/manual_boundary_wire_item.py @@ -0,0 +1,34 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +from PySide6.QtCore import QPointF, Qt +from PySide6.QtGui import QPainterPath, QPen +from PySide6.QtWidgets import QGraphicsPathItem + + +class ManualBoundaryWireItem(QGraphicsPathItem): + """Visual-only wire for an incomplete manual group boundary.""" + + def __init__(self, view, src_anchor, dst_anchor): + super().__init__() + self._view = view + self._src_anchor = src_anchor + self._dst_anchor = dst_anchor + self.setPen(QPen(view.theme.wire, 2, Qt.DashLine)) + self.setZValue(1) + self.update_position() + + def update_position(self) -> None: + p1 = self._src_anchor() + p2 = self._dst_anchor() + mid = QPointF((p1.x() + p2.x()) * 0.5, p1.y()) + path = QPainterPath() + path.moveTo(p1) + path.lineTo(mid) + path.lineTo(QPointF(mid.x(), p2.y())) + path.lineTo(p2) + self.setPath(path) diff --git a/pySimBlocks/gui/group_boundary_labels.py b/pySimBlocks/gui/group_boundary_labels.py index cc7ab43..3b90871 100644 --- a/pySimBlocks/gui/group_boundary_labels.py +++ b/pySimBlocks/gui/group_boundary_labels.py @@ -5,8 +5,7 @@ from __future__ import annotations -from pySimBlocks.gui.models.connection_instance import ConnectionInstance -from pySimBlocks.gui.models.project_state import ProjectState +from pySimBlocks.gui.services.group_boundary_service import find_port from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup @@ -24,6 +23,11 @@ def boundary_port_label( Input boundaries show the external source port (where the signal comes from). Output boundaries show the external destination port (where the signal goes). """ + if boundary.origin == "manual" and not boundary.linked_connection_uid: + external = find_port(state, boundary.external_port_uid) + if external is not None: + return _port_display(external) + linked = boundary.linked_port_uid if not linked or ":" not in linked: return "" diff --git a/pySimBlocks/gui/models/visual_group.py b/pySimBlocks/gui/models/visual_group.py index 57c8ee2..f750865 100644 --- a/pySimBlocks/gui/models/visual_group.py +++ b/pySimBlocks/gui/models/visual_group.py @@ -11,6 +11,7 @@ class BoundaryPort: uid: str direction: str linked_port_uid: str = "" + external_port_uid: str = "" origin: str = "auto" linked_connection_uid: str = "" label: str = "" @@ -23,6 +24,7 @@ def to_dict(self) -> dict[str, Any]: "uid": self.uid, "direction": self.direction, "linked_port_uid": self.linked_port_uid, + "external_port_uid": self.external_port_uid, "origin": self.origin, "linked_connection_uid": self.linked_connection_uid, } @@ -41,6 +43,7 @@ def from_dict(cls, data: dict[str, Any]) -> "BoundaryPort": uid=str(data.get("uid", "")), direction=str(data.get("direction", "")), linked_port_uid=str(data.get("linked_port_uid", "")), + external_port_uid=str(data.get("external_port_uid", "")), origin=str(data.get("origin", "auto")), linked_connection_uid=str(data.get("linked_connection_uid", "")), label=str(data.get("label", "")), diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 2589603..0ee7592 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -35,6 +35,17 @@ VisualGroup, ) from pySimBlocks.gui.group_boundary_labels import boundary_port_label +from pySimBlocks.gui.services.group_boundary_service import ( + BoundaryWiringState, + apply_wiring_state, + can_complete, + capture_wiring_state, + connection_endpoints, + find_connection_for_boundary, + port_key, + validate_external_link, + validate_internal_link, +) 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 @@ -54,6 +65,7 @@ RenameGroupCommand, ToggleOrientationCommand, UngroupCommand, + WireManualBoundaryCommand, ConnectionSnapshot, ) @@ -233,6 +245,182 @@ def ungroup_selected_group(self) -> bool: return False return self.ungroup(group_uid) + def try_wire_boundary_endpoints(self, src, dst) -> bool: + """Wire a manual boundary from a proxy or group port to a block port.""" + from pySimBlocks.gui.graphics.group_item import GroupBoundaryPortItem + from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyPortItem + from pySimBlocks.gui.graphics.port_item import PortItem + + proxy_port = None + boundary_port = None + block_port_item = None + for endpoint in (src, dst): + if isinstance(endpoint, GroupProxyPortItem): + proxy_port = endpoint + elif isinstance(endpoint, GroupBoundaryPortItem): + boundary_port = endpoint + elif isinstance(endpoint, PortItem): + block_port_item = endpoint + + if block_port_item is None: + return False + + member_port = block_port_item.instance + + if proxy_port is not None: + group_uid = self.view.current_view_group_uid + if group_uid is None: + return False + return self._wire_manual_boundary_internal( + group_uid, + proxy_port.parent_proxy.boundary.uid, + member_port, + ) + + if boundary_port is not None: + return self._wire_manual_boundary_external( + boundary_port.parent_group.group.uid, + boundary_port.boundary.uid, + member_port, + ) + return False + + def _wire_manual_boundary_internal( + self, + group_uid: str, + boundary_uid: str, + member_port: PortInstance, + ) -> bool: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None or not validate_internal_link(group, boundary, member_port): + return False + before = self._capture_boundary_wire_snapshot(group_uid, boundary_uid) + after_wiring = capture_wiring_state(boundary) + after_wiring.linked_port_uid = port_key(member_port) + connection_snapshot = self._connection_snapshot_for_wiring( + group, boundary, after_wiring + ) + after = (after_wiring, connection_snapshot) + self.undo_manager.push( + WireManualBoundaryCommand(self, group_uid, boundary_uid, before, after) + ) + return True + + def _wire_manual_boundary_external( + self, + group_uid: str, + boundary_uid: str, + external_port: PortInstance, + ) -> bool: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None or not validate_external_link(group, boundary, external_port): + return False + before = self._capture_boundary_wire_snapshot(group_uid, boundary_uid) + after_wiring = capture_wiring_state(boundary) + after_wiring.external_port_uid = port_key(external_port) + connection_snapshot = self._connection_snapshot_for_wiring( + group, boundary, after_wiring + ) + after = (after_wiring, connection_snapshot) + self.undo_manager.push( + WireManualBoundaryCommand(self, group_uid, boundary_uid, before, after) + ) + return True + + def _find_boundary_port( + self, + group: VisualGroup, + boundary_uid: str, + ) -> BoundaryPort | None: + return next( + (port for port in group.boundary_ports if port.uid == boundary_uid), + None, + ) + + def _capture_boundary_wire_snapshot( + self, + group_uid: str, + boundary_uid: str, + ) -> tuple[BoundaryWiringState, ConnectionSnapshot | None]: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return BoundaryWiringState(), None + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None: + return BoundaryWiringState(), None + connection = find_connection_for_boundary(self.project_state, boundary) + snapshot = ( + self._capture_connection_snapshot(connection) + if connection is not None + else None + ) + return capture_wiring_state(boundary), snapshot + + def _connection_snapshot_for_wiring( + self, + group: VisualGroup, + boundary: BoundaryPort, + wiring: BoundaryWiringState, + ) -> ConnectionSnapshot | None: + trial = BoundaryPort( + uid=boundary.uid, + direction=boundary.direction, + linked_port_uid=wiring.linked_port_uid, + external_port_uid=wiring.external_port_uid, + origin="manual", + ) + if not can_complete(self.project_state, trial): + return None + endpoints = connection_endpoints(self.project_state, trial) + if endpoints is None: + return None + src_port, dst_port = endpoints + return 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=None, + ) + + def _apply_boundary_wire_snapshot( + self, + group_uid: str, + boundary_uid: str, + wiring: BoundaryWiringState, + connection_snapshot: ConnectionSnapshot | None, + ) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None: + return + + existing = find_connection_for_boundary(self.project_state, boundary) + if existing is not None: + self._remove_connection(existing, refresh_boundaries=False) + + apply_wiring_state(boundary, wiring) + if boundary.origin == "manual" and wiring.linked_port_uid: + self._remove_auto_boundaries_for_member_port( + group, wiring.linked_port_uid, keep_uid=boundary_uid + ) + if connection_snapshot is not None: + connection = self._add_connection_from_snapshot(connection_snapshot) + if connection is not None: + boundary.linked_connection_uid = self._connection_key(connection) + else: + boundary.linked_connection_uid = "" + + self.view.refresh_visual_groups() + def boundary_port_flow_label( self, group: VisualGroup, @@ -994,6 +1182,11 @@ def _remove_manual_boundary_port(self, group_uid: str, boundary_uid: str) -> Non group = self.project_state.get_visual_group(group_uid) if group is None: return + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is not None: + connection = find_connection_for_boundary(self.project_state, boundary) + if connection is not None: + self._remove_connection(connection, refresh_boundaries=False) group.boundary_ports = [ port for port in group.boundary_ports if port.uid != boundary_uid ] @@ -1119,9 +1312,32 @@ def _connection_key(self, connection: ConnectionInstance) -> str: f"{connection.dst_block().uid}:{connection.dst_port.name}" ) + def _remove_auto_boundaries_for_member_port( + self, + group: VisualGroup, + member_port_key: str, + *, + keep_uid: str | None = None, + ) -> None: + """Drop auto boundary ports superseded by a manual port on the same member.""" + group.boundary_ports = [ + port + for port in group.boundary_ports + if not ( + port.origin == "auto" + and port.linked_port_uid == member_port_key + and port.uid != keep_uid + ) + ] + def _rebuild_group_boundary_ports(self, group: VisualGroup) -> None: """Recompute auto boundary ports, keeping manual ports and stable auto ids.""" manual_ports = [port for port in group.boundary_ports if port.origin == "manual"] + manual_member_keys = { + port.linked_port_uid + for port in manual_ports + if port.linked_port_uid + } existing_auto = { port.linked_port_uid: port for port in group.boundary_ports @@ -1130,6 +1346,8 @@ def _rebuild_group_boundary_ports(self, group: VisualGroup) -> None: rebuilt_auto: list[BoundaryPort] = [] for port in self._build_group_boundary_ports(group.members): + if port.linked_port_uid in manual_member_keys: + continue previous = existing_auto.get(port.linked_port_uid) if previous is not None: port.uid = previous.uid diff --git a/pySimBlocks/gui/services/group_boundary_service.py b/pySimBlocks/gui/services/group_boundary_service.py new file mode 100644 index 0000000..53fe69a --- /dev/null +++ b/pySimBlocks/gui/services/group_boundary_service.py @@ -0,0 +1,149 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from pySimBlocks.gui.models.port_instance import PortInstance +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup + +if TYPE_CHECKING: + from pySimBlocks.gui.models.connection_instance import ConnectionInstance + from pySimBlocks.gui.models.project_state import ProjectState + + +def port_key(port: PortInstance) -> str: + """Build a stable key for a simulation port.""" + return f"{port.block.uid}:{port.name}" + + +def parse_port_key(key: str) -> tuple[str, str] | None: + if not key or ":" not in key: + return None + block_uid, port_name = key.split(":", 1) + return block_uid, port_name + + +def find_port(state: ProjectState, key: str) -> PortInstance | None: + parsed = parse_port_key(key) + if parsed is None: + return None + block_uid, port_name = parsed + for block in state.blocks: + if block.uid != block_uid: + continue + for port in block.ports: + if port.name == port_name: + return port + return None + + +def is_manual(boundary: BoundaryPort) -> bool: + return boundary.origin == "manual" + + +def is_complete(boundary: BoundaryPort) -> bool: + return bool(boundary.linked_connection_uid) + + +@dataclass +class BoundaryWiringState: + """Snapshot of a manual boundary wiring state.""" + + linked_port_uid: str = "" + external_port_uid: str = "" + linked_connection_uid: str = "" + + +def capture_wiring_state(boundary: BoundaryPort) -> BoundaryWiringState: + return BoundaryWiringState( + linked_port_uid=boundary.linked_port_uid, + external_port_uid=boundary.external_port_uid, + linked_connection_uid=boundary.linked_connection_uid, + ) + + +def apply_wiring_state(boundary: BoundaryPort, state: BoundaryWiringState) -> None: + boundary.linked_port_uid = state.linked_port_uid + boundary.external_port_uid = state.external_port_uid + boundary.linked_connection_uid = state.linked_connection_uid + + +def validate_internal_link( + group: VisualGroup, + boundary: BoundaryPort, + member_port: PortInstance, +) -> bool: + """Return whether a member port may be wired to a manual boundary proxy.""" + if not is_manual(boundary): + return False + if member_port.block.uid not in group.members: + return False + if boundary.direction == "input" and member_port.direction != "input": + return False + if boundary.direction == "output" and member_port.direction != "output": + return False + return True + + +def validate_external_link( + group: VisualGroup, + boundary: BoundaryPort, + external_port: PortInstance, +) -> bool: + """Return whether an external port may be wired to a manual group border port.""" + if not is_manual(boundary): + return False + if external_port.block.uid in group.members: + return False + if boundary.direction == "input" and external_port.direction != "output": + return False + if boundary.direction == "output" and external_port.direction != "input": + return False + return True + + +def connection_endpoints( + state: ProjectState, + boundary: BoundaryPort, +) -> tuple[PortInstance, PortInstance] | None: + """Return src/dst simulation ports when a manual boundary is ready to complete.""" + member_port = find_port(state, boundary.linked_port_uid) + external_port = find_port(state, boundary.external_port_uid) + if member_port is None or external_port is None: + return None + if boundary.direction == "input": + return external_port, member_port + return member_port, external_port + + +def can_complete(state: ProjectState, boundary: BoundaryPort) -> bool: + if not is_manual(boundary) or is_complete(boundary): + return False + if not boundary.linked_port_uid or not boundary.external_port_uid: + return False + endpoints = connection_endpoints(state, boundary) + if endpoints is None: + return False + src_port, dst_port = endpoints + if not src_port.is_compatible(dst_port): + return False + return True + + +def find_connection_for_boundary( + state: ProjectState, + boundary: BoundaryPort, +) -> ConnectionInstance | None: + endpoints = connection_endpoints(state, boundary) + if endpoints is None: + return None + src_port, dst_port = endpoints + for connection in state.connections: + if connection.src_port is src_port and connection.dst_port is dst_port: + return connection + return None diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index ac78d50..d1f9c79 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -20,6 +20,7 @@ from __future__ import annotations +import copy from dataclasses import dataclass from typing import Any @@ -128,7 +129,7 @@ def __init__(self, controller, block_instance: BlockInstance): 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] + self._plots_before = copy.deepcopy(controller.project_state.plots) def redo(self) -> None: self._controller._remove_block(self._block_instance) @@ -139,7 +140,7 @@ def undo(self) -> None: 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.project_state.plots = copy.deepcopy(self._plots_before) self._controller.make_dirty() @@ -394,3 +395,34 @@ def undo(self) -> None: self._controller.view.refresh_visual_groups() self._controller.view.view_stack_changed.emit() self._controller.make_dirty() + + +class WireManualBoundaryCommand(QUndoCommand): + def __init__( + self, + controller, + group_uid: str, + boundary_uid: str, + before: tuple, + after: tuple, + ): + super().__init__("Wire Group Port") + self._controller = controller + self._group_uid = group_uid + self._boundary_uid = boundary_uid + self._before = before + self._after = after + + def redo(self) -> None: + wiring, connection = self._after + self._controller._apply_boundary_wire_snapshot( + self._group_uid, self._boundary_uid, wiring, connection + ) + self._controller.make_dirty() + + def undo(self) -> None: + wiring, connection = self._before + self._controller._apply_boundary_wire_snapshot( + self._group_uid, self._boundary_uid, wiring, connection + ) + self._controller.make_dirty() diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index e6ba950..3cb98c5 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -27,14 +27,16 @@ from PySide6.QtWidgets import QGraphicsScene, QGraphicsView, QInputDialog, QMenu from pySimBlocks.gui.graphics.block_item import BlockItem -from pySimBlocks.gui.graphics.group_item import GroupItem -from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyItem +from pySimBlocks.gui.graphics.group_item import GroupBoundaryPortItem, GroupItem +from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyItem, GroupProxyPortItem from pySimBlocks.gui.graphics.connection_item import ConnectionItem, OrthogonalRoute +from pySimBlocks.gui.graphics.manual_boundary_wire_item import ManualBoundaryWireItem from pySimBlocks.gui.graphics.port_item import PortItem from pySimBlocks.gui.graphics.theme import make_theme from pySimBlocks.gui.group_ports import GROUP_IN_TYPE, GROUP_OUT_TYPE, GROUP_PORTS_CATEGORY from pySimBlocks.gui.models.block_instance import BlockInstance from pySimBlocks.gui.models.connection_instance import ConnectionInstance +from pySimBlocks.gui.services.group_boundary_service import find_port if TYPE_CHECKING: from pySimBlocks.gui.project_controller import ProjectController @@ -70,7 +72,7 @@ def __init__(self): app.paletteChanged.connect(lambda *_: QTimer.singleShot(0, self._apply_theme_from_system)) self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate) - self.pending_port: PortItem | None = None + self.pending_port: PortItem | GroupProxyPortItem | GroupBoundaryPortItem | None = None self.temp_connection: ConnectionItem | None = None self.copied_block: BlockItem | None = None self.drop_event_pos: QPointF = QPointF(0, 0) @@ -78,6 +80,7 @@ def __init__(self): self.block_items: dict[str, BlockItem] = {} self.group_items: dict[str, GroupItem] = {} self.proxy_items: dict[str, GroupProxyItem] = {} + self.manual_boundary_wires: dict[str, ManualBoundaryWireItem] = {} self.connections: dict[ConnectionInstance, ConnectionItem] = {} self.view_stack: list[str] = [] @@ -307,6 +310,74 @@ def refresh_visual_groups(self) -> None: self._refresh_group_proxies(active_group) self._refresh_group_port_labels() + self.refresh_manual_boundary_wires() + + def refresh_manual_boundary_wires(self) -> None: + """Rebuild visual-only wires for incomplete manual group boundaries.""" + for wire in self.manual_boundary_wires.values(): + self.diagram_scene.removeItem(wire) + self.manual_boundary_wires.clear() + + if self.project_controller is None: + return + + state = self.project_controller.project_state + active_uid = self.current_view_group_uid + + if active_uid: + group = state.get_visual_group(active_uid) + if group is None: + return + for boundary in group.boundary_ports: + if boundary.origin != "manual" or boundary.linked_connection_uid: + continue + if not boundary.linked_port_uid: + continue + proxy = self.proxy_items.get(boundary.uid) + member_port = find_port(state, boundary.linked_port_uid) + if proxy is None or member_port is None: + continue + block_item = self.get_block_item_from_instance(member_port.block) + if block_item is None: + continue + port_item = block_item.get_port_item(member_port.name) + if port_item is None: + continue + wire = ManualBoundaryWireItem( + self, + proxy.member_anchor, + port_item.connection_anchor, + ) + self.diagram_scene.addItem(wire) + self.manual_boundary_wires[f"{boundary.uid}:internal"] = wire + return + + for group in state.visual_groups: + group_item = self.group_items.get(group.uid) + if group_item is None: + continue + for boundary in group.boundary_ports: + if boundary.origin != "manual" or boundary.linked_connection_uid: + continue + if not boundary.external_port_uid: + continue + boundary_item = group_item.boundary_port_items.get(boundary.uid) + external_port = find_port(state, boundary.external_port_uid) + if boundary_item is None or external_port is None: + continue + block_item = self.get_block_item_from_instance(external_port.block) + if block_item is None: + continue + port_item = block_item.get_port_item(external_port.name) + if port_item is None: + continue + wire = ManualBoundaryWireItem( + self, + boundary_item.connection_anchor, + port_item.connection_anchor, + ) + self.diagram_scene.addItem(wire) + self.manual_boundary_wires[f"{boundary.uid}:external"] = wire def _refresh_group_port_labels(self) -> None: """Refresh boundary and proxy labels after connection changes.""" @@ -354,6 +425,9 @@ def connection_anchor_for_port_item(self, port_item: PortItem) -> QPointF: if active_uid and self.project_controller is not None: group = self.project_controller.project_state.get_visual_group(active_uid) if group is not None: + member_anchor = self._manual_proxy_anchor_for_member_port(group, port_item) + if member_anchor is not None: + return member_anchor external_anchor = self._proxy_anchor_for_external_port(group, port_item) if external_anchor is not None: return external_anchor @@ -371,6 +445,20 @@ def connection_anchor_for_port_item(self, port_item: PortItem) -> QPointF: return port_item.connection_anchor() + def _manual_proxy_anchor_for_member_port(self, group, port_item: PortItem) -> QPointF | None: + """Route member ports to a manual proxy while the boundary is incomplete.""" + key = f"{port_item.instance.block.uid}:{port_item.instance.name}" + for boundary in group.boundary_ports: + if boundary.origin != "manual" or boundary.linked_connection_uid: + continue + if boundary.linked_port_uid != key: + continue + proxy = self.proxy_items.get(boundary.uid) + if proxy is None: + return None + return proxy.member_anchor() + return None + def _proxy_anchor_for_external_port(self, group, port_item: PortItem) -> QPointF | None: """In internal view, attach crossing wires to the member-facing proxy port.""" if self.project_controller is None: @@ -413,6 +501,8 @@ def on_proxy_moved(self, _proxy_item: GroupProxyItem) -> None: """Refresh wires after a group proxy is moved.""" for conn_item in self.connections.values(): conn_item.update_position() + for wire in self.manual_boundary_wires.values(): + wire.update_position() def on_connection_route_edited( self, @@ -474,12 +564,11 @@ def get_block_item_from_instance(self, block_instance: BlockInstance) -> BlockIt """ return self.block_items.get(block_instance.uid) - 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. - """ + def create_connection_event( + self, + port: PortItem | GroupProxyPortItem | GroupBoundaryPortItem, + ) -> None: + """Begin a wire-drag interaction from the given port item.""" if not self.pending_port: self.pending_port = port self.temp_connection = ConnectionItem(self.pending_port, None, None) @@ -515,6 +604,8 @@ def on_block_moved(self, block_item: BlockItem) -> None: for conn_inst, conn_item in self.connections.items(): if conn_inst.is_block_involved(block_item.instance): conn_item.update_position() + for wire in self.manual_boundary_wires.values(): + wire.update_position() def on_block_ports_refreshed(self, block_item: BlockItem) -> None: """Refresh all wire positions after the ports of a block have been updated. @@ -691,12 +782,25 @@ def mouseReleaseEvent(self, event) -> None: 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: + wire_types = (PortItem, GroupProxyPortItem, GroupBoundaryPortItem) + target = next((item for item in items if isinstance(item, wire_types)), None) + if target is None or target is self.pending_port: + self._cancel_temp_connection() + return + + if not ( + isinstance(self.pending_port, PortItem) + and isinstance(target, PortItem) + ): + if self.project_controller.try_wire_boundary_endpoints( + self.pending_port, target + ): + self._cancel_temp_connection() + return self._cancel_temp_connection() return - self.project_controller.add_connection(self.pending_port.instance, port.instance) + self.project_controller.add_connection(self.pending_port.instance, target.instance) self._cancel_temp_connection() def contextMenuEvent(self, event) -> None: @@ -769,6 +873,9 @@ def clear_scene(self) -> None: self.block_items.clear() self.group_items.clear() self.proxy_items.clear() + for wire in self.manual_boundary_wires.values(): + self.diagram_scene.removeItem(wire) + self.manual_boundary_wires.clear() self.connections.clear() self.view_stack = [] self.temp_connection = None From ed2eb9df03aa134e8617b49cc2e0b18636613016 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Mon, 15 Jun 2026 14:27:53 +0200 Subject: [PATCH 18/42] feat(gui): add and remove group members with drag-drop onto groups --- pySimBlocks/gui/graphics/block_item.py | 6 +- pySimBlocks/gui/project_controller.py | 136 ++++++++++++++++++++++++ pySimBlocks/gui/undo_redo/commands.py | 52 +++++++++ pySimBlocks/gui/widgets/diagram_view.py | 71 ++++++++++++- 4 files changed, 263 insertions(+), 2 deletions(-) diff --git a/pySimBlocks/gui/graphics/block_item.py b/pySimBlocks/gui/graphics/block_item.py index 7ea6165..667341f 100644 --- a/pySimBlocks/gui/graphics/block_item.py +++ b/pySimBlocks/gui/graphics/block_item.py @@ -311,7 +311,11 @@ def mouseReleaseEvent(self, event): if start_pos is None or start_rect is None: return - if start_pos != end_pos or start_rect != end_rect: + moved = start_pos != end_pos or start_rect != end_rect + if moved and self.view.try_drop_block_onto_group(self): + return + + if moved: self.view.project_controller.execute_move_resize_block( self.instance, start_pos, diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 0ee7592..252c1f6 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -54,6 +54,7 @@ AddBlockCommand, AddConnectionCommand, AddManualBoundaryCommand, + AddToGroupCommand, EditBlockParamsCommand, EditConnectionRouteCommand, GroupBlocksCommand, @@ -62,6 +63,7 @@ MoveResizeGroupCommand, RemoveBlockCommand, RemoveConnectionCommand, + RemoveFromGroupCommand, RenameGroupCommand, ToggleOrientationCommand, UngroupCommand, @@ -245,6 +247,49 @@ def ungroup_selected_group(self) -> bool: return False return self.ungroup(group_uid) + def add_block_to_group( + self, + group_uid: str, + block_instance: BlockInstance, + layout: dict[str, Any] | None = None, + ) -> bool: + """Add an existing block to a visual group (undoable).""" + if not self._can_add_block_to_group(group_uid, block_instance.uid): + return False + if layout is None: + layout = self._capture_block_layout(block_instance) + self.undo_manager.push( + AddToGroupCommand(self, group_uid, block_instance.uid, layout) + ) + return True + + def add_block_in_group_view( + self, + category: str, + block_type: str, + group_uid: str, + ) -> BlockInstance | None: + """Drop a new palette block into a group's internal view (undoable).""" + if self.project_state.get_visual_group(group_uid) is None: + return None + self.begin_macro("Add to Group") + try: + block = self.add_block(category, block_type) + layout = self._capture_block_layout(block) + if not self.add_block_to_group(group_uid, block, layout): + return None + return block + finally: + self.end_macro() + + def remove_block_from_group(self, group_uid: str, block_uid: str) -> bool: + """Remove a block from a visual group without deleting it (undoable).""" + group = self.project_state.get_visual_group(group_uid) + if group is None or block_uid not in group.members: + return False + self.undo_manager.push(RemoveFromGroupCommand(self, group_uid, block_uid)) + return True + def try_wire_boundary_endpoints(self, src, dst) -> bool: """Wire a manual boundary from a proxy or group port to a block port.""" from pySimBlocks.gui.graphics.group_item import GroupBoundaryPortItem @@ -1045,6 +1090,97 @@ def _remove_visual_group(self, group_uid: str) -> bool: """Remove a visual group without pushing undo.""" return self.project_state.remove_visual_group(group_uid) + def _group_containing_member(self, block_uid: str) -> VisualGroup | None: + """Return the visual group that lists ``block_uid`` as a member.""" + for group in self.project_state.visual_groups: + if block_uid in group.members: + return group + return None + + def _can_add_block_to_group(self, group_uid: str, block_uid: str) -> bool: + if self._find_block_by_uid(block_uid) is None: + return False + group = self.project_state.get_visual_group(group_uid) + if group is None or block_uid in group.members: + return False + owner = self._group_containing_member(block_uid) + return owner is None or owner.uid == group_uid + + def _apply_group_snapshot(self, snapshot: dict | None, group_uid: str) -> None: + """Restore a visual group from a serialized snapshot.""" + if snapshot is None: + if group_uid in self.view.view_stack: + self.view.navigate_out_of_group(group_uid) + self._remove_visual_group(group_uid) + else: + group = VisualGroup.from_dict(snapshot) + replaced = False + for index, existing in enumerate(self.project_state.visual_groups): + if existing.uid == group.uid: + self.project_state.visual_groups[index] = group + replaced = True + break + if not replaced: + self.project_state.visual_groups.append(group) + self.ensure_group_boundary_proxies(group) + self.view.refresh_visual_groups() + + def _add_member_to_group( + self, + group_uid: str, + block_uid: str, + layout: dict[str, Any], + ) -> bool: + group = self.project_state.get_visual_group(group_uid) + if group is None or not self._can_add_block_to_group(group_uid, block_uid): + return False + + group.members.append(block_uid) + group.member_layouts[block_uid] = dict(layout) + self._rebuild_group_boundary_ports(group) + self.view.refresh_visual_groups() + return True + + def _remove_boundaries_for_member(self, group: VisualGroup, block_uid: str) -> None: + """Remove boundary ports tied to a member block.""" + prefix = f"{block_uid}:" + for boundary in list(group.boundary_ports): + if not boundary.linked_port_uid.startswith(prefix): + continue + connection = find_connection_for_boundary(self.project_state, boundary) + if connection is not None: + self._remove_connection(connection, refresh_boundaries=False) + group.boundary_ports = [ + port + for port in group.boundary_ports + if not port.linked_port_uid.startswith(prefix) + ] + + def _remove_member_from_group(self, group_uid: str, block_uid: str) -> bool: + group = self.project_state.get_visual_group(group_uid) + if group is None or block_uid not in group.members: + return False + + layout = dict(group.member_layouts.get(block_uid, {})) + self._remove_boundaries_for_member(group, block_uid) + group.members = [uid for uid in group.members if uid != block_uid] + group.member_layouts.pop(block_uid, None) + + if not group.members: + if group_uid in self.view.view_stack: + self.view.navigate_out_of_group(group_uid) + self._remove_visual_group(group_uid) + else: + self._rebuild_group_boundary_ports(group) + block = self._find_block_by_uid(block_uid) + if block is not None and layout: + item = self.view.get_block_item_from_instance(block) + if item is not None: + self._apply_block_layout(item, layout) + + self.view.refresh_visual_groups() + return True + def _capture_member_layouts(self, member_uids: list[str]) -> dict[str, dict[str, Any]]: """Snapshot block item geometry for internal group view.""" layouts: dict[str, dict[str, Any]] = {} diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index d1f9c79..2d86094 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -239,6 +239,58 @@ def undo(self) -> None: self._controller.make_dirty() +class AddToGroupCommand(QUndoCommand): + def __init__( + self, + controller, + group_uid: str, + block_uid: str, + layout: dict[str, Any], + ): + super().__init__("Add to Group") + self._controller = controller + self._group_uid = group_uid + self._block_uid = block_uid + self._layout = dict(layout) + group = controller.project_state.get_visual_group(group_uid) + self._snapshot_before = group.to_dict() if group is not None else None + self._snapshot_after: dict | None = None + + def redo(self) -> None: + self._controller._add_member_to_group( + self._group_uid, self._block_uid, self._layout + ) + group = self._controller.project_state.get_visual_group(self._group_uid) + if group is not None: + self._snapshot_after = group.to_dict() + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._apply_group_snapshot(self._snapshot_before, self._group_uid) + self._controller.make_dirty() + + +class RemoveFromGroupCommand(QUndoCommand): + def __init__(self, controller, group_uid: str, block_uid: str): + super().__init__("Remove from Group") + self._controller = controller + self._group_uid = group_uid + self._block_uid = block_uid + group = controller.project_state.get_visual_group(group_uid) + self._snapshot_before = group.to_dict() if group is not None else None + self._snapshot_after: dict | None = None + + def redo(self) -> None: + self._controller._remove_member_from_group(self._group_uid, self._block_uid) + group = self._controller.project_state.get_visual_group(self._group_uid) + self._snapshot_after = group.to_dict() if group is not None else None + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._apply_group_snapshot(self._snapshot_before, self._group_uid) + self._controller.make_dirty() + + class MoveResizeGroupCommand(QUndoCommand): def __init__( self, diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 3cb98c5..47451f3 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -172,6 +172,32 @@ def get_selected_group_uid(self) -> str | None: return item.group.uid return None + def group_item_for_block_drop(self, block_item: BlockItem) -> GroupItem | None: + """Return the group under a block's center, for drag-and-drop membership.""" + if self.project_controller is None or self.current_view_group_uid is not None: + return None + center = block_item.sceneBoundingRect().center() + for group_item in self.group_items.values(): + if not group_item.isVisible(): + continue + if group_item.sceneBoundingRect().contains(center): + return group_item + return None + + def try_drop_block_onto_group(self, block_item: BlockItem) -> bool: + """Add a root-level block to the group it was dropped onto.""" + if self.project_controller is None: + return False + target_group = self.group_item_for_block_drop(block_item) + if target_group is None: + return False + layout = self.project_controller._capture_block_layout(block_item.instance) + return self.project_controller.add_block_to_group( + target_group.group.uid, + block_item.instance, + layout, + ) + @property def current_view_group_uid(self) -> str | None: """UID of the innermost group in the current view stack.""" @@ -652,7 +678,13 @@ def dropEvent(self, event) -> None: self.drop_event_pos, ) else: - self.project_controller.add_block(category, block_type) + group_uid = self.current_view_group_uid + if group_uid is not None and self.project_controller is not None: + self.project_controller.add_block_in_group_view( + category, block_type, group_uid + ) + else: + self.project_controller.add_block(category, block_type) event.acceptProposedAction() def keyPressEvent(self, event) -> None: @@ -813,6 +845,7 @@ def contextMenuEvent(self, event) -> None: selected_blocks = self.get_selected_block_instances() selected_group_uid = self.get_selected_group_uid() clicked_group = self._group_item_at(event) + clicked_block = self._block_item_at(event) target_group_uid = ( clicked_group.group.uid if clicked_group is not None else selected_group_uid ) @@ -823,6 +856,24 @@ def contextMenuEvent(self, event) -> None: lambda *_args: self.project_controller.group_selected_blocks() ) + if ( + self.current_view_group_uid is None + and target_group_uid is not None + and len(selected_blocks) == 1 + ): + block = selected_blocks[0] + add_to_group = menu.addAction("Add to group") + add_to_group.setEnabled( + self.project_controller._can_add_block_to_group( + target_group_uid, block.uid + ) + ) + add_to_group.triggered.connect( + lambda *_args, uid=target_group_uid, inst=block: ( + self.project_controller.add_block_to_group(uid, inst) + ) + ) + if target_group_uid is not None: enter_action = menu.addAction("Enter") enter_action.triggered.connect( @@ -847,6 +898,13 @@ def contextMenuEvent(self, event) -> None: add_in.triggered.connect(self._add_manual_group_input) add_out = menu.addAction("Add output") add_out.triggered.connect(self._add_manual_group_output) + if clicked_block is not None: + remove_from_group = menu.addAction("Remove from group") + remove_from_group.triggered.connect( + lambda *_args, uid=self.current_view_group_uid, block=clicked_block.instance: ( + self.project_controller.remove_block_from_group(uid, block.uid) + ) + ) menu.exec(event.globalPos()) @@ -957,6 +1015,17 @@ def _group_item_at(self, event) -> GroupItem | None: return item return None + def _block_item_at(self, event) -> BlockItem | None: + if hasattr(event, "position"): + view_pos = event.position().toPoint() + else: + view_pos = event.pos() + pos = self.mapToScene(view_pos) + for item in self.diagram_scene.items(pos): + if isinstance(item, BlockItem): + return item + return None + def _rename_group(self, group_uid: str) -> None: if self.project_controller is None: return From 9de93b9ce70df2a85cbcce1448454830ff73ff20 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Wed, 17 Jun 2026 12:25:03 +0200 Subject: [PATCH 19/42] feat(gui): auto-label group ports from internal links and allow manual rename --- pySimBlocks/gui/diagram_clipboard.py | 325 +++++++++++++++++++ pySimBlocks/gui/graphics/group_proxy_item.py | 22 +- pySimBlocks/gui/group_boundary_labels.py | 51 +-- pySimBlocks/gui/project_controller.py | 88 ++++- pySimBlocks/gui/undo_redo/commands.py | 63 ++++ pySimBlocks/gui/widgets/diagram_view.py | 71 +++- 6 files changed, 559 insertions(+), 61 deletions(-) create mode 100644 pySimBlocks/gui/diagram_clipboard.py diff --git a/pySimBlocks/gui/diagram_clipboard.py b/pySimBlocks/gui/diagram_clipboard.py new file mode 100644 index 0000000..be0c46c --- /dev/null +++ b/pySimBlocks/gui/diagram_clipboard.py @@ -0,0 +1,325 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** + +from __future__ import annotations + +import copy +import uuid +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from PySide6.QtCore import QPointF + +from pySimBlocks.gui.models.block_instance import BlockInstance +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup +from pySimBlocks.gui.undo_redo.commands import ConnectionSnapshot + +if TYPE_CHECKING: + from pySimBlocks.gui.graphics.block_item import BlockItem + from pySimBlocks.gui.project_controller import ProjectController + + +@dataclass +class ClipboardBlock: + """Serializable block data for copy/paste.""" + + source_uid: str + category: str + block_type: str + name: str + parameters: dict[str, Any] + layout: dict[str, Any] + + +@dataclass +class DiagramClipboard: + """In-memory clipboard for diagram selections.""" + + blocks: list[ClipboardBlock] = field(default_factory=list) + connections: list[ConnectionSnapshot] = field(default_factory=list) + group: dict[str, Any] | None = None + anchor_x: float = 0.0 + anchor_y: float = 0.0 + + +@dataclass +class PasteResult: + """Entities created by a paste operation (for undo).""" + + blocks: list[BlockInstance] = field(default_factory=list) + connections: list = field(default_factory=list) + group_uids: list[str] = field(default_factory=list) + + +def _layout_anchor(layouts: list[dict[str, Any]]) -> tuple[float, float]: + if not layouts: + return 0.0, 0.0 + return ( + min(float(layout.get("x", 0.0)) for layout in layouts), + min(float(layout.get("y", 0.0)) for layout in layouts), + ) + + +def _offset_layout(layout: dict[str, Any], dx: float, dy: float) -> dict[str, Any]: + out = dict(layout) + out["x"] = float(out.get("x", 0.0)) + dx + out["y"] = float(out.get("y", 0.0)) + dy + return out + + +def capture_blocks_clipboard( + controller: ProjectController, + block_items: list[BlockItem], +) -> DiagramClipboard | None: + """Capture selected blocks and their internal connections.""" + if not block_items: + return None + + member_uids = {item.instance.uid for item in block_items} + blocks: list[ClipboardBlock] = [] + layouts: list[dict[str, Any]] = [] + + for item in block_items: + instance = item.instance + layout = controller._capture_block_layout(instance) + layouts.append(layout) + blocks.append( + ClipboardBlock( + source_uid=instance.uid, + category=instance.meta.category, + block_type=instance.meta.type, + name=instance.name, + parameters=instance.parameters.copy(), + layout=layout, + ) + ) + + connections: list[ConnectionSnapshot] = [] + for connection in controller.project_state.connections: + src_uid = connection.src_block().uid + dst_uid = connection.dst_block().uid + if src_uid in member_uids and dst_uid in member_uids: + connections.append(controller._capture_connection_snapshot(connection)) + + anchor_x, anchor_y = _layout_anchor(layouts) + return DiagramClipboard( + blocks=blocks, + connections=connections, + anchor_x=anchor_x, + anchor_y=anchor_y, + ) + + +def capture_group_clipboard( + controller: ProjectController, + group: VisualGroup, +) -> DiagramClipboard | None: + """Capture a visual group, its members, and internal connections.""" + block_items: list[BlockItem] = [] + layouts: list[dict[str, Any]] = [] + blocks: list[ClipboardBlock] = [] + + for member_uid in group.members: + block = controller._find_block_by_uid(member_uid) + if block is None: + continue + layout = dict(group.member_layouts.get(member_uid, {})) + if not layout: + item = controller.view.get_block_item_from_instance(block) + if item is not None: + layout = controller._capture_block_layout(block) + layouts.append(layout) + blocks.append( + ClipboardBlock( + source_uid=block.uid, + category=block.meta.category, + block_type=block.meta.type, + name=block.name, + parameters=block.parameters.copy(), + layout=layout, + ) + ) + item = controller.view.get_block_item_from_instance(block) + if item is not None: + block_items.append(item) + + if not blocks: + return None + + member_uids = {block.source_uid for block in blocks} + connections: list[ConnectionSnapshot] = [] + for connection in controller.project_state.connections: + src_uid = connection.src_block().uid + dst_uid = connection.dst_block().uid + if src_uid in member_uids and dst_uid in member_uids: + connections.append(controller._capture_connection_snapshot(connection)) + + group_layout = group.layout or {} + anchor_x = float(group_layout.get("x", _layout_anchor(layouts)[0])) + anchor_y = float(group_layout.get("y", _layout_anchor(layouts)[1])) + + return DiagramClipboard( + blocks=blocks, + connections=connections, + group=copy.deepcopy(group.to_dict()), + anchor_x=anchor_x, + anchor_y=anchor_y, + ) + + +def capture_selection_clipboard(controller: ProjectController) -> DiagramClipboard | None: + """Capture the current diagram selection for copy.""" + from pySimBlocks.gui.graphics.block_item import BlockItem + from pySimBlocks.gui.graphics.group_item import GroupItem + + selected = controller.view.diagram_scene.selectedItems() + groups = [item for item in selected if isinstance(item, GroupItem)] + blocks = [item for item in selected if isinstance(item, BlockItem)] + + if len(groups) == 1 and not blocks: + return capture_group_clipboard(controller, groups[0].group) + if blocks: + return capture_blocks_clipboard(controller, blocks) + return None + + +def _remap_connection( + snapshot: ConnectionSnapshot, + uid_map: dict[str, str], + dx: float = 0.0, + dy: float = 0.0, +) -> ConnectionSnapshot | None: + if snapshot.src_block_uid not in uid_map or snapshot.dst_block_uid not in uid_map: + return None + points: list[QPointF] | None = None + if snapshot.points: + points = [QPointF(point.x() + dx, point.y() + dy) for point in snapshot.points] + return ConnectionSnapshot( + src_block_uid=uid_map[snapshot.src_block_uid], + src_port_name=snapshot.src_port_name, + dst_block_uid=uid_map[snapshot.dst_block_uid], + dst_port_name=snapshot.dst_port_name, + points=points, + ) + + +def _duplicate_group( + controller: ProjectController, + template: dict[str, Any], + uid_map: dict[str, str], + dx: float, + dy: float, + parent_uid: str | None, +) -> VisualGroup: + group = VisualGroup.from_dict(copy.deepcopy(template)) + group.uid = uuid.uuid4().hex + group.name = controller._make_unique_group_name(group.name) + group.parent_uid = parent_uid + group.members = [uid_map[uid] for uid in template.get("members", []) if uid in uid_map] + + member_layouts: dict[str, dict[str, Any]] = {} + for old_uid, layout in (template.get("member_layouts") or {}).items(): + if old_uid not in uid_map: + continue + member_layouts[uid_map[old_uid]] = dict(layout) + group.member_layouts = member_layouts + + if group.layout: + group.layout = _offset_layout(dict(group.layout), dx, dy) + + new_boundaries: list[BoundaryPort] = [] + for boundary in group.boundary_ports: + remapped = BoundaryPort( + uid=uuid.uuid4().hex, + direction=boundary.direction, + linked_port_uid="", + external_port_uid="", + origin=boundary.origin, + linked_connection_uid="", + label=boundary.label, + proxy_uid=uuid.uuid4().hex, + proxy_layout=dict(boundary.proxy_layout) if boundary.proxy_layout else {}, + ) + if boundary.linked_port_uid and ":" in boundary.linked_port_uid: + old_uid, port_name = boundary.linked_port_uid.split(":", 1) + if old_uid in uid_map: + remapped.linked_port_uid = f"{uid_map[old_uid]}:{port_name}" + new_boundaries.append(remapped) + group.boundary_ports = new_boundaries + + controller.project_state.visual_groups.append(group) + controller._rebuild_group_boundary_ports(group) + controller.ensure_group_boundary_proxies(group) + return group + + +def paste_clipboard( + controller: ProjectController, + clipboard: DiagramClipboard, + origin: QPointF, + *, + parent_group_uid: str | None = None, +) -> PasteResult: + """Paste clipboard contents at ``origin`` without pushing undo.""" + result = PasteResult() + if not clipboard.blocks: + return result + + dx = float(origin.x()) - clipboard.anchor_x + dy = float(origin.y()) - clipboard.anchor_y + uid_map: dict[str, str] = {} + + for block_data in clipboard.blocks: + meta = controller.resolve_block_meta(block_data.category, block_data.block_type) + block = BlockInstance(meta) + block.parameters = block_data.parameters.copy() + block.name = block_data.name + if clipboard.group is not None: + layout = dict(block_data.layout) + else: + layout = _offset_layout(block_data.layout, dx, dy) + created = controller._add_block(block, layout) + uid_map[block_data.source_uid] = created.uid + result.blocks.append(created) + if parent_group_uid is not None and clipboard.group is None: + controller._add_member_to_group(parent_group_uid, created.uid, layout) + + for snapshot in clipboard.connections: + remapped = _remap_connection(snapshot, uid_map, dx, dy) + if remapped is None: + continue + connection = controller._add_connection_from_snapshot(remapped) + if connection is not None: + result.connections.append(connection) + + if clipboard.group is not None: + group = _duplicate_group( + controller, + clipboard.group, + uid_map, + dx, + dy, + parent_group_uid, + ) + result.group_uids.append(group.uid) + + controller.view.refresh_visual_groups() + return result + + +def undo_paste(controller: ProjectController, result: PasteResult) -> None: + """Remove entities created by a paste operation.""" + for group_uid in result.group_uids: + if group_uid in controller.view.view_stack: + controller.view.navigate_out_of_group(group_uid) + controller._remove_visual_group(group_uid) + + for connection in list(result.connections): + controller._remove_connection(connection, refresh_boundaries=False) + + for block in list(result.blocks): + controller._remove_block(block) + + controller.view.refresh_visual_groups() diff --git a/pySimBlocks/gui/graphics/group_proxy_item.py b/pySimBlocks/gui/graphics/group_proxy_item.py index 3715322..d003ef9 100644 --- a/pySimBlocks/gui/graphics/group_proxy_item.py +++ b/pySimBlocks/gui/graphics/group_proxy_item.py @@ -123,15 +123,11 @@ def kind_label(self) -> str: return "In" if self.is_group_in else "Out" def center_label(self) -> str: - """External flow label (source for In, destination for Out), or In/Out.""" + """Return proxy label from member port or manual override.""" controller = self.view.project_controller - group_uid = self.view.current_view_group_uid - if controller is None or group_uid is None: + if controller is None: return self.kind_label - group = controller.project_state.get_visual_group(group_uid) - if group is None: - return self.kind_label - text = controller.boundary_port_flow_label(group, self.boundary) + text = controller.boundary_proxy_label(self.boundary) return text if text else self.kind_label def member_anchor(self) -> QPointF: @@ -188,6 +184,18 @@ def mouseReleaseEvent(self, event): end_pos, ) + def contextMenuEvent(self, event): + if self.view is not None: + global_pos = ( + event.screenPos() + if hasattr(event, "screenPos") + else event.globalPos() + ) + self.view._show_boundary_port_context_menu(self, global_pos) + event.accept() + return + super().contextMenuEvent(event) + def itemChange(self, change, value): if change == QGraphicsItem.ItemPositionChange and self.scene(): x = round(value.x() / self.GRID_DX) * self.GRID_DX diff --git a/pySimBlocks/gui/group_boundary_labels.py b/pySimBlocks/gui/group_boundary_labels.py index 3b90871..b325769 100644 --- a/pySimBlocks/gui/group_boundary_labels.py +++ b/pySimBlocks/gui/group_boundary_labels.py @@ -18,48 +18,13 @@ def boundary_port_label( group: VisualGroup, boundary: BoundaryPort, ) -> str: - """Return the flow label for a group boundary port. - - Input boundaries show the external source port (where the signal comes from). - Output boundaries show the external destination port (where the signal goes). - """ - if boundary.origin == "manual" and not boundary.linked_connection_uid: - external = find_port(state, boundary.external_port_uid) - if external is not None: - return _port_display(external) - + """Return the internal member-port label for a group boundary port.""" + if boundary.label.strip(): + return boundary.label.strip() linked = boundary.linked_port_uid - if not linked or ":" not in linked: + if not linked: return "" - member_uid, member_port_name = linked.split(":", 1) - members = set(group.members) - for connection in state.connections: - external_port = _external_port_for_boundary( - connection, members, boundary.direction, member_uid, member_port_name - ) - if external_port is not None: - return _port_display(external_port) - return "" - - -def _external_port_for_boundary( - connection: ConnectionInstance, - members: set[str], - direction: str, - member_uid: str, - member_port_name: str, -): - if direction == "input": - if ( - connection.dst_block().uid == member_uid - and connection.dst_port.name == member_port_name - and connection.src_block().uid not in members - ): - return connection.src_port - elif ( - connection.src_block().uid == member_uid - and connection.src_port.name == member_port_name - and connection.dst_block().uid not in members - ): - return connection.dst_port - return None + internal = find_port(state, linked) + if internal is None: + return "" + return _port_display(internal) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 252c1f6..05969d9 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -34,6 +34,12 @@ ProjectState, VisualGroup, ) +from pySimBlocks.gui.diagram_clipboard import ( + DiagramClipboard, + capture_selection_clipboard, + paste_clipboard, + undo_paste, +) from pySimBlocks.gui.group_boundary_labels import boundary_port_label from pySimBlocks.gui.services.group_boundary_service import ( BoundaryWiringState, @@ -42,6 +48,7 @@ capture_wiring_state, connection_endpoints, find_connection_for_boundary, + find_port, port_key, validate_external_link, validate_internal_link, @@ -64,7 +71,9 @@ RemoveBlockCommand, RemoveConnectionCommand, RemoveFromGroupCommand, + RenameBoundaryPortCommand, RenameGroupCommand, + PasteClipboardCommand, ToggleOrientationCommand, UngroupCommand, WireManualBoundaryCommand, @@ -151,9 +160,30 @@ def add_copy_block(self, block_instance: BlockInstance) -> BlockInstance: Returns: The newly created copy as a :class:`BlockInstance`. """ - copy = BlockInstance.copy(block_instance) - self.undo_manager.push(AddBlockCommand(self, copy)) - return copy + copy_block = BlockInstance.copy(block_instance) + self.undo_manager.push(AddBlockCommand(self, copy_block)) + return copy_block + + def copy_selection(self) -> bool: + """Copy the current diagram selection to the view clipboard.""" + clipboard = capture_selection_clipboard(self) + if clipboard is None: + return False + self.view.clipboard = clipboard + self.view.paste_generation = 0 + return True + + def paste_clipboard_at(self, origin: QPointF) -> bool: + """Paste the view clipboard at ``origin`` (undoable).""" + clipboard = self.view.clipboard + if clipboard is None or not clipboard.blocks: + return False + parent_uid = self.view.current_view_group_uid + self.undo_manager.push( + PasteClipboardCommand(self, clipboard, QPointF(origin), parent_uid) + ) + self.view.paste_generation += 1 + return True def rename_block(self, block_instance: BlockInstance, new_name: str) -> None: """Rename a block and update all references in logging and plot signals. @@ -474,6 +504,42 @@ def boundary_port_flow_label( """Return the flow label for a boundary (source for In, destination for Out).""" return boundary_port_label(self.project_state, group, boundary) + def boundary_proxy_label(self, boundary: BoundaryPort) -> str: + """Return proxy label: manual override, else linked internal port.""" + if boundary.label.strip(): + return boundary.label.strip() + linked = find_port(self.project_state, boundary.linked_port_uid) + if linked is None: + return "" + return str(linked.display_as or linked.name) + + def rename_boundary_port( + self, + group_uid: str, + boundary_uid: str, + new_label: str, + ) -> bool: + """Rename one proxy label (empty string resets automatic label).""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None: + return False + trimmed = new_label.strip() + if trimmed == boundary.label: + return False + self.undo_manager.push( + RenameBoundaryPortCommand( + self, + group_uid, + boundary_uid, + boundary.label, + trimmed, + ) + ) + return True + def rename_visual_group(self, group_uid: str, new_name: str) -> bool: """Rename a visual group (undoable).""" group = self.project_state.get_visual_group(group_uid) @@ -842,7 +908,11 @@ def _add_block( block_instance.name = self.make_unique_name(block_instance.name) block_instance.resolve_ports() self.project_state.add_block(block_instance) - self.view.add_block(block_instance, block_layout) + layout = dict(block_layout or {}) + self.view.add_block(block_instance, layout) + block_item = self.view.get_block_item_from_instance(block_instance) + if block_item is not None and ("x" in layout or "y" in layout): + self._apply_block_layout(block_item, layout) self.view.refresh_visual_groups() return block_instance @@ -1353,6 +1423,16 @@ def _set_proxy_layout( for conn_item in self.view.connections.values(): conn_item.update_position() + def _set_boundary_label(self, group_uid: str, boundary_uid: str, label: str) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None: + return + boundary.label = label.strip() + self.view.refresh_visual_groups() + def add_manual_boundary_port( self, group_uid: str, diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index 2d86094..a7ff863 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -449,6 +449,35 @@ def undo(self) -> None: self._controller.make_dirty() +class RenameBoundaryPortCommand(QUndoCommand): + def __init__( + self, + controller, + group_uid: str, + boundary_uid: str, + old_label: str, + new_label: str, + ): + super().__init__("Rename Group Port") + self._controller = controller + self._group_uid = group_uid + self._boundary_uid = boundary_uid + self._old_label = old_label + self._new_label = new_label + + def redo(self) -> None: + self._controller._set_boundary_label( + self._group_uid, self._boundary_uid, self._new_label + ) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._set_boundary_label( + self._group_uid, self._boundary_uid, self._old_label + ) + self._controller.make_dirty() + + class WireManualBoundaryCommand(QUndoCommand): def __init__( self, @@ -478,3 +507,37 @@ def undo(self) -> None: self._group_uid, self._boundary_uid, wiring, connection ) self._controller.make_dirty() + + +class PasteClipboardCommand(QUndoCommand): + def __init__( + self, + controller, + clipboard, + origin: QPointF, + parent_group_uid: str | None = None, + ): + super().__init__("Paste") + self._controller = controller + self._clipboard = clipboard + self._origin = QPointF(origin) + self._parent_group_uid = parent_group_uid + self._result = None + + def redo(self) -> None: + from pySimBlocks.gui.diagram_clipboard import paste_clipboard + + self._result = paste_clipboard( + self._controller, + self._clipboard, + self._origin, + parent_group_uid=self._parent_group_uid, + ) + self._controller.make_dirty() + + def undo(self) -> None: + from pySimBlocks.gui.diagram_clipboard import undo_paste + + if self._result is not None: + undo_paste(self._controller, self._result) + self._controller.make_dirty() diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 47451f3..26c44c9 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -28,6 +28,7 @@ from pySimBlocks.gui.graphics.block_item import BlockItem from pySimBlocks.gui.graphics.group_item import GroupBoundaryPortItem, GroupItem +from pySimBlocks.gui.diagram_clipboard import DiagramClipboard from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyItem, GroupProxyPortItem from pySimBlocks.gui.graphics.connection_item import ConnectionItem, OrthogonalRoute from pySimBlocks.gui.graphics.manual_boundary_wire_item import ManualBoundaryWireItem @@ -74,7 +75,8 @@ def __init__(self): self.pending_port: PortItem | GroupProxyPortItem | GroupBoundaryPortItem | None = None self.temp_connection: ConnectionItem | None = None - self.copied_block: BlockItem | None = None + self.clipboard: DiagramClipboard | None = None + self.paste_generation = 0 self.drop_event_pos: QPointF = QPointF(0, 0) self.project_controller: ProjectController | None self.block_items: dict[str, BlockItem] = {} @@ -712,16 +714,20 @@ def keyPressEvent(self, event) -> None: # 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] + if self.project_controller.copy_selection(): + event.accept() 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) + if self.clipboard and self.clipboard.blocks: + offset = 30 * (self.paste_generation + 1) + origin = QPointF( + self.clipboard.anchor_x + offset, + self.clipboard.anchor_y + offset, + ) + if self.project_controller.paste_clipboard_at(origin): + event.accept() return # DELETE @@ -841,6 +847,11 @@ def contextMenuEvent(self, event) -> None: super().contextMenuEvent(event) return + clicked_proxy = self._proxy_item_at(event) + if clicked_proxy is not None: + self._show_boundary_port_context_menu(clicked_proxy, event.globalPos()) + return + menu = QMenu(self) selected_blocks = self.get_selected_block_instances() selected_group_uid = self.get_selected_group_uid() @@ -1026,6 +1037,52 @@ def _block_item_at(self, event) -> BlockItem | None: return item return None + def _proxy_item_at(self, event) -> GroupProxyItem | None: + if hasattr(event, "position"): + view_pos = event.position().toPoint() + else: + view_pos = event.pos() + pos = self.mapToScene(view_pos) + for item in self.diagram_scene.items(pos): + current = item + while current is not None: + if isinstance(current, GroupProxyItem): + return current + current = current.parentItem() + return None + + def _show_boundary_port_context_menu( + self, + proxy_item: GroupProxyItem, + global_pos, + ) -> None: + """Show rename/reset menu for a group In/Out proxy.""" + if self.project_controller is None or self.current_view_group_uid is None: + return + + boundary = proxy_item.boundary + group_uid = self.current_view_group_uid + menu = QMenu(self) + rename_action = menu.addAction("Rename port") + reset_action = menu.addAction("Reset automatic name") + reset_action.setEnabled(bool(boundary.label.strip())) + + action = menu.exec(global_pos) + if action is rename_action: + current = boundary.label if boundary.label.strip() else proxy_item.center_label() + text, ok = QInputDialog.getText( + self, + "Rename Group Port", + "Proxy label:", + text=current, + ) + if ok: + self.project_controller.rename_boundary_port( + group_uid, boundary.uid, text + ) + elif action is reset_action: + self.project_controller.rename_boundary_port(group_uid, boundary.uid, "") + def _rename_group(self, group_uid: str) -> None: if self.project_controller is None: return From d416539f253835604c0c1569505fbdc687258ed0 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Wed, 17 Jun 2026 12:30:57 +0200 Subject: [PATCH 20/42] fix(gui): improve breadcrumb separator visibility in group navigation bar --- pySimBlocks/gui/widgets/view_navigation_bar.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pySimBlocks/gui/widgets/view_navigation_bar.py b/pySimBlocks/gui/widgets/view_navigation_bar.py index 38e7c9a..1fd43f7 100644 --- a/pySimBlocks/gui/widgets/view_navigation_bar.py +++ b/pySimBlocks/gui/widgets/view_navigation_bar.py @@ -8,6 +8,7 @@ from typing import Callable from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QHBoxLayout, QLabel, @@ -62,7 +63,13 @@ def set_view_stack(self, stack_uids: list[str]) -> None: def _separator(self) -> QLabel: label = QLabel("›", self) - label.setStyleSheet("color: palette(mid);") + font = QFont(label.font()) + if font.pointSize() > 0: + font.setPointSize(font.pointSize() + 2) + font.setBold(True) + label.setFont(font) + label.setStyleSheet("color: palette(windowText); padding: 0 4px;") + label.setAlignment(Qt.AlignCenter) return label def _add_segment_button(self, text: str, depth: int, enabled: bool) -> None: From e1f0a6134a2ee6b9648487e660acd7e41cd8123d Mon Sep 17 00:00:00 2001 From: Lukasik Date: Wed, 17 Jun 2026 15:57:32 +0200 Subject: [PATCH 21/42] fix(gui): restore grouped blocks on undo and remove connections when deleting In/Out ports --- pySimBlocks/gui/project_controller.py | 83 +++++++++++++++++-- .../gui/services/group_boundary_service.py | 24 ++++-- pySimBlocks/gui/undo_redo/commands.py | 52 +++++++++++- pySimBlocks/gui/widgets/diagram_view.py | 22 +++++ 4 files changed, 168 insertions(+), 13 deletions(-) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 05969d9..b2b3745 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -71,6 +71,7 @@ RemoveBlockCommand, RemoveConnectionCommand, RemoveFromGroupCommand, + RemoveBoundaryPortCommand, RenameBoundaryPortCommand, RenameGroupCommand, PasteClipboardCommand, @@ -945,6 +946,10 @@ def _remove_block(self, block_instance: BlockInstance) -> None: ] self.project_state.logging = [s for s in self.project_state.logging if s not in removed_signals] + for group in self.project_state.visual_groups: + if block_uid in group.members: + self._remove_boundaries_for_member(group, block_uid) + for i in reversed(range(len(self.project_state.plots))): plot = self.project_state.plots[i] if str(plot.get("layout", "")).strip().lower() == "manual": @@ -1384,7 +1389,29 @@ def _add_manual_boundary_port( self.ensure_group_boundary_proxies(group) self.view.refresh_visual_groups() - def _remove_manual_boundary_port(self, group_uid: str, boundary_uid: str) -> None: + def _restore_boundary_port( + self, + group_uid: str, + boundary: BoundaryPort, + wiring: BoundaryWiringState, + connection_snapshot: ConnectionSnapshot | None, + ) -> None: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + if any(port.uid == boundary.uid for port in group.boundary_ports): + return + restored = BoundaryPort.from_dict(boundary.to_dict()) + apply_wiring_state(restored, wiring) + group.boundary_ports.append(restored) + self.ensure_group_boundary_proxies(group) + if connection_snapshot is not None: + connection = self._add_connection_from_snapshot(connection_snapshot) + if connection is not None: + restored.linked_connection_uid = self._connection_key(connection) + self.view.refresh_visual_groups() + + def _remove_boundary_port(self, group_uid: str, boundary_uid: str) -> None: group = self.project_state.get_visual_group(group_uid) if group is None: return @@ -1453,6 +1480,23 @@ def add_manual_boundary_port( self.undo_manager.push(AddManualBoundaryCommand(self, group_uid, boundary)) return boundary + def remove_boundary_port(self, group_uid: str, boundary_uid: str) -> bool: + """Remove a GroupIn/GroupOut boundary port (undoable).""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None: + return False + self.undo_manager.push( + RemoveBoundaryPortCommand(self, group_uid, boundary_uid) + ) + return True + + def remove_manual_boundary_port(self, group_uid: str, boundary_uid: str) -> bool: + """Remove a manual GroupIn/GroupOut boundary port (undoable).""" + return self.remove_boundary_port(group_uid, boundary_uid) + def _compute_group_layout(self, member_uids: list[str]) -> dict[str, float]: """Compute a bounding layout for group members.""" margin = 16.0 @@ -1523,10 +1567,9 @@ def _build_group_boundary_ports(self, member_uids: list[str]) -> list[BoundaryPo def _connection_key(self, connection: ConnectionInstance) -> str: """Build a stable key for one diagram connection.""" - return ( - f"{connection.src_block().uid}:{connection.src_port.name}->" - f"{connection.dst_block().uid}:{connection.dst_port.name}" - ) + from pySimBlocks.gui.services.group_boundary_service import connection_key + + return connection_key(connection) def _remove_auto_boundaries_for_member_port( self, @@ -1584,6 +1627,14 @@ def _group_needs_boundary_refresh( if members.intersection(block_uids): return True + for uid in block_uids: + prefix = f"{uid}:" + for port in group.boundary_ports: + if port.linked_port_uid.startswith(prefix): + return True + if port.external_port_uid.startswith(prefix): + return True + for connection in self.project_state.connections: src_uid = connection.src_block().uid dst_uid = connection.dst_block().uid @@ -1593,11 +1644,31 @@ def _group_needs_boundary_refresh( return True return False + def _group_has_stale_boundaries(self, group: VisualGroup) -> bool: + """Return whether auto boundaries no longer match project connections.""" + members = set(group.members) + connection_keys = { + self._connection_key(connection) + for connection in self.project_state.connections + } + for port in group.boundary_ports: + if port.origin == "manual": + continue + if port.linked_port_uid: + block_uid = port.linked_port_uid.split(":", 1)[0] + if block_uid not in members or self._find_block_by_uid(block_uid) is None: + return True + if port.linked_connection_uid and port.linked_connection_uid not in connection_keys: + return True + return False + def _refresh_boundaries_for_member_uids(self, block_uids: set[str]) -> None: """Refresh boundary ports for groups affected by block or connection changes.""" changed = False for group in self.project_state.visual_groups: - if self._group_needs_boundary_refresh(group, block_uids): + if self._group_needs_boundary_refresh( + group, block_uids + ) or self._group_has_stale_boundaries(group): self._rebuild_group_boundary_ports(group) changed = True if changed: diff --git a/pySimBlocks/gui/services/group_boundary_service.py b/pySimBlocks/gui/services/group_boundary_service.py index 53fe69a..272a17e 100644 --- a/pySimBlocks/gui/services/group_boundary_service.py +++ b/pySimBlocks/gui/services/group_boundary_service.py @@ -135,15 +135,27 @@ def can_complete(state: ProjectState, boundary: BoundaryPort) -> bool: return True +def connection_key(connection: ConnectionInstance) -> str: + """Build a stable key for one diagram connection.""" + return ( + f"{connection.src_block().uid}:{connection.src_port.name}->" + f"{connection.dst_block().uid}:{connection.dst_port.name}" + ) + + def find_connection_for_boundary( state: ProjectState, boundary: BoundaryPort, ) -> ConnectionInstance | None: endpoints = connection_endpoints(state, boundary) - if endpoints is None: - return None - src_port, dst_port = endpoints - for connection in state.connections: - if connection.src_port is src_port and connection.dst_port is dst_port: - return connection + if endpoints is not None: + src_port, dst_port = endpoints + for connection in state.connections: + if connection.src_port is src_port and connection.dst_port is dst_port: + return connection + + if boundary.linked_connection_uid: + for connection in state.connections: + if connection_key(connection) == boundary.linked_connection_uid: + return connection return None diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index a7ff863..b4891ad 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -130,6 +130,11 @@ def __init__(self, controller, block_instance: BlockInstance): ] self._logging_before = list(controller.project_state.logging) self._plots_before = copy.deepcopy(controller.project_state.plots) + self._group_snapshots = { + group.uid: group.to_dict() + for group in controller.project_state.visual_groups + if block_instance.uid in group.members + } def redo(self) -> None: self._controller._remove_block(self._block_instance) @@ -141,6 +146,11 @@ def undo(self) -> None: self._controller._add_connection_from_snapshot(snapshot) self._controller.project_state.logging = list(self._logging_before) self._controller.project_state.plots = copy.deepcopy(self._plots_before) + for group_uid, group_snapshot in self._group_snapshots.items(): + self._controller._apply_group_snapshot(group_snapshot, group_uid) + group = self._controller.project_state.get_visual_group(group_uid) + if group is not None: + self._controller.apply_member_layouts(group) self._controller.make_dirty() @@ -387,12 +397,52 @@ def redo(self) -> None: self._controller.make_dirty() def undo(self) -> None: - self._controller._remove_manual_boundary_port( + self._controller._remove_boundary_port( self._group_uid, self._boundary.uid ) self._controller.make_dirty() +class RemoveBoundaryPortCommand(QUndoCommand): + def __init__(self, controller, group_uid: str, boundary_uid: str): + super().__init__("Remove Group Port") + self._controller = controller + self._group_uid = group_uid + self._boundary_uid = boundary_uid + group = controller.project_state.get_visual_group(group_uid) + boundary = ( + controller._find_boundary_port(group, boundary_uid) if group is not None else None + ) + self._boundary_snapshot = ( + BoundaryPort.from_dict(boundary.to_dict()) if boundary is not None else None + ) + wiring, connection = controller._capture_boundary_wire_snapshot( + group_uid, boundary_uid + ) + self._wiring = wiring + self._connection_snapshot = connection + + def redo(self) -> None: + if self._boundary_snapshot is None: + return + self._controller._remove_boundary_port(self._group_uid, self._boundary_uid) + self._controller.make_dirty() + + def undo(self) -> None: + if self._boundary_snapshot is None: + return + self._controller._restore_boundary_port( + self._group_uid, + self._boundary_snapshot, + self._wiring, + self._connection_snapshot, + ) + self._controller.make_dirty() + + +RemoveManualBoundaryCommand = RemoveBoundaryPortCommand + + class MoveProxyLayoutCommand(QUndoCommand): def __init__( self, diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 26c44c9..c79c4f4 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -926,7 +926,14 @@ def delete_selected(self) -> None: return self.project_controller.begin_macro("Delete Selection") try: + removed_boundaries: set[tuple[str, str]] = set() for item in selected_items: + boundary_key = self._boundary_key_for_item(item) + if boundary_key is not None: + if boundary_key not in removed_boundaries: + removed_boundaries.add(boundary_key) + self.project_controller.remove_boundary_port(*boundary_key) + continue if isinstance(item, GroupItem): self.project_controller.ungroup(item.group.uid) elif isinstance(item, BlockItem): @@ -936,6 +943,18 @@ def delete_selected(self) -> None: finally: self.project_controller.end_macro() + def _boundary_key_for_item(self, item) -> tuple[str, str] | None: + """Return (group_uid, boundary_uid) for a selected group boundary item.""" + if isinstance(item, GroupProxyPortItem): + item = item.parent_proxy + if isinstance(item, GroupProxyItem): + if self.current_view_group_uid is None: + return None + return self.current_view_group_uid, item.boundary.uid + if isinstance(item, GroupBoundaryPortItem): + return item.parent_group.group.uid, item.boundary.uid + return None + def clear_scene(self) -> None: """Remove all blocks and connections from the scene and reset state.""" self.diagram_scene.clear() @@ -1064,6 +1083,7 @@ def _show_boundary_port_context_menu( group_uid = self.current_view_group_uid menu = QMenu(self) rename_action = menu.addAction("Rename port") + delete_action = menu.addAction("Delete port") reset_action = menu.addAction("Reset automatic name") reset_action.setEnabled(bool(boundary.label.strip())) @@ -1080,6 +1100,8 @@ def _show_boundary_port_context_menu( self.project_controller.rename_boundary_port( group_uid, boundary.uid, text ) + elif action is delete_action: + self.project_controller.remove_boundary_port(group_uid, boundary.uid) elif action is reset_action: self.project_controller.rename_boundary_port(group_uid, boundary.uid, "") From 11a8a55d5fd585cc7fc42851e08461c037ebdd33 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Thu, 18 Jun 2026 13:39:16 +0200 Subject: [PATCH 22/42] fix(gui): delete group and member blocks on Suppr instead of ungrouping --- pySimBlocks/gui/project_controller.py | 22 ++++++++++ pySimBlocks/gui/undo_redo/commands.py | 53 +++++++++++++++++++++++++ pySimBlocks/gui/widgets/diagram_view.py | 2 +- 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index b2b3745..709916d 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -77,6 +77,7 @@ PasteClipboardCommand, ToggleOrientationCommand, UngroupCommand, + DeleteGroupCommand, WireManualBoundaryCommand, ConnectionSnapshot, ) @@ -264,6 +265,13 @@ def ungroup(self, group_uid: str) -> bool: self.undo_manager.push(UngroupCommand(self, group_uid)) return True + def delete_group(self, group_uid: str) -> bool: + """Delete a visual group and all its member blocks (undoable).""" + if self.project_state.get_visual_group(group_uid) is None: + return False + self.undo_manager.push(DeleteGroupCommand(self, group_uid)) + return True + def group_selected_blocks(self) -> VisualGroup | None: """Group currently selected blocks in the diagram view.""" blocks = self.view.get_selected_block_instances() @@ -1165,6 +1173,20 @@ def _remove_visual_group(self, group_uid: str) -> bool: """Remove a visual group without pushing undo.""" return self.project_state.remove_visual_group(group_uid) + def _delete_group(self, group_uid: str) -> None: + """Delete a visual group and all member blocks without pushing undo.""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + if group_uid in self.view.view_stack: + self.view.navigate_out_of_group(group_uid) + for member_uid in list(group.members): + block = self._find_block_by_uid(member_uid) + if block is not None: + self._remove_block(block) + self._remove_visual_group(group_uid) + self.view.refresh_visual_groups() + def _group_containing_member(self, block_uid: str) -> VisualGroup | None: """Return the visual group that lists ``block_uid`` as a member.""" for group in self.project_state.visual_groups: diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index b4891ad..d41d294 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -249,6 +249,59 @@ def undo(self) -> None: self._controller.make_dirty() +class DeleteGroupCommand(QUndoCommand): + def __init__(self, controller, group_uid: str): + super().__init__("Delete Group") + self._controller = controller + self._group_uid = group_uid + group = controller.project_state.get_visual_group(group_uid) + self._group_snapshot = group.to_dict() if group is not None else None + self._members: list[tuple[BlockInstance, dict]] = [] + self._connections: list[ConnectionSnapshot] = [] + seen_connections: set[int] = set() + if group is not None: + for member_uid in group.members: + block = controller._find_block_by_uid(member_uid) + if block is None: + continue + self._members.append( + (block, controller._capture_block_layout(block)) + ) + for connection in controller.project_state.get_connections_of_block( + block + ): + conn_id = id(connection) + if conn_id in seen_connections: + continue + seen_connections.add(conn_id) + self._connections.append( + controller._capture_connection_snapshot(connection) + ) + self._logging_before = list(controller.project_state.logging) + self._plots_before = copy.deepcopy(controller.project_state.plots) + + def redo(self) -> None: + if self._group_snapshot is None: + return + self._controller._delete_group(self._group_uid) + self._controller.make_dirty() + + def undo(self) -> None: + if self._group_snapshot is None: + return + for block, layout in self._members: + self._controller._add_block(block, 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 = copy.deepcopy(self._plots_before) + self._controller._apply_group_snapshot(self._group_snapshot, self._group_uid) + group = self._controller.project_state.get_visual_group(self._group_uid) + if group is not None: + self._controller.apply_member_layouts(group) + self._controller.make_dirty() + + class AddToGroupCommand(QUndoCommand): def __init__( self, diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index c79c4f4..36de167 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -935,7 +935,7 @@ def delete_selected(self) -> None: self.project_controller.remove_boundary_port(*boundary_key) continue if isinstance(item, GroupItem): - self.project_controller.ungroup(item.group.uid) + self.project_controller.delete_group(item.group.uid) elif isinstance(item, BlockItem): self.project_controller.remove_block(item.instance) elif isinstance(item, ConnectionItem): From 6b9ae2c9e6691dff614be194a9fa4422d7621462 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Thu, 18 Jun 2026 14:56:43 +0200 Subject: [PATCH 23/42] fix(gui): stabilize manual wire editing and routing --- pySimBlocks/gui/graphics/connection_item.py | 170 ++++++++++++++++---- pySimBlocks/gui/project_controller.py | 22 --- pySimBlocks/gui/undo_redo/commands.py | 53 ------ pySimBlocks/gui/widgets/diagram_view.py | 2 +- 4 files changed, 136 insertions(+), 111 deletions(-) diff --git a/pySimBlocks/gui/graphics/connection_item.py b/pySimBlocks/gui/graphics/connection_item.py index 6a86e2e..0e73fe5 100644 --- a/pySimBlocks/gui/graphics/connection_item.py +++ b/pySimBlocks/gui/graphics/connection_item.py @@ -18,7 +18,7 @@ # Authors: see Authors.txt # ****************************************************************************** -from PySide6.QtCore import Qt, QPointF +from PySide6.QtCore import Qt, QPointF, QRectF from PySide6.QtGui import QPen, QPainterPath, QPainterPathStroker from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem @@ -78,6 +78,7 @@ class ConnectionItem(QGraphicsPathItem): PICK_TOL = 10 GRID = 5 AXIS_EPS = 0.5 + JOG_EPS = 8.0 def __init__(self, src_port: PortItem | None, @@ -141,14 +142,28 @@ def update_position(self): view = self.src_port.parent_block.view p1 = view.connection_anchor_for_port_item(self.src_port) p2 = view.connection_anchor_for_port_item(self.dst_port) + + if self._route_drag_active: + if self.route and len(self.route.points) >= 2: + self.route.points[0] = p1 + self.route.points[-1] = p2 + self._apply_route(self.route.points, simplify=False) + return + if self.is_manual and self.route and len(self.route.points) >= 2: self.route.points[0] = p1 self.route.points[-1] = p2 + self.route.points = self._simplify_orthogonal_route(self.route.points) + if self._route_is_stale(p1, p2, self.route.points): + pts = self._compute_auto_route(p1, p2) + self.route = OrthogonalRoute(pts) + self.is_manual = False self._apply_route(self.route.points) return pts = self._compute_auto_route(p1, p2) self.route = OrthogonalRoute(pts) + self.is_manual = False self._apply_route(self.route.points) def update_temp_position(self, scene_pos: QPointF): @@ -158,8 +173,7 @@ def update_temp_position(self, scene_pos: QPointF): scene_pos: Current mouse position in scene coordinates. """ p1 = self._valid_port.connection_anchor() - pts = [p1, scene_pos] - self._apply_route(pts) + self._apply_route([p1, scene_pos], simplify=False) def apply_manual_route(self, points: list[QPointF]): """Apply a persisted manual route to the connection. @@ -244,21 +258,31 @@ def mouseMoveEvent(self, event): return i = self.route.dragged_index - a = self.route.points[i] - b = self.route.points[i + 1] + points = self.route.points + if i < 0 or i + 1 >= len(points): + self.route.dragged_index = None + self._route_drag_active = False + self.ungrabMouse() + return + + a = points[i] + b = points[i + 1] pos = event.scenePos() if abs(a.x() - b.x()) < self.AXIS_EPS: # vertical segment x = self._snap(pos.x()) - self.route.points[i] = QPointF(x, a.y()) - self.route.points[i + 1] = QPointF(x, b.y()) + points[i] = QPointF(x, a.y()) + points[i + 1] = QPointF(x, b.y()) elif abs(a.y() - b.y()) < self.AXIS_EPS: # horizontal segment y = self._snap(pos.y()) - self.route.points[i] = QPointF(a.x(), y) - self.route.points[i + 1] = QPointF(b.x(), y) + points[i] = QPointF(a.x(), y) + points[i + 1] = QPointF(b.x(), y) - self._apply_route(self.route.points) + else: + return + + self._apply_route(points, simplify=False) def mouseReleaseEvent(self, event): """Finish manual segment dragging.""" @@ -270,6 +294,7 @@ def mouseReleaseEvent(self, event): self.ungrabMouse() super().mouseReleaseEvent(event) if was_dragging and event.button() == Qt.LeftButton and self.route is not None: + self._apply_route(self.route.points) view = self.src_port.parent_block.view new_points = [QPointF(point) for point in self.route.points] view.on_connection_route_edited( @@ -286,12 +311,8 @@ def mouseReleaseEvent(self, event): def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]: """Compute an orthogonal route between two port anchors.""" - src_block = self.src_port.parent_block - dst_block = self.dst_port.parent_block - - # Use the visual block rect (not selection handle hit area) for routing. - src_rect = src_block.mapRectToScene(src_block.rect()) - dst_rect = dst_block.mapRectToScene(dst_block.rect()) + src_rect = self._routing_rect_for_port(self.src_port) + dst_rect = self._routing_rect_for_port(self.dst_port) src_out_sign = 1 if not self.src_port.is_on_left_side else -1 dst_in_sign = -1 if self.dst_port.is_on_left_side else 1 @@ -299,22 +320,31 @@ def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]: p1_out = QPointF(p1.x() + src_out_sign * self.OFFSET, p1.y()) p2_in = QPointF(p2.x() + dst_in_sign * self.OFFSET, p2.y()) - same_block = src_block is dst_block + same_block = self.src_port.parent_block is self.dst_port.parent_block u_turn = ((p2_in.x() - p1_out.x()) * src_out_sign) < 0 is_feedback = same_block or u_turn if not is_feedback: - mid_x = (p1_out.x() + p2_in.x()) * 0.5 - candidate = [ - p1, p1_out, - QPointF(mid_x, p1.y()), - QPointF(mid_x, p2.y()), - p2_in, p2 - ] + if abs(p1.y() - p2.y()) < self.AXIS_EPS: + straight = [p1, p1_out, p2_in, p2] + path = self._path_from(straight) + if not (path.intersects(src_rect) or path.intersects(dst_rect)): + return straight + + if abs(p1.y() - p2.y()) <= self.JOG_EPS: + candidate = [p1, p1_out, QPointF(p2.x(), p1.y()), p2] + else: + mid_x = (p1_out.x() + p2_in.x()) * 0.5 + candidate = [ + p1, p1_out, + QPointF(mid_x, p1.y()), + QPointF(mid_x, p2.y()), + p2_in, p2 + ] path = self._path_from(candidate) if not (path.intersects(src_rect) or path.intersects(dst_rect)): - return candidate + return self._simplify_orthogonal_route(candidate) # fallback / feedback routing candidates_y = [ @@ -332,23 +362,93 @@ def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]: key=lambda y: abs(p1.y() - y) + abs(p2.y() - y) ) - return [ - p1, p1_out, - QPointF(p1_out.x(), route_y), - QPointF(p2_in.x(), route_y), - p2_in, p2 - ] + return self._simplify_orthogonal_route( + [ + p1, p1_out, + QPointF(p1_out.x(), route_y), + QPointF(p2_in.x(), route_y), + p2_in, p2 + ] + ) + + def _routing_rect_for_port(self, port_item: PortItem) -> QRectF: + """Return the scene rectangle used for obstacle avoidance.""" + block_item = port_item.parent_block + view = block_item.view + block_uid = port_item.instance.block.uid + + if view.current_view_group_uid is None: + for group_item in view.group_items.values(): + if not group_item.isVisible(): + continue + if block_uid in group_item.group.members: + return group_item.mapRectToScene(group_item.rect()) + + return block_item.mapRectToScene(block_item.rect()) + + def _wire_length(self, points: list[QPointF]) -> float: + total = 0.0 + for index in range(len(points) - 1): + a = points[index] + b = points[index + 1] + total += abs(a.x() - b.x()) + abs(a.y() - b.y()) + return total + + def _simplify_orthogonal_route(self, points: list[QPointF]) -> list[QPointF]: + if len(points) <= 2: + return [QPointF(point) for point in points] + + simplified = [QPointF(points[0])] + for index in range(1, len(points) - 1): + prev_pt = simplified[-1] + current = points[index] + next_pt = points[index + 1] + same_vertical = ( + abs(prev_pt.x() - current.x()) < self.AXIS_EPS + and abs(current.x() - next_pt.x()) < self.AXIS_EPS + ) + same_horizontal = ( + abs(prev_pt.y() - current.y()) < self.AXIS_EPS + and abs(current.y() - next_pt.y()) < self.AXIS_EPS + ) + if same_vertical or same_horizontal: + continue + simplified.append(QPointF(current)) + simplified.append(QPointF(points[-1])) + return simplified + + def _route_is_stale(self, p1: QPointF, p2: QPointF, points: list[QPointF]) -> bool: + if len(points) < 2: + return True + + for index in range(len(points) - 1): + if self._wire_length([points[index], points[index + 1]]) < self.AXIS_EPS: + return True + + auto_points = self._compute_auto_route(p1, p2) + manual_len = self._wire_length(points) + auto_len = self._wire_length(auto_points) + return manual_len > auto_len * 1.35 + 30.0 def _snap(self, v: float) -> float: """Snap a scalar coordinate to the routing grid.""" return round(v / self.GRID) * self.GRID - def _apply_route(self, points: list[QPointF]): + def _apply_route(self, points: list[QPointF], *, simplify: bool = True): """Apply a route by building and setting the corresponding path.""" - path = QPainterPath(points[0]) - for p in points[1:]: - path.lineTo(p) + cleaned = ( + self._simplify_orthogonal_route(points) + if simplify + else [QPointF(point) for point in points] + ) + if len(cleaned) < 2: + return + path = QPainterPath(cleaned[0]) + for point in cleaned[1:]: + path.lineTo(point) self.setPath(path) + if self.route is not None: + self.route.points = cleaned def _path_from(self, pts: list[QPointF]) -> QPainterPath: """Build a painter path from an ordered list of route points.""" diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 709916d..b2b3745 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -77,7 +77,6 @@ PasteClipboardCommand, ToggleOrientationCommand, UngroupCommand, - DeleteGroupCommand, WireManualBoundaryCommand, ConnectionSnapshot, ) @@ -265,13 +264,6 @@ def ungroup(self, group_uid: str) -> bool: self.undo_manager.push(UngroupCommand(self, group_uid)) return True - def delete_group(self, group_uid: str) -> bool: - """Delete a visual group and all its member blocks (undoable).""" - if self.project_state.get_visual_group(group_uid) is None: - return False - self.undo_manager.push(DeleteGroupCommand(self, group_uid)) - return True - def group_selected_blocks(self) -> VisualGroup | None: """Group currently selected blocks in the diagram view.""" blocks = self.view.get_selected_block_instances() @@ -1173,20 +1165,6 @@ def _remove_visual_group(self, group_uid: str) -> bool: """Remove a visual group without pushing undo.""" return self.project_state.remove_visual_group(group_uid) - def _delete_group(self, group_uid: str) -> None: - """Delete a visual group and all member blocks without pushing undo.""" - group = self.project_state.get_visual_group(group_uid) - if group is None: - return - if group_uid in self.view.view_stack: - self.view.navigate_out_of_group(group_uid) - for member_uid in list(group.members): - block = self._find_block_by_uid(member_uid) - if block is not None: - self._remove_block(block) - self._remove_visual_group(group_uid) - self.view.refresh_visual_groups() - def _group_containing_member(self, block_uid: str) -> VisualGroup | None: """Return the visual group that lists ``block_uid`` as a member.""" for group in self.project_state.visual_groups: diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index d41d294..b4891ad 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -249,59 +249,6 @@ def undo(self) -> None: self._controller.make_dirty() -class DeleteGroupCommand(QUndoCommand): - def __init__(self, controller, group_uid: str): - super().__init__("Delete Group") - self._controller = controller - self._group_uid = group_uid - group = controller.project_state.get_visual_group(group_uid) - self._group_snapshot = group.to_dict() if group is not None else None - self._members: list[tuple[BlockInstance, dict]] = [] - self._connections: list[ConnectionSnapshot] = [] - seen_connections: set[int] = set() - if group is not None: - for member_uid in group.members: - block = controller._find_block_by_uid(member_uid) - if block is None: - continue - self._members.append( - (block, controller._capture_block_layout(block)) - ) - for connection in controller.project_state.get_connections_of_block( - block - ): - conn_id = id(connection) - if conn_id in seen_connections: - continue - seen_connections.add(conn_id) - self._connections.append( - controller._capture_connection_snapshot(connection) - ) - self._logging_before = list(controller.project_state.logging) - self._plots_before = copy.deepcopy(controller.project_state.plots) - - def redo(self) -> None: - if self._group_snapshot is None: - return - self._controller._delete_group(self._group_uid) - self._controller.make_dirty() - - def undo(self) -> None: - if self._group_snapshot is None: - return - for block, layout in self._members: - self._controller._add_block(block, 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 = copy.deepcopy(self._plots_before) - self._controller._apply_group_snapshot(self._group_snapshot, self._group_uid) - group = self._controller.project_state.get_visual_group(self._group_uid) - if group is not None: - self._controller.apply_member_layouts(group) - self._controller.make_dirty() - - class AddToGroupCommand(QUndoCommand): def __init__( self, diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 36de167..c79c4f4 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -935,7 +935,7 @@ def delete_selected(self) -> None: self.project_controller.remove_boundary_port(*boundary_key) continue if isinstance(item, GroupItem): - self.project_controller.delete_group(item.group.uid) + self.project_controller.ungroup(item.group.uid) elif isinstance(item, BlockItem): self.project_controller.remove_block(item.instance) elif isinstance(item, ConnectionItem): From b425ab4f2f74bcee4faab1ca8a224ddb8b3b119d Mon Sep 17 00:00:00 2001 From: Lukasik Date: Fri, 19 Jun 2026 16:14:59 +0200 Subject: [PATCH 24/42] feat(gui): add nested visual groups and show wires across different groups --- pySimBlocks/gui/models/project_state.py | 2 +- pySimBlocks/gui/project_controller.py | 214 +++++++++++++++++++-- pySimBlocks/gui/services/project_loader.py | 50 ++++- pySimBlocks/gui/services/yaml_tools.py | 1 + pySimBlocks/gui/undo_redo/commands.py | 78 ++++++-- pySimBlocks/gui/widgets/diagram_view.py | 124 +++++++++--- 6 files changed, 410 insertions(+), 59 deletions(-) diff --git a/pySimBlocks/gui/models/project_state.py b/pySimBlocks/gui/models/project_state.py index 2b75007..1c4c598 100644 --- a/pySimBlocks/gui/models/project_state.py +++ b/pySimBlocks/gui/models/project_state.py @@ -222,7 +222,7 @@ def remove_block_from_groups(self, block_uid: str) -> None: for group in self.visual_groups: if block_uid in group.members: group.members = [uid for uid in group.members if uid != block_uid] - if not group.members: + if not group.members and not group.child_group_uids: to_remove.append(group) for group in to_remove: self.visual_groups.remove(group) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index b2b3745..47d28d8 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -237,8 +237,15 @@ def remove_block(self, block_instance: BlockInstance) -> None: """ self.undo_manager.push(RemoveBlockCommand(self, block_instance)) - def group_blocks(self, blocks: list[BlockInstance], name: str | None = None) -> VisualGroup | None: - """Create a visual group from an explicit block selection (undoable).""" + def group_blocks( + self, + blocks: list[BlockInstance], + name: str | None = None, + *, + child_group_uids: list[str] | None = None, + parent_uid: str | None = None, + ) -> VisualGroup | None: + """Create a visual group from blocks and/or child groups (undoable).""" unique_blocks: list[BlockInstance] = [] seen = set() for block in blocks: @@ -247,13 +254,35 @@ def group_blocks(self, blocks: list[BlockInstance], name: str | None = None) -> seen.add(block.uid) unique_blocks.append(block) - if len(unique_blocks) < 2: - raise ValueError("At least two blocks are required to create a group.") + child_uids = list(dict.fromkeys(child_group_uids or [])) + if parent_uid is None: + parent_uid = self.view.current_view_group_uid + + if len(unique_blocks) + len(child_uids) < 2: + return None + + for block in unique_blocks: + if not self._block_at_view_level(block.uid, parent_uid): + return None + + for child_uid in child_uids: + child = self.project_state.get_visual_group(child_uid) + if child is None or child.parent_uid != parent_uid: + return None - self.undo_manager.push(GroupBlocksCommand(self, unique_blocks, name)) - member_uids = {b.uid for b in unique_blocks} + self.undo_manager.push( + GroupBlocksCommand( + self, + unique_blocks, + name, + child_group_uids=child_uids, + parent_uid=parent_uid, + ) + ) for group in reversed(self.project_state.visual_groups): - if set(group.members) == member_uids: + if set(group.members) == {b.uid for b in unique_blocks} and set( + group.child_group_uids + ) == set(child_uids): return group return None @@ -265,11 +294,16 @@ def ungroup(self, group_uid: str) -> bool: return True def group_selected_blocks(self) -> VisualGroup | None: - """Group currently selected blocks in the diagram view.""" + """Group the current diagram selection of blocks and/or groups.""" blocks = self.view.get_selected_block_instances() - if len(blocks) < 2: + child_uids = self.view.get_selected_group_uids() + if len(blocks) + len(child_uids) < 2: return None - return self.group_blocks(blocks) + return self.group_blocks( + blocks, + child_group_uids=child_uids, + parent_uid=self.view.current_view_group_uid, + ) def ungroup_selected_group(self) -> bool: """Ungroup the currently selected visual group.""" @@ -763,6 +797,8 @@ def load_project(self, loader: "ProjectLoader") -> None: loader.load(self, self.project_state.directory_path) for group in self.project_state.visual_groups: self.ensure_group_boundary_proxies(group) + for group in self.project_state.visual_groups: + self.apply_member_layouts(group) self.view.refresh_visual_groups() @@ -1144,21 +1180,138 @@ def _create_visual_group( self, blocks: list[BlockInstance], name: str | None = None, + *, + child_group_uids: list[str] | None = None, + parent_uid: str | None = None, ) -> VisualGroup: """Create and register a visual group without pushing undo.""" + if parent_uid is None: + parent_uid = self.view.current_view_group_uid + member_uids = [b.uid for b in blocks] + child_uids = list(dict.fromkeys(child_group_uids or [])) + content_uids = self._group_content_uids(member_uids, child_uids) + group = VisualGroup( uid=uuid.uuid4().hex, name=self._make_unique_group_name(name or "Group"), members=member_uids, - parent_uid=None, - layout=self._compute_group_layout(member_uids), - boundary_ports=self._build_group_boundary_ports(member_uids), - child_group_uids=[], + parent_uid=parent_uid, + layout=self._compute_group_layout(member_uids, child_uids), + boundary_ports=self._build_group_boundary_ports(content_uids), + child_group_uids=child_uids, member_layouts=self._capture_member_layouts(member_uids), ) self.ensure_group_boundary_proxies(group) self.project_state.visual_groups.append(group) + self._apply_group_creation_side_effects(group) + return group + + def _group_content_uids( + self, + member_uids: list[str], + child_group_uids: list[str] | None = None, + ) -> list[str]: + """Return all block uids contained in a group selection.""" + content: set[str] = set(member_uids) + for child_uid in child_group_uids or []: + child = self.project_state.get_visual_group(child_uid) + if child is None: + continue + content.update(self._group_content_uids(child.members, child.child_group_uids)) + return list(content) + + def _group_content_uids_for_group(self, group: VisualGroup) -> list[str]: + """Return all block uids owned by a group and its descendants.""" + return self._group_content_uids(group.members, group.child_group_uids) + + def _apply_group_creation_side_effects(self, group: VisualGroup) -> None: + """Link a newly created group to its parent and child groups.""" + self._attach_group_to_parent(group) + if group.parent_uid: + parent = self.project_state.get_visual_group(group.parent_uid) + if parent is not None: + moved_uids = set(group.members) + if moved_uids.intersection(parent.members): + parent.members = [ + uid for uid in parent.members if uid not in moved_uids + ] + self._rebuild_group_boundary_ports(parent) + for child_uid in group.child_group_uids: + self._reparent_child_group(child_uid, group.uid) + + def _attach_group_to_parent(self, group: VisualGroup) -> None: + """Register a new group under its parent container.""" + if not group.parent_uid: + return + parent = self.project_state.get_visual_group(group.parent_uid) + if parent is None: + group.parent_uid = None + return + if group.uid not in parent.child_group_uids: + parent.child_group_uids.append(group.uid) + + def _reparent_child_group(self, child_uid: str, new_parent_uid: str) -> None: + """Move a child group under a new parent group.""" + child = self.project_state.get_visual_group(child_uid) + if child is None: + return + old_parent_uid = child.parent_uid + if old_parent_uid and old_parent_uid != new_parent_uid: + old_parent = self.project_state.get_visual_group(old_parent_uid) + if old_parent is not None: + old_parent.child_group_uids = [ + uid for uid in old_parent.child_group_uids if uid != child_uid + ] + child.parent_uid = new_parent_uid + + def _detach_group_from_parent(self, group: VisualGroup) -> None: + """Remove a group from its parent's child list.""" + if not group.parent_uid: + return + parent = self.project_state.get_visual_group(group.parent_uid) + if parent is None: + return + parent.child_group_uids = [ + uid for uid in parent.child_group_uids if uid != group.uid + ] + + def _promote_child_groups(self, group: VisualGroup) -> None: + """Reparent child groups to the dissolved group's parent.""" + grandparent_uid = group.parent_uid + for child_uid in list(group.child_group_uids): + child = self.project_state.get_visual_group(child_uid) + if child is None: + continue + child.parent_uid = grandparent_uid + if grandparent_uid: + grandparent = self.project_state.get_visual_group(grandparent_uid) + if grandparent is not None and child_uid not in grandparent.child_group_uids: + grandparent.child_group_uids.append(child_uid) + + def _block_at_view_level(self, block_uid: str, view_group_uid: str | None) -> bool: + """Return whether a block is a direct member of the active view level.""" + owner = self._group_containing_member(block_uid) + if view_group_uid is None: + return owner is None + return owner is not None and owner.uid == view_group_uid + + def _group_exposing_boundary_for_block( + self, + block_uid: str, + view_group_uid: str | None, + ) -> VisualGroup | None: + """Return the group whose border should expose a member port at this view level.""" + group = self._group_containing_member(block_uid) + if group is None: + return None + while group.parent_uid != view_group_uid: + if group.parent_uid is None: + return group if view_group_uid is None else None + parent = self.project_state.get_visual_group(group.parent_uid) + if parent is None: + return None + group = parent return group def _remove_visual_group(self, group_uid: str) -> bool: @@ -1241,7 +1394,7 @@ def _remove_member_from_group(self, group_uid: str, block_uid: str) -> bool: group.members = [uid for uid in group.members if uid != block_uid] group.member_layouts.pop(block_uid, None) - if not group.members: + if not group.members and not group.child_group_uids: if group_uid in self.view.view_stack: self.view.navigate_out_of_group(group_uid) self._remove_visual_group(group_uid) @@ -1310,7 +1463,9 @@ def save_member_layouts(self, group: VisualGroup) -> None: group.member_layouts[uid] = self._capture_block_layout(block) def restore_members_after_ungroup(self, group: VisualGroup) -> None: - """Place ungrouped members using their internal layouts.""" + """Place ungrouped members and promote child groups to the parent level.""" + self._promote_child_groups(group) + self._detach_group_from_parent(group) self.apply_member_layouts(group) def ensure_group_boundary_proxies(self, group: VisualGroup) -> None: @@ -1497,8 +1652,12 @@ def remove_manual_boundary_port(self, group_uid: str, boundary_uid: str) -> bool """Remove a manual GroupIn/GroupOut boundary port (undoable).""" return self.remove_boundary_port(group_uid, boundary_uid) - def _compute_group_layout(self, member_uids: list[str]) -> dict[str, float]: - """Compute a bounding layout for group members.""" + def _compute_group_layout( + self, + member_uids: list[str], + child_group_uids: list[str] | None = None, + ) -> dict[str, float]: + """Compute a bounding layout for group members and child groups.""" margin = 16.0 min_x = float("inf") min_y = float("inf") @@ -1520,6 +1679,17 @@ def _compute_group_layout(self, member_uids: list[str]) -> dict[str, float]: max_x = max(max_x, rect.right()) max_y = max(max_y, rect.bottom()) + for child_uid in child_group_uids or []: + item = self.view.group_items.get(child_uid) + if item is None: + continue + found = True + rect = item.sceneBoundingRect() + min_x = min(min_x, rect.left()) + min_y = min(min_y, rect.top()) + max_x = max(max_x, rect.right()) + max_y = max(max_y, rect.bottom()) + if not found: return {"x": 0.0, "y": 0.0, "width": 160.0, "height": 100.0} @@ -1604,7 +1774,9 @@ def _rebuild_group_boundary_ports(self, group: VisualGroup) -> None: } rebuilt_auto: list[BoundaryPort] = [] - for port in self._build_group_boundary_ports(group.members): + for port in self._build_group_boundary_ports( + self._group_content_uids_for_group(group) + ): if port.linked_port_uid in manual_member_keys: continue previous = existing_auto.get(port.linked_port_uid) @@ -1623,7 +1795,7 @@ def _group_needs_boundary_refresh( block_uids: set[str], ) -> bool: """Return whether a group may need boundary ports recomputed.""" - members = set(group.members) + members = set(self._group_content_uids_for_group(group)) if members.intersection(block_uids): return True @@ -1646,7 +1818,7 @@ def _group_needs_boundary_refresh( def _group_has_stale_boundaries(self, group: VisualGroup) -> bool: """Return whether auto boundaries no longer match project connections.""" - members = set(group.members) + members = set(self._group_content_uids_for_group(group)) connection_keys = { self._connection_key(connection) for connection in self.project_state.connections diff --git a/pySimBlocks/gui/services/project_loader.py b/pySimBlocks/gui/services/project_loader.py index 3239afa..a81c156 100644 --- a/pySimBlocks/gui/services/project_loader.py +++ b/pySimBlocks/gui/services/project_loader.py @@ -77,13 +77,16 @@ def load(self, controller: ProjectController, directory: Path): gui_data = project_data.get("gui", {}) layout_blocks, layout_conns, layout_warnings = self._load_layout_data(gui_data) + member_layouts_by_uid = self._collect_group_member_layouts(gui_data) for w in layout_warnings: print(f"[Layout warning] {w}") controller.clear() self._load_simulation(controller, sim_data) - self._load_blocks(controller, diagram_data, layout_blocks) + self._load_blocks( + controller, diagram_data, layout_blocks, member_layouts_by_uid + ) self._load_connections(controller, diagram_data, layout_conns) self._load_logging(controller, sim_data) self._load_plots(controller, sim_data) @@ -109,10 +112,11 @@ def _load_blocks( controller: ProjectController, diagram_data: dict, layout_blocks: dict | None = None, + member_layouts_by_uid: dict[str, dict] | None = None, ): """Create block instances and restore their layout metadata.""" positions, position_warnings = self._compute_block_positions( - diagram_data, layout_blocks + diagram_data, layout_blocks, member_layouts_by_uid ) for w in position_warnings: print(f"[Layout blocks warning] {w}") @@ -137,7 +141,11 @@ def _load_blocks( controller.view.drop_event_pos = positions.get(name, QPointF(0, 0)) block_meta = controller.resolve_block_meta(category, block_type) - block = controller._add_block(BlockInstance(block_meta), block_layout) + block_uid = desc.get("uid") + block_instance = BlockInstance(block_meta) + if isinstance(block_uid, str) and block_uid.strip(): + block_instance.uid = block_uid.strip() + block = controller._add_block(block_instance, block_layout) controller.rename_block(block, name) raw_params = desc.get("parameters", {}) @@ -304,10 +312,28 @@ def _load_layout_data(self, gui_data: dict) -> tuple[dict, dict, list[str]]: return blocks, conns, warnings + def _collect_group_member_layouts(self, gui_data: dict) -> dict[str, dict]: + """Merge member layouts from all visual groups, keyed by block uid.""" + layouts: dict[str, dict] = {} + groups = gui_data.get("groups", []) + if not isinstance(groups, list): + return layouts + for group in groups: + if not isinstance(group, dict): + continue + member_layouts = group.get("member_layouts", {}) + if not isinstance(member_layouts, dict): + continue + for uid, layout in member_layouts.items(): + if isinstance(uid, str) and isinstance(layout, dict): + layouts[uid] = layout + return layouts + def _compute_block_positions( self, diagram_data: dict, layout_blocks: dict | None, + member_layouts_by_uid: dict[str, dict] | None = None, ) -> tuple[dict[str, QPointF], list[str]]: """Compute block positions from saved layout or fallback auto-placement.""" warnings = [] @@ -332,6 +358,18 @@ def _compute_block_positions( continue name = block["name"] + block_uid = block.get("uid") + if ( + isinstance(block_uid, str) + and member_layouts_by_uid + and block_uid in member_layouts_by_uid + ): + entry = member_layouts_by_uid[block_uid] + x_val = entry.get("x") + y_val = entry.get("y") + if isinstance(x_val, (int, float)) and isinstance(y_val, (int, float)): + positions[name] = QPointF(float(x_val), float(y_val)) + continue if layout_blocks and name in layout_blocks: entry = layout_blocks[name] @@ -346,7 +384,11 @@ def _compute_block_positions( ) else: - if layout_blocks is not None: + if layout_blocks is not None and not ( + isinstance(block_uid, str) + and member_layouts_by_uid + and block_uid in member_layouts_by_uid + ): warnings.append( f"Block '{name}' not found in project.yaml gui.layout.blocks, auto-placed." ) diff --git a/pySimBlocks/gui/services/yaml_tools.py b/pySimBlocks/gui/services/yaml_tools.py index 3b49735..d15991f 100644 --- a/pySimBlocks/gui/services/yaml_tools.py +++ b/pySimBlocks/gui/services/yaml_tools.py @@ -202,6 +202,7 @@ def _build_blocks_section(project_state: ProjectState) -> list[dict]: "name": b.name, "category": b.meta.category, "type": b.meta.type, + "uid": b.uid, "parameters": params, } ) diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index b4891ad..91059ef 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -191,36 +191,92 @@ def undo(self) -> None: class GroupBlocksCommand(QUndoCommand): - def __init__(self, controller, blocks: list[BlockInstance], name: str | None = None): + def __init__( + self, + controller, + blocks: list[BlockInstance], + name: str | None = None, + *, + child_group_uids: list[str] | None = None, + parent_uid: str | None = None, + ): super().__init__("Group Blocks") self._controller = controller self._blocks = list(blocks) self._name = name + self._child_group_uids = list(child_group_uids or []) + self._parent_uid = parent_uid self._group_uid: str | None = None self._group_snapshot: dict | None = None + self._child_snapshots_before: dict[str, dict] = {} + self._parent_children_before: list[str] | None = None + self._parent_snapshot_before: dict | None = None def redo(self) -> None: if self._group_snapshot is not None: group = VisualGroup.from_dict(self._group_snapshot) self._controller.project_state.visual_groups.append(group) self._group_uid = group.uid + self._controller.ensure_group_boundary_proxies(group) + self._controller._apply_group_creation_side_effects(group) else: - group = self._controller._create_visual_group(self._blocks, self._name) + for child_uid in self._child_group_uids: + child = self._controller.project_state.get_visual_group(child_uid) + if child is not None: + self._child_snapshots_before[child_uid] = child.to_dict() + if self._parent_uid: + parent = self._controller.project_state.get_visual_group(self._parent_uid) + if parent is not None: + self._parent_children_before = list(parent.child_group_uids) + self._parent_snapshot_before = parent.to_dict() + + group = self._controller._create_visual_group( + self._blocks, + self._name, + child_group_uids=self._child_group_uids, + parent_uid=self._parent_uid, + ) self._group_uid = group.uid self._group_snapshot = group.to_dict() self._controller.view.refresh_visual_groups() self._controller.make_dirty() def undo(self) -> None: - if self._group_uid: - group = self._controller.project_state.get_visual_group(self._group_uid) - if group is not None: - self._group_snapshot = group.to_dict() - if self._group_uid in self._controller.view.view_stack: - self._controller.view.navigate_out_of_group(self._group_uid) - self._controller._remove_visual_group(self._group_uid) - self._controller.view.refresh_visual_groups() - self._controller.make_dirty() + if not self._group_uid: + return + group = self._controller.project_state.get_visual_group(self._group_uid) + if group is not None: + self._group_snapshot = group.to_dict() + self._controller._detach_group_from_parent(group) + if self._group_uid in self._controller.view.view_stack: + self._controller.view.navigate_out_of_group(self._group_uid) + self._controller._remove_visual_group(self._group_uid) + + for child_uid, snapshot in self._child_snapshots_before.items(): + child = VisualGroup.from_dict(snapshot) + replaced = False + for index, existing in enumerate(self._controller.project_state.visual_groups): + if existing.uid == child_uid: + self._controller.project_state.visual_groups[index] = child + replaced = True + break + if not replaced: + self._controller.project_state.visual_groups.append(child) + + if self._parent_uid and self._parent_children_before is not None: + parent = self._controller.project_state.get_visual_group(self._parent_uid) + if parent is not None: + parent.child_group_uids = list(self._parent_children_before) + + if self._parent_snapshot_before is not None: + parent = VisualGroup.from_dict(self._parent_snapshot_before) + for index, existing in enumerate(self._controller.project_state.visual_groups): + if existing.uid == parent.uid: + self._controller.project_state.visual_groups[index] = parent + break + + self._controller.view.refresh_visual_groups() + self._controller.make_dirty() class UngroupCommand(QUndoCommand): diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index c79c4f4..8d3992c 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -163,16 +163,22 @@ def get_selected_block_instances(self) -> list[BlockInstance]: """Return block instances currently selected on the diagram.""" selected = [] for item in self.diagram_scene.selectedItems(): - if isinstance(item, BlockItem): + if isinstance(item, BlockItem) and item.isVisible(): selected.append(item.instance) return selected + def get_selected_group_uids(self) -> list[str]: + """Return UIDs of all selected visible group items.""" + uids: list[str] = [] + for item in self.diagram_scene.selectedItems(): + if isinstance(item, GroupItem) and item.isVisible(): + uids.append(item.group.uid) + return uids + def get_selected_group_uid(self) -> str | None: """Return the UID of a selected group item, if any.""" - for item in self.diagram_scene.selectedItems(): - if isinstance(item, GroupItem): - return item.group.uid - return None + uids = self.get_selected_group_uids() + return uids[0] if uids else None def group_item_for_block_drop(self, block_item: BlockItem) -> GroupItem | None: """Return the group under a block's center, for drag-and-drop membership.""" @@ -270,7 +276,9 @@ def refresh_visual_groups(self) -> None: state = self.project_controller.project_state all_member_uids: set[str] = set() for group in state.visual_groups: - all_member_uids.update(group.members) + all_member_uids.update( + self.project_controller._group_content_uids_for_group(group) + ) active_uid = self.current_view_group_uid active_group = state.get_visual_group(active_uid) if active_uid else None @@ -309,7 +317,10 @@ def refresh_visual_groups(self) -> None: for block_uid, block_item in self.block_items.items(): block_item.setVisible(block_uid not in all_member_uids) for group_uid, group_item in self.group_items.items(): - group_item.setVisible(True) + group = state.get_visual_group(group_uid) + group_item.setVisible( + group is not None and group.parent_uid is None + ) else: member_set = set(active_group.members) child_groups = set(active_group.child_group_uids) @@ -321,16 +332,7 @@ def refresh_visual_groups(self) -> None: for conn_inst, conn_item in self.connections.items(): src_uid = conn_inst.src_block().uid dst_uid = conn_inst.dst_block().uid - - if active_group is None: - src_in = src_uid in all_member_uids - dst_in = dst_uid in all_member_uids - visible = not (src_in and dst_in) - else: - members = set(active_group.members) - src_in = src_uid in members - dst_in = dst_uid in members - visible = src_in and dst_in or (src_in ^ dst_in) + visible = self._connection_visible(active_group, src_uid, dst_uid) conn_item.setVisible(visible) if visible: @@ -444,6 +446,63 @@ def _refresh_group_proxies(self, active_group) -> None: ) item.setVisible(True) + def _child_group_uid_for_block(self, parent_group, block_uid: str) -> str | None: + """Return the direct child group uid that contains ``block_uid``.""" + if self.project_controller is None: + return None + for child_uid in parent_group.child_group_uids: + child = self.project_controller.project_state.get_visual_group(child_uid) + if child is None: + continue + if block_uid in self.project_controller._group_content_uids_for_group(child): + return child_uid + return None + + def _connection_visible( + self, + active_group, + src_uid: str, + dst_uid: str, + ) -> bool: + """Decide whether a connection should be drawn at the current view level.""" + if self.project_controller is None: + return True + + if active_group is None: + src_group = self.project_controller._group_exposing_boundary_for_block( + src_uid, None + ) + dst_group = self.project_controller._group_exposing_boundary_for_block( + dst_uid, None + ) + if src_group is not None and dst_group is not None: + return src_group.uid != dst_group.uid + return True + + members = set(active_group.members) + src_in = src_uid in members + dst_in = dst_uid in members + + if active_group.child_group_uids: + content_uids = set( + self.project_controller._group_content_uids_for_group(active_group) + ) + src_in_content = src_uid in content_uids + dst_in_content = dst_uid in content_uids + + if not src_in_content or not dst_in_content: + return src_in_content != dst_in_content + + src_child = self._child_group_uid_for_block(active_group, src_uid) + dst_child = self._child_group_uid_for_block(active_group, dst_uid) + if src_child != dst_child: + return True + if src_child is None: + return src_uid in members and dst_uid in members + return False + + return (src_uid in members and dst_uid in members) or (src_in ^ dst_in) + def connection_anchor_for_port_item(self, port_item: PortItem) -> QPointF: """Return the scene anchor for a port, redirecting through group borders when collapsed.""" block_uid = port_item.instance.block.uid @@ -453,6 +512,19 @@ def connection_anchor_for_port_item(self, port_item: PortItem) -> QPointF: if active_uid and self.project_controller is not None: group = self.project_controller.project_state.get_visual_group(active_uid) if group is not None: + if group.child_group_uids: + child_uid = self._child_group_uid_for_block(group, block_uid) + if child_uid is not None: + child_item = self.group_items.get(child_uid) + if child_item is not None and child_item.isVisible(): + boundary_uid = child_item.find_boundary_for_member_port( + block_uid, port_name + ) + if boundary_uid is not None: + anchor = child_item.get_boundary_anchor(boundary_uid) + if anchor is not None: + return anchor + member_anchor = self._manual_proxy_anchor_for_member_port(group, port_item) if member_anchor is not None: return member_anchor @@ -462,7 +534,13 @@ def connection_anchor_for_port_item(self, port_item: PortItem) -> QPointF: return port_item.connection_anchor() for group_item in self.group_items.values(): - if block_uid not in group_item.group.members: + if not group_item.isVisible(): + continue + exposing_group = self.project_controller._group_exposing_boundary_for_block( + block_uid, + self.current_view_group_uid, + ) + if exposing_group is None or exposing_group.uid != group_item.group.uid: continue boundary_uid = group_item.find_boundary_for_member_port(block_uid, port_name) if boundary_uid is None: @@ -492,7 +570,7 @@ def _proxy_anchor_for_external_port(self, group, port_item: PortItem) -> QPointF if self.project_controller is None: return None - members = set(group.members) + members = set(self.project_controller._group_content_uids_for_group(group)) port_instance = port_item.instance for connection in self.project_controller.project_state.connections: if connection.src_port is not port_instance and connection.dst_port is not port_instance: @@ -854,15 +932,17 @@ def contextMenuEvent(self, event) -> None: menu = QMenu(self) selected_blocks = self.get_selected_block_instances() - selected_group_uid = self.get_selected_group_uid() + selected_group_uids = self.get_selected_group_uids() clicked_group = self._group_item_at(event) clicked_block = self._block_item_at(event) target_group_uid = ( - clicked_group.group.uid if clicked_group is not None else selected_group_uid + clicked_group.group.uid if clicked_group is not None else ( + selected_group_uids[0] if selected_group_uids else None + ) ) group_action = menu.addAction("Group") - group_action.setEnabled(len(selected_blocks) >= 2) + group_action.setEnabled(len(selected_blocks) + len(selected_group_uids) >= 2) group_action.triggered.connect( lambda *_args: self.project_controller.group_selected_blocks() ) From 996713f2381087f6045447ba4b15193a030a1237 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Fri, 19 Jun 2026 16:30:10 +0200 Subject: [PATCH 25/42] fix(gui): refresh group proxies before routing wires on view change --- pySimBlocks/gui/widgets/diagram_view.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 8d3992c..3950608 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -329,6 +329,9 @@ def refresh_visual_groups(self) -> None: for group_uid, group_item in self.group_items.items(): group_item.setVisible(group_uid in child_groups) + self._refresh_group_proxies(active_group) + self._refresh_group_port_labels() + for conn_inst, conn_item in self.connections.items(): src_uid = conn_inst.src_block().uid dst_uid = conn_inst.dst_block().uid @@ -338,8 +341,6 @@ def refresh_visual_groups(self) -> None: if visible: conn_item.update_position() - self._refresh_group_proxies(active_group) - self._refresh_group_port_labels() self.refresh_manual_boundary_wires() def refresh_manual_boundary_wires(self) -> None: From dc9ffb9f13b350470ac2f50301d95d12ca5544e8 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Mon, 22 Jun 2026 11:06:54 +0200 Subject: [PATCH 26/42] fix(gui): restore delete group with recursive removal of nested content --- pySimBlocks/gui/project_controller.py | 41 +++++++++++++++ pySimBlocks/gui/undo_redo/commands.py | 67 ++++++++++++++++++++++++- pySimBlocks/gui/widgets/diagram_view.py | 2 +- 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 47d28d8..bd51f41 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -77,6 +77,7 @@ PasteClipboardCommand, ToggleOrientationCommand, UngroupCommand, + DeleteGroupCommand, WireManualBoundaryCommand, ConnectionSnapshot, ) @@ -293,6 +294,13 @@ def ungroup(self, group_uid: str) -> bool: self.undo_manager.push(UngroupCommand(self, group_uid)) return True + def delete_group(self, group_uid: str) -> bool: + """Delete a visual group and all nested content (undoable).""" + if self.project_state.get_visual_group(group_uid) is None: + return False + self.undo_manager.push(DeleteGroupCommand(self, group_uid)) + return True + def group_selected_blocks(self) -> VisualGroup | None: """Group the current diagram selection of blocks and/or groups.""" blocks = self.view.get_selected_block_instances() @@ -1318,6 +1326,39 @@ def _remove_visual_group(self, group_uid: str) -> bool: """Remove a visual group without pushing undo.""" return self.project_state.remove_visual_group(group_uid) + def _collect_group_delete_targets( + self, group_uid: str + ) -> tuple[list[str], list[str]]: + """Return nested group uids (children first) and all member block uids.""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return [], [] + group_uids: list[str] = [] + block_uids: list[str] = [] + for child_uid in group.child_group_uids: + child_groups, child_blocks = self._collect_group_delete_targets(child_uid) + group_uids.extend(child_groups) + block_uids.extend(child_blocks) + block_uids.extend(group.members) + group_uids.append(group_uid) + return group_uids, block_uids + + def _delete_group(self, group_uid: str) -> None: + """Delete a visual group, its descendants, and all member blocks.""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return + if group_uid in self.view.view_stack: + self.view.navigate_out_of_group(group_uid) + for child_uid in list(group.child_group_uids): + self._delete_group(child_uid) + for member_uid in list(group.members): + block = self._find_block_by_uid(member_uid) + if block is not None: + self._remove_block(block) + self._remove_visual_group(group_uid) + self.view.refresh_visual_groups() + def _group_containing_member(self, block_uid: str) -> VisualGroup | None: """Return the visual group that lists ``block_uid`` as a member.""" for group in self.project_state.visual_groups: diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index 91059ef..72d3926 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -301,8 +301,71 @@ def undo(self) -> None: if self._group_snapshot: group = VisualGroup.from_dict(self._group_snapshot) self._controller.project_state.visual_groups.append(group) - self._controller.view.refresh_visual_groups() - self._controller.make_dirty() + self._controller.view.refresh_visual_groups() + self._controller.make_dirty() + + +class DeleteGroupCommand(QUndoCommand): + def __init__(self, controller, group_uid: str): + super().__init__("Delete Group") + self._controller = controller + self._root_group_uid = group_uid + self._group_snapshots: dict[str, dict] = {} + self._group_uids_order: list[str] = [] + self._members: list[tuple[BlockInstance, dict]] = [] + self._connections: list[ConnectionSnapshot] = [] + + group_uids, block_uids = controller._collect_group_delete_targets(group_uid) + self._group_uids_order = group_uids + for uid in group_uids: + group = controller.project_state.get_visual_group(uid) + if group is not None: + self._group_snapshots[uid] = group.to_dict() + + seen_blocks: set[str] = set() + seen_connections: set[int] = set() + for member_uid in block_uids: + if member_uid in seen_blocks: + continue + seen_blocks.add(member_uid) + block = controller._find_block_by_uid(member_uid) + if block is None: + continue + self._members.append( + (block, controller._capture_block_layout(block)) + ) + for connection in controller.project_state.get_connections_of_block(block): + conn_id = id(connection) + if conn_id in seen_connections: + continue + seen_connections.add(conn_id) + self._connections.append( + controller._capture_connection_snapshot(connection) + ) + + self._logging_before = list(controller.project_state.logging) + self._plots_before = copy.deepcopy(controller.project_state.plots) + + def redo(self) -> None: + self._controller._delete_group(self._root_group_uid) + self._controller.make_dirty() + + def undo(self) -> None: + for block, layout in self._members: + self._controller._add_block(block, 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 = copy.deepcopy(self._plots_before) + for uid in self._group_uids_order: + snapshot = self._group_snapshots.get(uid) + if snapshot is not None: + self._controller._apply_group_snapshot(snapshot, uid) + for uid in self._group_uids_order: + group = self._controller.project_state.get_visual_group(uid) + if group is not None: + self._controller.apply_member_layouts(group) + self._controller.make_dirty() class AddToGroupCommand(QUndoCommand): diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 3950608..409990b 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -1016,7 +1016,7 @@ def delete_selected(self) -> None: self.project_controller.remove_boundary_port(*boundary_key) continue if isinstance(item, GroupItem): - self.project_controller.ungroup(item.group.uid) + self.project_controller.delete_group(item.group.uid) elif isinstance(item, BlockItem): self.project_controller.remove_block(item.instance) elif isinstance(item, ConnectionItem): From 5f48de14777e92ad56b547baccf86e061e1e018c Mon Sep 17 00:00:00 2001 From: Lukasik Date: Mon, 22 Jun 2026 11:26:18 +0200 Subject: [PATCH 27/42] feat(gui): generalize copy-paste for blocks and nested visual groups --- pySimBlocks/gui/diagram_clipboard.py | 306 +++++++++++++++++++++------ 1 file changed, 244 insertions(+), 62 deletions(-) diff --git a/pySimBlocks/gui/diagram_clipboard.py b/pySimBlocks/gui/diagram_clipboard.py index be0c46c..1286fe1 100644 --- a/pySimBlocks/gui/diagram_clipboard.py +++ b/pySimBlocks/gui/diagram_clipboard.py @@ -39,7 +39,8 @@ class DiagramClipboard: blocks: list[ClipboardBlock] = field(default_factory=list) connections: list[ConnectionSnapshot] = field(default_factory=list) - group: dict[str, Any] | None = None + groups: list[dict[str, Any]] = field(default_factory=list) + root_group_uids: list[str] = field(default_factory=list) anchor_x: float = 0.0 anchor_y: float = 0.0 @@ -69,6 +70,73 @@ def _offset_layout(layout: dict[str, Any], dx: float, dy: float) -> dict[str, An return out +def _collect_subgroup_uids_postorder( + controller: ProjectController, + group_uid: str, +) -> list[str]: + """Return group uids in a subtree, children before parents.""" + group = controller.project_state.get_visual_group(group_uid) + if group is None: + return [] + ordered: list[str] = [] + for child_uid in group.child_group_uids: + ordered.extend(_collect_subgroup_uids_postorder(controller, child_uid)) + ordered.append(group_uid) + return ordered + + +def _root_groups_in_selection( + controller: ProjectController, + group_uids: set[str], +) -> list[str]: + """Return selected groups whose parent is outside the selection.""" + roots: list[str] = [] + for uid in group_uids: + group = controller.project_state.get_visual_group(uid) + if group is None: + continue + if group.parent_uid not in group_uids: + roots.append(uid) + return roots + + +def _ordered_group_uids( + controller: ProjectController, + root_group_uids: list[str], +) -> list[str]: + """Collect subtrees from several roots without duplicates (post-order).""" + ordered: list[str] = [] + seen: set[str] = set() + for root_uid in root_group_uids: + for group_uid in _collect_subgroup_uids_postorder(controller, root_uid): + if group_uid in seen: + continue + ordered.append(group_uid) + seen.add(group_uid) + return ordered + + +def _capture_internal_connections( + controller: ProjectController, + member_uids: set[str], +) -> list[ConnectionSnapshot]: + connections: list[ConnectionSnapshot] = [] + for connection in controller.project_state.connections: + src_uid = connection.src_block().uid + dst_uid = connection.dst_block().uid + if src_uid in member_uids and dst_uid in member_uids: + connections.append(controller._capture_connection_snapshot(connection)) + return connections + + +def _block_uids_in_group_templates(templates: list[dict[str, Any]]) -> set[str]: + return { + uid + for template in templates + for uid in template.get("members", []) + } + + def capture_blocks_clipboard( controller: ProjectController, block_items: list[BlockItem], @@ -96,74 +164,149 @@ def capture_blocks_clipboard( ) ) - connections: list[ConnectionSnapshot] = [] - for connection in controller.project_state.connections: - src_uid = connection.src_block().uid - dst_uid = connection.dst_block().uid - if src_uid in member_uids and dst_uid in member_uids: - connections.append(controller._capture_connection_snapshot(connection)) - anchor_x, anchor_y = _layout_anchor(layouts) return DiagramClipboard( blocks=blocks, - connections=connections, + connections=_capture_internal_connections(controller, member_uids), anchor_x=anchor_x, anchor_y=anchor_y, ) -def capture_group_clipboard( +def capture_groups_clipboard( controller: ProjectController, - group: VisualGroup, + selected_group_uids: list[str], ) -> DiagramClipboard | None: - """Capture a visual group, its members, and internal connections.""" - block_items: list[BlockItem] = [] - layouts: list[dict[str, Any]] = [] + """Capture one or more visual groups with nested children and internal wiring.""" + if not selected_group_uids: + return None + + selected_set = set(selected_group_uids) + root_group_uids = _root_groups_in_selection(controller, selected_set) + ordered_group_uids = _ordered_group_uids(controller, root_group_uids) + if not ordered_group_uids: + return None + blocks: list[ClipboardBlock] = [] + seen_block_uids: set[str] = set() + anchor_layouts: list[dict[str, Any]] = [] - for member_uid in group.members: - block = controller._find_block_by_uid(member_uid) - if block is None: + for group_uid in ordered_group_uids: + group = controller.project_state.get_visual_group(group_uid) + if group is None: continue - layout = dict(group.member_layouts.get(member_uid, {})) - if not layout: - item = controller.view.get_block_item_from_instance(block) - if item is not None: + for member_uid in group.members: + if member_uid in seen_block_uids: + continue + block = controller._find_block_by_uid(member_uid) + if block is None: + continue + layout = dict(group.member_layouts.get(member_uid, {})) + if not layout: layout = controller._capture_block_layout(block) - layouts.append(layout) - blocks.append( - ClipboardBlock( - source_uid=block.uid, - category=block.meta.category, - block_type=block.meta.type, - name=block.name, - parameters=block.parameters.copy(), - layout=layout, + seen_block_uids.add(member_uid) + blocks.append( + ClipboardBlock( + source_uid=block.uid, + category=block.meta.category, + block_type=block.meta.type, + name=block.name, + parameters=block.parameters.copy(), + layout=layout, + ) ) - ) - item = controller.view.get_block_item_from_instance(block) - if item is not None: - block_items.append(item) - if not blocks: + for root_uid in root_group_uids: + root = controller.project_state.get_visual_group(root_uid) + if root is None: + continue + if root.layout: + anchor_layouts.append(dict(root.layout)) + else: + member_layouts = [ + dict(root.member_layouts.get(uid, {})) + for uid in root.members + if uid in root.member_layouts + ] + if member_layouts: + anchor_layouts.extend(member_layouts) + + if not blocks and not ordered_group_uids: return None - member_uids = {block.source_uid for block in blocks} - connections: list[ConnectionSnapshot] = [] - for connection in controller.project_state.connections: - src_uid = connection.src_block().uid - dst_uid = connection.dst_block().uid - if src_uid in member_uids and dst_uid in member_uids: - connections.append(controller._capture_connection_snapshot(connection)) - - group_layout = group.layout or {} - anchor_x = float(group_layout.get("x", _layout_anchor(layouts)[0])) - anchor_y = float(group_layout.get("y", _layout_anchor(layouts)[1])) + anchor_x, anchor_y = _layout_anchor(anchor_layouts) + groups = [ + copy.deepcopy(controller.project_state.get_visual_group(group_uid).to_dict()) + for group_uid in ordered_group_uids + if controller.project_state.get_visual_group(group_uid) is not None + ] return DiagramClipboard( blocks=blocks, - connections=connections, - group=copy.deepcopy(group.to_dict()), + connections=_capture_internal_connections(controller, seen_block_uids), + groups=groups, + root_group_uids=root_group_uids, + anchor_x=anchor_x, + anchor_y=anchor_y, + ) + + +def capture_group_clipboard( + controller: ProjectController, + group: VisualGroup, +) -> DiagramClipboard | None: + """Capture a visual group, its descendants, and internal connections.""" + return capture_groups_clipboard(controller, [group.uid]) + + +def _capture_mixed_selection_clipboard( + controller: ProjectController, + selected_group_uids: list[str], + block_items: list[BlockItem], +) -> DiagramClipboard | None: + """Capture selected groups (full subtrees) plus standalone blocks.""" + group_clipboard = capture_groups_clipboard(controller, selected_group_uids) + selected_set = set(selected_group_uids) + grouped_block_uids: set[str] = set() + for group_uid in selected_set: + group = controller.project_state.get_visual_group(group_uid) + if group is None: + continue + grouped_block_uids.update(controller._group_content_uids_for_group(group)) + + standalone_items = [ + item for item in block_items if item.instance.uid not in grouped_block_uids + ] + if group_clipboard is None and not standalone_items: + return None + if group_clipboard is None: + return capture_blocks_clipboard(controller, standalone_items) + + if not standalone_items: + return group_clipboard + + block_clipboard = capture_blocks_clipboard(controller, standalone_items) + if block_clipboard is None: + return group_clipboard + + all_member_uids = { + block.source_uid for block in group_clipboard.blocks + block_clipboard.blocks + } + anchor_layouts = [ + dict(block.layout) + for block in block_clipboard.blocks + ] + for root_uid in group_clipboard.root_group_uids: + root = controller.project_state.get_visual_group(root_uid) + if root is not None and root.layout: + anchor_layouts.append(dict(root.layout)) + + anchor_x, anchor_y = _layout_anchor(anchor_layouts) + return DiagramClipboard( + blocks=group_clipboard.blocks + block_clipboard.blocks, + connections=_capture_internal_connections(controller, all_member_uids), + groups=group_clipboard.groups, + root_group_uids=group_clipboard.root_group_uids, anchor_x=anchor_x, anchor_y=anchor_y, ) @@ -178,8 +321,14 @@ def capture_selection_clipboard(controller: ProjectController) -> DiagramClipboa groups = [item for item in selected if isinstance(item, GroupItem)] blocks = [item for item in selected if isinstance(item, BlockItem)] - if len(groups) == 1 and not blocks: - return capture_group_clipboard(controller, groups[0].group) + if groups and blocks: + return _capture_mixed_selection_clipboard( + controller, + [item.group.uid for item in groups], + blocks, + ) + if groups: + return capture_groups_clipboard(controller, [item.group.uid for item in groups]) if blocks: return capture_blocks_clipboard(controller, blocks) return None @@ -209,15 +358,18 @@ def _duplicate_group( controller: ProjectController, template: dict[str, Any], uid_map: dict[str, str], + gid_map: dict[str, str], dx: float, dy: float, - parent_uid: str | None, ) -> VisualGroup: group = VisualGroup.from_dict(copy.deepcopy(template)) group.uid = uuid.uuid4().hex group.name = controller._make_unique_group_name(group.name) - group.parent_uid = parent_uid + group.parent_uid = None group.members = [uid_map[uid] for uid in template.get("members", []) if uid in uid_map] + group.child_group_uids = [ + gid_map[uid] for uid in template.get("child_group_uids", []) if uid in gid_map + ] member_layouts: dict[str, dict[str, Any]] = {} for old_uid, layout in (template.get("member_layouts") or {}).items(): @@ -264,54 +416,84 @@ def paste_clipboard( ) -> PasteResult: """Paste clipboard contents at ``origin`` without pushing undo.""" result = PasteResult() - if not clipboard.blocks: + if not clipboard.blocks and not clipboard.groups: return result dx = float(origin.x()) - clipboard.anchor_x dy = float(origin.y()) - clipboard.anchor_y uid_map: dict[str, str] = {} + has_groups = bool(clipboard.groups) + grouped_source_uids = _block_uids_in_group_templates(clipboard.groups) for block_data in clipboard.blocks: meta = controller.resolve_block_meta(block_data.category, block_data.block_type) block = BlockInstance(meta) block.parameters = block_data.parameters.copy() block.name = block_data.name - if clipboard.group is not None: + if has_groups and block_data.source_uid in grouped_source_uids: layout = dict(block_data.layout) else: layout = _offset_layout(block_data.layout, dx, dy) created = controller._add_block(block, layout) uid_map[block_data.source_uid] = created.uid result.blocks.append(created) - if parent_group_uid is not None and clipboard.group is None: + if ( + parent_group_uid is not None + and (not has_groups or block_data.source_uid not in grouped_source_uids) + ): controller._add_member_to_group(parent_group_uid, created.uid, layout) + connection_offset_dx = 0.0 if has_groups else dx + connection_offset_dy = 0.0 if has_groups else dy for snapshot in clipboard.connections: - remapped = _remap_connection(snapshot, uid_map, dx, dy) + remapped = _remap_connection( + snapshot, + uid_map, + connection_offset_dx, + connection_offset_dy, + ) if remapped is None: continue connection = controller._add_connection_from_snapshot(remapped) if connection is not None: result.connections.append(connection) - if clipboard.group is not None: + gid_map: dict[str, str] = {} + created_groups: list[tuple[dict[str, Any], VisualGroup]] = [] + for template in clipboard.groups: + old_uid = str(template["uid"]) + root_offset_dx = dx if old_uid in clipboard.root_group_uids else 0.0 + root_offset_dy = dy if old_uid in clipboard.root_group_uids else 0.0 group = _duplicate_group( controller, - clipboard.group, + template, uid_map, - dx, - dy, - parent_group_uid, + gid_map, + root_offset_dx, + root_offset_dy, ) + gid_map[old_uid] = group.uid + created_groups.append((template, group)) result.group_uids.append(group.uid) + for template, group in created_groups: + old_uid = str(template["uid"]) + parent_old = template.get("parent_uid") + if parent_old and parent_old in gid_map: + group.parent_uid = gid_map[str(parent_old)] + controller._attach_group_to_parent(group) + elif old_uid in clipboard.root_group_uids: + group.parent_uid = parent_group_uid + if parent_group_uid is not None: + controller._attach_group_to_parent(group) + controller.view.refresh_visual_groups() return result def undo_paste(controller: ProjectController, result: PasteResult) -> None: """Remove entities created by a paste operation.""" - for group_uid in result.group_uids: + for group_uid in reversed(result.group_uids): if group_uid in controller.view.view_stack: controller.view.navigate_out_of_group(group_uid) controller._remove_visual_group(group_uid) From ac31e3cc2c224ca05d56e16e0d8c763c65597801 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Tue, 23 Jun 2026 11:56:02 +0200 Subject: [PATCH 28/42] fix(gui): preserve group boundaries on wire delete and reconnect across groups --- pySimBlocks/gui/graphics/group_item.py | 46 ++- .../gui/graphics/manual_boundary_wire_item.py | 23 +- pySimBlocks/gui/project_controller.py | 300 +++++++++++++++++- pySimBlocks/gui/undo_redo/commands.py | 8 +- pySimBlocks/gui/widgets/diagram_view.py | 24 +- 5 files changed, 388 insertions(+), 13 deletions(-) diff --git a/pySimBlocks/gui/graphics/group_item.py b/pySimBlocks/gui/graphics/group_item.py index cd622ec..65a2d53 100644 --- a/pySimBlocks/gui/graphics/group_item.py +++ b/pySimBlocks/gui/graphics/group_item.py @@ -156,14 +156,47 @@ def __init__(self, group: VisualGroup, view: "DiagramView"): self.setZValue(-1) self.sync_boundary_ports() + def _border_boundary_ports(self) -> list[BoundaryPort]: + """Return boundary ports that should appear on the group rectangle.""" + return list(self.group.boundary_ports) + + def boundary_anchor_for(self, boundary: BoundaryPort) -> QPointF: + """Return the scene anchor for a boundary port on this group.""" + port_item = self.boundary_port_items.get(boundary.uid) + if port_item is not None: + return port_item.connection_anchor() + return self._boundary_anchor_from_geometry(boundary) + + def _boundary_anchor_from_geometry(self, boundary: BoundaryPort) -> QPointF: + """Compute a boundary anchor when no border port item is shown.""" + rect = self.rect() + same_direction = [ + port + for port in self._border_boundary_ports() + if port.direction == boundary.direction + ] + if boundary not in same_direction: + same_direction = [ + port for port in self.group.boundary_ports if port.direction == boundary.direction + ] + index = same_direction.index(boundary) if boundary in same_direction else 0 + total = len(same_direction) + y = rect.height() * (index + 1) / (total + 1) + if boundary.direction == "input": + local = QPointF(-GroupBoundaryPortItem.R, y) + else: + local = QPointF(rect.width() + GroupBoundaryPortItem.L, y) + return self.mapToScene(local) + def sync_boundary_ports(self) -> None: """Rebuild boundary port items from group metadata.""" for item in list(self.boundary_port_items.values()): item.setParentItem(None) self.boundary_port_items.clear() - inputs = [p for p in self.group.boundary_ports if p.direction == "input"] - outputs = [p for p in self.group.boundary_ports if p.direction == "output"] + border_ports = self._border_boundary_ports() + inputs = [p for p in border_ports if p.direction == "input"] + outputs = [p for p in border_ports if p.direction == "output"] rect = self.rect() for index, boundary in enumerate(inputs): @@ -186,10 +219,13 @@ def refresh_boundary_port_labels(self) -> None: item.update_port_label() def get_boundary_anchor(self, boundary_uid: str) -> QPointF | None: - port_item = self.boundary_port_items.get(boundary_uid) - if port_item is None: + boundary = next( + (port for port in self.group.boundary_ports if port.uid == boundary_uid), + None, + ) + if boundary is None: return None - return port_item.connection_anchor() + return self.boundary_anchor_for(boundary) def find_boundary_for_member_port(self, block_uid: str, port_name: str) -> str | None: key = f"{block_uid}:{port_name}" diff --git a/pySimBlocks/gui/graphics/manual_boundary_wire_item.py b/pySimBlocks/gui/graphics/manual_boundary_wire_item.py index 66598bd..d06a9d7 100644 --- a/pySimBlocks/gui/graphics/manual_boundary_wire_item.py +++ b/pySimBlocks/gui/graphics/manual_boundary_wire_item.py @@ -6,20 +6,34 @@ from __future__ import annotations from PySide6.QtCore import QPointF, Qt -from PySide6.QtGui import QPainterPath, QPen +from PySide6.QtGui import QPainterPath, QPainterPathStroker, QPen from PySide6.QtWidgets import QGraphicsPathItem class ManualBoundaryWireItem(QGraphicsPathItem): """Visual-only wire for an incomplete manual group boundary.""" - def __init__(self, view, src_anchor, dst_anchor): + def __init__( + self, + view, + src_anchor, + dst_anchor, + *, + group_uid: str, + boundary_uid: str, + side: str, + ): super().__init__() self._view = view self._src_anchor = src_anchor self._dst_anchor = dst_anchor + self.group_uid = group_uid + self.boundary_uid = boundary_uid + self.side = side self.setPen(QPen(view.theme.wire, 2, Qt.DashLine)) self.setZValue(1) + self.setFlag(QGraphicsPathItem.ItemIsSelectable, True) + self.setAcceptedMouseButtons(Qt.LeftButton) self.update_position() def update_position(self) -> None: @@ -32,3 +46,8 @@ def update_position(self) -> None: path.lineTo(QPointF(mid.x(), p2.y())) path.lineTo(p2) self.setPath(path) + + def shape(self): + stroker = QPainterPathStroker() + stroker.setWidth(12) + return stroker.createStroke(self.path()) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index bd51f41..f2a6072 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -20,8 +20,9 @@ from __future__ import annotations -from pathlib import Path +import copy import uuid +from pathlib import Path from typing import TYPE_CHECKING, Any, Callable from PySide6.QtCore import QObject, Signal, QPointF, QRectF @@ -364,11 +365,22 @@ def remove_block_from_group(self, group_uid: str, block_uid: str) -> bool: return True def try_wire_boundary_endpoints(self, src, dst) -> bool: - """Wire a manual boundary from a proxy or group port to a block port.""" + """Wire group boundaries, proxies, or group-to-group borders.""" from pySimBlocks.gui.graphics.group_item import GroupBoundaryPortItem from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyPortItem from pySimBlocks.gui.graphics.port_item import PortItem + boundary_ports = [ + endpoint + for endpoint in (src, dst) + if isinstance(endpoint, GroupBoundaryPortItem) + ] + if len(boundary_ports) == 2: + return self._wire_group_boundaries_together( + boundary_ports[0], + boundary_ports[1], + ) + proxy_port = None boundary_port = None block_port_item = None @@ -403,6 +415,42 @@ def try_wire_boundary_endpoints(self, src, dst) -> bool: ) return False + def _wire_group_boundaries_together( + self, + port_a, + port_b, + ) -> bool: + """Connect two group border ports across different groups.""" + group_a = port_a.parent_group.group + group_b = port_b.parent_group.group + if group_a.uid == group_b.uid: + return False + + boundary_a = port_a.boundary + boundary_b = port_b.boundary + member_a = find_port(self.project_state, boundary_a.linked_port_uid) + member_b = find_port(self.project_state, boundary_b.linked_port_uid) + if member_a is None or member_b is None: + return False + + if boundary_a.direction == "output" and boundary_b.direction == "input": + src_port, dst_port = member_a, member_b + elif boundary_a.direction == "input" and boundary_b.direction == "output": + src_port, dst_port = member_b, member_a + else: + return False + + if not src_port.is_compatible(dst_port): + return False + dst_connections = self.project_state.get_connections_of_port(dst_port) + if not dst_port.can_accept_connection(dst_connections): + return False + + from pySimBlocks.gui.undo_redo.commands import AddConnectionCommand + + self.undo_manager.push(AddConnectionCommand(self, src_port, dst_port, None)) + return True + def _wire_manual_boundary_internal( self, group_uid: str, @@ -641,6 +689,93 @@ def is_name_available(self, name: str, current=None) -> bool: # Connection methods # -------------------------------------------------------------------------- + def try_connect_boundary_ports( + self, + port1: PortInstance, + port2: PortInstance, + ) -> bool: + """Complete or wire a manual group boundary instead of a direct connection.""" + if port1 is port2: + return False + + src_group = self._group_containing_member(port1.block.uid) + dst_group = self._group_containing_member(port2.block.uid) + if ( + src_group is not None + and dst_group is not None + and src_group.uid != dst_group.uid + ): + return False + + for group in self.project_state.visual_groups: + members = set(self._group_content_uids_for_group(group)) + for boundary in group.boundary_ports: + if boundary.origin != "manual" or boundary.linked_connection_uid: + continue + + internal = ( + find_port(self.project_state, boundary.linked_port_uid) + if boundary.linked_port_uid + else None + ) + external = ( + find_port(self.project_state, boundary.external_port_uid) + if boundary.external_port_uid + else None + ) + ports = {port1, port2} + + if ( + internal is not None + and external is not None + and ports == {internal, external} + ): + return self._complete_manual_boundary(group.uid, boundary.uid) + + if internal is not None and internal in ports: + external_candidate = port2 if port1 is internal else port1 + if validate_external_link(group, boundary, external_candidate): + return self._wire_manual_boundary_external( + group.uid, + boundary.uid, + external_candidate, + ) + + if external is not None and external in ports: + internal_candidate = port2 if port1 is external else port1 + if validate_internal_link(group, boundary, internal_candidate): + return self._wire_manual_boundary_internal( + group.uid, + boundary.uid, + internal_candidate, + ) + return False + + def _complete_manual_boundary(self, group_uid: str, boundary_uid: str) -> bool: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None or not can_complete(self.project_state, boundary): + return False + before = self._capture_boundary_wire_snapshot(group_uid, boundary_uid) + wiring = capture_wiring_state(boundary) + connection_snapshot = self._connection_snapshot_for_wiring( + group, boundary, wiring + ) + if connection_snapshot is None: + return False + self.undo_manager.push( + WireManualBoundaryCommand( + self, + group_uid, + boundary_uid, + before, + (wiring, connection_snapshot), + ) + ) + return True + def add_connection( self, port1: PortInstance, @@ -658,6 +793,8 @@ def add_connection( port2: Second port (input or output). points: Optional list of intermediate waypoints for the wire. """ + if self.try_connect_boundary_ports(port1, port2): + return if not port1.is_compatible(port2): return src_port, dst_port = ( @@ -1046,11 +1183,120 @@ def _remove_connection( refresh_boundaries: bool = True, ) -> None: block_uids = {connection.src_block().uid, connection.dst_block().uid} + self._preserve_boundaries_on_connection_remove(connection) self.project_state.remove_connection(connection) self.view.remove_connection(connection) if refresh_boundaries: self._refresh_boundaries_for_member_uids(block_uids) + def _capture_boundaries_for_connection( + self, + connection: ConnectionInstance, + ) -> list[tuple[str, dict[str, Any]]]: + """Snapshot group boundaries tied to a diagram connection (for undo).""" + key = self._connection_key(connection) + captured: list[tuple[str, dict[str, Any]]] = [] + seen: set[tuple[str, str]] = set() + for group in self.project_state.visual_groups: + for boundary in group.boundary_ports: + linked = find_connection_for_boundary(self.project_state, boundary) + if linked is not connection and boundary.linked_connection_uid != key: + continue + item = (group.uid, boundary.uid) + if item in seen: + continue + seen.add(item) + captured.append((group.uid, copy.deepcopy(boundary.to_dict()))) + return captured + + def _preserve_boundaries_on_connection_remove( + self, + connection: ConnectionInstance, + ) -> None: + """Keep boundary proxies wired but mark affected boundaries incomplete.""" + key = self._connection_key(connection) + src_uid = connection.src_block().uid + dst_uid = connection.dst_block().uid + changed = False + + for group in self.project_state.visual_groups: + members = set(self._group_content_uids_for_group(group)) + src_in = src_uid in members + dst_in = dst_uid in members + if src_in == dst_in: + continue + + internal_port = connection.dst_port if dst_in else connection.src_port + external_port = connection.src_port if dst_in else connection.dst_port + internal_key = port_key(internal_port) + external_key = port_key(external_port) + + boundary = next( + ( + port + for port in group.boundary_ports + if port.linked_connection_uid == key + or ( + port.linked_port_uid == internal_key + and find_connection_for_boundary(self.project_state, port) + is connection + ) + ), + None, + ) + if boundary is None: + continue + + external_group = self._group_containing_member(external_port.block.uid) + is_cross_group = ( + external_group is not None and external_group.uid != group.uid + ) + + if is_cross_group: + boundary.linked_connection_uid = "" + if boundary.origin == "manual" and boundary.external_port_uid == external_key: + boundary.external_port_uid = "" + changed = True + continue + + if boundary.origin == "manual": + boundary.linked_connection_uid = "" + if not boundary.linked_port_uid: + boundary.linked_port_uid = internal_key + if not boundary.external_port_uid: + boundary.external_port_uid = external_key + else: + boundary.origin = "manual" + boundary.linked_port_uid = internal_key + boundary.external_port_uid = external_key + boundary.linked_connection_uid = "" + + self.ensure_group_boundary_proxies(group) + changed = True + + if changed: + self.view.refresh_visual_groups() + + def _restore_boundary_snapshots( + self, + snapshots: list[tuple[str, dict[str, Any]]], + ) -> None: + for group_uid, boundary_dict in snapshots: + group = self.project_state.get_visual_group(group_uid) + if group is None: + continue + restored = BoundaryPort.from_dict(boundary_dict) + replaced = False + for index, boundary in enumerate(group.boundary_ports): + if boundary.uid == restored.uid: + group.boundary_ports[index] = restored + replaced = True + break + if not replaced: + group.boundary_ports.append(restored) + self.ensure_group_boundary_proxies(group) + self.view.refresh_visual_groups() + def _find_block_by_uid(self, block_uid: str) -> BlockInstance | None: for block in self.project_state.blocks: if block.uid == block_uid: @@ -1689,6 +1935,44 @@ def remove_boundary_port(self, group_uid: str, boundary_uid: str) -> bool: ) return True + def disconnect_manual_boundary_side( + self, + group_uid: str, + boundary_uid: str, + side: str, + ) -> bool: + """Disconnect one side of an incomplete manual boundary wire (undoable).""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None or boundary.origin != "manual": + return False + if side not in ("internal", "external"): + return False + + before = self._capture_boundary_wire_snapshot(group_uid, boundary_uid) + after_wiring = capture_wiring_state(boundary) + if side == "internal": + if not after_wiring.linked_port_uid: + return False + after_wiring.linked_port_uid = "" + else: + if not after_wiring.external_port_uid: + return False + after_wiring.external_port_uid = "" + after_wiring.linked_connection_uid = "" + self.undo_manager.push( + WireManualBoundaryCommand( + self, + group_uid, + boundary_uid, + before, + (after_wiring, None), + ) + ) + return True + def remove_manual_boundary_port(self, group_uid: str, boundary_uid: str) -> bool: """Remove a manual GroupIn/GroupOut boundary port (undoable).""" return self.remove_boundary_port(group_uid, boundary_uid) @@ -1827,6 +2111,18 @@ def _rebuild_group_boundary_ports(self, group: VisualGroup) -> None: port.proxy_layout = dict(previous.proxy_layout) rebuilt_auto.append(port) + rebuilt_keys = {port.linked_port_uid for port in rebuilt_auto} + for port in group.boundary_ports: + if port.origin != "auto": + continue + if not port.linked_port_uid or port.linked_connection_uid: + continue + if port.linked_port_uid in manual_member_keys: + continue + if port.linked_port_uid in rebuilt_keys: + continue + rebuilt_auto.append(port) + group.boundary_ports = manual_ports + rebuilt_auto self.ensure_group_boundary_proxies(group) diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index 72d3926..77e9c86 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -107,6 +107,9 @@ def __init__(self, controller, connection_instance): self._controller = controller self._snapshot = controller._capture_connection_snapshot(connection_instance) self._connection_instance = connection_instance + self._boundary_snapshots = controller._capture_boundaries_for_connection( + connection_instance + ) def redo(self) -> None: if self._connection_instance is not None: @@ -114,7 +117,10 @@ def redo(self) -> None: self._controller.make_dirty() def undo(self) -> None: - self._connection_instance = self._controller._add_connection_from_snapshot(self._snapshot) + self._connection_instance = self._controller._add_connection_from_snapshot( + self._snapshot + ) + self._controller._restore_boundary_snapshots(self._boundary_snapshots) self._controller.make_dirty() diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 409990b..3d841ed 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -378,6 +378,9 @@ def refresh_manual_boundary_wires(self) -> None: self, proxy.member_anchor, port_item.connection_anchor, + group_uid=active_uid, + boundary_uid=boundary.uid, + side="internal", ) self.diagram_scene.addItem(wire) self.manual_boundary_wires[f"{boundary.uid}:internal"] = wire @@ -392,9 +395,8 @@ def refresh_manual_boundary_wires(self) -> None: continue if not boundary.external_port_uid: continue - boundary_item = group_item.boundary_port_items.get(boundary.uid) external_port = find_port(state, boundary.external_port_uid) - if boundary_item is None or external_port is None: + if external_port is None: continue block_item = self.get_block_item_from_instance(external_port.block) if block_item is None: @@ -404,8 +406,11 @@ def refresh_manual_boundary_wires(self) -> None: continue wire = ManualBoundaryWireItem( self, - boundary_item.connection_anchor, + lambda item=group_item, port=boundary: item.boundary_anchor_for(port), port_item.connection_anchor, + group_uid=group.uid, + boundary_uid=boundary.uid, + side="external", ) self.diagram_scene.addItem(wire) self.manual_boundary_wires[f"{boundary.uid}:external"] = wire @@ -917,6 +922,13 @@ def mouseReleaseEvent(self, event) -> None: self._cancel_temp_connection() return + if self.project_controller.try_connect_boundary_ports( + self.pending_port.instance, + target.instance, + ): + self._cancel_temp_connection() + return + self.project_controller.add_connection(self.pending_port.instance, target.instance) self._cancel_temp_connection() @@ -1019,6 +1031,12 @@ def delete_selected(self) -> None: self.project_controller.delete_group(item.group.uid) elif isinstance(item, BlockItem): self.project_controller.remove_block(item.instance) + elif isinstance(item, ManualBoundaryWireItem): + self.project_controller.disconnect_manual_boundary_side( + item.group_uid, + item.boundary_uid, + item.side, + ) elif isinstance(item, ConnectionItem): self.project_controller.remove_connection(item.instance) finally: From 313ab18c74d8f797575c3e6fd4f0350d52408b30 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Tue, 23 Jun 2026 11:56:59 +0200 Subject: [PATCH 29/42] example(basics): add nested_groups control loop demo --- .../basics/nested_groups/gui/parameters.py | 14 + .../basics/nested_groups/gui/project.yaml | 355 ++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 examples/basics/nested_groups/gui/parameters.py create mode 100644 examples/basics/nested_groups/gui/project.yaml diff --git a/examples/basics/nested_groups/gui/parameters.py b/examples/basics/nested_groups/gui/parameters.py new file mode 100644 index 0000000..fefe293 --- /dev/null +++ b/examples/basics/nested_groups/gui/parameters.py @@ -0,0 +1,14 @@ +import numpy as np +import control as ct + +# 3-state coupled dynamics to generate richer state trajectories for plotting. +A = np.array([ + [0.95, 0.10, 0.00], + [0.00, 0.97, 0.08], + [0.00, 0.00, 0.93], +]) +B = np.array([[1.0], [0.7], [0.4]]) +C = np.array([[1.0, 0.0, 0.0]]) + +K = ct.place(A, B, [0.86, 0.88, 0.90]) +G = np.linalg.inv(C @ np.linalg.inv(np.eye(3) - A + B @ K) @ B) diff --git a/examples/basics/nested_groups/gui/project.yaml b/examples/basics/nested_groups/gui/project.yaml new file mode 100644 index 0000000..40badea --- /dev/null +++ b/examples/basics/nested_groups/gui/project.yaml @@ -0,0 +1,355 @@ +schema_version: 1 +project: + name: gui +simulation: + dt: 0.02 + T: 2.0 + solver: fixed + external_module: parameters.py + logging: + - controller.outputs.u + - step.outputs.out + - system.outputs.y + - dist.outputs.out + plots: + - title: Reference vs output + mode: overlay + signals: + - step.outputs.out + - system.outputs.y + - title: Control and disturbance + mode: overlay + signals: + - controller.outputs.u + - dist.outputs.out +diagram: + blocks: + - name: step + category: sources + type: step + uid: 99f6d3c88fd148f9894da926d23db49d + parameters: + value_before: [[0.0]] + value_after: [[1.0]] + start_time: 0.5 + - name: dist + category: sources + type: step + uid: f8a5da1628a042c49ea65e9eb688e7e3 + parameters: + value_before: [[0.0]] + value_after: [[0.15]] + start_time: 1.0 + - name: gain_dist + category: operators + type: gain + uid: 936e4fe7174b44bda9b1d3d08bfdaf10 + parameters: + gain: 0.8 + multiplication: Element wise (K * u) + - name: sum_err + category: operators + type: sum + uid: 41e669bef8b34c5c82859ef228798300 + parameters: + signs: +- + - name: controller + category: controllers + type: state_feedback + uid: 3643a75d10754f21afb56a8a24844cdb + parameters: + K: '#K' + G: '#G' + - name: sum_plant + category: operators + type: sum + uid: db6d7d946a14478fa5fed05018e0a64a + parameters: + signs: ++ + - name: system + category: systems + type: linear_state_space + uid: 3f552e25cb9647bba4ce08cf897de600 + parameters: + A: '#A' + B: '#B' + C: '#C' + - name: monitor + category: operators + type: gain + uid: 5417b603712a41db8d3ef5d0df87b393 + parameters: + gain: 1.0 + multiplication: Element wise (K * u) + connections: + - name: c1 + ports: + - step.out + - sum_err.in1 + - name: c2 + ports: + - sum_err.out + - controller.r + - name: c3 + ports: + - controller.u + - sum_plant.in1 + - name: c4 + ports: + - dist.out + - gain_dist.in + - name: c5 + ports: + - gain_dist.out + - sum_plant.in2 + - name: c6 + ports: + - sum_plant.out + - system.u + - name: c7 + ports: + - system.x + - controller.x + - name: c8 + ports: + - system.y + - monitor.in + - name: c9 + ports: + - system.y + - sum_err.in2 +gui: + layout: + blocks: + step: + x: -360.0 + y: -20.0 + orientation: normal + width: 120.0 + height: 60.0 + monitor: + x: 155.0 + y: 30.0 + orientation: normal + width: 120.0 + height: 60.0 + connections: + c7: + route: [[368.0, 36.0], [376.0, 36.0], [376.0, 65.0], [-214.0, 65.0], [-214.0, + 46.5], [-206.0, 46.5]] + c9: + route: [[368.0, 72.0], [376.0, 72.0], [376.0, 90.0], [-214.0, 90.0], [-214.0, + 74.75], [-206.0, 74.75]] + groups: + - uid: df140fea1de3469c95fec651ffb8aa88 + name: Regulator + parent_uid: 3729168ddf894f62a7b4f1633b4b3014 + members: + - 41e669bef8b34c5c82859ef228798300 + - 3643a75d10754f21afb56a8a24844cdb + layout: + x: -200.0 + y: -10.0 + width: 213.0 + height: 113.0 + boundary_ports: + - uid: 08f79e2164134e2c8ab06cfcd05550a2 + direction: input + linked_port_uid: 41e669bef8b34c5c82859ef228798300:in1 + external_port_uid: '' + origin: auto + linked_connection_uid: 99f6d3c88fd148f9894da926d23db49d:out->41e669bef8b34c5c82859ef228798300:in1 + proxy_uid: 67d0670170ff4e3987328be0a602dd57 + proxy_layout: + x: -270.0 + y: -5.0 + - uid: 38c21c64aacb4610bdf4d0c7c0da5dba + direction: output + linked_port_uid: 3643a75d10754f21afb56a8a24844cdb:u + external_port_uid: '' + origin: auto + linked_connection_uid: 3643a75d10754f21afb56a8a24844cdb:u->db6d7d946a14478fa5fed05018e0a64a:in1 + proxy_uid: 5742f80d4bbd41e292ee8b3b70a14329 + proxy_layout: + x: 302.0 + y: 74.0 + - uid: ac6c2cd7c54a4ead86c817d54809eae3 + direction: input + linked_port_uid: 3643a75d10754f21afb56a8a24844cdb:x + external_port_uid: '' + origin: auto + linked_connection_uid: 3f552e25cb9647bba4ce08cf897de600:x->3643a75d10754f21afb56a8a24844cdb:x + proxy_uid: 2d016beee74c4c26be65953288f9c879 + proxy_layout: + x: -40.0 + y: 60.0 + - uid: ac83d89c4ab54857b0efca7bc0fbf245 + direction: input + linked_port_uid: 41e669bef8b34c5c82859ef228798300:in2 + external_port_uid: '' + origin: auto + linked_connection_uid: 3f552e25cb9647bba4ce08cf897de600:y->41e669bef8b34c5c82859ef228798300:in2 + proxy_uid: 0517465a5a4944449899eca849cbd0bd + proxy_layout: + x: -250.0 + y: 45.0 + child_group_uids: [] + member_layouts: + 41e669bef8b34c5c82859ef228798300: + x: -120.0 + y: -20.0 + orientation: normal + width: 120.0 + height: 60.0 + 3643a75d10754f21afb56a8a24844cdb: + x: 80.0 + y: -20.0 + orientation: normal + width: 120.0 + height: 60.0 + - uid: 00adc8fcee504a3cb1e38ce68b2e1abe + name: Plant + parent_uid: 3729168ddf894f62a7b4f1633b4b3014 + members: + - db6d7d946a14478fa5fed05018e0a64a + - 3f552e25cb9647bba4ce08cf897de600 + layout: + x: 170.0 + y: 0.0 + width: 183.0 + height: 108.0 + boundary_ports: + - uid: 5a91745e91ef4a7cad101ad596d27f2f + direction: input + linked_port_uid: db6d7d946a14478fa5fed05018e0a64a:in1 + external_port_uid: '' + origin: auto + linked_connection_uid: 3643a75d10754f21afb56a8a24844cdb:u->db6d7d946a14478fa5fed05018e0a64a:in1 + proxy_uid: d48a66209a8a4d01989c198ba6c0b017 + proxy_layout: + x: -355.0 + y: -75.0 + - uid: 092bf439152c4dc5b044bbb5c7b52cec + direction: input + linked_port_uid: db6d7d946a14478fa5fed05018e0a64a:in2 + external_port_uid: '' + origin: auto + linked_connection_uid: 936e4fe7174b44bda9b1d3d08bfdaf10:out->db6d7d946a14478fa5fed05018e0a64a:in2 + proxy_uid: fea1ec7b46d04a0fbbf9748935f13bfe + proxy_layout: + x: -330.0 + y: -20.0 + - uid: cd7a4d785d9847708bab0c993ae90a2d + direction: output + linked_port_uid: 3f552e25cb9647bba4ce08cf897de600:x + external_port_uid: '' + origin: auto + linked_connection_uid: 3f552e25cb9647bba4ce08cf897de600:x->3643a75d10754f21afb56a8a24844cdb:x + proxy_uid: 4ed0bac4df17420eaddb138dde7d7486 + proxy_layout: + x: 225.0 + y: -65.0 + - uid: 4509861d585f48afbb33d26161148b27 + direction: output + linked_port_uid: 3f552e25cb9647bba4ce08cf897de600:y + external_port_uid: '' + origin: auto + linked_connection_uid: 3f552e25cb9647bba4ce08cf897de600:y->5417b603712a41db8d3ef5d0df87b393:in + proxy_uid: d9c727c58bc94de0a3c8b131f937abac + proxy_layout: + x: 240.0 + y: -15.0 + child_group_uids: [] + member_layouts: + db6d7d946a14478fa5fed05018e0a64a: + x: -210.0 + y: -65.0 + orientation: normal + width: 120.0 + height: 60.0 + 3f552e25cb9647bba4ce08cf897de600: + x: -50.0 + y: -50.0 + orientation: normal + width: 120.0 + height: 60.0 + - uid: 3729168ddf894f62a7b4f1633b4b3014 + name: ControlLoop + parent_uid: null + members: [] + layout: + x: -170.0 + y: -15.0 + width: 236.0 + height: 111.0 + boundary_ports: + - uid: 638da3ee3cce4a7b92e8166c1e549a1f + direction: input + linked_port_uid: 41e669bef8b34c5c82859ef228798300:in1 + external_port_uid: '' + origin: auto + linked_connection_uid: 99f6d3c88fd148f9894da926d23db49d:out->41e669bef8b34c5c82859ef228798300:in1 + proxy_uid: b5fc602472d34016842a0c7639b2bc4b + proxy_layout: + x: -370.0 + y: -85.0 + - uid: c8796c93c6514e638cda4450cf860449 + direction: input + linked_port_uid: db6d7d946a14478fa5fed05018e0a64a:in2 + external_port_uid: '' + origin: auto + linked_connection_uid: 936e4fe7174b44bda9b1d3d08bfdaf10:out->db6d7d946a14478fa5fed05018e0a64a:in2 + proxy_uid: 4e7aa520fe664695a9a0820fc17a4b3b + proxy_layout: + x: 40.0 + y: 100.0 + - uid: 0cb3d8ef367845439a6f2ee922e19528 + direction: output + linked_port_uid: 3f552e25cb9647bba4ce08cf897de600:y + external_port_uid: '' + origin: auto + linked_connection_uid: 3f552e25cb9647bba4ce08cf897de600:y->5417b603712a41db8d3ef5d0df87b393:in + proxy_uid: e28a0652c51f4f59a0d4e0360728e6bb + proxy_layout: + x: 420.0 + y: -5.0 + child_group_uids: + - df140fea1de3469c95fec651ffb8aa88 + - 00adc8fcee504a3cb1e38ce68b2e1abe + member_layouts: {} + - uid: f670518bf86645199e288e7828da1735 + name: Disturbance + parent_uid: null + members: + - f8a5da1628a042c49ea65e9eb688e7e3 + - 936e4fe7174b44bda9b1d3d08bfdaf10 + layout: + x: -400.0 + y: 285.0 + width: 368.0 + height: 108.0 + boundary_ports: + - uid: e0d819822a4048ca9f21b4c544c4c7ad + direction: output + linked_port_uid: 936e4fe7174b44bda9b1d3d08bfdaf10:out + external_port_uid: '' + origin: auto + linked_connection_uid: 936e4fe7174b44bda9b1d3d08bfdaf10:out->db6d7d946a14478fa5fed05018e0a64a:in2 + proxy_uid: bdd4c962e4a34698846ecf6fd6935847 + proxy_layout: + x: 302.0 + y: 74.0 + child_group_uids: [] + member_layouts: + f8a5da1628a042c49ea65e9eb688e7e3: + x: -360.0 + y: 180.0 + orientation: normal + width: 120.0 + height: 60.0 + 936e4fe7174b44bda9b1d3d08bfdaf10: + x: -160.0 + y: 180.0 + orientation: normal + width: 120.0 + height: 60.0 From 280e616670caf6fb8a42ca6811ce08775ebcf343 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Tue, 23 Jun 2026 14:39:43 +0200 Subject: [PATCH 30/42] fix(gui): nested group boundaries, wire delete/reconnect and In/Out paste and numbered --- pySimBlocks/gui/diagram_clipboard.py | 201 +++++++++++++++- pySimBlocks/gui/graphics/group_item.py | 51 ++++- pySimBlocks/gui/group_boundary_labels.py | 40 +++- pySimBlocks/gui/project_controller.py | 216 +++++++++++++++++- .../gui/services/group_boundary_service.py | 9 +- pySimBlocks/gui/widgets/diagram_view.py | 79 +++++-- 6 files changed, 544 insertions(+), 52 deletions(-) diff --git a/pySimBlocks/gui/diagram_clipboard.py b/pySimBlocks/gui/diagram_clipboard.py index 1286fe1..4ed4de4 100644 --- a/pySimBlocks/gui/diagram_clipboard.py +++ b/pySimBlocks/gui/diagram_clipboard.py @@ -33,6 +33,18 @@ class ClipboardBlock: layout: dict[str, Any] +@dataclass +class ClipboardBoundaryPort: + """Serializable GroupIn/GroupOut proxy for copy/paste.""" + + source_uid: str + direction: str + label: str + layout: dict[str, Any] + linked_port_uid: str = "" + external_port_uid: str = "" + + @dataclass class DiagramClipboard: """In-memory clipboard for diagram selections.""" @@ -41,10 +53,18 @@ class DiagramClipboard: connections: list[ConnectionSnapshot] = field(default_factory=list) groups: list[dict[str, Any]] = field(default_factory=list) root_group_uids: list[str] = field(default_factory=list) + boundary_ports: list[ClipboardBoundaryPort] = field(default_factory=list) anchor_x: float = 0.0 anchor_y: float = 0.0 +def clipboard_has_content(clipboard: DiagramClipboard | None) -> bool: + """Return whether a clipboard carries pasteable diagram data.""" + if clipboard is None: + return False + return bool(clipboard.blocks or clipboard.groups or clipboard.boundary_ports) + + @dataclass class PasteResult: """Entities created by a paste operation (for undo).""" @@ -52,6 +72,7 @@ class PasteResult: blocks: list[BlockInstance] = field(default_factory=list) connections: list = field(default_factory=list) group_uids: list[str] = field(default_factory=list) + boundary_ports: list[tuple[str, str]] = field(default_factory=list) def _layout_anchor(layouts: list[dict[str, Any]]) -> tuple[float, float]: @@ -312,6 +333,65 @@ def _capture_mixed_selection_clipboard( ) +def _selected_proxy_items(selected) -> list: + """Return unique GroupProxyItem instances from a scene selection.""" + from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyItem, GroupProxyPortItem + + proxies: list[GroupProxyItem] = [] + seen: set[str] = set() + for item in selected: + if isinstance(item, GroupProxyPortItem): + item = item.parent_proxy + if not isinstance(item, GroupProxyItem): + continue + if item.boundary.uid in seen: + continue + seen.add(item.boundary.uid) + proxies.append(item) + return proxies + + +def capture_proxies_clipboard( + controller: ProjectController, + proxy_items: list, +) -> DiagramClipboard | None: + """Capture selected GroupIn/GroupOut proxies from the active internal view.""" + if not proxy_items or controller.view.current_view_group_uid is None: + return None + + boundary_ports: list[ClipboardBoundaryPort] = [] + layouts: list[dict[str, Any]] = [] + for proxy in proxy_items: + boundary = proxy.boundary + layout = dict(boundary.proxy_layout) if boundary.proxy_layout else { + "x": float(proxy.pos().x()), + "y": float(proxy.pos().y()), + } + layouts.append(layout) + linked_port_uid = "" + external_port_uid = "" + if boundary.origin == "manual" and not boundary.linked_connection_uid: + linked_port_uid = boundary.linked_port_uid + external_port_uid = boundary.external_port_uid + boundary_ports.append( + ClipboardBoundaryPort( + source_uid=boundary.uid, + direction=boundary.direction, + label=boundary.label, + layout=layout, + linked_port_uid=linked_port_uid, + external_port_uid=external_port_uid, + ) + ) + + anchor_x, anchor_y = _layout_anchor(layouts) + return DiagramClipboard( + boundary_ports=boundary_ports, + anchor_x=anchor_x, + anchor_y=anchor_y, + ) + + def capture_selection_clipboard(controller: ProjectController) -> DiagramClipboard | None: """Capture the current diagram selection for copy.""" from pySimBlocks.gui.graphics.block_item import BlockItem @@ -320,18 +400,47 @@ def capture_selection_clipboard(controller: ProjectController) -> DiagramClipboa selected = controller.view.diagram_scene.selectedItems() groups = [item for item in selected if isinstance(item, GroupItem)] blocks = [item for item in selected if isinstance(item, BlockItem)] + proxies = _selected_proxy_items(selected) if groups and blocks: - return _capture_mixed_selection_clipboard( + clipboard = _capture_mixed_selection_clipboard( controller, [item.group.uid for item in groups], blocks, ) - if groups: - return capture_groups_clipboard(controller, [item.group.uid for item in groups]) - if blocks: - return capture_blocks_clipboard(controller, blocks) - return None + elif groups: + clipboard = capture_groups_clipboard(controller, [item.group.uid for item in groups]) + elif blocks: + clipboard = capture_blocks_clipboard(controller, blocks) + elif proxies: + clipboard = capture_proxies_clipboard(controller, proxies) + else: + return None + + if clipboard is None: + return None + if not proxies: + return clipboard + + proxy_clipboard = capture_proxies_clipboard(controller, proxies) + if proxy_clipboard is None: + return clipboard + + anchor_layouts = [ + dict(port.layout) for port in proxy_clipboard.boundary_ports + ] + for block in clipboard.blocks: + anchor_layouts.append(dict(block.layout)) + for root_uid in clipboard.root_group_uids: + root = controller.project_state.get_visual_group(root_uid) + if root is not None and root.layout: + anchor_layouts.append(dict(root.layout)) + + anchor_x, anchor_y = _layout_anchor(anchor_layouts) + clipboard.boundary_ports = proxy_clipboard.boundary_ports + clipboard.anchor_x = anchor_x + clipboard.anchor_y = anchor_y + return clipboard def _remap_connection( @@ -407,6 +516,68 @@ def _duplicate_group( return group +def _paste_boundary_label( + controller: ProjectController, + group: VisualGroup, + port_data: ClipboardBoundaryPort, +) -> str: + """Choose a unique label for a pasted GroupIn/GroupOut.""" + preferred = port_data.label.strip() or controller._proxy_default_label(port_data.direction) + used: set[str] = set() + for port in group.boundary_ports: + if port.origin != "manual" or port.direction != port_data.direction: + continue + name = port.label.strip() or controller._proxy_default_label(port.direction) + used.add(name) + if preferred not in used: + return preferred + index = 1 + while f"{preferred}_{index}" in used: + index += 1 + return f"{preferred}_{index}" + + +def _paste_boundary_ports( + controller: ProjectController, + clipboard: DiagramClipboard, + origin: QPointF, + uid_map: dict[str, str], + *, + parent_group_uid: str | None, + result: PasteResult, +) -> None: + """Create GroupIn/GroupOut proxies from clipboard boundary ports.""" + if not clipboard.boundary_ports or parent_group_uid is None: + return + + group = controller.project_state.get_visual_group(parent_group_uid) + if group is None: + return + + dx = float(origin.x()) - clipboard.anchor_x + dy = float(origin.y()) - clipboard.anchor_y + for port_data in clipboard.boundary_ports: + layout = _offset_layout(dict(port_data.layout), dx, dy) + boundary = BoundaryPort( + uid=uuid.uuid4().hex, + direction=port_data.direction, + origin="manual", + label=_paste_boundary_label(controller, group, port_data), + proxy_uid=uuid.uuid4().hex, + proxy_layout=layout, + ) + if port_data.linked_port_uid and ":" in port_data.linked_port_uid: + old_uid, port_name = port_data.linked_port_uid.split(":", 1) + if old_uid in uid_map: + boundary.linked_port_uid = f"{uid_map[old_uid]}:{port_name}" + if port_data.external_port_uid and ":" in port_data.external_port_uid: + old_uid, port_name = port_data.external_port_uid.split(":", 1) + if old_uid in uid_map: + boundary.external_port_uid = f"{uid_map[old_uid]}:{port_name}" + controller._add_manual_boundary_port(parent_group_uid, boundary) + result.boundary_ports.append((parent_group_uid, boundary.uid)) + + def paste_clipboard( controller: ProjectController, clipboard: DiagramClipboard, @@ -416,7 +587,7 @@ def paste_clipboard( ) -> PasteResult: """Paste clipboard contents at ``origin`` without pushing undo.""" result = PasteResult() - if not clipboard.blocks and not clipboard.groups: + if not clipboard_has_content(clipboard): return result dx = float(origin.x()) - clipboard.anchor_x @@ -487,12 +658,28 @@ def paste_clipboard( if parent_group_uid is not None: controller._attach_group_to_parent(group) + target_group_uid = parent_group_uid + if target_group_uid is None and clipboard.boundary_ports: + target_group_uid = controller.view.current_view_group_uid + + _paste_boundary_ports( + controller, + clipboard, + origin, + uid_map, + parent_group_uid=target_group_uid, + result=result, + ) + controller.view.refresh_visual_groups() return result def undo_paste(controller: ProjectController, result: PasteResult) -> None: """Remove entities created by a paste operation.""" + for group_uid, boundary_uid in reversed(result.boundary_ports): + controller._remove_boundary_port(group_uid, boundary_uid) + for group_uid in reversed(result.group_uids): if group_uid in controller.view.view_stack: controller.view.navigate_out_of_group(group_uid) diff --git a/pySimBlocks/gui/graphics/group_item.py b/pySimBlocks/gui/graphics/group_item.py index 65a2d53..d3aa495 100644 --- a/pySimBlocks/gui/graphics/group_item.py +++ b/pySimBlocks/gui/graphics/group_item.py @@ -167,17 +167,30 @@ def boundary_anchor_for(self, boundary: BoundaryPort) -> QPointF: return port_item.connection_anchor() return self._boundary_anchor_from_geometry(boundary) + def _boundaries_for_direction(self, direction: str) -> list[BoundaryPort]: + """Return border ports for one side in stable display order.""" + previous_y = { + uid: float(item.pos().y()) + for uid, item in self.boundary_port_items.items() + } + return sorted( + [ + port + for port in self._border_boundary_ports() + if port.direction == direction + ], + key=lambda boundary: self._border_sort_key(boundary, previous_y), + ) + def _boundary_anchor_from_geometry(self, boundary: BoundaryPort) -> QPointF: """Compute a boundary anchor when no border port item is shown.""" rect = self.rect() - same_direction = [ - port - for port in self._border_boundary_ports() - if port.direction == boundary.direction - ] + same_direction = self._boundaries_for_direction(boundary.direction) if boundary not in same_direction: same_direction = [ - port for port in self.group.boundary_ports if port.direction == boundary.direction + port + for port in self.group.boundary_ports + if port.direction == boundary.direction ] index = same_direction.index(boundary) if boundary in same_direction else 0 total = len(same_direction) @@ -188,15 +201,37 @@ def _boundary_anchor_from_geometry(self, boundary: BoundaryPort) -> QPointF: local = QPointF(rect.width() + GroupBoundaryPortItem.L, y) return self.mapToScene(local) + def _border_sort_key( + self, + boundary: BoundaryPort, + previous_y: dict[str, float], + ) -> tuple[float, str]: + """Keep border ports in a stable vertical order across rebuilds.""" + if boundary.uid in previous_y: + return (previous_y[boundary.uid], boundary.uid) + if boundary.proxy_layout: + return (float(boundary.proxy_layout.get("y", 0.0)), boundary.uid) + return (1e9, boundary.linked_port_uid or boundary.uid) + def sync_boundary_ports(self) -> None: """Rebuild boundary port items from group metadata.""" + previous_y = { + uid: float(item.pos().y()) + for uid, item in self.boundary_port_items.items() + } for item in list(self.boundary_port_items.values()): item.setParentItem(None) self.boundary_port_items.clear() border_ports = self._border_boundary_ports() - inputs = [p for p in border_ports if p.direction == "input"] - outputs = [p for p in border_ports if p.direction == "output"] + inputs = sorted( + [p for p in border_ports if p.direction == "input"], + key=lambda boundary: self._border_sort_key(boundary, previous_y), + ) + outputs = sorted( + [p for p in border_ports if p.direction == "output"], + key=lambda boundary: self._border_sort_key(boundary, previous_y), + ) rect = self.rect() for index, boundary in enumerate(inputs): diff --git a/pySimBlocks/gui/group_boundary_labels.py b/pySimBlocks/gui/group_boundary_labels.py index b325769..aeee02e 100644 --- a/pySimBlocks/gui/group_boundary_labels.py +++ b/pySimBlocks/gui/group_boundary_labels.py @@ -5,7 +5,10 @@ from __future__ import annotations -from pySimBlocks.gui.services.group_boundary_service import find_port +from pySimBlocks.gui.services.group_boundary_service import ( + find_connection_for_boundary, + find_port, +) from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup @@ -13,18 +16,41 @@ def _port_display(port) -> str: return str(port.display_as or port.name) +def proxy_default_label(boundary: BoundaryPort) -> str: + """Return the default GroupIn/GroupOut name for one boundary direction.""" + return "In" if boundary.direction == "input" else "Out" + + +def manual_boundary_display_label(boundary: BoundaryPort) -> str: + """Return the proxy name shown on the group border and inside the group.""" + if boundary.label.strip(): + return boundary.label.strip() + return proxy_default_label(boundary) + + def boundary_port_label( state: ProjectState, group: VisualGroup, boundary: BoundaryPort, ) -> str: - """Return the internal member-port label for a group boundary port.""" + """Return the label shown on a group border port.""" + if boundary.origin == "manual": + return manual_boundary_display_label(boundary) if boundary.label.strip(): return boundary.label.strip() - linked = boundary.linked_port_uid - if not linked: - return "" - internal = find_port(state, linked) + + internal = ( + find_port(state, boundary.linked_port_uid) + if boundary.linked_port_uid + else None + ) if internal is None: return "" - return _port_display(internal) + + connection = find_connection_for_boundary(state, boundary) + if connection is None: + return _port_display(internal) + + if boundary.direction == "input": + return _port_display(connection.src_port) + return _port_display(connection.dst_port) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index f2a6072..d6c225b 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -38,10 +38,14 @@ from pySimBlocks.gui.diagram_clipboard import ( DiagramClipboard, capture_selection_clipboard, + clipboard_has_content, paste_clipboard, undo_paste, ) -from pySimBlocks.gui.group_boundary_labels import boundary_port_label +from pySimBlocks.gui.group_boundary_labels import ( + boundary_port_label, + manual_boundary_display_label, +) from pySimBlocks.gui.services.group_boundary_service import ( BoundaryWiringState, apply_wiring_state, @@ -50,6 +54,7 @@ connection_endpoints, find_connection_for_boundary, find_port, + is_complete, port_key, validate_external_link, validate_internal_link, @@ -179,7 +184,9 @@ def copy_selection(self) -> bool: def paste_clipboard_at(self, origin: QPointF) -> bool: """Paste the view clipboard at ``origin`` (undoable).""" clipboard = self.view.clipboard - if clipboard is None or not clipboard.blocks: + if not clipboard_has_content(clipboard): + return False + if clipboard.boundary_ports and self.view.current_view_group_uid is None: return False parent_uid = self.view.current_view_group_uid self.undo_manager.push( @@ -392,6 +399,9 @@ def try_wire_boundary_endpoints(self, src, dst) -> bool: elif isinstance(endpoint, PortItem): block_port_item = endpoint + if proxy_port is not None and boundary_port is not None: + return self._wire_proxy_to_child_group_border(proxy_port, boundary_port) + if block_port_item is None: return False @@ -408,9 +418,15 @@ def try_wire_boundary_endpoints(self, src, dst) -> bool: ) if boundary_port is not None: + group_uid = boundary_port.parent_group.group.uid + boundary_uid = boundary_port.boundary.uid + if self._wire_auto_boundary_external( + group_uid, boundary_uid, member_port + ): + return True return self._wire_manual_boundary_external( - boundary_port.parent_group.group.uid, - boundary_port.boundary.uid, + group_uid, + boundary_uid, member_port, ) return False @@ -461,7 +477,9 @@ def _wire_manual_boundary_internal( if group is None: return False boundary = self._find_boundary_port(group, boundary_uid) - if boundary is None or not validate_internal_link(group, boundary, member_port): + if boundary is None or not self._validate_internal_link( + group, boundary, member_port + ): return False before = self._capture_boundary_wire_snapshot(group_uid, boundary_uid) after_wiring = capture_wiring_state(boundary) @@ -475,6 +493,108 @@ def _wire_manual_boundary_internal( ) return True + def _validate_internal_link( + self, + group: VisualGroup, + boundary: BoundaryPort, + member_port: PortInstance, + ) -> bool: + """Validate an internal boundary link against the full group content.""" + content_uids = set(self._group_content_uids_for_group(group)) + return validate_internal_link( + group, + boundary, + member_port, + content_uids=content_uids, + ) + + def _wire_proxy_to_child_group_border( + self, + proxy_port, + child_boundary_port, + ) -> bool: + """Wire a parent-group proxy to a child group's border port.""" + active_uid = self.view.current_view_group_uid + if active_uid is None: + return False + + parent_group = self.project_state.get_visual_group(active_uid) + child_group = child_boundary_port.parent_group.group + if parent_group is None or child_group.uid not in parent_group.child_group_uids: + return False + + boundary_uid = proxy_port.parent_proxy.boundary.uid + boundary = self._find_boundary_port(parent_group, boundary_uid) + if boundary is None: + return False + + member_port = find_port( + self.project_state, + child_boundary_port.boundary.linked_port_uid, + ) + if member_port is None: + return False + + return self._wire_manual_boundary_internal( + active_uid, + boundary_uid, + member_port, + ) + + def _validate_auto_external_link( + self, + group: VisualGroup, + boundary: BoundaryPort, + external_port: PortInstance, + ) -> PortInstance | None: + """Return the internal port when an incomplete auto boundary may be rewired.""" + if boundary.origin != "auto" or boundary.linked_connection_uid: + return None + if not boundary.linked_port_uid: + return None + internal = find_port(self.project_state, boundary.linked_port_uid) + if internal is None: + return None + members = set(self._group_content_uids_for_group(group)) + if external_port.block.uid in members: + return None + if boundary.direction == "input": + if external_port.direction != "output" or internal.direction != "input": + return None + src_port, dst_port = external_port, internal + else: + if external_port.direction != "input" or internal.direction != "output": + return None + src_port, dst_port = internal, external_port + if not src_port.is_compatible(dst_port): + return None + dst_connections = self.project_state.get_connections_of_port(dst_port) + if not dst_port.can_accept_connection(dst_connections): + return None + return internal + + def _wire_auto_boundary_external( + self, + group_uid: str, + boundary_uid: str, + external_port: PortInstance, + ) -> bool: + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None: + return False + internal = self._validate_auto_external_link(group, boundary, external_port) + if internal is None: + return False + if boundary.direction == "input": + src_port, dst_port = external_port, internal + else: + src_port, dst_port = internal, external_port + self.undo_manager.push(AddConnectionCommand(self, src_port, dst_port, None)) + return True + def _wire_manual_boundary_external( self, group_uid: str, @@ -596,7 +716,9 @@ def boundary_port_flow_label( return boundary_port_label(self.project_state, group, boundary) def boundary_proxy_label(self, boundary: BoundaryPort) -> str: - """Return proxy label: manual override, else linked internal port.""" + """Return the label drawn on a GroupIn/GroupOut proxy.""" + if boundary.origin == "manual": + return manual_boundary_display_label(boundary) if boundary.label.strip(): return boundary.label.strip() linked = find_port(self.project_state, boundary.linked_port_uid) @@ -707,9 +829,28 @@ def try_connect_boundary_ports( ): return False + ports = {port1, port2} for group in self.project_state.visual_groups: members = set(self._group_content_uids_for_group(group)) for boundary in group.boundary_ports: + if boundary.origin == "auto" and not boundary.linked_connection_uid: + internal = ( + find_port(self.project_state, boundary.linked_port_uid) + if boundary.linked_port_uid + else None + ) + if internal is not None and internal in ports: + external_candidate = port2 if port1 is internal else port1 + if self._validate_auto_external_link( + group, boundary, external_candidate + ) is not None: + return self._wire_auto_boundary_external( + group.uid, + boundary.uid, + external_candidate, + ) + continue + if boundary.origin != "manual" or boundary.linked_connection_uid: continue @@ -723,7 +864,6 @@ def try_connect_boundary_ports( if boundary.external_port_uid else None ) - ports = {port1, port2} if ( internal is not None @@ -743,7 +883,9 @@ def try_connect_boundary_ports( if external is not None and external in ports: internal_candidate = port2 if port1 is external else port1 - if validate_internal_link(group, boundary, internal_candidate): + if self._validate_internal_link( + group, boundary, internal_candidate + ): return self._wire_manual_boundary_internal( group.uid, boundary.uid, @@ -1251,6 +1393,7 @@ def _preserve_boundaries_on_connection_remove( is_cross_group = ( external_group is not None and external_group.uid != group.uid ) + external_at_root = external_group is None if is_cross_group: boundary.linked_connection_uid = "" @@ -1259,6 +1402,11 @@ def _preserve_boundaries_on_connection_remove( changed = True continue + if external_at_root and boundary.origin != "manual": + boundary.linked_connection_uid = "" + changed = True + continue + if boundary.origin == "manual": boundary.linked_connection_uid = "" if not boundary.linked_port_uid: @@ -1899,9 +2047,43 @@ def _set_boundary_label(self, group_uid: str, boundary_uid: str, label: str) -> boundary = self._find_boundary_port(group, boundary_uid) if boundary is None: return - boundary.label = label.strip() + trimmed = label.strip() + if not trimmed and boundary.origin == "manual": + trimmed = self._make_unique_boundary_label( + group, + boundary.direction, + exclude_uid=boundary_uid, + ) + boundary.label = trimmed self.view.refresh_visual_groups() + def _proxy_default_label(self, direction: str) -> str: + return "In" if direction == "input" else "Out" + + def _make_unique_boundary_label( + self, + group: VisualGroup, + direction: str, + *, + exclude_uid: str | None = None, + ) -> str: + """Return a unique default GroupIn/GroupOut name inside one group.""" + base = self._proxy_default_label(direction) + used: set[str] = set() + for port in group.boundary_ports: + if port.uid == exclude_uid or port.origin != "manual": + continue + if port.direction != direction: + continue + name = port.label.strip() or self._proxy_default_label(port.direction) + used.add(name) + if base not in used: + return base + index = 1 + while f"{base}_{index}" in used: + index += 1 + return f"{base}_{index}" + def add_manual_boundary_port( self, group_uid: str, @@ -1916,6 +2098,7 @@ def add_manual_boundary_port( uid=uuid.uuid4().hex, direction=direction, origin="manual", + label=self._make_unique_boundary_label(group, direction), proxy_uid=uuid.uuid4().hex, proxy_layout={"x": float(pos.x()), "y": float(pos.y())}, ) @@ -1946,7 +2129,7 @@ def disconnect_manual_boundary_side( if group is None: return False boundary = self._find_boundary_port(group, boundary_uid) - if boundary is None or boundary.origin != "manual": + if boundary is None or is_complete(boundary): return False if side not in ("internal", "external"): return False @@ -2123,6 +2306,19 @@ def _rebuild_group_boundary_ports(self, group: VisualGroup) -> None: continue rebuilt_auto.append(port) + previous_auto_order = [ + port.linked_port_uid + for port in group.boundary_ports + if port.origin == "auto" and port.linked_port_uid + ] + + def _auto_boundary_order(port: BoundaryPort) -> tuple[int, str]: + try: + return (previous_auto_order.index(port.linked_port_uid), port.uid) + except ValueError: + return (len(previous_auto_order), port.linked_port_uid or port.uid) + + rebuilt_auto.sort(key=_auto_boundary_order) group.boundary_ports = manual_ports + rebuilt_auto self.ensure_group_boundary_proxies(group) diff --git a/pySimBlocks/gui/services/group_boundary_service.py b/pySimBlocks/gui/services/group_boundary_service.py index 272a17e..d2a10a0 100644 --- a/pySimBlocks/gui/services/group_boundary_service.py +++ b/pySimBlocks/gui/services/group_boundary_service.py @@ -77,11 +77,14 @@ def validate_internal_link( group: VisualGroup, boundary: BoundaryPort, member_port: PortInstance, + *, + content_uids: set[str] | None = None, ) -> bool: - """Return whether a member port may be wired to a manual boundary proxy.""" - if not is_manual(boundary): + """Return whether a member port may be wired to a boundary proxy.""" + if is_complete(boundary): return False - if member_port.block.uid not in group.members: + scope = content_uids if content_uids is not None else set(group.members) + if member_port.block.uid not in scope: return False if boundary.direction == "input" and member_port.direction != "input": return False diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 3d841ed..e5cdc4b 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -28,7 +28,7 @@ from pySimBlocks.gui.graphics.block_item import BlockItem from pySimBlocks.gui.graphics.group_item import GroupBoundaryPortItem, GroupItem -from pySimBlocks.gui.diagram_clipboard import DiagramClipboard +from pySimBlocks.gui.diagram_clipboard import DiagramClipboard, clipboard_has_content from pySimBlocks.gui.graphics.group_proxy_item import GroupProxyItem, GroupProxyPortItem from pySimBlocks.gui.graphics.connection_item import ConnectionItem, OrthogonalRoute from pySimBlocks.gui.graphics.manual_boundary_wire_item import ManualBoundaryWireItem @@ -344,7 +344,7 @@ def refresh_visual_groups(self) -> None: self.refresh_manual_boundary_wires() def refresh_manual_boundary_wires(self) -> None: - """Rebuild visual-only wires for incomplete manual group boundaries.""" + """Rebuild visual-only wires for incomplete group boundaries.""" for wire in self.manual_boundary_wires.values(): self.diagram_scene.removeItem(wire) self.manual_boundary_wires.clear() @@ -360,24 +360,46 @@ def refresh_manual_boundary_wires(self) -> None: if group is None: return for boundary in group.boundary_ports: - if boundary.origin != "manual" or boundary.linked_connection_uid: + if boundary.linked_connection_uid: continue if not boundary.linked_port_uid: continue - proxy = self.proxy_items.get(boundary.uid) - member_port = find_port(state, boundary.linked_port_uid) - if proxy is None or member_port is None: + if not self._proxy_applies_to_active_group(group, boundary): continue - block_item = self.get_block_item_from_instance(member_port.block) - if block_item is None: + member_port = find_port(state, boundary.linked_port_uid) + if member_port is None: continue - port_item = block_item.get_port_item(member_port.name) - if port_item is None: + proxy = self.proxy_items.get(boundary.uid) + if proxy is None: continue + child_uid = ( + self._child_group_uid_for_block(group, member_port.block.uid) + if group.child_group_uids + else None + ) + if child_uid is not None: + child_item = self.group_items.get(child_uid) + if child_item is None: + continue + boundary_uid = child_item.find_boundary_for_member_port( + member_port.block.uid, + member_port.name, + ) + if boundary_uid is None: + continue + member_anchor = lambda item=child_item, uid=boundary_uid: item.get_boundary_anchor(uid) + else: + block_item = self.get_block_item_from_instance(member_port.block) + if block_item is None: + continue + port_item = block_item.get_port_item(member_port.name) + if port_item is None: + continue + member_anchor = port_item.connection_anchor wire = ManualBoundaryWireItem( self, proxy.member_anchor, - port_item.connection_anchor, + member_anchor, group_uid=active_uid, boundary_uid=boundary.uid, side="internal", @@ -387,8 +409,10 @@ def refresh_manual_boundary_wires(self) -> None: return for group in state.visual_groups: + if group.parent_uid is not None: + continue group_item = self.group_items.get(group.uid) - if group_item is None: + if group_item is None or not group_item.isVisible(): continue for boundary in group.boundary_ports: if boundary.origin != "manual" or boundary.linked_connection_uid: @@ -399,7 +423,7 @@ def refresh_manual_boundary_wires(self) -> None: if external_port is None: continue block_item = self.get_block_item_from_instance(external_port.block) - if block_item is None: + if block_item is None or not block_item.isVisible(): continue port_item = block_item.get_port_item(external_port.name) if port_item is None: @@ -422,6 +446,21 @@ def _refresh_group_port_labels(self) -> None: for proxy_item in self.proxy_items.values(): proxy_item.update() + def _proxy_applies_to_active_group( + self, + group, + boundary, + ) -> bool: + """Return whether a proxy belongs to the current internal group view.""" + if not boundary.linked_port_uid: + return True + block_uid = boundary.linked_port_uid.split(":", 1)[0] + if block_uid in group.members: + return True + if group.child_group_uids: + return self._child_group_uid_for_block(group, block_uid) is not None + return False + def _refresh_group_proxies(self, active_group) -> None: """Create or hide GroupIn/GroupOut proxy items for the active internal view.""" if active_group is None: @@ -429,13 +468,19 @@ def _refresh_group_proxies(self, active_group) -> None: item.setVisible(False) return - active_boundary_uids = {boundary.uid for boundary in active_group.boundary_ports} + active_boundary_uids = { + boundary.uid + for boundary in active_group.boundary_ports + if self._proxy_applies_to_active_group(active_group, boundary) + } for uid in list(self.proxy_items.keys()): if uid not in active_boundary_uids: item = self.proxy_items.pop(uid) self.diagram_scene.removeItem(item) for boundary in active_group.boundary_ports: + if not self._proxy_applies_to_active_group(active_group, boundary): + continue item = self.proxy_items.get(boundary.uid) if item is None: item = GroupProxyItem(boundary, self) @@ -558,10 +603,10 @@ def connection_anchor_for_port_item(self, port_item: PortItem) -> QPointF: return port_item.connection_anchor() def _manual_proxy_anchor_for_member_port(self, group, port_item: PortItem) -> QPointF | None: - """Route member ports to a manual proxy while the boundary is incomplete.""" + """Route member ports to a proxy while the boundary is incomplete.""" key = f"{port_item.instance.block.uid}:{port_item.instance.name}" for boundary in group.boundary_ports: - if boundary.origin != "manual" or boundary.linked_connection_uid: + if boundary.linked_connection_uid: continue if boundary.linked_port_uid != key: continue @@ -804,7 +849,7 @@ def keyPressEvent(self, event) -> None: # PASTE if event.key() == Qt.Key_V and event.modifiers() & Qt.ControlModifier: - if self.clipboard and self.clipboard.blocks: + if clipboard_has_content(self.clipboard): offset = 30 * (self.paste_generation + 1) origin = QPointF( self.clipboard.anchor_x + offset, From 0a2c844fd235755c145f37c0826ae458045bfd92 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Tue, 23 Jun 2026 15:54:56 +0200 Subject: [PATCH 31/42] fix(gui): preserve auto boundary endpoints for inside/outside rewiring --- pySimBlocks/gui/project_controller.py | 66 ++++++++++++++++++++++++- pySimBlocks/gui/widgets/diagram_view.py | 2 + 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index d6c225b..6016b5b 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -411,9 +411,23 @@ def try_wire_boundary_endpoints(self, src, dst) -> bool: group_uid = self.view.current_view_group_uid if group_uid is None: return False + boundary_uid = proxy_port.parent_proxy.boundary.uid + group = self.project_state.get_visual_group(group_uid) + boundary = ( + self._find_boundary_port(group, boundary_uid) if group is not None else None + ) + if ( + boundary is not None + and boundary.origin == "auto" + and not boundary.linked_connection_uid + and self._wire_auto_boundary_internal( + group_uid, boundary_uid, member_port + ) + ): + return True return self._wire_manual_boundary_internal( group_uid, - proxy_port.parent_proxy.boundary.uid, + boundary_uid, member_port, ) @@ -541,6 +555,38 @@ def _wire_proxy_to_child_group_border( member_port, ) + def _wire_auto_boundary_internal( + self, + group_uid: str, + boundary_uid: str, + member_port: PortInstance, + ) -> bool: + """Restore an auto boundary by wiring its proxy to the member port.""" + group = self.project_state.get_visual_group(group_uid) + if group is None: + return False + boundary = self._find_boundary_port(group, boundary_uid) + if boundary is None or boundary.origin != "auto" or boundary.linked_connection_uid: + return False + if not self._validate_internal_link(group, boundary, member_port): + return False + if not boundary.external_port_uid: + return False + external = find_port(self.project_state, boundary.external_port_uid) + if external is None: + return False + if boundary.direction == "input": + src_port, dst_port = external, member_port + else: + src_port, dst_port = member_port, external + if not src_port.is_compatible(dst_port): + return False + dst_connections = self.project_state.get_connections_of_port(dst_port) + if not dst_port.can_accept_connection(dst_connections): + return False + self.undo_manager.push(AddConnectionCommand(self, src_port, dst_port, None)) + return True + def _validate_auto_external_link( self, group: VisualGroup, @@ -849,6 +895,21 @@ def try_connect_boundary_ports( boundary.uid, external_candidate, ) + external = ( + find_port(self.project_state, boundary.external_port_uid) + if boundary.external_port_uid + else None + ) + if external is not None and external in ports: + internal_candidate = port2 if port1 is external else port1 + if self._validate_internal_link( + group, boundary, internal_candidate + ): + return self._wire_auto_boundary_internal( + group.uid, + boundary.uid, + internal_candidate, + ) continue if boundary.origin != "manual" or boundary.linked_connection_uid: @@ -1399,10 +1460,13 @@ def _preserve_boundaries_on_connection_remove( boundary.linked_connection_uid = "" if boundary.origin == "manual" and boundary.external_port_uid == external_key: boundary.external_port_uid = "" + elif boundary.origin != "manual": + boundary.external_port_uid = external_key changed = True continue if external_at_root and boundary.origin != "manual": + boundary.external_port_uid = external_key boundary.linked_connection_uid = "" changed = True continue diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index e5cdc4b..e2571c1 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -362,6 +362,8 @@ def refresh_manual_boundary_wires(self) -> None: for boundary in group.boundary_ports: if boundary.linked_connection_uid: continue + if boundary.origin != "manual": + continue if not boundary.linked_port_uid: continue if not self._proxy_applies_to_active_group(group, boundary): From a43852e3d1633b415319550c3fc7b59bdf7f487e Mon Sep 17 00:00:00 2001 From: Lukasik Date: Tue, 23 Jun 2026 16:17:30 +0200 Subject: [PATCH 32/42] fix(gui): wire child group border ports to parent group members --- pySimBlocks/gui/project_controller.py | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 6016b5b..e45c04f 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -432,6 +432,8 @@ def try_wire_boundary_endpoints(self, src, dst) -> bool: ) if boundary_port is not None: + if self._wire_child_border_to_parent_member(boundary_port, member_port): + return True group_uid = boundary_port.parent_group.group.uid boundary_uid = boundary_port.boundary.uid if self._wire_auto_boundary_external( @@ -445,6 +447,37 @@ def try_wire_boundary_endpoints(self, src, dst) -> bool: ) return False + def _wire_child_border_to_parent_member( + self, + boundary_port, + member_port: PortInstance, + ) -> bool: + """Wire a child-group border port to a direct member of the active parent.""" + active_uid = self.view.current_view_group_uid + if active_uid is None: + return False + child_group = boundary_port.parent_group.group + parent = self.project_state.get_visual_group(active_uid) + if parent is None or child_group.parent_uid != parent.uid: + return False + if member_port.block.uid not in parent.members: + return False + boundary = boundary_port.boundary + internal = find_port(self.project_state, boundary.linked_port_uid) + if internal is None: + return False + if boundary.direction == "input": + src_port, dst_port = member_port, internal + else: + src_port, dst_port = internal, member_port + if not src_port.is_compatible(dst_port): + return False + dst_connections = self.project_state.get_connections_of_port(dst_port) + if not dst_port.can_accept_connection(dst_connections): + return False + self.undo_manager.push(AddConnectionCommand(self, src_port, dst_port, None)) + return True + def _wire_group_boundaries_together( self, port_a, From 4ab6ef4209798db96080100048bbc576218cafea Mon Sep 17 00:00:00 2001 From: Lukasik Date: Wed, 24 Jun 2026 11:12:01 +0200 Subject: [PATCH 33/42] feat(gui): allow Ctrl+R to flip group and In/Out proxy display --- pySimBlocks/gui/graphics/connection_item.py | 41 ++++++---- pySimBlocks/gui/graphics/group_item.py | 61 ++++++++++++--- pySimBlocks/gui/graphics/group_proxy_item.py | 58 ++++++++++---- pySimBlocks/gui/project_controller.py | 55 ++++++++++++- pySimBlocks/gui/undo_redo/commands.py | 46 +++++++++++ pySimBlocks/gui/widgets/diagram_view.py | 81 ++++++++++++++++++-- 6 files changed, 297 insertions(+), 45 deletions(-) diff --git a/pySimBlocks/gui/graphics/connection_item.py b/pySimBlocks/gui/graphics/connection_item.py index 0e73fe5..b04b87c 100644 --- a/pySimBlocks/gui/graphics/connection_item.py +++ b/pySimBlocks/gui/graphics/connection_item.py @@ -314,8 +314,8 @@ def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]: src_rect = self._routing_rect_for_port(self.src_port) dst_rect = self._routing_rect_for_port(self.dst_port) - src_out_sign = 1 if not self.src_port.is_on_left_side else -1 - dst_in_sign = -1 if self.dst_port.is_on_left_side else 1 + src_out_sign = self._wire_side_sign(p1, src_rect) + dst_in_sign = self._wire_side_sign(p2, dst_rect) p1_out = QPointF(p1.x() + src_out_sign * self.OFFSET, p1.y()) p2_in = QPointF(p2.x() + dst_in_sign * self.OFFSET, p2.y()) @@ -362,6 +362,23 @@ def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]: key=lambda y: abs(p1.y() - y) + abs(p2.y() - y) ) + if src_rect.left() <= p2_in.x() <= src_rect.right(): + approach_x = ( + src_rect.left() - self.DETOUR + if dst_in_sign < 0 + else src_rect.right() + self.DETOUR + ) + return self._simplify_orthogonal_route( + [ + p1, + p1_out, + QPointF(p1_out.x(), route_y), + QPointF(approach_x, route_y), + QPointF(approach_x, p2.y()), + p2, + ] + ) + return self._simplify_orthogonal_route( [ p1, p1_out, @@ -371,20 +388,16 @@ def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]: ] ) + def _wire_side_sign(self, anchor: QPointF, rect: QRectF) -> int: + """Return -1 when the anchor is on the left edge, +1 on the right.""" + dist_left = abs(anchor.x() - rect.left()) + dist_right = abs(anchor.x() - rect.right()) + return -1 if dist_left <= dist_right else 1 + def _routing_rect_for_port(self, port_item: PortItem) -> QRectF: """Return the scene rectangle used for obstacle avoidance.""" - block_item = port_item.parent_block - view = block_item.view - block_uid = port_item.instance.block.uid - - if view.current_view_group_uid is None: - for group_item in view.group_items.values(): - if not group_item.isVisible(): - continue - if block_uid in group_item.group.members: - return group_item.mapRectToScene(group_item.rect()) - - return block_item.mapRectToScene(block_item.rect()) + view = port_item.parent_block.view + return view.routing_rect_for_port_item(port_item) def _wire_length(self, points: list[QPointF]) -> float: total = 0.0 diff --git a/pySimBlocks/gui/graphics/group_item.py b/pySimBlocks/gui/graphics/group_item.py index d3aa495..951df88 100644 --- a/pySimBlocks/gui/graphics/group_item.py +++ b/pySimBlocks/gui/graphics/group_item.py @@ -42,11 +42,17 @@ def __init__(self, boundary: BoundaryPort, parent_group: "GroupItem"): def is_input(self) -> bool: return self.boundary.direction == "input" + @property + def is_on_left_side(self) -> bool: + return self.pos().x() < (self.parent_group.rect().width() * 0.5) + def connection_anchor(self) -> QPointF: if self.is_input: - local = QPointF(-self.R, 0) + x = -self.R if self.is_on_left_side else self.R + local = QPointF(x, 0) else: - local = QPointF(self.L, 0) + x = self.L if not self.is_on_left_side else -self.L + local = QPointF(x, 0) return self.mapToScene(local) def update_port_label(self) -> None: @@ -64,7 +70,7 @@ def update_port_label(self) -> None: def _layout_port_label(self) -> None: label_rect = self.label.boundingRect() - if self.is_input: + if self.is_on_left_side: self.label.setPos( self.R + self.MARGIN, -label_rect.height() / 2, @@ -95,9 +101,10 @@ def shape(self): if self.is_input: path.addEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R) else: + tip_x = self.L if not self.is_on_left_side else -self.L path.moveTo(0, -self.H) path.lineTo(0, self.H) - path.lineTo(self.L, 0) + path.lineTo(tip_x, 0) path.closeSubpath() return path @@ -115,7 +122,8 @@ def paint(self, painter, option, widget=None): path = QPainterPath() path.moveTo(0, -self.H) path.lineTo(0, self.H) - path.lineTo(self.L, 0) + tip_x = self.L if not self.is_on_left_side else -self.L + path.lineTo(tip_x, 0) path.closeSubpath() painter.drawPath(path) @@ -137,6 +145,9 @@ def __init__(self, group: VisualGroup, view: "DiagramView"): super().__init__(0, 0, width, height) self.group = group self.view = view + self.orientation = layout.get("orientation", "normal") + if self.orientation not in {"normal", "flipped"}: + self.orientation = "normal" self.boundary_port_items: dict[str, GroupBoundaryPortItem] = {} self._resize_handle: str | None = None self._resize_start_mouse: QPointF | None = None @@ -195,10 +206,19 @@ def _boundary_anchor_from_geometry(self, boundary: BoundaryPort) -> QPointF: index = same_direction.index(boundary) if boundary in same_direction else 0 total = len(same_direction) y = rect.height() * (index + 1) / (total + 1) + flipped = self.orientation == "flipped" if boundary.direction == "input": - local = QPointF(-GroupBoundaryPortItem.R, y) + local = ( + QPointF(rect.width() + GroupBoundaryPortItem.R, y) + if flipped + else QPointF(-GroupBoundaryPortItem.R, y) + ) else: - local = QPointF(rect.width() + GroupBoundaryPortItem.L, y) + local = ( + QPointF(-GroupBoundaryPortItem.L, y) + if flipped + else QPointF(rect.width() + GroupBoundaryPortItem.L, y) + ) return self.mapToScene(local) def _border_sort_key( @@ -233,21 +253,43 @@ def sync_boundary_ports(self) -> None: key=lambda boundary: self._border_sort_key(boundary, previous_y), ) rect = self.rect() + flipped = self.orientation == "flipped" + width = rect.width() for index, boundary in enumerate(inputs): item = GroupBoundaryPortItem(boundary, self) y = rect.height() * (index + 1) / (len(inputs) + 1) - item.setPos(0, y) + item.setPos(width if flipped else 0, y) item.update_port_label() self.boundary_port_items[boundary.uid] = item for index, boundary in enumerate(outputs): item = GroupBoundaryPortItem(boundary, self) y = rect.height() * (index + 1) / (len(outputs) + 1) - item.setPos(rect.width(), y) + item.setPos(0 if flipped else width, y) item.update_port_label() self.boundary_port_items[boundary.uid] = item + def set_orientation(self, orientation: str) -> None: + if orientation not in {"normal", "flipped"}: + return + self.orientation = orientation + self.sync_boundary_ports() + layout = dict(self.group.layout or {}) + layout["orientation"] = orientation + self.group.layout = layout + members = set( + self.view.project_controller._group_content_uids_for_group(self.group) + ) + for conn_inst, conn_item in self.view.connections.items(): + if ( + conn_inst.src_block().uid in members + or conn_inst.dst_block().uid in members + ) and conn_item.is_manual: + conn_item.invalidate_manual_route() + self.view.on_group_moved(self) + self.update() + def refresh_boundary_port_labels(self) -> None: """Update external port labels after diagram connections change.""" for item in self.boundary_port_items.values(): @@ -412,6 +454,7 @@ def apply_geometry(self, pos: QPointF, rect: QRectF) -> None: "y": float(pos.y()), "width": float(rect.width()), "height": float(rect.height()), + "orientation": self.orientation, } def mouseDoubleClickEvent(self, event): diff --git a/pySimBlocks/gui/graphics/group_proxy_item.py b/pySimBlocks/gui/graphics/group_proxy_item.py index d003ef9..5cf282b 100644 --- a/pySimBlocks/gui/graphics/group_proxy_item.py +++ b/pySimBlocks/gui/graphics/group_proxy_item.py @@ -29,11 +29,17 @@ def __init__(self, is_output: bool, parent_proxy: "GroupProxyItem"): self.is_output = is_output self.parent_proxy = parent_proxy + @property + def is_on_left_side(self) -> bool: + return self.pos().x() < (self.parent_proxy.rect().width() * 0.5) + def connection_anchor(self) -> QPointF: if self.is_output: - local = QPointF(self.L, 0) + x = self.L if not self.is_on_left_side else -self.L + local = QPointF(x, 0) else: - local = QPointF(-self.R, 0) + x = -self.R if self.is_on_left_side else self.R + local = QPointF(x, 0) return self.mapToScene(local) def boundingRect(self) -> QRectF: @@ -42,9 +48,10 @@ def boundingRect(self) -> QRectF: def shape(self): path = QPainterPath() if self.is_output: + tip_x = self.L if not self.is_on_left_side else -self.L path.moveTo(0, -self.H) path.lineTo(0, self.H) - path.lineTo(self.L, 0) + path.lineTo(tip_x, 0) path.closeSubpath() else: path.addEllipse(-self.R, -self.R, 2 * self.R, 2 * self.R) @@ -67,7 +74,8 @@ def paint(self, painter, option, widget=None): path = QPainterPath() path.moveTo(0, -self.H) path.lineTo(0, self.H) - path.lineTo(self.L, 0) + tip_x = self.L if not self.is_on_left_side else -self.L + path.lineTo(tip_x, 0) path.closeSubpath() painter.drawPath(path) else: @@ -92,17 +100,17 @@ def __init__(self, boundary: BoundaryPort, view: "DiagramView"): super().__init__(0, 0, self.WIDTH, self.HEIGHT) self.boundary = boundary self.view = view - rect = self.rect() - mid_y = rect.height() / 2 + layout = boundary.proxy_layout or {} + self.orientation = layout.get("orientation", "normal") + if self.orientation not in {"normal", "flipped"}: + self.orientation = "normal" if self.is_group_in: self.port_item = GroupProxyPortItem(is_output=True, parent_proxy=self) - self.port_item.setPos(rect.width(), mid_y) else: self.port_item = GroupProxyPortItem(is_output=False, parent_proxy=self) - self.port_item.setPos(0, mid_y) + self._layout_port() - layout = boundary.proxy_layout or {} self.setPos(QPointF(float(layout.get("x", 0.0)), float(layout.get("y", 0.0)))) self.setFlag(QGraphicsRectItem.ItemIsMovable) self.setFlag(QGraphicsRectItem.ItemIsSelectable) @@ -134,6 +142,29 @@ def member_anchor(self) -> QPointF: """Anchor used for wires between the proxy and group members.""" return self.port_item.connection_anchor() + def _layout_port(self) -> None: + rect = self.rect() + mid_y = rect.height() / 2 + flipped = self.orientation == "flipped" + if self.is_group_in: + self.port_item.setPos(0 if flipped else rect.width(), mid_y) + else: + self.port_item.setPos(rect.width() if flipped else 0, mid_y) + + def set_orientation(self, orientation: str) -> None: + if orientation not in {"normal", "flipped"}: + return + self.orientation = orientation + self._layout_port() + layout = dict(self.boundary.proxy_layout or {}) + layout["orientation"] = orientation + self.boundary.proxy_layout = layout + for conn_item in self.view.connections.values(): + if conn_item.is_manual: + conn_item.invalidate_manual_route() + self.view.on_proxy_moved(self) + self.update() + def paint(self, painter, option, widget=None): t = self.view.theme selected = bool(option.state & QStyle.State_Selected) @@ -204,10 +235,11 @@ def itemChange(self, change, value): if change == QGraphicsItem.ItemPositionHasChanged and self.view.project_controller: pos = self.pos() - self.boundary.proxy_layout = { - "x": float(pos.x()), - "y": float(pos.y()), - } + layout = dict(self.boundary.proxy_layout or {}) + layout["x"] = float(pos.x()) + layout["y"] = float(pos.y()) + layout["orientation"] = self.orientation + self.boundary.proxy_layout = layout self.view.on_proxy_moved(self) return super().itemChange(change, value) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index e45c04f..e8ac592 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -82,6 +82,8 @@ RenameGroupCommand, PasteClipboardCommand, ToggleOrientationCommand, + ToggleGroupOrientationCommand, + ToggleProxyOrientationCommand, UngroupCommand, DeleteGroupCommand, WireManualBoundaryCommand, @@ -1089,6 +1091,28 @@ def execute_toggle_orientation(self, block_instance: BlockInstance) -> None: ToggleOrientationCommand(self, block_instance.uid, old_orientation, new_orientation) ) + def execute_toggle_group_orientation(self, group_uid: str) -> None: + group_item = self.view.group_items.get(group_uid) + if group_item is None: + return + old_orientation = group_item.orientation + new_orientation = "flipped" if old_orientation == "normal" else "normal" + self.undo_manager.push( + ToggleGroupOrientationCommand(self, group_uid, old_orientation, new_orientation) + ) + + def execute_toggle_proxy_orientation(self, group_uid: str, boundary_uid: str) -> None: + proxy_item = self.view.proxy_items.get(boundary_uid) + if proxy_item is None: + return + old_orientation = proxy_item.orientation + new_orientation = "flipped" if old_orientation == "normal" else "normal" + self.undo_manager.push( + ToggleProxyOrientationCommand( + self, group_uid, boundary_uid, old_orientation, new_orientation + ) + ) + def execute_edit_connection_route( self, connection: ConnectionInstance, @@ -1611,16 +1635,32 @@ def _set_group_geometry(self, group_uid: str, pos: QPointF, rect: QRectF) -> Non return group_item = self.view.group_items.get(group_uid) if group_item is None: - group.layout = { + layout = dict(group.layout or {}) + layout.update({ "x": float(pos.x()), "y": float(pos.y()), "width": float(rect.width()), "height": float(rect.height()), - } + }) + group.layout = layout return group_item.apply_geometry(pos, rect) self.view.on_group_moved(group_item) + def _set_group_orientation(self, group_uid: str, orientation: str) -> None: + group_item = self.view.group_items.get(group_uid) + if group_item is None: + return + group_item.set_orientation(orientation) + + def _set_proxy_orientation( + self, group_uid: str, boundary_uid: str, orientation: str + ) -> None: + proxy_item = self.view.proxy_items.get(boundary_uid) + if proxy_item is None: + return + proxy_item.set_orientation(orientation) + 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: @@ -2060,7 +2100,11 @@ def save_proxy_layouts(self, group: VisualGroup) -> None: if item is None: continue pos = item.pos() - boundary.proxy_layout = {"x": float(pos.x()), "y": float(pos.y())} + boundary.proxy_layout = { + "x": float(pos.x()), + "y": float(pos.y()), + "orientation": item.orientation, + } def _add_manual_boundary_port( self, @@ -2130,7 +2174,10 @@ def _set_proxy_layout( ) if boundary is None: return - boundary.proxy_layout = {"x": float(pos.x()), "y": float(pos.y())} + layout = dict(boundary.proxy_layout or {}) + layout["x"] = float(pos.x()) + layout["y"] = float(pos.y()) + boundary.proxy_layout = layout proxy_item = self.view.proxy_items.get(boundary_uid) if proxy_item is not None: proxy_item.setPos(QPointF(pos)) diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index 77e9c86..72bb696 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -196,6 +196,52 @@ def undo(self) -> None: self._controller.make_dirty() +class ToggleGroupOrientationCommand(QUndoCommand): + def __init__(self, controller, group_uid: str, old_orientation: str, new_orientation: str): + super().__init__("Flip Group") + self._controller = controller + self._group_uid = group_uid + self._old_orientation = old_orientation + self._new_orientation = new_orientation + + def redo(self) -> None: + self._controller._set_group_orientation(self._group_uid, self._new_orientation) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._set_group_orientation(self._group_uid, self._old_orientation) + self._controller.make_dirty() + + +class ToggleProxyOrientationCommand(QUndoCommand): + def __init__( + self, + controller, + group_uid: str, + boundary_uid: str, + old_orientation: str, + new_orientation: str, + ): + super().__init__("Flip Proxy") + self._controller = controller + self._group_uid = group_uid + self._boundary_uid = boundary_uid + self._old_orientation = old_orientation + self._new_orientation = new_orientation + + def redo(self) -> None: + self._controller._set_proxy_orientation( + self._group_uid, self._boundary_uid, self._new_orientation + ) + self._controller.make_dirty() + + def undo(self) -> None: + self._controller._set_proxy_orientation( + self._group_uid, self._boundary_uid, self._old_orientation + ) + self._controller.make_dirty() + + class GroupBlocksCommand(QUndoCommand): def __init__( self, diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index e2571c1..62bc99b 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -298,6 +298,9 @@ def refresh_visual_groups(self) -> None: else: item.group = group if group.layout: + orientation = group.layout.get("orientation", "normal") + if orientation in {"normal", "flipped"}: + item.orientation = orientation item.apply_geometry( QPointF( float(group.layout.get("x", 0.0)), @@ -491,12 +494,16 @@ def _refresh_group_proxies(self, active_group) -> None: else: item.boundary = boundary if boundary.proxy_layout: + orientation = boundary.proxy_layout.get("orientation", "normal") + if orientation in {"normal", "flipped"}: + item.orientation = orientation item.setPos( QPointF( float(boundary.proxy_layout.get("x", 0.0)), float(boundary.proxy_layout.get("y", 0.0)), ) ) + item._layout_port() item.setVisible(True) def _child_group_uid_for_block(self, parent_group, block_uid: str) -> str | None: @@ -604,6 +611,45 @@ def connection_anchor_for_port_item(self, port_item: PortItem) -> QPointF: return port_item.connection_anchor() + def proxy_item_for_port_item(self, port_item: PortItem) -> GroupProxyItem | None: + """Return the proxy that currently owns a port's routed anchor.""" + redirected = self.connection_anchor_for_port_item(port_item) + direct = port_item.connection_anchor() + if ( + abs(redirected.x() - direct.x()) < 0.5 + and abs(redirected.y() - direct.y()) < 0.5 + ): + return None + for proxy in self.proxy_items.values(): + if not proxy.isVisible(): + continue + anchor = proxy.member_anchor() + if ( + abs(anchor.x() - redirected.x()) < 0.5 + and abs(anchor.y() - redirected.y()) < 0.5 + ): + return proxy + return None + + def routing_rect_for_port_item(self, port_item: PortItem) -> QRectF: + """Return the obstacle rectangle used when routing wires to a port.""" + proxy = self.proxy_item_for_port_item(port_item) + if proxy is not None: + return proxy.mapRectToScene(proxy.rect()) + + block_item = port_item.parent_block + block_uid = port_item.instance.block.uid + if self.project_controller is not None and self.current_view_group_uid is None: + exposing = self.project_controller._group_exposing_boundary_for_block( + block_uid, self.current_view_group_uid + ) + if exposing is not None: + group_item = self.group_items.get(exposing.uid) + if group_item is not None and group_item.isVisible(): + return group_item.mapRectToScene(group_item.rect()) + + return block_item.mapRectToScene(block_item.rect()) + def _manual_proxy_anchor_for_member_port(self, group, port_item: PortItem) -> QPointF | None: """Route member ports to a proxy while the boundary is incomplete.""" key = f"{port_item.instance.block.uid}:{port_item.instance.name}" @@ -896,13 +942,38 @@ def keyPressEvent(self, event) -> None: event.accept() return - # ROTATE BLOCK + # ROTATE BLOCK / GROUP / PROXY 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: + selected_blocks = [ + i for i in self.diagram_scene.selectedItems() if isinstance(i, BlockItem) + ] + selected_groups = [ + i for i in self.diagram_scene.selectedItems() if isinstance(i, GroupItem) + ] + selected_proxies: list[GroupProxyItem] = [] + seen_proxy_uids: set[str] = set() + for item in self.diagram_scene.selectedItems(): + if isinstance(item, GroupProxyPortItem): + item = item.parent_proxy + if not isinstance(item, GroupProxyItem): + continue + if item.boundary.uid in seen_proxy_uids: + continue + seen_proxy_uids.add(item.boundary.uid) + selected_proxies.append(item) + for item in selected_blocks: self.project_controller.execute_toggle_orientation(item.instance) - return + for item in selected_groups: + self.project_controller.execute_toggle_group_orientation(item.group.uid) + if self.current_view_group_uid is not None: + for item in selected_proxies: + self.project_controller.execute_toggle_proxy_orientation( + self.current_view_group_uid, + item.boundary.uid, + ) + if selected_blocks or selected_groups or selected_proxies: + event.accept() + return # CENTER VIEW if event.key() == Qt.Key_Space and not event.modifiers(): From 4d22175fd986ef920700611d66fc3fdfdc9e8d04 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Wed, 24 Jun 2026 11:39:31 +0200 Subject: [PATCH 34/42] fix(gui): preserve partial group boundary wiring on connection delete --- pySimBlocks/gui/project_controller.py | 116 ++++++++---------- .../gui/services/group_boundary_service.py | 10 +- pySimBlocks/gui/widgets/diagram_view.py | 4 +- 3 files changed, 62 insertions(+), 68 deletions(-) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index e8ac592..3ac9124 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -414,19 +414,6 @@ def try_wire_boundary_endpoints(self, src, dst) -> bool: if group_uid is None: return False boundary_uid = proxy_port.parent_proxy.boundary.uid - group = self.project_state.get_visual_group(group_uid) - boundary = ( - self._find_boundary_port(group, boundary_uid) if group is not None else None - ) - if ( - boundary is not None - and boundary.origin == "auto" - and not boundary.linked_connection_uid - and self._wire_auto_boundary_internal( - group_uid, boundary_uid, member_port - ) - ): - return True return self._wire_manual_boundary_internal( group_uid, boundary_uid, @@ -438,10 +425,6 @@ def try_wire_boundary_endpoints(self, src, dst) -> bool: return True group_uid = boundary_port.parent_group.group.uid boundary_uid = boundary_port.boundary.uid - if self._wire_auto_boundary_external( - group_uid, boundary_uid, member_port - ): - return True return self._wire_manual_boundary_external( group_uid, boundary_uid, @@ -912,42 +895,8 @@ def try_connect_boundary_ports( ports = {port1, port2} for group in self.project_state.visual_groups: - members = set(self._group_content_uids_for_group(group)) for boundary in group.boundary_ports: - if boundary.origin == "auto" and not boundary.linked_connection_uid: - internal = ( - find_port(self.project_state, boundary.linked_port_uid) - if boundary.linked_port_uid - else None - ) - if internal is not None and internal in ports: - external_candidate = port2 if port1 is internal else port1 - if self._validate_auto_external_link( - group, boundary, external_candidate - ) is not None: - return self._wire_auto_boundary_external( - group.uid, - boundary.uid, - external_candidate, - ) - external = ( - find_port(self.project_state, boundary.external_port_uid) - if boundary.external_port_uid - else None - ) - if external is not None and external in ports: - internal_candidate = port2 if port1 is external else port1 - if self._validate_internal_link( - group, boundary, internal_candidate - ): - return self._wire_auto_boundary_internal( - group.uid, - boundary.uid, - internal_candidate, - ) - continue - - if boundary.origin != "manual" or boundary.linked_connection_uid: + if boundary.linked_connection_uid: continue internal = ( @@ -1469,6 +1418,19 @@ def _capture_boundaries_for_connection( captured.append((group.uid, copy.deepcopy(boundary.to_dict()))) return captured + def _view_is_inside_group(self, view_group_uid: str | None, group: VisualGroup) -> bool: + """Return whether the active view is inside a group or one of its descendants.""" + if view_group_uid is None: + return False + if view_group_uid == group.uid: + return True + child = self.project_state.get_visual_group(view_group_uid) + while child is not None and child.parent_uid: + if child.parent_uid == group.uid: + return True + child = self.project_state.get_visual_group(child.parent_uid) + return False + def _preserve_boundaries_on_connection_remove( self, connection: ConnectionInstance, @@ -1477,6 +1439,7 @@ def _preserve_boundaries_on_connection_remove( key = self._connection_key(connection) src_uid = connection.src_block().uid dst_uid = connection.dst_block().uid + view_group_uid = self.view.current_view_group_uid changed = False for group in self.project_state.visual_groups: @@ -1512,33 +1475,55 @@ def _preserve_boundaries_on_connection_remove( external_group is not None and external_group.uid != group.uid ) external_at_root = external_group is None + deleting_inside = self._view_is_inside_group(view_group_uid, group) if is_cross_group: boundary.linked_connection_uid = "" if boundary.origin == "manual" and boundary.external_port_uid == external_key: boundary.external_port_uid = "" elif boundary.origin != "manual": - boundary.external_port_uid = external_key + if deleting_inside: + boundary.external_port_uid = external_key + boundary.linked_port_uid = "" + else: + boundary.linked_port_uid = internal_key + boundary.external_port_uid = "" changed = True continue if external_at_root and boundary.origin != "manual": - boundary.external_port_uid = external_key boundary.linked_connection_uid = "" + if deleting_inside: + boundary.external_port_uid = external_key + boundary.linked_port_uid = "" + else: + boundary.linked_port_uid = internal_key + boundary.external_port_uid = "" changed = True continue if boundary.origin == "manual": boundary.linked_connection_uid = "" - if not boundary.linked_port_uid: - boundary.linked_port_uid = internal_key - if not boundary.external_port_uid: + if deleting_inside: boundary.external_port_uid = external_key + boundary.linked_port_uid = "" + elif external_at_root: + boundary.linked_port_uid = internal_key + boundary.external_port_uid = "" + else: + if not boundary.linked_port_uid: + boundary.linked_port_uid = internal_key + if not boundary.external_port_uid: + boundary.external_port_uid = external_key else: boundary.origin = "manual" - boundary.linked_port_uid = internal_key - boundary.external_port_uid = external_key boundary.linked_connection_uid = "" + if deleting_inside: + boundary.external_port_uid = external_key + boundary.linked_port_uid = "" + else: + boundary.linked_port_uid = internal_key + boundary.external_port_uid = "" self.ensure_group_boundary_proxies(group) changed = True @@ -2273,7 +2258,7 @@ def disconnect_manual_boundary_side( if group is None: return False boundary = self._find_boundary_port(group, boundary_uid) - if boundary is None or is_complete(boundary): + if boundary is None or boundary.linked_connection_uid: return False if side not in ("internal", "external"): return False @@ -2436,17 +2421,24 @@ def _rebuild_group_boundary_ports(self, group: VisualGroup) -> None: port.uid = previous.uid port.proxy_uid = previous.proxy_uid port.proxy_layout = dict(previous.proxy_layout) + port.external_port_uid = previous.external_port_uid + if previous.linked_port_uid: + port.linked_port_uid = previous.linked_port_uid + if not port.linked_connection_uid: + port.linked_connection_uid = previous.linked_connection_uid rebuilt_auto.append(port) rebuilt_keys = {port.linked_port_uid for port in rebuilt_auto} for port in group.boundary_ports: if port.origin != "auto": continue - if not port.linked_port_uid or port.linked_connection_uid: + if port.linked_connection_uid: + continue + if not port.linked_port_uid and not port.external_port_uid: continue if port.linked_port_uid in manual_member_keys: continue - if port.linked_port_uid in rebuilt_keys: + if port.linked_port_uid and port.linked_port_uid in rebuilt_keys: continue rebuilt_auto.append(port) diff --git a/pySimBlocks/gui/services/group_boundary_service.py b/pySimBlocks/gui/services/group_boundary_service.py index d2a10a0..2f396a5 100644 --- a/pySimBlocks/gui/services/group_boundary_service.py +++ b/pySimBlocks/gui/services/group_boundary_service.py @@ -98,8 +98,10 @@ def validate_external_link( boundary: BoundaryPort, external_port: PortInstance, ) -> bool: - """Return whether an external port may be wired to a manual group border port.""" - if not is_manual(boundary): + """Return whether an external port may be wired to a group border port.""" + if is_complete(boundary): + return False + if boundary.origin not in ("manual", "auto"): return False if external_port.block.uid in group.members: return False @@ -125,7 +127,9 @@ def connection_endpoints( def can_complete(state: ProjectState, boundary: BoundaryPort) -> bool: - if not is_manual(boundary) or is_complete(boundary): + if is_complete(boundary): + return False + if boundary.origin not in ("manual", "auto"): return False if not boundary.linked_port_uid or not boundary.external_port_uid: return False diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index 62bc99b..b8b1b5d 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -365,8 +365,6 @@ def refresh_manual_boundary_wires(self) -> None: for boundary in group.boundary_ports: if boundary.linked_connection_uid: continue - if boundary.origin != "manual": - continue if not boundary.linked_port_uid: continue if not self._proxy_applies_to_active_group(group, boundary): @@ -420,7 +418,7 @@ def refresh_manual_boundary_wires(self) -> None: if group_item is None or not group_item.isVisible(): continue for boundary in group.boundary_ports: - if boundary.origin != "manual" or boundary.linked_connection_uid: + if boundary.linked_connection_uid: continue if not boundary.external_port_uid: continue From 6acc4e04e8a94e8c5be8ca49d87b66c8b1e88d67 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Wed, 24 Jun 2026 15:58:57 +0200 Subject: [PATCH 35/42] fix(gui): refresh dashed boundary wires when a group is moved --- pySimBlocks/gui/widgets/diagram_view.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pySimBlocks/gui/widgets/diagram_view.py b/pySimBlocks/gui/widgets/diagram_view.py index b8b1b5d..01f0e45 100644 --- a/pySimBlocks/gui/widgets/diagram_view.py +++ b/pySimBlocks/gui/widgets/diagram_view.py @@ -755,6 +755,8 @@ def on_group_moved(self, group_item: GroupItem) -> None: """Refresh wires after a group container is moved.""" for conn_inst, conn_item in self.connections.items(): conn_item.update_position() + for wire in self.manual_boundary_wires.values(): + wire.update_position() def get_block_item_from_instance(self, block_instance: BlockInstance) -> BlockItem | None: """Return the visual BlockItem for the given block instance, or None. From 2909d09e9943cc884f9f40e6e368ddb9efafa6b6 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Thu, 25 Jun 2026 11:22:28 +0200 Subject: [PATCH 36/42] fix(gui): keep per-panel series styles when enlarging manual plot preview --- pySimBlocks/gui/dialogs/plot_dialog.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pySimBlocks/gui/dialogs/plot_dialog.py b/pySimBlocks/gui/dialogs/plot_dialog.py index f2adcc4..df4f009 100644 --- a/pySimBlocks/gui/dialogs/plot_dialog.py +++ b/pySimBlocks/gui/dialogs/plot_dialog.py @@ -986,6 +986,16 @@ def _draw_manual_panel( self._axis_to_panel_key[id(ax)] = key self._highlight_manual_axis(ax, key == f"manual::{self._manual.active}") + @staticmethod + def _manual_panel_index_from_key(key: str) -> int: + """Return manual panel index encoded in a preview panel key.""" + if key.startswith("manual::"): + try: + return int(key.split("::", 1)[1]) + except ValueError: + pass + return 0 + def _render_manual_plots_preview(self) -> None: """Render all manual plot panels in a 2-column grid (last odd spans full width).""" self._refresh_subplot_filter([], keep_current=False) @@ -1026,7 +1036,8 @@ def _render_manual_plots_preview(self) -> None: if len(panels) == 1: title, key, series = panels[0] ax = self.figure.add_subplot(111) - self._draw_manual_panel(ax, time, title, key, series, 0) + panel_idx = self._manual_panel_index_from_key(key) + self._draw_manual_panel(ax, time, title, key, series, panel_idx) self._finalize_layout() return @@ -1037,7 +1048,8 @@ def _render_manual_plots_preview(self) -> None: ax = self.figure.add_subplot(gs[i // 2, :]) else: ax = self.figure.add_subplot(gs[i // 2, i % 2]) - self._draw_manual_panel(ax, time, title, key, series, i) + panel_idx = self._manual_panel_index_from_key(key) + self._draw_manual_panel(ax, time, title, key, series, panel_idx) self._finalize_layout() def _highlight_manual_axis(self, ax, selected: bool) -> None: From 44fa8d16becb35cbc1bb4eaf31655a9a56b514dc Mon Sep 17 00:00:00 2001 From: Lukasik Date: Thu, 25 Jun 2026 15:50:42 +0200 Subject: [PATCH 37/42] fix(gui): preserve manual plot zoom when switching active panel --- pySimBlocks/gui/dialogs/plot_dialog.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pySimBlocks/gui/dialogs/plot_dialog.py b/pySimBlocks/gui/dialogs/plot_dialog.py index df4f009..e202500 100644 --- a/pySimBlocks/gui/dialogs/plot_dialog.py +++ b/pySimBlocks/gui/dialogs/plot_dialog.py @@ -690,14 +690,14 @@ def _select_manual_plot(self, index: int) -> None: if index < 0 or index >= len(self._manual.selections): return if index == self._manual.active: - self._update_preview_plot() + self._refresh_manual_panel_highlights() return self._save_active_manual_selection() self._save_active_manual_title() self._manual.active = index self._load_active_manual_title() self._load_manual_selection_to_tree(self._manual.selections[index]) - self._update_preview_plot() + self._refresh_manual_panel_highlights() def _save_active_manual_selection(self) -> None: """Persist the signal tree checks into the active manual plot.""" @@ -1060,6 +1060,18 @@ def _highlight_manual_axis(self, ax, selected: bool) -> None: spine.set_linewidth(width) spine.set_edgecolor(color) + def _refresh_manual_panel_highlights(self) -> None: + """Update active-panel borders without redrawing plot data (keeps zoom).""" + if not self._uses_manual_layout() or not self.figure.axes: + return + active_key = f"manual::{self._manual.active}" + for ax in self.figure.axes: + key = self._axis_to_panel_key.get(id(ax)) + if key is None or not key.startswith("manual::"): + continue + self._highlight_manual_axis(ax, key == active_key) + self.canvas.draw_idle() + def _refresh_subplot_filter(self, panels: list[tuple[str, str, list[tuple[str, np.ndarray]]]], keep_current: bool): """Rebuild subplot check-list from panel descriptors.""" current_enabled = self._selected_subplot_keys() if keep_current else set() From 76e6489c02608a57d242cd0dbe57b1649d276a66 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Fri, 26 Jun 2026 13:14:08 +0200 Subject: [PATCH 38/42] fix(gui): keep manually edited wire routes when blocks move --- pySimBlocks/gui/graphics/connection_item.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/pySimBlocks/gui/graphics/connection_item.py b/pySimBlocks/gui/graphics/connection_item.py index b04b87c..41f2db7 100644 --- a/pySimBlocks/gui/graphics/connection_item.py +++ b/pySimBlocks/gui/graphics/connection_item.py @@ -153,12 +153,7 @@ def update_position(self): if self.is_manual and self.route and len(self.route.points) >= 2: self.route.points[0] = p1 self.route.points[-1] = p2 - self.route.points = self._simplify_orthogonal_route(self.route.points) - if self._route_is_stale(p1, p2, self.route.points): - pts = self._compute_auto_route(p1, p2) - self.route = OrthogonalRoute(pts) - self.is_manual = False - self._apply_route(self.route.points) + self._apply_route(self.route.points, simplify=False) return pts = self._compute_auto_route(p1, p2) @@ -430,19 +425,6 @@ def _simplify_orthogonal_route(self, points: list[QPointF]) -> list[QPointF]: simplified.append(QPointF(points[-1])) return simplified - def _route_is_stale(self, p1: QPointF, p2: QPointF, points: list[QPointF]) -> bool: - if len(points) < 2: - return True - - for index in range(len(points) - 1): - if self._wire_length([points[index], points[index + 1]]) < self.AXIS_EPS: - return True - - auto_points = self._compute_auto_route(p1, p2) - manual_len = self._wire_length(points) - auto_len = self._wire_length(auto_points) - return manual_len > auto_len * 1.35 + 30.0 - def _snap(self, v: float) -> float: """Snap a scalar coordinate to the routing grid.""" return round(v / self.GRID) * self.GRID From 55dfc9006f34f79e688d79409b20f78d55328ae8 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Fri, 26 Jun 2026 13:15:40 +0200 Subject: [PATCH 39/42] test(gui): add visual group tests and coverage documentation --- .../test_coverage_groupes_visuels.md | 194 +++ tests/gui/test_diagram_clipboard.py | 296 +++++ tests/gui/test_group_boundary_labels.py | 99 ++ tests/gui/test_group_orientation.py | 136 ++ tests/gui/test_manual_boundary_wiring.py | 1124 +++++++++++++++++ tests/gui/test_nested_visual_groups.py | 218 ++++ tests/gui/test_visual_group_controller.py | 189 +++ tests/gui/test_visual_group_persistence.py | 138 ++ tests/gui/test_visual_group_view.py | 393 ++++++ 9 files changed, 2787 insertions(+) create mode 100644 docs/Developer_Guide/test_coverage_groupes_visuels.md create mode 100644 tests/gui/test_diagram_clipboard.py create mode 100644 tests/gui/test_group_boundary_labels.py create mode 100644 tests/gui/test_group_orientation.py create mode 100644 tests/gui/test_manual_boundary_wiring.py create mode 100644 tests/gui/test_nested_visual_groups.py create mode 100644 tests/gui/test_visual_group_controller.py create mode 100644 tests/gui/test_visual_group_persistence.py create mode 100644 tests/gui/test_visual_group_view.py diff --git a/docs/Developer_Guide/test_coverage_groupes_visuels.md b/docs/Developer_Guide/test_coverage_groupes_visuels.md new file mode 100644 index 0000000..0fba703 --- /dev/null +++ b/docs/Developer_Guide/test_coverage_groupes_visuels.md @@ -0,0 +1,194 @@ +# Couverture des tests — groupes visuels + +Ce document recense les **77 tests** dédiés aux groupes visuels, répartis en **8 fichiers** sous `tests/gui/`. + +--- + +## Synthèse + +| Fichier | Tests | Domaine principal | +|---------|------:|-------------------| +| `test_visual_group_controller.py` | 8 | Logique contrôleur (CRUD groupe, membres, frontières) | +| `test_visual_group_view.py` | 14 | Vue, navigation, raccourcis, proxies, undo layout | +| `test_visual_group_persistence.py` | 4 | Sérialisation YAML (save/load, rétrocompatibilité) | +| `test_nested_visual_groups.py` | 8 | Hiérarchie imbriquée, visibilité, suppression | +| `test_group_orientation.py` | 3 | Ctrl+R sur groupe et proxy In/Out | +| `test_group_boundary_labels.py` | 4 | Libellés des ports de frontière | +| `test_diagram_clipboard.py` | 8 | Copier-coller blocs, groupes, proxies | +| `test_manual_boundary_wiring.py` | 28 | Câblage partiel, fils `---`, reconnexion (In **et** Out) | +| **Total** | **77** | | + +--- + +## `test_visual_group_controller.py` + +Logique `ProjectController` : création, suppression, appartenance, frontières auto. + +| Test | Cas couvert | +|------|-------------| +| `test_group_blocks_creates_visual_group_with_boundaries` | Groupage de 2 blocs avec connexion interne + sortie vers l'extérieur ; frontière auto de direction `output` | +| `test_ungroup_removes_visual_group` | Dé-groupement supprime l'entité ; `ungroup` sur uid absent retourne `False` | +| `test_remove_block_updates_group_membership` | `remove_block` retire le membre du groupe ; groupe supprimé quand vide | +| `test_add_block_to_group_moves_membership` | `add_block_to_group` ajoute un bloc existant au groupe (undoable) | +| `test_add_block_in_group_view_creates_member` | Drop palette en vue interne (`add_block_in_group_view`) crée et ajoute le bloc | +| `test_remove_block_from_group_keeps_block_visible_at_parent` | `remove_block_from_group` retire le membre sans supprimer le bloc ; réaffichage au parent | +| `test_cannot_add_block_already_owned_by_another_group` | Un bloc déjà membre d'un groupe ne peut pas être ajouté à un autre | +| `test_boundary_ports_recomputed_when_connections_change` | Ajout d'une connexion traversante → nouvelle frontière `input` ; suppression → frontière orpheline conservée (uid membre, pas de `linked_connection_uid`) | + +--- + +## `test_visual_group_view.py` + +Interaction `DiagramView` : visibilité, navigation, raccourcis, proxies, ports manuels. + +| Test | Cas couvert | +|------|-------------| +| `test_group_hides_members_and_shows_group_item` | Au niveau racine : membres masqués, `GroupItem` visible, blocs externes visibles | +| `test_group_captures_member_layouts_and_internal_view_applies_them` | Positions capturées au groupage ; restaurées en vue interne ; sauvegardées à la sortie | +| `test_double_click_enters_group_view` | Entrée dans le groupe : membres visibles, rectangle groupe masqué | +| `test_keyboard_shortcut_groups_selection` | `Ctrl+Shift+G` groupe la sélection | +| `test_keyboard_shortcut_ungroups_selection` | `Ctrl+Shift+U` dé-groupe le groupe sélectionné ; membres réaffichés | +| `test_undo_redo_move_resize_group` | Déplacement/redimensionnement du rectangle groupe (undo/redo) | +| `test_redo_move_resize_after_redo_group_keeps_same_uid` | Undo group + undo move, puis redo : même `uid` de groupe, move rejoué correctement | +| `test_group_boundary_proxies_assigned_on_creation` | Chaque frontière a un `proxy_uid` et un `proxy_layout` à la création | +| `test_proxies_visible_only_in_internal_view` | Proxies In/Out masqués à la racine, visibles en vue interne | +| `test_palette_exposes_group_ports_in_internal_view` | Catégorie `group_ports` (In/Out) visible uniquement en vue interne | +| `test_add_manual_boundary_port_creates_proxy` | Ajout port manuel `input` → proxy visible, label `In`, position sauvegardée | +| `test_add_second_manual_in_gets_unique_name` | Deuxième In nommé `In_1` ; labels affichés sur le rectangle parent | +| `test_undo_removes_manual_ports_before_ungrouping` | Undo port manuel puis undo groupage dans le bon ordre | +| `test_undo_connection_route_edit` | Undo/redo édition de route de connexion *(hors groupes — test de régression undo général)* | + +--- + +## `test_visual_group_persistence.py` + +Persistance `gui.groups` dans `project.yaml`. + +| Test | Cas couvert | +|------|-------------| +| `test_visual_group_from_dict_preserves_group_layout_with_member_layouts` | Désérialisation unitaire `VisualGroup.from_dict` (layout groupe + `member_layouts`) | +| `test_build_project_yaml_includes_gui_groups` | `build_project_yaml` écrit `gui.groups` avec frontières manuelles et layouts | +| `test_loader_restores_visual_groups_from_yaml` | Chargement projet avec groupes : uid, frontière manuelle, `member_layouts` | +| `test_loader_keeps_backward_compatibility_without_gui_groups` | Projet sans `gui.groups` → liste vide, pas d'erreur | + +--- + +## `test_nested_visual_groups.py` + +Groupes parents/enfants, visibilité multi-niveaux, suppression récursive. + +| Test | Cas couvert | +|------|-------------| +| `test_group_block_with_existing_subgroup` | Grouper un bloc + un sous-groupe existant : `child_group_uids`, `parent_uid` | +| `test_nested_group_visibility_at_root_and_inside_parent` | Masquage récursif à la racine ; enfant visible dans le parent | +| `test_group_selected_block_and_subgroup_from_context_menu` | Sélection bloc + `GroupItem` → groupage avec sous-groupe enfant | +| `test_ungroup_parent_promotes_child_groups` | Dé-grouper le parent promeut les enfants à la racine (`parent_uid = null`) | +| `test_create_subgroup_inside_parent_view` | Création sous-groupe depuis la vue interne du parent | +| `test_cross_group_connection_visible_at_root` | Connexion entre deux groupes frères visible à la racine | +| `test_cross_child_connection_visible_inside_parent` | Connexion entre enfants masquée à la racine, visible dans le parent | +| `test_delete_group_removes_nested_members_and_child_groups` | `delete_group` sur boucle imbriquée supprime blocs + sous-groupes ; undo restaure | + +--- + +## `test_group_orientation.py` + +Retournement visuel `Ctrl+R` sans modifier les blocs membres. + +| Test | Cas couvert | +|------|-------------| +| `test_ctrl_r_flips_group_display_without_touching_members` | Ports de frontière inversés (`normal` → `flipped`) ; orientation des membres inchangée | +| `test_flipped_group_wire_exits_away_from_anchor` | Routage fil externe : segment sort du rectangle dans le bon sens après flip | +| `test_ctrl_r_flips_proxy_display_without_touching_members` | Flip proxy In/Out en vue interne ; membre non retourné | + +--- + +## `test_group_boundary_labels.py` + +Libellés affichés sur les ports de frontière (`group_boundary_labels.py`). + +| Test | Cas couvert | +|------|-------------| +| `test_boundary_port_label_input_shows_external_source` | Frontière auto entrée → label = `display_as` du bloc source externe | +| `test_boundary_port_label_output_shows_external_destination` | Frontière auto sortie → label = `display_as` du bloc destination externe | +| `test_boundary_port_label_manual_defaults_to_in_or_out` | Port manuel sans câblage → `In` / `Out` | +| `test_boundary_port_label_manual_keeps_proxy_name_when_internally_wired` | Port manuel câblé en interne garde le label proxy (`In`) | + +--- + +## `test_diagram_clipboard.py` + +Copier-coller via `diagram_clipboard.py`. + +| Test | Cas couvert | +|------|-------------| +| `test_copy_paste_nested_group_preserves_hierarchy` | Copier boucle imbriquée → 3 groupes, 4 blocs, noms suffixés `_1`, hiérarchie préservée | +| `test_copy_paste_multiple_root_groups` | Copier deux groupes racine → deux racines collées avec noms uniques | +| `test_copy_paste_blocks_only` | Copier blocs + connexion sans groupe | +| `test_paste_nested_group_undo` | `undo_paste` annule duplication imbriquée | +| `test_paste_group_inside_parent_view` | Coller un sous-groupe dans la vue parent → `parent_uid` correct | +| `test_copy_paste_manual_in_out_proxies` | Copier/coller proxies In/Out avec positions et labels uniques | +| `test_copy_paste_proxy_with_member_block_preserves_internal_link` | Copier proxy + bloc membre → `linked_port_uid` recâblé sur le nouveau bloc | +| `test_paste_proxies_requires_internal_group_view` | Coller proxies hors vue interne refusé (`paste_clipboard_at` → `False`) | + +--- + +## `test_manual_boundary_wiring.py` + +Câblage partiel des frontières auto et manuelles, fils en pointillés, reconnexion côté par côté. + +| Test | Cas couvert | +|------|-------------| +| `test_delete_incomplete_internal_boundary_wire_keeps_proxy` | Suppression fil interne manuel (In) → proxy conservé, `linked_port_uid` vidé | +| `test_delete_incomplete_internal_boundary_wire_keeps_output_proxy` | Même scénario pour proxy **Out** (câblage `gain.output` ↔ GroupOut) | +| `test_delete_incomplete_external_boundary_wire_keeps_group_port` | Suppression fil externe après câblage partiel (In) → état externe vidé, interne conservé | +| `test_delete_incomplete_external_boundary_wire_keeps_output_group_port` | Même scénario pour proxy **Out** (externe sur `sum.input`) | +| `test_delete_completed_group_connection_keeps_proxy_and_shows_dashed_wire` | Suppression connexion complète (In) → fil interne `---`, pas de fil externe à la racine | +| `test_delete_completed_group_connection_keeps_output_proxy_and_dashed_wire` | Même scénario pour proxy **Out** | +| `test_delete_block_to_group_connection_keeps_internal_dashed_wire` | Suppression fil auto entrée à la racine → frontière orpheline + fil interne `---` | +| `test_delete_block_to_group_output_connection_keeps_internal_dashed_wire` | Même scénario pour frontière auto **sortie** | +| `test_delete_inside_group_keeps_external_reconnect_state` | Suppression fil interne auto (entrée) → fil externe `---` ; reconnexion externe puis interne | +| `test_delete_inside_group_keeps_external_reconnect_state_for_output` | Même scénario pour frontière auto **sortie** | +| `test_delete_internal_dashed_after_root_delete_keeps_nothing` | Suppression fil interne (In) après déconnexion racine → état entièrement vidé | +| `test_delete_internal_dashed_after_root_delete_keeps_nothing_for_output` | Même scénario pour proxy **Out** | +| `test_delete_auto_boundary_connection_keeps_proxy_in_group_view` | Suppression fil auto entrée → proxy visible + fil interne `---` | +| `test_delete_auto_output_boundary_connection_keeps_proxy_in_group_view` | Même scénario pour frontière auto **sortie** | +| `test_reconnect_block_to_group_via_border_port` | Reconnexion externe via port rectangle après suppression | +| `test_reconnect_after_delete_via_boundary_ports` | Reconnexion directe bloc↔bloc restaure `linked_connection_uid` | +| `test_cross_group_delete_keeps_border_ports_without_dashed_wires` | Suppression fil entre deux groupes → ports conservés, pas de fils `---` à la racine | +| `test_cross_group_reconnect_via_group_boundary_ports` | Reconnexion port sortie groupe A → port entrée groupe B | +| `test_auto_boundary_internal_dashed_after_cross_group_delete` | Après suppression cross-group → fil interne `---` visible dans le groupe source | +| `test_parent_group_shows_proxies_for_child_boundaries` | Proxies parent pour frontières enfant (y compris imbriqué) | +| `test_parent_with_only_child_groups_shows_proxies` | Groupe sans membres directs, uniquement enfants → tous les proxies visibles | +| `test_cross_child_group_delete_and_reconnect_inside_parent` | Suppression/reconnexion entre enfants depuis la vue parent | +| `test_delete_inside_nested_group_preserves_parent_external` | Suppression fil dans sous-groupe → état externe parent préservé + fil `---` racine | +| `test_reconnect_inside_nested_group_after_delete` | Reconnexion interne dans sous-groupe imbriqué | +| `test_reconnect_outside_nested_group_after_delete` | Reconnexion externe parent puis interne enfant (2 étapes) | +| `test_group_border_ports_keep_vertical_order_after_rebuild` | Ordre vertical des ports conservé après `_rebuild_group_boundary_ports` | +| `test_cross_group_delete_does_not_spawn_multiple_root_dashed_wires` | Pas de fils `---` parasites à la racine après suppression cross-group | +| `test_proxy_wires_to_child_group_border_inside_parent` | Proxy parent câblé vers port frontière d'un groupe enfant | + +--- + +## Commande de vérification + +```bash +python -m pytest tests/gui/test_visual_group*.py \ + tests/gui/test_group*.py \ + tests/gui/test_manual_boundary_wiring.py \ + tests/gui/test_diagram_clipboard.py \ + tests/gui/test_nested_visual_groups.py -v + +python -m pytest tests/ -q +``` + +### Sans affichage (offscreen / CI) + +```bash +QT_QPA_PLATFORM=offscreen python -m pytest tests/gui/test_visual_group*.py \ + tests/gui/test_group*.py \ + tests/gui/test_manual_boundary_wiring.py \ + tests/gui/test_diagram_clipboard.py \ + tests/gui/test_nested_visual_groups.py -v + +QT_QPA_PLATFORM=offscreen python -m pytest tests/ -q +``` diff --git a/tests/gui/test_diagram_clipboard.py b/tests/gui/test_diagram_clipboard.py new file mode 100644 index 0000000..e242fff --- /dev/null +++ b/tests/gui/test_diagram_clipboard.py @@ -0,0 +1,296 @@ +from PySide6.QtCore import QPointF + +from pySimBlocks.gui.diagram_clipboard import ( + capture_groups_clipboard, + capture_proxies_clipboard, + capture_selection_clipboard, + clipboard_has_content, + paste_clipboard, + undo_paste, +) +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 _select_items(view, *items): + view.diagram_scene.clearSelection() + for item in items: + item.setSelected(True) + + +def _first_port(block, direction: str): + for port in block.ports: + if port.direction == direction: + return port + raise AssertionError(f"No {direction} port on {block.name}") + + +def _nested_control_loop(controller): + sum_err = controller.add_block("operators", "sum") + controller_block = controller.add_block("controllers", "state_feedback") + sum_plant = controller.add_block("operators", "sum") + system = controller.add_block("systems", "linear_state_space") + regulator = controller.group_blocks([sum_err, controller_block], name="Regulator") + plant = controller.group_blocks([sum_plant, system], name="Plant") + control_loop = controller.group_blocks( + [], + child_group_uids=[regulator.uid, plant.uid], + name="ControlLoop", + ) + return control_loop, regulator, plant, [sum_err, controller_block, sum_plant, system] + + +def test_copy_paste_nested_group_preserves_hierarchy(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + control_loop, regulator, plant, blocks = _nested_control_loop(controller) + _select_items(view, view.group_items[control_loop.uid]) + clipboard = capture_selection_clipboard(controller) + assert clipboard is not None + assert len(clipboard.groups) == 3 + assert set(clipboard.root_group_uids) == {control_loop.uid} + + origin = QPointF( + float(control_loop.layout.get("x", 0.0)) + 120.0, + float(control_loop.layout.get("y", 0.0)) + 80.0, + ) + result = paste_clipboard(controller, clipboard, origin) + assert len(result.group_uids) == 3 + assert len(result.blocks) == 4 + + pasted_roots = [ + group + for group in controller.project_state.visual_groups + if group.uid in result.group_uids and group.parent_uid is None + ] + assert len(pasted_roots) == 1 + pasted_root = pasted_roots[0] + assert len(pasted_root.child_group_uids) == 2 + + child_names = { + controller.project_state.get_visual_group(uid).name + for uid in pasted_root.child_group_uids + } + assert child_names == {"Regulator_1", "Plant_1"} + + pasted_block_uids = {block.uid for block in result.blocks} + for child_uid in pasted_root.child_group_uids: + child = controller.project_state.get_visual_group(child_uid) + assert child is not None + assert set(child.members).issubset(pasted_block_uids) + + +def test_copy_paste_multiple_root_groups(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + group_a = controller.group_blocks([a, b], name="GroupA") + group_b = controller.group_blocks([c, d], name="GroupB") + + clipboard = capture_groups_clipboard(controller, [group_a.uid, group_b.uid]) + assert clipboard is not None + assert set(clipboard.root_group_uids) == {group_a.uid, group_b.uid} + assert len(clipboard.groups) == 2 + assert len(clipboard.blocks) == 4 + + result = paste_clipboard(controller, clipboard, QPointF(400.0, 200.0)) + pasted_roots = [ + controller.project_state.get_visual_group(uid) + for uid in result.group_uids + if controller.project_state.get_visual_group(uid).parent_uid is None + ] + assert len(pasted_roots) == 2 + assert {group.name for group in pasted_roots} == {"GroupA_1", "GroupB_1"} + + +def test_copy_paste_blocks_only(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + controller.add_connection(_first_port(a, "output"), _first_port(b, "input")) + + _select_items(view, view.get_block_item_from_instance(a), view.get_block_item_from_instance(b)) + clipboard = capture_selection_clipboard(controller) + assert clipboard is not None + assert not clipboard.groups + assert len(clipboard.blocks) == 2 + assert len(clipboard.connections) == 1 + + before_blocks = len(controller.project_state.blocks) + result = paste_clipboard(controller, clipboard, QPointF(300.0, 100.0)) + assert len(controller.project_state.blocks) == before_blocks + 2 + assert len(result.connections) == 1 + + +def test_paste_nested_group_undo(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + control_loop, _regulator, _plant, member_blocks = _nested_control_loop(controller) + clipboard = capture_groups_clipboard(controller, [control_loop.uid]) + result = paste_clipboard(controller, clipboard, QPointF(500.0, 300.0)) + + assert len(controller.project_state.visual_groups) == 6 + assert len(controller.project_state.blocks) == 8 + + undo_paste(controller, result) + assert len(controller.project_state.visual_groups) == 3 + assert len(controller.project_state.blocks) == 4 + for block in member_blocks: + assert controller._find_block_by_uid(block.uid) is not None + + +def test_paste_group_inside_parent_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + inner = controller.group_blocks([a, b], name="Inner") + parent = controller.group_blocks([c], child_group_uids=[inner.uid], name="Parent") + + clipboard = capture_groups_clipboard(controller, [inner.uid]) + view.enter_group(parent.uid) + + result = paste_clipboard( + controller, + clipboard, + QPointF(200.0, 150.0), + parent_group_uid=parent.uid, + ) + pasted_groups = [ + controller.project_state.get_visual_group(uid) for uid in result.group_uids + ] + assert len(pasted_groups) == 1 + assert pasted_groups[0].parent_uid == parent.uid + assert pasted_groups[0].uid in parent.child_group_uids + + +def test_copy_paste_manual_in_out_proxies(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b], name="Group") + view.enter_group(group.uid) + + first = controller.add_manual_boundary_port(group.uid, "input", QPointF(40.0, 50.0)) + second = controller.add_manual_boundary_port(group.uid, "output", QPointF(120.0, 50.0)) + _select_items( + view, + view.proxy_items[first.uid], + view.proxy_items[second.uid], + ) + + clipboard = capture_selection_clipboard(controller) + assert clipboard is not None + assert clipboard_has_content(clipboard) + assert len(clipboard.boundary_ports) == 2 + assert not clipboard.blocks + + manual_before = sum( + 1 for port in group.boundary_ports if port.origin == "manual" + ) + result = paste_clipboard(controller, clipboard, QPointF(70.0, 80.0)) + assert len(result.boundary_ports) == 2 + + group = controller.project_state.get_visual_group(group.uid) + manual_after = [port for port in group.boundary_ports if port.origin == "manual"] + assert len(manual_after) == manual_before + 2 + pasted_uids = {uid for _, uid in result.boundary_ports} + pasted = sorted( + [port for port in manual_after if port.uid in pasted_uids], + key=lambda port: port.proxy_layout.get("x", 0.0), + ) + assert {port.direction for port in pasted} == {"input", "output"} + assert {port.label for port in pasted} == {"In_1", "Out_1"} + assert pasted[0].proxy_layout["x"] == 70.0 + assert pasted[0].proxy_layout["y"] == 80.0 + assert pasted[1].proxy_layout["x"] == 150.0 + assert pasted[1].proxy_layout["y"] == 80.0 + assert all(port.uid in view.proxy_items for port in pasted) + + undo_paste(controller, result) + group = controller.project_state.get_visual_group(group.uid) + assert len([port for port in group.boundary_ports if port.origin == "manual"]) == manual_before + + +def test_copy_paste_proxy_with_member_block_preserves_internal_link(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b], name="Group") + view.enter_group(group.uid) + + boundary = controller.add_manual_boundary_port(group.uid, "input", QPointF(30.0, 40.0)) + gain_in = view.get_block_item_from_instance(b).get_port_item(_first_port(b, "input").name) + assert controller.try_wire_boundary_endpoints( + view.proxy_items[boundary.uid].port_item, + gain_in, + ) + + _select_items( + view, + view.proxy_items[boundary.uid], + view.get_block_item_from_instance(b), + ) + clipboard = capture_selection_clipboard(controller) + assert clipboard is not None + assert len(clipboard.boundary_ports) == 1 + assert len(clipboard.blocks) == 1 + + result = paste_clipboard(controller, clipboard, QPointF(60.0, 70.0)) + assert len(result.boundary_ports) == 1 + assert len(result.blocks) == 1 + + pasted_boundary_uid = result.boundary_ports[0][1] + pasted_block_uid = result.blocks[0].uid + group = controller.project_state.get_visual_group(group.uid) + pasted_boundary = next( + port for port in group.boundary_ports if port.uid == pasted_boundary_uid + ) + assert pasted_boundary.linked_port_uid == f"{pasted_block_uid}:{_first_port(b, 'input').name}" + + +def test_paste_proxies_requires_internal_group_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b], name="Group") + view.enter_group(group.uid) + boundary = controller.add_manual_boundary_port(group.uid, "input", QPointF(20.0, 30.0)) + clipboard = capture_proxies_clipboard(controller, [view.proxy_items[boundary.uid]]) + view.exit_group_view() + + assert controller.paste_clipboard_at(QPointF(50.0, 60.0)) is False + result = paste_clipboard(controller, clipboard, QPointF(50.0, 60.0), parent_group_uid=None) + assert not result.boundary_ports diff --git a/tests/gui/test_group_boundary_labels.py b/tests/gui/test_group_boundary_labels.py new file mode 100644 index 0000000..1e4a27a --- /dev/null +++ b/tests/gui/test_group_boundary_labels.py @@ -0,0 +1,99 @@ +from PySide6.QtCore import QPointF + +from pySimBlocks.gui.group_boundary_labels import ( + boundary_port_label, + manual_boundary_display_label, +) +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup + + +def _first_port(block, direction: str): + for port in block.ports: + if port.direction == direction: + return port + raise AssertionError(f"No {direction} port on {block.name}") + + +def _port_named(block, name: str): + for port in block.ports: + if port.name == name: + return port + raise AssertionError(f"No port named {name} for block {block.name}") + + +def _create_window(qtbot, tmp_path): + from pySimBlocks.gui.main_window import MainWindow + + 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 test_boundary_port_label_input_shows_external_source(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + src = controller.add_block("sources", "constant") + _first_port(src, "output").display_as = "u" + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _first_port(out, "input")) + + group = controller.group_blocks([gain, out], name="G") + boundary = next(p for p in group.boundary_ports if p.direction == "input") + + assert boundary_port_label(controller.project_state, group, boundary) == "u" + + +def test_boundary_port_label_output_shows_external_destination(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + sink = controller.add_block("operators", "sum") + _port_named(sink, "in1").display_as = "y" + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _port_named(sink, "in1")) + + group = controller.group_blocks([src, gain], name="G") + boundary = next(p for p in group.boundary_ports if p.direction == "output") + + assert boundary_port_label(controller.project_state, group, boundary) == "y" + + +def test_boundary_port_label_manual_defaults_to_in_or_out(): + boundary = BoundaryPort(uid="b1", direction="input", origin="manual") + group = VisualGroup(uid="g1", name="G", members=["m1"], boundary_ports=[boundary]) + + class _State: + connections = [] + + assert boundary_port_label(_State(), group, boundary) == "In" + assert manual_boundary_display_label(boundary) == "In" + + out = BoundaryPort(uid="b2", direction="output", origin="manual") + assert manual_boundary_display_label(out) == "Out" + + +def test_boundary_port_label_manual_keeps_proxy_name_when_internally_wired(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + group = controller.group_blocks([src, gain]) + view.enter_group(group.uid) + boundary = controller.add_manual_boundary_port(group.uid, "input", QPointF(40.0, 50.0)) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "input") + ) + + assert boundary.label == "In" + assert boundary_port_label(controller.project_state, group, boundary) == "In" + assert controller.boundary_proxy_label(boundary) == "In" diff --git a/tests/gui/test_group_orientation.py b/tests/gui/test_group_orientation.py new file mode 100644 index 0000000..bf4920e --- /dev/null +++ b/tests/gui/test_group_orientation.py @@ -0,0 +1,136 @@ +from PySide6.QtCore import Qt, QPointF +from PySide6.QtGui import QKeyEvent + +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 _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 test_ctrl_r_flips_group_display_without_touching_members(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _first_port(out, "input")) + group = controller.group_blocks([src, gain], name="Loop") + + src_item = view.get_block_item_from_instance(src) + gain_item = view.get_block_item_from_instance(gain) + group_item = view.group_items[group.uid] + assert group_item.orientation == "normal" + + output_ports = [ + item + for item in group_item.boundary_port_items.values() + if item.boundary.direction == "output" + ] + assert output_ports + assert output_ports[0].pos().x() == group_item.rect().width() + + view.diagram_scene.clearSelection() + group_item.setSelected(True) + view.keyPressEvent( + QKeyEvent( + QKeyEvent.Type.KeyPress, Qt.Key.Key_R, Qt.KeyboardModifier.ControlModifier + ) + ) + + assert group_item.orientation == "flipped" + assert group.layout["orientation"] == "flipped" + assert src_item.orientation == "normal" + assert gain_item.orientation == "normal" + flipped_outputs = [ + item + for item in group_item.boundary_port_items.values() + if item.boundary.direction == "output" + ] + assert flipped_outputs + assert flipped_outputs[0].pos().x() == 0 + + +def test_flipped_group_wire_exits_away_from_anchor(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _first_port(out, "input")) + group = controller.group_blocks([src, gain], name="Loop") + + group_item = view.group_items[group.uid] + connection = next( + conn + for conn in controller.project_state.connections + if {conn.src_block().uid, conn.dst_block().uid} == {gain.uid, out.uid} + ) + conn_item = view.connections[connection] + + anchor_before, outbound_before = conn_item.route.points[:2] + assert outbound_before.x() > anchor_before.x() + + view.diagram_scene.clearSelection() + group_item.setSelected(True) + view.keyPressEvent( + QKeyEvent( + QKeyEvent.Type.KeyPress, Qt.Key.Key_R, Qt.KeyboardModifier.ControlModifier + ) + ) + + anchor_after, outbound_after = conn_item.route.points[:2] + assert outbound_after.x() < anchor_after.x() + + +def test_ctrl_r_flips_proxy_display_without_touching_members(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + group = controller.group_blocks([gain, out], name="Loop") + view.enter_group(group.uid) + + boundary = controller.add_manual_boundary_port( + group.uid, "input", QPointF(50.0, 60.0) + ) + proxy = view.proxy_items[boundary.uid] + gain_item = view.get_block_item_from_instance(gain) + assert gain_item.orientation == "normal" + assert proxy.port_item.pos().x() == proxy.rect().width() + + view.diagram_scene.clearSelection() + proxy.setSelected(True) + view.keyPressEvent( + QKeyEvent( + QKeyEvent.Type.KeyPress, Qt.Key.Key_R, Qt.KeyboardModifier.ControlModifier + ) + ) + + assert proxy.orientation == "flipped" + assert boundary.proxy_layout["orientation"] == "flipped" + assert gain_item.orientation == "normal" + assert proxy.port_item.pos().x() == 0 + + + diff --git a/tests/gui/test_manual_boundary_wiring.py b/tests/gui/test_manual_boundary_wiring.py new file mode 100644 index 0000000..88e0510 --- /dev/null +++ b/tests/gui/test_manual_boundary_wiring.py @@ -0,0 +1,1124 @@ +from PySide6.QtCore import QPointF + +from pySimBlocks.gui.graphics.manual_boundary_wire_item import ManualBoundaryWireItem +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 _first_port(block, direction: str): + for port in block.ports: + if port.direction == direction: + return port + raise AssertionError(f"No {direction} port on {block.name}") + + +def _manual_input_boundary(controller, view, group): + view.enter_group(group.uid) + return controller.add_manual_boundary_port( + group.uid, "input", QPointF(40.0, 50.0) + ) + + +def _manual_output_boundary(controller, view, group): + view.enter_group(group.uid) + return controller.add_manual_boundary_port( + group.uid, "output", QPointF(120.0, 50.0) + ) + + +def test_delete_incomplete_internal_boundary_wire_keeps_proxy(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + group = controller.group_blocks([src, gain]) + boundary = _manual_input_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "input") + ) + + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + wire.setSelected(True) + view.delete_selected() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert boundary.linked_port_uid == "" + assert boundary.uid in view.proxy_items + assert f"{boundary.uid}:internal" not in view.manual_boundary_wires + + +def test_delete_incomplete_internal_boundary_wire_keeps_output_proxy(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + group = controller.group_blocks([src, gain]) + boundary = _manual_output_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "output") + ) + + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + wire.setSelected(True) + view.delete_selected() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert boundary.linked_port_uid == "" + assert boundary.uid in view.proxy_items + assert f"{boundary.uid}:internal" not in view.manual_boundary_wires + + +def test_delete_incomplete_external_boundary_wire_keeps_group_port(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_input_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "input") + ) + + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "output") + ) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.linked_connection_uid + + controller.remove_connection(controller.project_state.connections[0]) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert boundary.linked_port_uid + assert boundary.external_port_uid == "" + assert not boundary.linked_connection_uid + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + assert isinstance(wire, ManualBoundaryWireItem) + wire.setSelected(True) + view.delete_selected() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert boundary.linked_port_uid == "" + assert boundary.external_port_uid == "" + assert boundary.uid in view.proxy_items + + +def test_delete_incomplete_external_boundary_wire_keeps_output_group_port(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_output_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "output") + ) + + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "input") + ) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.linked_connection_uid + + controller.remove_connection(controller.project_state.connections[0]) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert boundary.linked_port_uid + assert boundary.external_port_uid == "" + assert not boundary.linked_connection_uid + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + assert isinstance(wire, ManualBoundaryWireItem) + wire.setSelected(True) + view.delete_selected() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert boundary.linked_port_uid == "" + assert boundary.external_port_uid == "" + assert boundary.uid in view.proxy_items + + +def test_delete_completed_group_connection_keeps_proxy_and_shows_dashed_wire( + qtbot, tmp_path +): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_input_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "input") + ) + + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "output") + ) + boundary = controller._find_boundary_port(group, boundary.uid) + connection = controller.project_state.connections[0] + assert boundary.linked_connection_uid + + controller.remove_connection(connection) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert not boundary.linked_connection_uid + assert boundary.linked_port_uid + assert boundary.external_port_uid == "" + + view.enter_group(group.uid) + assert boundary.uid in view.proxy_items + assert view.proxy_items[boundary.uid].isVisible() + + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:internal" in view.manual_boundary_wires + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:external" not in view.manual_boundary_wires + + +def test_delete_completed_group_connection_keeps_output_proxy_and_dashed_wire( + qtbot, tmp_path +): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_output_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "output") + ) + + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "input") + ) + boundary = controller._find_boundary_port(group, boundary.uid) + connection = controller.project_state.connections[0] + assert boundary.linked_connection_uid + + controller.remove_connection(connection) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary is not None + assert not boundary.linked_connection_uid + assert boundary.linked_port_uid + assert boundary.external_port_uid == "" + + view.enter_group(group.uid) + assert boundary.uid in view.proxy_items + assert view.proxy_items[boundary.uid].isVisible() + + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:internal" in view.manual_boundary_wires + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:external" not in view.manual_boundary_wires + + +def test_delete_block_to_group_connection_keeps_internal_dashed_wire(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + step = controller.add_block("sources", "step") + member = controller.add_block("operators", "sum") + group = controller.group_blocks([member, controller.add_block("operators", "gain")], name="ControlLoop") + controller.add_connection(_first_port(step, "output"), _port_named(member, "in1")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + controller.remove_connection(connection) + view.refresh_visual_groups() + view.refresh_manual_boundary_wires() + + assert len(view.manual_boundary_wires) == 0 + + group = controller.project_state.get_visual_group(group.uid) + boundary = next(port for port in group.boundary_ports if port.direction == "input") + assert boundary.origin == "auto" + assert boundary.external_port_uid == "" + assert boundary.linked_port_uid + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:internal" in view.manual_boundary_wires + + +def test_delete_block_to_group_output_connection_keeps_internal_dashed_wire(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + sink = controller.add_block("operators", "sum") + member = controller.add_block("operators", "gain") + group = controller.group_blocks( + [member, controller.add_block("sources", "constant")], + name="ControlLoop", + ) + controller.add_connection(_first_port(member, "output"), _first_port(sink, "input")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + controller.remove_connection(connection) + view.refresh_visual_groups() + view.refresh_manual_boundary_wires() + + assert len(view.manual_boundary_wires) == 0 + + group = controller.project_state.get_visual_group(group.uid) + boundary = next(port for port in group.boundary_ports if port.direction == "output") + assert boundary.origin == "auto" + assert boundary.external_port_uid == "" + assert boundary.linked_port_uid + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:internal" in view.manual_boundary_wires + + +def test_delete_inside_group_keeps_external_reconnect_state(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + step = controller.add_block("sources", "step") + gain = controller.add_block("operators", "gain") + group = controller.group_blocks( + [controller.add_block("sources", "constant"), gain], + name="ControlLoop", + ) + controller.add_connection(_first_port(step, "output"), _first_port(gain, "input")) + view.refresh_visual_groups() + + group = controller.project_state.get_visual_group(group.uid) + boundary = next(port for port in group.boundary_ports if port.origin == "auto") + connection = next(iter(controller.project_state.connections)) + + view.enter_group(group.uid) + controller.remove_connection(connection) + view.refresh_visual_groups() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.origin == "auto" + assert boundary.external_port_uid + assert boundary.linked_port_uid == "" + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:external" in view.manual_boundary_wires + + border_port = view.group_items[group.uid].boundary_port_items[boundary.uid] + step_port = view.get_block_item_from_instance(step).get_port_item( + _first_port(step, "output").name + ) + assert controller.try_wire_boundary_endpoints(step_port, border_port) + assert len(controller.project_state.connections) == 0 + + view.enter_group(group.uid) + proxy_port = view.proxy_items[boundary.uid].port_item + gain_port = view.get_block_item_from_instance(gain).get_port_item( + _first_port(gain, "input").name + ) + assert controller.try_wire_boundary_endpoints(proxy_port, gain_port) + assert len(controller.project_state.connections) == 1 + + controller.remove_connection(controller.project_state.connections[0]) + view.refresh_visual_groups() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.external_port_uid + assert boundary.linked_port_uid == "" + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:external" in view.manual_boundary_wires + + assert controller.try_wire_boundary_endpoints(step_port, border_port) + view.enter_group(group.uid) + assert controller.try_wire_boundary_endpoints(proxy_port, gain_port) + assert len(controller.project_state.connections) == 1 + + +def test_delete_inside_group_keeps_external_reconnect_state_for_output(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + sink = controller.add_block("operators", "sum") + gain = controller.add_block("operators", "gain") + group = controller.group_blocks( + [controller.add_block("sources", "constant"), gain], + name="ControlLoop", + ) + controller.add_connection(_first_port(gain, "output"), _first_port(sink, "input")) + view.refresh_visual_groups() + + group = controller.project_state.get_visual_group(group.uid) + boundary = next( + port for port in group.boundary_ports + if port.origin == "auto" and port.direction == "output" + ) + connection = next(iter(controller.project_state.connections)) + + view.enter_group(group.uid) + controller.remove_connection(connection) + view.refresh_visual_groups() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.origin == "auto" + assert boundary.external_port_uid + assert boundary.linked_port_uid == "" + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:external" in view.manual_boundary_wires + + border_port = view.group_items[group.uid].boundary_port_items[boundary.uid] + sink_port = view.get_block_item_from_instance(sink).get_port_item( + _first_port(sink, "input").name + ) + assert controller.try_wire_boundary_endpoints(border_port, sink_port) + assert len(controller.project_state.connections) == 0 + + view.enter_group(group.uid) + proxy_port = view.proxy_items[boundary.uid].port_item + gain_port = view.get_block_item_from_instance(gain).get_port_item( + _first_port(gain, "output").name + ) + assert controller.try_wire_boundary_endpoints(gain_port, proxy_port) + assert len(controller.project_state.connections) == 1 + + +def test_delete_internal_dashed_after_root_delete_keeps_nothing(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_input_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "input") + ) + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "output") + ) + controller.remove_connection(controller.project_state.connections[0]) + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + wire.setSelected(True) + view.delete_selected() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.linked_port_uid == "" + assert boundary.external_port_uid == "" + + +def test_delete_internal_dashed_after_root_delete_keeps_nothing_for_output(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_output_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "output") + ) + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "input") + ) + controller.remove_connection(controller.project_state.connections[0]) + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + wire.setSelected(True) + view.delete_selected() + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.linked_port_uid == "" + assert boundary.external_port_uid == "" + + +def test_delete_auto_boundary_connection_keeps_proxy_in_group_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + controller.add_connection(_first_port(external, "output"), _first_port(gain, "input")) + + group = controller.project_state.get_visual_group(group.uid) + auto_boundary = next( + port for port in group.boundary_ports if port.origin == "auto" + ) + connection = next( + connection + for connection in controller.project_state.connections + if connection.dst_block().uid == gain.uid + and connection.src_block().uid == external.uid + ) + controller.remove_connection(connection) + + boundary = controller._find_boundary_port(group, auto_boundary.uid) + assert boundary is not None + assert boundary.origin == "auto" + assert boundary.external_port_uid == "" + assert boundary.linked_port_uid + assert not boundary.linked_connection_uid + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + assert f"{auto_boundary.uid}:internal" in view.manual_boundary_wires + assert auto_boundary.uid in view.proxy_items + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert not view.manual_boundary_wires + + +def test_delete_auto_output_boundary_connection_keeps_proxy_in_group_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + controller.add_connection(_first_port(gain, "output"), _first_port(external, "input")) + + group = controller.project_state.get_visual_group(group.uid) + auto_boundary = next( + port for port in group.boundary_ports + if port.origin == "auto" and port.direction == "output" + ) + connection = next( + connection + for connection in controller.project_state.connections + if connection.src_block().uid == gain.uid + and connection.dst_block().uid == external.uid + ) + controller.remove_connection(connection) + + boundary = controller._find_boundary_port(group, auto_boundary.uid) + assert boundary is not None + assert boundary.origin == "auto" + assert boundary.external_port_uid == "" + assert boundary.linked_port_uid + assert not boundary.linked_connection_uid + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + assert f"{auto_boundary.uid}:internal" in view.manual_boundary_wires + assert auto_boundary.uid in view.proxy_items + + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert not view.manual_boundary_wires + + +def test_reconnect_block_to_group_via_border_port(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + step = controller.add_block("sources", "step") + gain = controller.add_block("operators", "gain") + group = controller.group_blocks( + [controller.add_block("sources", "constant"), gain], + name="ControlLoop", + ) + controller.add_connection(_first_port(step, "output"), _first_port(gain, "input")) + view.refresh_visual_groups() + + group = controller.project_state.get_visual_group(group.uid) + boundary = next(port for port in group.boundary_ports if port.origin == "auto") + connection = next(iter(controller.project_state.connections)) + controller.remove_connection(connection) + view.refresh_visual_groups() + + border_port = view.group_items[group.uid].boundary_port_items[boundary.uid] + step_port = view.get_block_item_from_instance(step).get_port_item( + _first_port(step, "output").name + ) + assert controller.try_wire_boundary_endpoints(step_port, border_port) + + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.linked_connection_uid + assert len(controller.project_state.connections) == 1 + view.refresh_manual_boundary_wires() + assert not view.manual_boundary_wires + + +def test_reconnect_after_delete_via_boundary_ports(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([controller.add_block("sources", "constant"), gain]) + controller.add_connection(_first_port(external, "output"), _first_port(gain, "input")) + + group = controller.project_state.get_visual_group(group.uid) + boundary = next(port for port in group.boundary_ports if port.origin == "auto") + connection = next( + conn + for conn in controller.project_state.connections + if conn.dst_block().uid == gain.uid and conn.src_block().uid == external.uid + ) + controller.remove_connection(connection) + + controller.add_connection( + _first_port(external, "output"), + _first_port(gain, "input"), + ) + boundary = controller._find_boundary_port(group, boundary.uid) + assert boundary.linked_connection_uid + assert len(controller.project_state.connections) == 1 + + +def test_cross_group_delete_keeps_border_ports_without_dashed_wires(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + group_a = controller.group_blocks([a, b], name="GroupA") + group_b = controller.group_blocks([c, d], name="GroupB") + controller.add_connection(_first_port(b, "output"), _first_port(c, "input")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + boundary_a = next(port for port in group_a.boundary_ports if port.origin == "auto") + boundary_b = next(port for port in group_b.boundary_ports if port.origin == "auto") + + controller.remove_connection(connection) + view.refresh_visual_groups() + view.refresh_manual_boundary_wires() + + assert not view.manual_boundary_wires + assert boundary_a.uid in view.group_items[group_a.uid].boundary_port_items + assert boundary_b.uid in view.group_items[group_b.uid].boundary_port_items + assert not boundary_a.linked_connection_uid + assert not boundary_b.linked_connection_uid + + +def test_cross_group_reconnect_via_group_boundary_ports(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + group_a = controller.group_blocks([a, b], name="GroupA") + group_b = controller.group_blocks([c, d], name="GroupB") + controller.add_connection(_first_port(b, "output"), _first_port(c, "input")) + + connection = next(iter(controller.project_state.connections)) + controller.remove_connection(connection) + view.refresh_visual_groups() + + port_a = view.group_items[group_a.uid].boundary_port_items[ + next(port.uid for port in group_a.boundary_ports if port.direction == "output") + ] + port_b = view.group_items[group_b.uid].boundary_port_items[ + next(port.uid for port in group_b.boundary_ports if port.direction == "input") + ] + + assert controller._wire_group_boundaries_together(port_a, port_b) + assert len(controller.project_state.connections) == 1 + restored = controller.project_state.connections[0] + assert restored.src_block().uid == b.uid + assert restored.dst_block().uid == c.uid + + +def test_auto_boundary_internal_dashed_after_cross_group_delete(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + dist = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + plant_in = controller.add_block("operators", "sum") + group_a = controller.group_blocks([dist, gain], name="Disturbance") + group_b = controller.group_blocks([plant_in], name="Plant") + controller.add_connection(_first_port(gain, "output"), _first_port(plant_in, "input")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + out_boundary = next( + port for port in group_a.boundary_ports if port.direction == "output" + ) + controller.remove_connection(connection) + view.refresh_visual_groups() + + view.enter_group(group_a.uid) + view.refresh_manual_boundary_wires() + assert f"{out_boundary.uid}:internal" in view.manual_boundary_wires + assert out_boundary.uid in view.proxy_items + + +def test_parent_group_shows_proxies_for_child_boundaries(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + monitor = controller.add_block("operators", "gain") + inner = controller.group_blocks([c, d], name="Plant") + parent = controller.group_blocks( + [monitor], + child_group_uids=[inner.uid], + name="ControlLoop", + ) + external = controller.add_block("sources", "constant") + controller.add_connection(_first_port(external, "output"), _first_port(c, "input")) + + parent_boundary = next( + port + for port in controller.project_state.get_visual_group(parent.uid).boundary_ports + if port.linked_port_uid.startswith(c.uid) + ) + view.enter_group(parent.uid) + assert parent_boundary.uid in view.proxy_items + assert view.proxy_items[parent_boundary.uid].isVisible() + + view.enter_group(inner.uid) + inner_boundary = next( + port for port in controller.project_state.get_visual_group(inner.uid).boundary_ports + if port.direction == "input" + ) + assert inner_boundary.uid in view.proxy_items + + +def test_parent_with_only_child_groups_shows_proxies(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + regulator_a = controller.add_block("operators", "sum") + regulator_b = controller.add_block("controllers", "state_feedback") + plant_a = controller.add_block("operators", "sum") + plant_b = controller.add_block("systems", "linear_state_space") + regulator = controller.group_blocks([regulator_a, regulator_b], name="Regulator") + plant = controller.group_blocks([plant_a, plant_b], name="Plant") + control_loop = controller.group_blocks( + [], + child_group_uids=[regulator.uid, plant.uid], + name="ControlLoop", + ) + step = controller.add_block("sources", "step") + controller.add_connection(_first_port(step, "output"), _first_port(regulator_a, "input")) + + view.enter_group(control_loop.uid) + assert len(view.proxy_items) == len(control_loop.boundary_ports) + for proxy in view.proxy_items.values(): + assert proxy.isVisible() + + +def test_cross_child_group_delete_and_reconnect_inside_parent(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + inner_a = controller.group_blocks([a, b], name="InnerA") + inner_b = controller.group_blocks([c, d], name="InnerB") + parent = controller.group_blocks( + [], + child_group_uids=[inner_a.uid, inner_b.uid], + name="Parent", + ) + controller.add_connection(_first_port(b, "output"), _first_port(c, "input")) + + connection = next(iter(controller.project_state.connections)) + controller.remove_connection(connection) + view.enter_group(parent.uid) + view.refresh_visual_groups() + view.refresh_manual_boundary_wires() + + assert not view.manual_boundary_wires + inner_a = controller.project_state.get_visual_group(inner_a.uid) + inner_b = controller.project_state.get_visual_group(inner_b.uid) + out_uid = next(port.uid for port in inner_a.boundary_ports if port.direction == "output") + in_uid = next(port.uid for port in inner_b.boundary_ports if port.direction == "input") + assert out_uid in view.group_items[inner_a.uid].boundary_port_items + assert in_uid in view.group_items[inner_b.uid].boundary_port_items + + port_out = view.group_items[inner_a.uid].boundary_port_items[out_uid] + port_in = view.group_items[inner_b.uid].boundary_port_items[in_uid] + assert controller._wire_group_boundaries_together(port_out, port_in) + assert len(controller.project_state.connections) == 1 + + +def _nested_control_loop(controller): + sum_err = controller.add_block("operators", "sum") + controller_block = controller.add_block("controllers", "state_feedback") + sum_plant = controller.add_block("operators", "sum") + system = controller.add_block("systems", "linear_state_space") + regulator = controller.group_blocks([sum_err, controller_block], name="Regulator") + plant = controller.group_blocks([sum_plant, system], name="Plant") + control_loop = controller.group_blocks( + [], + child_group_uids=[regulator.uid, plant.uid], + name="ControlLoop", + ) + return control_loop, regulator, plant, sum_err + + +def test_delete_inside_nested_group_preserves_parent_external(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + step = controller.add_block("sources", "step") + control_loop, regulator, _plant, sum_err = _nested_control_loop(controller) + controller.add_connection(_first_port(step, "output"), _port_named(sum_err, "in1")) + view.refresh_visual_groups() + + control_loop = controller.project_state.get_visual_group(control_loop.uid) + regulator = controller.project_state.get_visual_group(regulator.uid) + connection = next(iter(controller.project_state.connections)) + + view.enter_group(regulator.uid) + controller.remove_connection(connection) + view.refresh_visual_groups() + + assert len(controller.project_state.connections) == 0 + regulator_boundary = next( + port for port in regulator.boundary_ports if port.direction == "input" + ) + assert regulator_boundary.external_port_uid + assert regulator_boundary.linked_port_uid == "" + assert not regulator_boundary.linked_connection_uid + + parent_boundary = next( + port for port in control_loop.boundary_ports if port.direction == "input" + ) + assert parent_boundary.origin == "auto" + assert parent_boundary.external_port_uid == f"{step.uid}:{_first_port(step, 'output').name}" + assert parent_boundary.linked_port_uid == "" + assert not parent_boundary.linked_connection_uid + + while view.current_view_group_uid is not None: + view.pop_view_level() + view.refresh_manual_boundary_wires() + assert f"{parent_boundary.uid}:external" in view.manual_boundary_wires + + +def test_reconnect_inside_nested_group_after_delete(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + step = controller.add_block("sources", "step") + control_loop, regulator, _plant, sum_err = _nested_control_loop(controller) + controller.add_connection(_first_port(step, "output"), _port_named(sum_err, "in1")) + view.refresh_visual_groups() + + regulator = controller.project_state.get_visual_group(regulator.uid) + connection = next(iter(controller.project_state.connections)) + boundary = next(port for port in regulator.boundary_ports if port.direction == "input") + + view.enter_group(regulator.uid) + controller.remove_connection(connection) + view.refresh_visual_groups() + + proxy_port = view.proxy_items[boundary.uid].port_item + sum_port = view.get_block_item_from_instance(sum_err).get_port_item( + _port_named(sum_err, "in1").name + ) + assert controller.try_wire_boundary_endpoints(proxy_port, sum_port) + assert len(controller.project_state.connections) == 1 + restored = controller.project_state.connections[0] + assert restored.src_block().uid == step.uid + assert restored.dst_block().uid == sum_err.uid + + +def test_reconnect_outside_nested_group_after_delete(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + step = controller.add_block("sources", "step") + control_loop, regulator, _plant, sum_err = _nested_control_loop(controller) + controller.add_connection(_first_port(step, "output"), _port_named(sum_err, "in1")) + view.refresh_visual_groups() + + control_loop = controller.project_state.get_visual_group(control_loop.uid) + connection = next(iter(controller.project_state.connections)) + parent_boundary = next( + port for port in control_loop.boundary_ports if port.direction == "input" + ) + + view.enter_group(regulator.uid) + controller.remove_connection(connection) + view.pop_view_level() + view.refresh_visual_groups() + + border_port = view.group_items[control_loop.uid].boundary_port_items[parent_boundary.uid] + step_port = view.get_block_item_from_instance(step).get_port_item( + _first_port(step, "output").name + ) + assert controller.try_wire_boundary_endpoints(step_port, border_port) + assert len(controller.project_state.connections) == 0 + + view.enter_group(regulator.uid) + regulator = controller.project_state.get_visual_group(regulator.uid) + boundary = next(port for port in regulator.boundary_ports if port.direction == "input") + proxy_port = view.proxy_items[boundary.uid].port_item + sum_port = view.get_block_item_from_instance(sum_err).get_port_item( + _port_named(sum_err, "in1").name + ) + assert controller.try_wire_boundary_endpoints(proxy_port, sum_port) + assert len(controller.project_state.connections) == 1 + + +def _port_named(block, name: str): + for port in block.ports: + if port.name == name: + return port + raise AssertionError(f"No port named {name} for block {block.name}") + + +def test_group_border_ports_keep_vertical_order_after_rebuild(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + top = controller.add_block("operators", "sum") + middle = controller.add_block("operators", "sum") + bottom = controller.add_block("operators", "sum") + ext_top = controller.add_block("sources", "constant") + ext_middle = controller.add_block("sources", "step") + ext_bottom = controller.add_block("sources", "constant") + controller.add_connection(_first_port(ext_top, "output"), _port_named(top, "in1")) + controller.add_connection(_first_port(ext_middle, "output"), _port_named(middle, "in1")) + controller.add_connection(_first_port(ext_bottom, "output"), _port_named(bottom, "in1")) + group = controller.group_blocks([top, middle, bottom], name="Plant") + view.refresh_visual_groups() + + group_item = view.group_items[group.uid] + positions_before = { + uid: float(item.pos().y()) + for uid, item in group_item.boundary_port_items.items() + } + + inputs = [p for p in group.boundary_ports if p.direction == "input"] + outputs = [p for p in group.boundary_ports if p.direction == "output"] + group.boundary_ports = list(reversed(inputs)) + outputs + controller._rebuild_group_boundary_ports(group) + group_item.sync_boundary_ports() + + for uid, y in positions_before.items(): + assert float(group_item.boundary_port_items[uid].pos().y()) == y + + +def test_cross_group_delete_does_not_spawn_multiple_root_dashed_wires(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + group_a = controller.group_blocks([a, b], name="Disturbance") + group_b = controller.group_blocks([c, d], name="ControlLoop") + controller.add_connection(_first_port(b, "output"), _first_port(c, "input")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + controller.remove_connection(connection) + view.refresh_visual_groups() + view.refresh_manual_boundary_wires() + + assert len(view.manual_boundary_wires) == 0 + + +def test_proxy_wires_to_child_group_border_inside_parent(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + external = controller.add_block("sources", "constant") + inner_a = controller.add_block("operators", "sum") + inner_b = controller.add_block("controllers", "state_feedback") + controller.add_connection(_first_port(external, "output"), _port_named(inner_a, "in1")) + inner = controller.group_blocks([inner_a, inner_b], name="Regulator") + monitor = controller.add_block("operators", "gain") + parent = controller.group_blocks( + [monitor], + child_group_uids=[inner.uid], + name="ControlLoop", + ) + view.enter_group(parent.uid) + + boundary = controller.add_manual_boundary_port(parent.uid, "input", QPointF(20.0, 40.0)) + inner_group = controller.project_state.get_visual_group(inner.uid) + child_boundary = next( + port for port in inner_group.boundary_ports if port.direction == "input" + ) + child_port = view.group_items[inner.uid].boundary_port_items[child_boundary.uid] + + assert controller.try_wire_boundary_endpoints( + view.proxy_items[boundary.uid].port_item, + child_port, + ) + + parent = controller.project_state.get_visual_group(parent.uid) + wired = controller._find_boundary_port(parent, boundary.uid) + assert wired.linked_port_uid == f"{inner_a.uid}:{_port_named(inner_a, 'in1').name}" + view.refresh_manual_boundary_wires() + assert f"{boundary.uid}:internal" in view.manual_boundary_wires + + +def _wire_path_end(wire: ManualBoundaryWireItem) -> QPointF: + path = wire.path() + return path.pointAtPercent(1.0) + + +def test_dashed_wire_follows_child_group_move_in_parent_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + external = controller.add_block("sources", "constant") + inner_a = controller.add_block("operators", "sum") + inner_b = controller.add_block("controllers", "state_feedback") + controller.add_connection(_first_port(external, "output"), _port_named(inner_a, "in1")) + inner = controller.group_blocks([inner_a, inner_b], name="Regulator") + monitor = controller.add_block("operators", "gain") + parent = controller.group_blocks( + [monitor], + child_group_uids=[inner.uid], + name="ControlLoop", + ) + view.enter_group(parent.uid) + + boundary = controller.add_manual_boundary_port(parent.uid, "input", QPointF(20.0, 40.0)) + inner_group = controller.project_state.get_visual_group(inner.uid) + child_boundary = next( + port for port in inner_group.boundary_ports if port.direction == "input" + ) + child_port = view.group_items[inner.uid].boundary_port_items[child_boundary.uid] + assert controller.try_wire_boundary_endpoints( + view.proxy_items[boundary.uid].port_item, + child_port, + ) + + view.pop_view_level() + controller.remove_connection(controller.project_state.connections[0]) + view.enter_group(parent.uid) + view.refresh_manual_boundary_wires() + + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + child_item = view.group_items[inner.uid] + anchor_before = child_item.get_boundary_anchor(child_boundary.uid) + + child_item.setPos(child_item.pos() + QPointF(50.0, 30.0)) + qtbot.wait(10) + + anchor_after = child_item.get_boundary_anchor(child_boundary.uid) + wire_end = _wire_path_end(wire) + assert anchor_before != anchor_after + assert abs(wire_end.x() - anchor_after.x()) < 1.0 + assert abs(wire_end.y() - anchor_after.y()) < 1.0 + + +def test_dashed_wire_follows_member_block_move_in_group_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + external = controller.add_block("operators", "sum") + group = controller.group_blocks([src, gain]) + boundary = _manual_input_boundary(controller, view, group) + controller._wire_manual_boundary_internal( + group.uid, boundary.uid, _first_port(gain, "input") + ) + + view.pop_view_level() + controller._wire_manual_boundary_external( + group.uid, boundary.uid, _first_port(external, "output") + ) + controller.remove_connection(controller.project_state.connections[0]) + + view.enter_group(group.uid) + view.refresh_manual_boundary_wires() + wire = view.manual_boundary_wires[f"{boundary.uid}:internal"] + gain_item = view.get_block_item_from_instance(gain) + port_item = gain_item.get_port_item(_first_port(gain, "input").name) + + gain_item.setPos(gain_item.pos() + QPointF(40.0, 25.0)) + qtbot.wait(10) + + wire_end = _wire_path_end(wire) + anchor_after = port_item.connection_anchor() + assert abs(wire_end.x() - anchor_after.x()) < 1.0 + assert abs(wire_end.y() - anchor_after.y()) < 1.0 diff --git a/tests/gui/test_nested_visual_groups.py b/tests/gui/test_nested_visual_groups.py new file mode 100644 index 0000000..994b033 --- /dev/null +++ b/tests/gui/test_nested_visual_groups.py @@ -0,0 +1,218 @@ +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 _first_port(block, direction: str): + for port in block.ports: + if port.direction == direction: + return port + raise AssertionError(f"No {direction} port on {block.name}") + + +def _select_items(view, *items): + view.diagram_scene.clearSelection() + for item in items: + item.setSelected(True) + + +def test_group_block_with_existing_subgroup(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + inner = controller.group_blocks([a, b], name="Inner") + + assert inner is not None + parent = controller.group_blocks( + [c], + child_group_uids=[inner.uid], + name="Outer", + ) + + assert parent is not None + assert parent.parent_uid is None + assert inner.parent_uid == parent.uid + assert parent.child_group_uids == [inner.uid] + assert set(parent.members) == {c.uid} + + +def test_nested_group_visibility_at_root_and_inside_parent(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + inner = controller.group_blocks([a, b], name="Inner") + parent = controller.group_blocks([c], child_group_uids=[inner.uid], name="Outer") + + view.refresh_visual_groups() + assert view.get_block_item_from_instance(a).isVisible() is False + assert view.group_items[inner.uid].isVisible() is False + assert view.group_items[parent.uid].isVisible() is True + + view.enter_group(parent.uid) + assert view.get_block_item_from_instance(c).isVisible() is True + assert view.get_block_item_from_instance(a).isVisible() is False + assert view.group_items[inner.uid].isVisible() is True + + +def test_group_selected_block_and_subgroup_from_context_menu(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + inner = controller.group_blocks([a, b], name="Inner") + + _select_items( + view, + view.get_block_item_from_instance(c), + view.group_items[inner.uid], + ) + controller.group_selected_blocks() + + outer = next( + group + for group in controller.project_state.visual_groups + if group.uid != inner.uid + ) + assert outer.child_group_uids == [inner.uid] + assert inner.parent_uid == outer.uid + + +def test_ungroup_parent_promotes_child_groups(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + inner = controller.group_blocks([a, b], name="Inner") + parent = controller.group_blocks([c], child_group_uids=[inner.uid], name="Outer") + + assert controller.ungroup(parent.uid) + promoted = controller.project_state.get_visual_group(inner.uid) + assert promoted is not None + assert promoted.parent_uid is None + assert controller.project_state.get_visual_group(parent.uid) is None + + +def test_create_subgroup_inside_parent_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + parent = controller.group_blocks([a, b, c], name="Parent") + view.enter_group(parent.uid) + + child = controller.group_blocks([a, b], name="Child", parent_uid=parent.uid) + + assert child is not None + assert child.parent_uid == parent.uid + assert child.uid in parent.child_group_uids + assert set(parent.members) == {c.uid} + assert set(child.members) == {a.uid, b.uid} + + +def test_cross_group_connection_visible_at_root(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + controller.group_blocks([a, b], name="GroupA") + controller.group_blocks([c, d], name="GroupB") + + controller.add_connection(_first_port(b, "output"), _first_port(c, "input")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + assert view.connections[connection].isVisible() is True + + +def test_cross_child_connection_visible_inside_parent(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + inner_a = controller.group_blocks([a, b], name="InnerA") + inner_b = controller.group_blocks([c, d], name="InnerB") + parent = controller.group_blocks( + [], + child_group_uids=[inner_a.uid, inner_b.uid], + name="Parent", + ) + assert parent is not None + + controller.add_connection(_first_port(b, "output"), _first_port(c, "input")) + view.refresh_visual_groups() + + connection = next(iter(controller.project_state.connections)) + conn_item = view.connections[connection] + assert conn_item.isVisible() is False + + view.enter_group(parent.uid) + view.refresh_visual_groups() + assert conn_item.isVisible() is True + + +def test_delete_group_removes_nested_members_and_child_groups(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + sum_err = controller.add_block("operators", "sum") + controller_block = controller.add_block("controllers", "state_feedback") + sum_plant = controller.add_block("operators", "sum") + system = controller.add_block("systems", "linear_state_space") + regulator = controller.group_blocks([sum_err, controller_block], name="Regulator") + plant = controller.group_blocks([sum_plant, system], name="Plant") + control_loop = controller.group_blocks( + [], + child_group_uids=[regulator.uid, plant.uid], + name="ControlLoop", + ) + assert control_loop is not None + + member_uids = { + sum_err.uid, + controller_block.uid, + sum_plant.uid, + system.uid, + } + assert controller.delete_group(control_loop.uid) + + assert controller.project_state.get_visual_group(control_loop.uid) is None + assert controller.project_state.get_visual_group(regulator.uid) is None + assert controller.project_state.get_visual_group(plant.uid) is None + for uid in member_uids: + assert controller._find_block_by_uid(uid) is None + + window.undo_manager.stack.undo() + assert controller.project_state.get_visual_group(control_loop.uid) is not None + for uid in member_uids: + assert controller._find_block_by_uid(uid) is not None diff --git a/tests/gui/test_visual_group_controller.py b/tests/gui/test_visual_group_controller.py new file mode 100644 index 0000000..3faa1a9 --- /dev/null +++ b/tests/gui/test_visual_group_controller.py @@ -0,0 +1,189 @@ +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 _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 test_group_blocks_creates_visual_group_with_boundaries(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _first_port(out, "input")) + + group = controller.group_blocks([src, gain], name="Loop") + + assert group.name == "Loop" + assert set(group.members) == {src.uid, gain.uid} + assert len(group.boundary_ports) == 1 + assert group.boundary_ports[0].direction == "output" + assert group.boundary_ports[0].origin == "auto" + + +def test_ungroup_removes_visual_group(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + + assert len(controller.project_state.visual_groups) == 1 + assert controller.ungroup(group.uid) + assert controller.project_state.visual_groups == [] + assert not controller.ungroup("missing") + + +def test_remove_block_updates_group_membership(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + + controller.remove_block(a) + restored = controller.project_state.get_visual_group(group.uid) + assert restored is not None + assert restored.members == [b.uid] + + controller.remove_block(b) + assert controller.project_state.get_visual_group(group.uid) is None + + +def _port_named(block, name: str): + for port in block.ports: + if port.name == name: + return port + raise AssertionError(f"No port named {name} for block {block.name}") + + +def test_add_block_to_group_moves_membership(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + group = controller.group_blocks([a, b]) + + assert controller.add_block_to_group(group.uid, c) + restored = controller.project_state.get_visual_group(group.uid) + assert restored is not None + assert set(restored.members) == {a.uid, b.uid, c.uid} + + +def test_add_block_in_group_view_creates_member(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.enter_group(group.uid) + + added = controller.add_block_in_group_view("operators", "sum", group.uid) + assert added is not None + restored = controller.project_state.get_visual_group(group.uid) + assert restored is not None + assert added.uid in restored.members + assert view.get_block_item_from_instance(added).isVisible() + + +def test_remove_block_from_group_keeps_block_visible_at_parent(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + group = controller.group_blocks([a, b, c]) + view.refresh_visual_groups() + + assert controller.remove_block_from_group(group.uid, c.uid) + restored = controller.project_state.get_visual_group(group.uid) + assert restored is not None + assert restored.members == [a.uid, b.uid] + assert controller._find_block_by_uid(c.uid) is not None + + view.refresh_visual_groups() + assert view.get_block_item_from_instance(c).isVisible() + + +def test_cannot_add_block_already_owned_by_another_group(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + c = controller.add_block("operators", "sum") + d = controller.add_block("operators", "gain") + group_a = controller.group_blocks([a, b]) + group_b = controller.group_blocks([c, d]) + + assert not controller.add_block_to_group(group_b.uid, a) + assert controller.project_state.get_visual_group(group_a.uid).members == [a.uid, b.uid] + + +def test_boundary_ports_recomputed_when_connections_change(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + src = controller.add_block("sources", "constant") + summ = controller.add_block("operators", "sum") + out = controller.add_block("operators", "sum") + + controller.add_connection(_first_port(src, "output"), _port_named(summ, "in1")) + controller.add_connection(_first_port(summ, "output"), _first_port(out, "input")) + + group = controller.group_blocks([src, summ], name="Loop") + assert len(group.boundary_ports) == 1 + assert group.boundary_ports[0].direction == "output" + + ext = controller.add_block("sources", "step") + connections_before = len(controller.project_state.connections) + controller.add_connection(_first_port(ext, "output"), _port_named(summ, "in2")) + assert len(controller.project_state.connections) == connections_before + 1 + + restored = controller.project_state.get_visual_group(group.uid) + assert restored is not None + assert len(restored.boundary_ports) == 2 + directions = {port.direction for port in restored.boundary_ports} + assert directions == {"input", "output"} + + crossing = next( + connection + for connection in controller.project_state.connections + if connection.src_block().uid == ext.uid + ) + controller.remove_connection(crossing) + + restored = controller.project_state.get_visual_group(group.uid) + assert restored is not None + assert len(restored.boundary_ports) == 2 + input_boundary = next( + port for port in restored.boundary_ports if port.direction == "input" + ) + assert input_boundary.origin == "auto" + assert not input_boundary.linked_connection_uid + assert input_boundary.external_port_uid == "" + assert input_boundary.linked_port_uid.endswith(":in2") diff --git a/tests/gui/test_visual_group_persistence.py b/tests/gui/test_visual_group_persistence.py new file mode 100644 index 0000000..073948e --- /dev/null +++ b/tests/gui/test_visual_group_persistence.py @@ -0,0 +1,138 @@ +from pySimBlocks.gui.main_window import MainWindow +from pySimBlocks.gui.models.visual_group import BoundaryPort, VisualGroup +from pySimBlocks.gui.services.yaml_tools import build_project_yaml + + +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 test_visual_group_from_dict_preserves_group_layout_with_member_layouts(): + group = VisualGroup.from_dict( + { + "uid": "grp_1", + "name": "Group", + "members": ["a", "b"], + "layout": {"x": -24.0, "y": -24.0, "width": 168.0, "height": 108.0}, + "member_layouts": { + "a": {"x": 0.0, "y": 0.0, "width": 120.0, "height": 60.0}, + }, + } + ) + assert group.layout["x"] == -24.0 + assert group.layout["width"] == 168.0 + + +def test_build_project_yaml_includes_gui_groups(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + state = window.project_state + + state.visual_groups = [ + VisualGroup( + uid="grp_1", + name="Group 1", + parent_uid=None, + members=["a", "b"], + layout={"x": 10.0, "y": 20.0, "width": 120.0, "height": 70.0}, + boundary_ports=[ + BoundaryPort( + uid="bp_1", + direction="input", + linked_port_uid="p_1", + origin="manual", + linked_connection_uid="", + proxy_layout={"x": 1.0, "y": 2.0}, + ) + ], + child_group_uids=[], + member_layouts={ + "a": {"x": 10.0, "y": 20.0, "width": 120.0, "height": 60.0}, + }, + ) + ] + + raw = build_project_yaml(state, {}) + assert "gui" in raw + assert "groups" in raw["gui"] + assert len(raw["gui"]["groups"]) == 1 + group = raw["gui"]["groups"][0] + assert group["uid"] == "grp_1" + assert group["boundary_ports"][0]["origin"] == "manual" + assert group["member_layouts"]["a"]["x"] == 10.0 + + +def test_loader_restores_visual_groups_from_yaml(qtbot, tmp_path): + project_yaml = tmp_path / "project.yaml" + project_yaml.write_text( + """schema_version: 1 +project: + name: grouped_project +simulation: + dt: 0.01 + T: 1.0 + solver: fixed +diagram: + blocks: [] + connections: [] +gui: + layout: + blocks: {} + groups: + - uid: "grp_1" + name: "Loop" + parent_uid: null + members: ["a", "b"] + layout: + x: 10.0 + y: 20.0 + width: 120.0 + height: 70.0 + boundary_ports: + - uid: "bp_1" + direction: input + linked_port_uid: "port_a" + origin: manual + linked_connection_uid: "" + proxy_layout: {x: 0.0, y: 0.0} + child_group_uids: [] + member_layouts: + a: {x: 5.0, y: 6.0, width: 120.0, height: 60.0} +""" + ) + + window = _create_window(qtbot, tmp_path) + + groups = window.project_state.visual_groups + assert len(groups) == 1 + assert groups[0].uid == "grp_1" + assert groups[0].boundary_ports[0].origin == "manual" + assert groups[0].member_layouts["a"]["x"] == 5.0 + + +def test_loader_keeps_backward_compatibility_without_gui_groups(qtbot, tmp_path): + project_yaml = tmp_path / "project.yaml" + project_yaml.write_text( + """schema_version: 1 +project: + name: no_groups +simulation: + dt: 0.01 + T: 1.0 + solver: fixed +diagram: + blocks: [] + connections: [] +gui: + layout: + blocks: {} +""" + ) + + window = _create_window(qtbot, tmp_path) + + assert window.project_state.visual_groups == [] diff --git a/tests/gui/test_visual_group_view.py b/tests/gui/test_visual_group_view.py new file mode 100644 index 0000000..11b7074 --- /dev/null +++ b/tests/gui/test_visual_group_view.py @@ -0,0 +1,393 @@ +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 _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 test_group_hides_members_and_shows_group_item(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _first_port(out, "input")) + + group = controller.group_blocks([src, gain]) + view.refresh_visual_groups() + + assert group is not None + assert len(view.group_items) == 1 + group_item = view.group_items[group.uid] + assert group_item.isVisible() + + src_item = view.get_block_item_from_instance(src) + gain_item = view.get_block_item_from_instance(gain) + out_item = view.get_block_item_from_instance(out) + assert not src_item.isVisible() + assert not gain_item.isVisible() + assert out_item.isVisible() + + +def test_group_captures_member_layouts_and_internal_view_applies_them(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + item_a = view.get_block_item_from_instance(a) + item_b = view.get_block_item_from_instance(b) + item_a.setPos(100.0, 50.0) + item_b.setPos(200.0, 80.0) + + group = controller.group_blocks([a, b]) + assert group is not None + assert group.member_layouts[a.uid]["x"] == 100.0 + assert group.member_layouts[b.uid]["x"] == 200.0 + + item_a.setPos(999.0, 999.0) + view.enter_group(group.uid) + assert item_a.pos().x() == 100.0 + assert item_b.pos().x() == 200.0 + + item_a.setPos(120.0, 60.0) + view.exit_group_view() + assert group.member_layouts[a.uid]["x"] == 120.0 + + +def test_double_click_enters_group_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.refresh_visual_groups() + + view.enter_group(group.uid) + assert view.current_view_group_uid == group.uid + assert view.get_block_item_from_instance(a).isVisible() + assert view.get_block_item_from_instance(b).isVisible() + assert not view.group_items[group.uid].isVisible() + + +def test_keyboard_shortcut_groups_selection(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + item_a = view.get_block_item_from_instance(a) + item_b = view.get_block_item_from_instance(b) + item_a.setSelected(True) + item_b.setSelected(True) + + view.setFocus() + qtbot.waitUntil(lambda: view.hasFocus()) + qtbot.keyClick(view.viewport(), Qt.Key_G, Qt.ControlModifier | Qt.ShiftModifier) + + assert len(controller.project_state.visual_groups) == 1 + + +def test_keyboard_shortcut_ungroups_selection(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.refresh_visual_groups() + + group_item = view.group_items[group.uid] + view.diagram_scene.clearSelection() + group_item.setSelected(True) + view.setFocus() + qtbot.waitUntil(lambda: view.hasFocus()) + qtbot.keyClick(view.viewport(), Qt.Key_U, Qt.ControlModifier | Qt.ShiftModifier) + + assert controller.project_state.visual_groups == [] + assert view.get_block_item_from_instance(a).isVisible() + assert view.get_block_item_from_instance(b).isVisible() + + +def test_undo_redo_move_resize_group(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + stack = window.undo_manager.stack + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.refresh_visual_groups() + + group_item = view.group_items[group.uid] + old_pos = QPointF(group_item.pos()) + old_rect = QRectF(group_item.rect()) + new_pos = QPointF(old_pos.x() + 30.0, old_pos.y() + 15.0) + new_rect = QRectF(0.0, 0.0, old_rect.width() + 40.0, old_rect.height() + 20.0) + + controller.execute_move_resize_group( + group.uid, old_pos, old_rect, new_pos, new_rect + ) + + assert group_item.pos() == new_pos + assert group_item.rect().width() == new_rect.width() + assert group_item.rect().height() == new_rect.height() + assert group.layout["width"] == new_rect.width() + assert group.layout["height"] == new_rect.height() + + stack.undo() + assert group_item.pos() == old_pos + assert group_item.rect().width() == old_rect.width() + assert group_item.rect().height() == old_rect.height() + assert group.layout["width"] == old_rect.width() + assert group.layout["height"] == old_rect.height() + + stack.redo() + assert group_item.pos() == new_pos + assert group_item.rect().width() == new_rect.width() + assert group_item.rect().height() == new_rect.height() + assert group.layout["width"] == new_rect.width() + assert group.layout["height"] == new_rect.height() + + +def test_redo_move_resize_after_redo_group_keeps_same_uid(qtbot, tmp_path): + """Move/resize commands must keep working after group undo then redo.""" + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + stack = window.undo_manager.stack + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.refresh_visual_groups() + group_uid = group.uid + + group_item = view.group_items[group_uid] + old_pos = QPointF(group_item.pos()) + old_rect = QRectF(group_item.rect()) + new_pos = QPointF(old_pos.x() + 30.0, old_pos.y() + 15.0) + new_rect = QRectF(0.0, 0.0, old_rect.width() + 40.0, old_rect.height() + 20.0) + + controller.execute_move_resize_group( + group_uid, old_pos, old_rect, new_pos, new_rect + ) + + stack.undo() # undo move + stack.undo() # undo group + + assert controller.project_state.visual_groups == [] + assert group_uid not in view.group_items + + stack.redo() # redo group + assert len(controller.project_state.visual_groups) == 1 + restored = controller.project_state.visual_groups[0] + assert restored.uid == group_uid + group_item = view.group_items[group_uid] + assert group_item.pos() == old_pos + + stack.redo() # redo move + assert group_item.pos() == new_pos + assert group_item.rect().width() == new_rect.width() + assert restored.layout["width"] == new_rect.width() + + +def test_group_boundary_proxies_assigned_on_creation(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + + src = controller.add_block("sources", "constant") + gain = controller.add_block("operators", "gain") + out = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(gain, "input")) + controller.add_connection(_first_port(gain, "output"), _first_port(out, "input")) + + group = controller.group_blocks([src, gain]) + assert len(group.boundary_ports) >= 1 + for boundary in group.boundary_ports: + assert boundary.proxy_uid + assert boundary.proxy_layout + + +def test_proxies_visible_only_in_internal_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.refresh_visual_groups() + + assert len(view.proxy_items) == 0 + + view.enter_group(group.uid) + assert len(view.proxy_items) == len(group.boundary_ports) + for proxy in view.proxy_items.values(): + assert proxy.isVisible() + + view.exit_group_view() + assert len(view.proxy_items) == len(group.boundary_ports) + for proxy in view.proxy_items.values(): + assert not proxy.isVisible() + + +def test_palette_exposes_group_ports_in_internal_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + + assert "group_ports" not in window.get_categories() + + view.enter_group(group.uid) + assert "group_ports" in window.get_categories() + assert window.get_blocks("group_ports") == ["In", "Out"] + + +def test_add_manual_boundary_port_creates_proxy(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + auto_count = len(group.boundary_ports) + + view.enter_group(group.uid) + boundary = controller.add_manual_boundary_port( + group.uid, "input", QPointF(50.0, 60.0) + ) + + assert boundary is not None + assert boundary.origin == "manual" + assert boundary.direction == "input" + assert boundary.label == "In" + assert len(group.boundary_ports) == auto_count + 1 + assert boundary.uid in view.proxy_items + assert view.proxy_items[boundary.uid].isVisible() + assert boundary.proxy_layout["x"] == 50.0 + + +def test_add_second_manual_in_gets_unique_name(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + view.enter_group(group.uid) + + first = controller.add_manual_boundary_port(group.uid, "input", QPointF(40.0, 50.0)) + second = controller.add_manual_boundary_port(group.uid, "input", QPointF(80.0, 50.0)) + + assert first.label == "In" + assert second.label == "In_1" + view.pop_view_level() + view.refresh_visual_groups() + group_item = view.group_items[group.uid] + assert group_item.boundary_port_items[first.uid].label.toPlainText() == "In" + assert group_item.boundary_port_items[second.uid].label.toPlainText() == "In_1" + + +def test_undo_removes_manual_ports_before_ungrouping(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + stack = window.undo_manager.stack + + a = controller.add_block("sources", "constant") + b = controller.add_block("operators", "gain") + group = controller.group_blocks([a, b]) + + view.enter_group(group.uid) + controller.add_manual_boundary_port(group.uid, "input", QPointF(40.0, 50.0)) + assert any(port.origin == "manual" for port in group.boundary_ports) + + stack.undo() + assert not any(port.origin == "manual" for port in group.boundary_ports) + assert controller.project_state.get_visual_group(group.uid) is not None + + stack.undo() + assert controller.project_state.get_visual_group(group.uid) is None + + +def test_undo_connection_route_edit(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + stack = window.undo_manager.stack + + src = controller.add_block("sources", "constant") + dst = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(dst, "input")) + + conn = controller.project_state.connections[0] + conn_item = view.connections[conn] + conn_item.update_position() + old_points = [QPointF(point) for point in conn_item.route.points] + new_points = [QPointF(point) for point in old_points] + new_points[2] = QPointF(new_points[2].x() + 30.0, new_points[2].y()) + conn_item.apply_manual_route(new_points) + + controller.execute_edit_connection_route(conn, old_points, new_points) + assert conn_item.route.points[2].x() == new_points[2].x() + + stack.undo() + assert conn_item.route.points[2].x() == old_points[2].x() + + stack.redo() + assert conn_item.route.points[2].x() == new_points[2].x() + + +def test_manual_route_preserved_when_block_moves(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + src = controller.add_block("sources", "constant") + dst = controller.add_block("operators", "sum") + controller.add_connection(_first_port(src, "output"), _first_port(dst, "input")) + + conn = controller.project_state.connections[0] + conn_item = view.connections[conn] + conn_item.update_position() + manual_points = [QPointF(point) for point in conn_item.route.points] + manual_points[2] = QPointF(manual_points[2].x() + 40.0, manual_points[2].y()) + conn_item.apply_manual_route(manual_points) + + bend_x = conn_item.route.points[2].x() + dst_item = view.get_block_item_from_instance(dst) + dst_item.setPos(dst_item.pos() + QPointF(50.0, 20.0)) + qtbot.wait(10) + + assert conn_item.is_manual + assert conn_item.route.points[2].x() == bend_x From 0057fc4789496061355c900cb68df6a4d082ae11 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Fri, 26 Jun 2026 13:30:10 +0200 Subject: [PATCH 40/42] fix(gui): recompute manual wire routes when group view anchors change --- pySimBlocks/gui/graphics/connection_item.py | 44 +++++++++++++++++++-- tests/gui/test_nested_visual_groups.py | 43 ++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/pySimBlocks/gui/graphics/connection_item.py b/pySimBlocks/gui/graphics/connection_item.py index 41f2db7..01daa27 100644 --- a/pySimBlocks/gui/graphics/connection_item.py +++ b/pySimBlocks/gui/graphics/connection_item.py @@ -110,6 +110,8 @@ def __init__(self, self.route: OrthogonalRoute | None = None self._route_drag_active = False self._route_points_before_drag: list[QPointF] | None = None + self._manual_src_redirected: bool | None = None + self._manual_dst_redirected: bool | None = None if points and len(points) >= 2: self.apply_manual_route(points) @@ -151,14 +153,18 @@ def update_position(self): return if self.is_manual and self.route and len(self.route.points) >= 2: - self.route.points[0] = p1 - self.route.points[-1] = p2 - self._apply_route(self.route.points, simplify=False) - return + if self._manual_anchor_context_matches(): + self.route.points[0] = p1 + self.route.points[-1] = p2 + self._apply_route(self.route.points, simplify=False) + return + self.is_manual = False pts = self._compute_auto_route(p1, p2) self.route = OrthogonalRoute(pts) self.is_manual = False + self._manual_src_redirected = None + self._manual_dst_redirected = None self._apply_route(self.route.points) def update_temp_position(self, scene_pos: QPointF): @@ -178,12 +184,15 @@ def apply_manual_route(self, points: list[QPointF]): """ self.route = OrthogonalRoute(points) self.is_manual = True + self._capture_manual_anchor_context() self._apply_route(self.route.points) def invalidate_manual_route(self): """Discard any manual route so the next update recomputes it.""" self.is_manual = False self.route = None + self._manual_src_redirected = None + self._manual_dst_redirected = None def segment_at(self, scene_pos: QPointF) -> int | None: """Return the route segment index located near the given scene point. @@ -290,6 +299,7 @@ def mouseReleaseEvent(self, event): super().mouseReleaseEvent(event) if was_dragging and event.button() == Qt.LeftButton and self.route is not None: self._apply_route(self.route.points) + self._capture_manual_anchor_context() view = self.src_port.parent_block.view new_points = [QPointF(point) for point in self.route.points] view.on_connection_route_edited( @@ -304,6 +314,32 @@ def mouseReleaseEvent(self, event): # Private Methods # -------------------------------------------------------------------------- + def _anchor_redirected(self, port_item: PortItem) -> bool: + """Return whether the current view routes this port through a group border or proxy.""" + view = port_item.parent_block.view + redirected = view.connection_anchor_for_port_item(port_item) + direct = port_item.connection_anchor() + return ( + abs(redirected.x() - direct.x()) > 0.5 + or abs(redirected.y() - direct.y()) > 0.5 + ) + + def _capture_manual_anchor_context(self) -> None: + """Remember whether each endpoint used a redirected anchor when the route was edited.""" + self._manual_src_redirected = self._anchor_redirected(self.src_port) + self._manual_dst_redirected = self._anchor_redirected(self.dst_port) + + def _manual_anchor_context_matches(self) -> bool: + """Return whether the current view still uses the same anchor redirection as when edited.""" + src_redirected = self._anchor_redirected(self.src_port) + dst_redirected = self._anchor_redirected(self.dst_port) + if self._manual_src_redirected is None or self._manual_dst_redirected is None: + return not src_redirected and not dst_redirected + return ( + self._manual_src_redirected == src_redirected + and self._manual_dst_redirected == dst_redirected + ) + def _compute_auto_route(self, p1: QPointF, p2: QPointF) -> list[QPointF]: """Compute an orthogonal route between two port anchors.""" src_rect = self._routing_rect_for_port(self.src_port) diff --git a/tests/gui/test_nested_visual_groups.py b/tests/gui/test_nested_visual_groups.py index 994b033..8d49a10 100644 --- a/tests/gui/test_nested_visual_groups.py +++ b/tests/gui/test_nested_visual_groups.py @@ -1,3 +1,5 @@ +from PySide6.QtCore import QPointF + from pySimBlocks.gui.main_window import MainWindow @@ -216,3 +218,44 @@ def test_delete_group_removes_nested_members_and_child_groups(qtbot, tmp_path): assert controller.project_state.get_visual_group(control_loop.uid) is not None for uid in member_uids: assert controller._find_block_by_uid(uid) is not None + + +def _wire_length(points) -> float: + total = 0.0 + for index in range(len(points) - 1): + a = points[index] + b = points[index + 1] + total += abs(a.x() - b.x()) + abs(a.y() - b.y()) + return total + + +def test_manual_route_recomputed_in_nested_group_view(qtbot, tmp_path): + window = _create_window(qtbot, tmp_path) + controller = window.project_controller + view = window.view + + external = controller.add_block("sources", "constant") + inner_a = controller.add_block("operators", "sum") + inner_b = controller.add_block("controllers", "state_feedback") + controller.add_connection(_first_port(external, "output"), _first_port(inner_a, "input")) + inner = controller.group_blocks([inner_a, inner_b], name="Regulator") + controller.group_blocks( + [controller.add_block("operators", "gain")], + child_group_uids=[inner.uid], + name="ControlLoop", + ) + + connection = next(iter(controller.project_state.connections)) + conn_item = view.connections[connection] + conn_item.update_position() + manual_points = [QPointF(point) for point in conn_item.route.points] + manual_points.insert(2, QPointF(manual_points[1].x() + 300.0, manual_points[1].y() + 250.0)) + conn_item.apply_manual_route(manual_points) + absurd_length = _wire_length(conn_item.route.points) + + view.enter_group(inner.uid) + view.refresh_visual_groups() + conn_item.update_position() + + assert not conn_item.is_manual + assert _wire_length(conn_item.route.points) < absurd_length * 0.5 From 22d99bcdc876fbfcf91234ae6f0b2d5249d17308 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Fri, 26 Jun 2026 14:36:07 +0200 Subject: [PATCH 41/42] fix(gui): avoid duplicate boundary proxies when undoing connection delete --- pySimBlocks/gui/project_controller.py | 29 ++++++++++++--- pySimBlocks/gui/undo_redo/commands.py | 3 +- tests/gui/test_nested_visual_groups.py | 50 ++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/pySimBlocks/gui/project_controller.py b/pySimBlocks/gui/project_controller.py index 3ac9124..220a0e0 100644 --- a/pySimBlocks/gui/project_controller.py +++ b/pySimBlocks/gui/project_controller.py @@ -1516,7 +1516,6 @@ def _preserve_boundaries_on_connection_remove( if not boundary.external_port_uid: boundary.external_port_uid = external_key else: - boundary.origin = "manual" boundary.linked_connection_uid = "" if deleting_inside: boundary.external_port_uid = external_key @@ -1597,7 +1596,12 @@ def _capture_connection_snapshot(self, connection: ConnectionInstance) -> Connec points=points, ) - def _add_connection_from_snapshot(self, snapshot: ConnectionSnapshot) -> ConnectionInstance | None: + def _add_connection_from_snapshot( + self, + snapshot: ConnectionSnapshot, + *, + refresh_boundaries: bool = True, + ) -> 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: @@ -1609,9 +1613,10 @@ def _add_connection_from_snapshot(self, snapshot: ConnectionSnapshot) -> Connect connection_instance = ConnectionInstance(src_port, dst_port) self.project_state.add_connection(connection_instance) self.view.add_connection(connection_instance, snapshot.points) - self._refresh_boundaries_for_member_uids( - {src_port.block.uid, dst_port.block.uid} - ) + if refresh_boundaries: + self._refresh_boundaries_for_member_uids( + {src_port.block.uid, dst_port.block.uid} + ) return connection_instance def _set_group_geometry(self, group_uid: str, pos: QPointF, rect: QRectF) -> None: @@ -2429,6 +2434,11 @@ def _rebuild_group_boundary_ports(self, group: VisualGroup) -> None: rebuilt_auto.append(port) rebuilt_keys = {port.linked_port_uid for port in rebuilt_auto} + rebuilt_connection_keys = { + port.linked_connection_uid + for port in rebuilt_auto + if port.linked_connection_uid + } for port in group.boundary_ports: if port.origin != "auto": continue @@ -2440,6 +2450,15 @@ def _rebuild_group_boundary_ports(self, group: VisualGroup) -> None: continue if port.linked_port_uid and port.linked_port_uid in rebuilt_keys: continue + if ( + not port.linked_port_uid + and port.external_port_uid + and any( + port.external_port_uid.split(":", 1)[0] in key + for key in rebuilt_connection_keys + ) + ): + continue rebuilt_auto.append(port) previous_auto_order = [ diff --git a/pySimBlocks/gui/undo_redo/commands.py b/pySimBlocks/gui/undo_redo/commands.py index 72bb696..df6dbaa 100644 --- a/pySimBlocks/gui/undo_redo/commands.py +++ b/pySimBlocks/gui/undo_redo/commands.py @@ -118,7 +118,8 @@ def redo(self) -> None: def undo(self) -> None: self._connection_instance = self._controller._add_connection_from_snapshot( - self._snapshot + self._snapshot, + refresh_boundaries=False, ) self._controller._restore_boundary_snapshots(self._boundary_snapshots) self._controller.make_dirty() diff --git a/tests/gui/test_nested_visual_groups.py b/tests/gui/test_nested_visual_groups.py index 8d49a10..3e1a1b4 100644 --- a/tests/gui/test_nested_visual_groups.py +++ b/tests/gui/test_nested_visual_groups.py @@ -259,3 +259,53 @@ def test_manual_route_recomputed_in_nested_group_view(qtbot, tmp_path): assert not conn_item.is_manual assert _wire_length(conn_item.route.points) < absurd_length * 0.5 + + +def test_undo_connection_delete_does_not_duplicate_output_proxy(qtbot, tmp_path): + import shutil + from pathlib import Path + + example_dir = ( + Path(__file__).resolve().parents[2] / "examples/basics/nested_groups/gui" + ) + project_dir = tmp_path / "nested" + shutil.copytree(example_dir, project_dir) + + window = _create_window(qtbot, project_dir) + controller = window.project_controller + view = window.view + + control_loop_uid = "3729168ddf894f62a7b4f1633b4b3014" + system_uid = "3f552e25cb9647bba4ce08cf897de600" + monitor_uid = "5417b603712a41db8d3ef5d0df87b393" + + connection = next( + conn + for conn in controller.project_state.connections + if conn.src_block().uid == system_uid + and conn.src_port.name == "y" + and conn.dst_block().uid == monitor_uid + ) + + view.enter_group(control_loop_uid) + controller.remove_connection(connection) + + control_loop = controller.project_state.get_visual_group(control_loop_uid) + assert control_loop is not None + + window.undo_manager.stack.undo() + + output_boundaries = [ + port for port in control_loop.boundary_ports if port.direction == "output" + ] + assert len(output_boundaries) == 1 + assert output_boundaries[0].linked_connection_uid + assert len(controller.project_state.connections) == 9 + + view.refresh_visual_groups() + visible_outputs = [ + boundary_uid + for boundary_uid, proxy in view.proxy_items.items() + if proxy.isVisible() and proxy.boundary.direction == "output" + ] + assert len(visible_outputs) == 1 From eb888fe1ef285c635bfb86863881652e28f1e891 Mon Sep 17 00:00:00 2001 From: Lukasik Date: Fri, 26 Jun 2026 15:37:40 +0200 Subject: [PATCH 42/42] fix(gui): scope manual wire routes to the active diagram view level --- pySimBlocks/gui/graphics/connection_item.py | 12 +++++- tests/gui/test_nested_visual_groups.py | 41 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/pySimBlocks/gui/graphics/connection_item.py b/pySimBlocks/gui/graphics/connection_item.py index 01daa27..1024790 100644 --- a/pySimBlocks/gui/graphics/connection_item.py +++ b/pySimBlocks/gui/graphics/connection_item.py @@ -112,6 +112,7 @@ def __init__(self, self._route_points_before_drag: list[QPointF] | None = None self._manual_src_redirected: bool | None = None self._manual_dst_redirected: bool | None = None + self._manual_view_group_uid: str | None = None if points and len(points) >= 2: self.apply_manual_route(points) @@ -165,6 +166,7 @@ def update_position(self): self.is_manual = False self._manual_src_redirected = None self._manual_dst_redirected = None + self._manual_view_group_uid = None self._apply_route(self.route.points) def update_temp_position(self, scene_pos: QPointF): @@ -193,6 +195,7 @@ def invalidate_manual_route(self): self.route = None self._manual_src_redirected = None self._manual_dst_redirected = None + self._manual_view_group_uid = None def segment_at(self, scene_pos: QPointF) -> int | None: """Return the route segment index located near the given scene point. @@ -325,12 +328,17 @@ def _anchor_redirected(self, port_item: PortItem) -> bool: ) def _capture_manual_anchor_context(self) -> None: - """Remember whether each endpoint used a redirected anchor when the route was edited.""" + """Remember view level and anchor redirection when the route was edited.""" + view = self.src_port.parent_block.view + self._manual_view_group_uid = view.current_view_group_uid self._manual_src_redirected = self._anchor_redirected(self.src_port) self._manual_dst_redirected = self._anchor_redirected(self.dst_port) def _manual_anchor_context_matches(self) -> bool: - """Return whether the current view still uses the same anchor redirection as when edited.""" + """Return whether the current view matches the one used when the route was edited.""" + view = self.src_port.parent_block.view + if view.current_view_group_uid != self._manual_view_group_uid: + return False src_redirected = self._anchor_redirected(self.src_port) dst_redirected = self._anchor_redirected(self.dst_port) if self._manual_src_redirected is None or self._manual_dst_redirected is None: diff --git a/tests/gui/test_nested_visual_groups.py b/tests/gui/test_nested_visual_groups.py index 3e1a1b4..6ee2084 100644 --- a/tests/gui/test_nested_visual_groups.py +++ b/tests/gui/test_nested_visual_groups.py @@ -309,3 +309,44 @@ def test_undo_connection_delete_does_not_duplicate_output_proxy(qtbot, tmp_path) if proxy.isVisible() and proxy.boundary.direction == "output" ] assert len(visible_outputs) == 1 + + +def test_manual_route_recomputed_when_entering_control_loop_from_root(qtbot, tmp_path): + import shutil + from pathlib import Path + + example_dir = ( + Path(__file__).resolve().parents[2] / "examples/basics/nested_groups/gui" + ) + project_dir = tmp_path / "nested" + shutil.copytree(example_dir, project_dir) + + window = _create_window(qtbot, project_dir) + controller = window.project_controller + view = window.view + + control_loop_uid = "3729168ddf894f62a7b4f1633b4b3014" + controller_uid = "3643a75d10754f21afb56a8a24844cdb" + sum_plant_uid = "db6d7d946a14478fa5fed05018e0a64a" + + connection = next( + conn + for conn in controller.project_state.connections + if conn.src_block().uid == controller_uid + and conn.src_port.name == "u" + and conn.dst_block().uid == sum_plant_uid + and conn.dst_port.name == "in1" + ) + conn_item = view.connections[connection] + conn_item.update_position() + manual_points = [QPointF(point) for point in conn_item.route.points] + manual_points.insert(2, QPointF(manual_points[1].x() + 350.0, manual_points[1].y() + 200.0)) + conn_item.apply_manual_route(manual_points) + absurd_length = _wire_length(conn_item.route.points) + + view.enter_group(control_loop_uid) + view.refresh_visual_groups() + conn_item.update_position() + + assert not conn_item.is_manual + assert _wire_length(conn_item.route.points) < absurd_length * 0.5