diff --git a/src/bedit_core/bondgraph/causality.py b/src/bedit_core/bondgraph/causality.py
index 4de4e71..c80cd79 100644
--- a/src/bedit_core/bondgraph/causality.py
+++ b/src/bedit_core/bondgraph/causality.py
@@ -83,6 +83,7 @@ class _CausalityEngine():
def clear_causalities(self) -> None:
for bond in self._network.bonds:
bond.connection.causality = BondCausality.NONE
+ bond.connection.undesired = False
def propagate_from_port(self, port: NetworkPort, causality: BondCausality) -> None:
attached_connections = self._network.bonds_for(port)
@@ -106,7 +107,7 @@ class _CausalityEngine():
def propagate_to_neighbor(self, component: Component, connection: NetworkBond) -> None:
# Get neighor
is_source = (component == connection.source.component)
- neighbor = connection.target if is_source else connection.target
+ neighbor = connection.target if is_source else connection.source
# Direct the evaluation based on what type of port it is
if neighbor.port.causality_preference in [PortCausality.SINGLE_EFFORT_IN, PortCausality.SINGLE_FLOW_IN]:
self.evaluate_junction_constraints(neighbor)
diff --git a/src/bedit_gui/commands/causality_command.py b/src/bedit_gui/commands/causality_command.py
new file mode 100644
index 0000000..d86dfb9
--- /dev/null
+++ b/src/bedit_gui/commands/causality_command.py
@@ -0,0 +1,44 @@
+from PySide6.QtGui import QUndoCommand
+
+from bedit_core.models import BondCausality, BondConnection, Component, ConnectionID, GraphImplementation
+
+CausalityState = dict[ConnectionID, tuple[BondCausality, bool]]
+
+
+def causality_state(component: Component) -> CausalityState:
+ state = {}
+ if not isinstance(component.implementation, GraphImplementation):
+ return state
+ for connection_id, connection in component.implementation.graph.connections.items():
+ if isinstance(connection, BondConnection):
+ state[connection_id] = (connection.causality, connection.undesired)
+ for child in component.implementation.graph.components.values():
+ state.update(causality_state(child))
+ return state
+
+
+class ChangeCausalityCommand(QUndoCommand):
+ def __init__(self, document: object, component: Component, inferred_component: Component) -> None:
+ super().__init__("Infer causality")
+ self.document = document
+ self.component = component
+ self.old_state = causality_state(component)
+ self.new_state = causality_state(inferred_component)
+
+ def redo(self) -> None:
+ self._apply(self.component, self.new_state)
+ self.document.model_changed.emit(self.document.model)
+
+ def undo(self) -> None:
+ self._apply(self.component, self.old_state)
+ self.document.model_changed.emit(self.document.model)
+
+ @classmethod
+ def _apply(cls, component: Component, state: CausalityState) -> None:
+ if not isinstance(component.implementation, GraphImplementation):
+ return
+ for connection_id, connection in component.implementation.graph.connections.items():
+ if isinstance(connection, BondConnection) and connection_id in state:
+ connection.causality, connection.undesired = state[connection_id]
+ for child in component.implementation.graph.components.values():
+ cls._apply(child, state)
diff --git a/src/bedit_gui/commands/graph_connection_command.py b/src/bedit_gui/commands/graph_connection_command.py
new file mode 100644
index 0000000..27e09ab
--- /dev/null
+++ b/src/bedit_gui/commands/graph_connection_command.py
@@ -0,0 +1,54 @@
+from copy import deepcopy
+
+from PySide6.QtGui import QUndoCommand
+
+from bedit_core.models import Component, Connection, ConnectionID, GraphImplementation
+
+
+class AddGraphConnectionCommand(QUndoCommand):
+ def __init__(self, document: object, graph_component: Component, connection: Connection) -> None:
+ super().__init__("Add connection")
+ if not isinstance(graph_component.implementation, GraphImplementation):
+ raise TypeError("component must have a graph implementation")
+ self.document = document
+ self.graph = graph_component.implementation.graph
+ self.connection_id = ConnectionID()
+ self.connection = connection
+
+ def redo(self) -> None:
+ self.graph.connections[self.connection_id] = self.connection
+ self.document.model_changed.emit(self.document.model)
+
+ def undo(self) -> None:
+ self.graph.connections.pop(self.connection_id, None)
+ self.document.model_changed.emit(self.document.model)
+
+
+class DeleteGraphConnectionCommand(QUndoCommand):
+ def __init__(self, document: object, graph_component: Component, connection_id: ConnectionID) -> None:
+ super().__init__("Delete connection")
+ if not isinstance(graph_component.implementation, GraphImplementation):
+ raise TypeError("component must have a graph implementation")
+ self.document = document
+ self.graph_id = document.component_id(graph_component)
+ self.connections = graph_component.implementation.graph.connections
+ self.connection_id = connection_id
+ self.connection = self.connections[connection_id]
+ self.index = list(self.connections).index(connection_id)
+ database = document._graph_database(False)
+ graph = database.graphs.get(self.graph_id) if database is not None else None
+ self.visual_connection = deepcopy(graph.connections.get(connection_id)) if graph is not None else None
+
+ def redo(self) -> None:
+ self.connections.pop(self.connection_id, None)
+ self.document._set_graph_connection_points(self.graph_id, self.connection_id, None)
+ self.document.model_changed.emit(self.document.model)
+
+ def undo(self) -> None:
+ items = list(self.connections.items())
+ items.insert(self.index, (self.connection_id, self.connection))
+ self.connections.clear()
+ self.connections.update(items)
+ if self.visual_connection is not None:
+ self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.visual_connection.points)
+ self.document.model_changed.emit(self.document.model)
diff --git a/src/bedit_gui/controllers/clipboard_controller.py b/src/bedit_gui/controllers/clipboard_controller.py
index 1a46847..d792791 100644
--- a/src/bedit_gui/controllers/clipboard_controller.py
+++ b/src/bedit_gui/controllers/clipboard_controller.py
@@ -196,7 +196,7 @@ class GraphEditorClipboardHandler(ClipboardHandler):
return self._target() is not None and self.clipboard.has_format(ClipboardService.COMPONENTS_MIME)
def can_delete(self) -> bool:
- return self.can_copy()
+ return bool(self._selected_components() or self.editor.selected_connection_ids())
def copy(self) -> None:
components = self._selected_components()
@@ -225,7 +225,13 @@ class GraphEditorClipboardHandler(ClipboardHandler):
self.document.paste_graph_components(graph_component, components, icons, positions)
def delete(self) -> None:
- self.document.delete_components(self._selected_components())
+ components = self._selected_components()
+ connection_ids = self.editor.selected_connection_ids()
+ if components:
+ self.document.delete_components(components)
+ graph_component = self.editor.component()
+ if graph_component is not None and connection_ids:
+ self.document.delete_graph_connections(graph_component, connection_ids)
def _write_components(self, components: list[Component]) -> None:
payload = export_components(self.document, components)
diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py
index 93607b1..f90492f 100644
--- a/src/bedit_gui/controllers/document_tree_controller.py
+++ b/src/bedit_gui/controllers/document_tree_controller.py
@@ -2,7 +2,7 @@ from collections.abc import Callable
from functools import partial
from typing import Protocol
-from PySide6.QtCore import QEvent, QObject, QPoint, QSize, Qt
+from PySide6.QtCore import QEvent, QItemSelectionModel, QObject, QPoint, QSize, Qt
from PySide6.QtGui import QMouseEvent
from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu
@@ -69,7 +69,10 @@ class DocumentTreeController(QObject):
self.model.rename_component_requested.connect(self.document.rename_component)
window.graph_editor.component_move_requested.connect(self.document.move_graph_component)
window.graph_editor.component_context_menu_requested.connect(self._show_graph_component_context_menu)
+ window.graph_editor.component_open_requested.connect(self._open_graph_component)
window.graph_editor.connection_points_change_requested.connect(self.document.change_graph_connection_points)
+ window.graph_editor.connection_add_requested.connect(self.document.add_graph_connection)
+ window.graph_editor.connections_delete_requested.connect(self.document.delete_graph_connections)
# Add deselection with esc to this widget
window.ui.actionEscape.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
@@ -176,6 +179,12 @@ class DocumentTreeController(QObject):
if component is not None:
self._show_component_context_menu(component, global_position)
+ def _open_graph_component(self, component_id: ComponentID) -> None:
+ index = self.model.component_index(component_id)
+ if index.isValid():
+ self.window.ui.documentTree.selectionModel().setCurrentIndex(index, QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows)
+ self.window.ui.documentTree.scrollTo(index)
+
def _show_component_context_menu(self, component: Component, global_position: QPoint) -> None:
menu = QMenu(self.window.ui.documentTree)
edit_interface = menu.addAction("Edit Interface")
diff --git a/src/bedit_gui/controllers/simulation_controller.py b/src/bedit_gui/controllers/simulation_controller.py
index d898bd3..8e34c9d 100644
--- a/src/bedit_gui/controllers/simulation_controller.py
+++ b/src/bedit_gui/controllers/simulation_controller.py
@@ -62,6 +62,7 @@ class SimulationController(QObject):
self.window.statusBar().showMessage(f"Compiling {component_path}…")
logger.info("Compiling model: %s", component_path)
try:
+ self.document.infer_causality(component)
build = self.compiler(component, build_directory)
except (OSError, RuntimeError, ValueError) as exc:
logger.exception("Could not compile model %s", component_path)
diff --git a/src/bedit_gui/documents/document.py b/src/bedit_gui/documents/document.py
index 6e8fd31..6dc38d5 100644
--- a/src/bedit_gui/documents/document.py
+++ b/src/bedit_gui/documents/document.py
@@ -6,12 +6,15 @@ from pathlib import Path
from PySide6.QtCore import QObject, Signal
from PySide6.QtGui import QUndoStack
-from bedit_core.models import ID, Component, ComponentID, ConnectionID, GraphImplementation, Port, PortID, Parameter, ParameterID
+from bedit_core.models import ID, Component, ComponentID, Connection, ConnectionID, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import Document as CoreDocument
+from bedit_core.bondgraph import causality_inference
+from bedit_gui.commands.causality_command import ChangeCausalityCommand, causality_state
from bedit_gui.commands.change_icon_command import ChangeIconCommand
from bedit_gui.commands.equation_text_command import ChangeEquationTextCommand
from bedit_gui.commands.graph_position_command import MoveGraphComponentCommand
from bedit_gui.commands.graph_connection_points_command import ChangeGraphConnectionPointsCommand
+from bedit_gui.commands.graph_connection_command import AddGraphConnectionCommand, DeleteGraphConnectionCommand
from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand
from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand
from bedit_gui.commands.rename_component_command import RenameComponentCommand
@@ -102,6 +105,12 @@ class Document(QObject):
def rename_component(self, component: Component, name: str) -> None:
self.undo_stack.push(RenameComponentCommand(self, component, name))
+ def infer_causality(self, component: Component) -> None:
+ inferred_component = deepcopy(component)
+ causality_inference(inferred_component)
+ if causality_state(component) != causality_state(inferred_component):
+ self.undo_stack.push(ChangeCausalityCommand(self, component, inferred_component))
+
def component_id(self, component: Component) -> ComponentID:
def find(components: dict[ComponentID, Component]) -> ComponentID | None:
for component_id, candidate in components.items():
@@ -169,6 +178,20 @@ class Document(QObject):
graph_id = self.component_id(graph_component)
self.undo_stack.push(ChangeGraphConnectionPointsCommand(self, graph_id, connection_id, points, text))
+ def add_graph_connection(self, graph_component: Component, connection: Connection) -> None:
+ self.undo_stack.push(AddGraphConnectionCommand(self, graph_component, connection))
+
+ def delete_graph_connections(self, graph_component: Component, connection_ids: list[ConnectionID]) -> None:
+ if not isinstance(graph_component.implementation, GraphImplementation):
+ return
+ connection_ids = [connection_id for connection_id in connection_ids if connection_id in graph_component.implementation.graph.connections]
+ if not connection_ids:
+ return
+ self.undo_stack.beginMacro("Delete connections")
+ for connection_id in connection_ids:
+ self.undo_stack.push(DeleteGraphConnectionCommand(self, graph_component, connection_id))
+ self.undo_stack.endMacro()
+
def _set_graph_connection_points(self, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None:
if points is None:
database = self._graph_database(False)
diff --git a/src/bedit_gui/ui/forms/graph_editor_widget.ui b/src/bedit_gui/ui/forms/graph_editor_widget.ui
index 0dd6d1e..720203a 100644
--- a/src/bedit_gui/ui/forms/graph_editor_widget.ui
+++ b/src/bedit_gui/ui/forms/graph_editor_widget.ui
@@ -72,6 +72,9 @@
+
+ true
+
:/icons/icons/edit-select.png:/icons/icons/edit-select.png
@@ -84,6 +87,9 @@
+
+ true
+
:/icons/icons/network-connect.png:/icons/icons/network-connect.png
diff --git a/src/bedit_gui/ui/forms/graph_editor_widget_ui.py b/src/bedit_gui/ui/forms/graph_editor_widget_ui.py
index 9d65f34..5da323b 100644
--- a/src/bedit_gui/ui/forms/graph_editor_widget_ui.py
+++ b/src/bedit_gui/ui/forms/graph_editor_widget_ui.py
@@ -32,11 +32,13 @@ class Ui_graphEditorWidget(object):
self.actionZoomToFit.setIcon(icon)
self.actionMouseMode = QAction(graphEditorWidget)
self.actionMouseMode.setObjectName(u"actionMouseMode")
+ self.actionMouseMode.setCheckable(True)
icon1 = QIcon()
icon1.addFile(u":/icons/icons/edit-select.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionMouseMode.setIcon(icon1)
self.actionConnectionMode = QAction(graphEditorWidget)
self.actionConnectionMode.setObjectName(u"actionConnectionMode")
+ self.actionConnectionMode.setCheckable(True)
icon2 = QIcon()
icon2.addFile(u":/icons/icons/network-connect.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionConnectionMode.setIcon(icon2)
diff --git a/src/bedit_gui/views/graph_editor_widget.py b/src/bedit_gui/views/graph_editor_widget.py
index 64ca052..570e467 100644
--- a/src/bedit_gui/views/graph_editor_widget.py
+++ b/src/bedit_gui/views/graph_editor_widget.py
@@ -1,13 +1,14 @@
from __future__ import annotations
+from enum import Enum
from itertools import pairwise
from math import hypot
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt, Signal
-from PySide6.QtGui import QBrush, QColor, QCursor, QPainter, QPainterPath, QPen, QWheelEvent
+from PySide6.QtGui import QActionGroup, QBrush, QColor, QCursor, QKeySequence, QMouseEvent, QPainter, QPainterPath, QPen, QShortcut, QWheelEvent
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsView, QMenu, QWidget
-from bedit_core.models import BondCausality, BondConnection, Component, ComponentID, ConnectionID, GraphImplementation, SignalConnection
+from bedit_core.models import BondCausality, BondConnection, BondPort, Component, ComponentID, Connection, ConnectionID, GraphImplementation, Port, PortID, SignalConnection, SignalDirection, SignalPort
from bedit_gui.models import Graph, GraphConnection, Icon
from bedit_gui.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget
from bedit_gui.utils.icon import get_pixmap_bounding_box, render_icon
@@ -28,6 +29,11 @@ ARROW_HALF_WIDTH = 16.0
CAUSALITY_TICK_HALF_LENGTH = 16.0
+class GraphEditorMode(Enum):
+ NORMAL = "normal"
+ CONNECTION = "connection"
+
+
class GraphGraphicsScene(QGraphicsScene):
"""Graph canvas with a lightweight dotted-line grid."""
@@ -59,15 +65,23 @@ class GraphConnectionItem(QGraphicsPathItem):
self.setPath(self._connection_path(points, half_arrow, tick_at_source))
pen = QPen(QColor("#202020"), CONNECTION_WIDTH)
self.setPen(pen)
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.setZValue(-1)
def contextMenuEvent(self, event) -> None:
if self.editor is None or self.connection_id is None:
return
+ if not self.isSelected():
+ self.scene().clearSelection()
+ self.setSelected(True)
menu = QMenu(self.editor)
add_point = menu.addAction("Add Point")
- if menu.exec(event.screenPos()) is add_point:
+ delete_connection = menu.addAction("Delete Connection")
+ selected = menu.exec(event.screenPos())
+ if selected is add_point:
self.editor.add_connection_point(self.connection_id, event.scenePos())
+ elif selected is delete_connection:
+ self.editor.delete_selected_connections()
event.accept()
@staticmethod
@@ -123,7 +137,7 @@ class GraphComponentItem(QGraphicsPixmapItem):
self._drag_start = QPointF()
self._dragging = False
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
- self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, editor.mode is GraphEditorMode.NORMAL)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
@@ -136,11 +150,18 @@ class GraphComponentItem(QGraphicsPixmapItem):
return result
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
+ if event.button() == Qt.MouseButton.LeftButton and self.editor.mode is GraphEditorMode.CONNECTION:
+ self.editor.choose_connection_component(self.component_id, event.screenPos())
+ event.accept()
+ return
self._drag_start = QPointF(self.pos())
self._dragging = True
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
+ if self.editor.mode is GraphEditorMode.CONNECTION:
+ event.accept()
+ return
super().mouseReleaseEvent(event)
self._dragging = False
position = (round(self.pos().x()), round(self.pos().y()))
@@ -148,6 +169,13 @@ class GraphComponentItem(QGraphicsPixmapItem):
if self.pos() != self._drag_start:
self.editor.finish_component_move(self.component_id, position)
+ def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None:
+ if event.button() == Qt.MouseButton.LeftButton:
+ self.editor.open_component(self.component_id)
+ event.accept()
+ return
+ super().mouseDoubleClickEvent(event)
+
def contextMenuEvent(self, event) -> None:
if not self.isSelected():
self.scene().clearSelection()
@@ -205,7 +233,10 @@ class GraphConnectionPointItem(QGraphicsEllipseItem):
class GraphEditorWidget(QWidget):
component_move_requested = Signal(object, object, object)
component_context_menu_requested = Signal(object, object)
+ component_open_requested = Signal(object)
connection_points_change_requested = Signal(object, object, object, str)
+ connection_add_requested = Signal(object, object)
+ connections_delete_requested = Signal(object, object)
def __init__(self, parent: QWidget | None = None, snap_to_grid_size: int = 4) -> None:
super().__init__(parent)
@@ -214,6 +245,10 @@ class GraphEditorWidget(QWidget):
self.ui.setupUi(self)
self._component: Component | None = None
self._graph = Graph()
+ self._icons: dict[ComponentID, Icon] = {}
+ self._mode = GraphEditorMode.NORMAL
+ self._connection_start: ComponentID | None = None
+ self._connection_preview: QGraphicsPathItem | None = None
self._component_items: dict[ComponentID, GraphComponentItem] = {}
self._component_bounds: dict[ComponentID, QRectF] = {}
self._connection_items: dict[ConnectionID, GraphConnectionItem] = {}
@@ -224,7 +259,18 @@ class GraphEditorWidget(QWidget):
self.ui.graphicsView.setScene(self.scene)
self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
self.ui.graphicsView.viewport().installEventFilter(self)
+ self._mode_actions = QActionGroup(self)
+ self._mode_actions.setExclusive(True)
+ self._mode_actions.addAction(self.ui.actionMouseMode)
+ self._mode_actions.addAction(self.ui.actionConnectionMode)
+ self.ui.actionMouseMode.triggered.connect(lambda: self.set_mode(GraphEditorMode.NORMAL))
+ self.ui.actionConnectionMode.triggered.connect(lambda: self.set_mode(GraphEditorMode.CONNECTION))
+ self.ui.actionMouseMode.setChecked(True)
+ self._mode_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Space), self)
+ self._mode_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
+ self._mode_shortcut.activated.connect(self.toggle_mode)
self.ui.actionZoomToFit.triggered.connect(self.zoom_to_fit)
+ self.ui.graphicsView.viewport().setMouseTracking(True)
self.ui.graphicsView.centerOn(0, 0)
@property
@@ -236,12 +282,32 @@ class GraphEditorWidget(QWidget):
raise ValueError("snap-to-grid size must be positive")
self._snap_to_grid_size = size
+ @property
+ def mode(self) -> GraphEditorMode:
+ return self._mode
+
+ def set_mode(self, mode: GraphEditorMode) -> None:
+ if mode is self._mode:
+ return
+ self._mode = mode
+ self.ui.actionMouseMode.setChecked(mode is GraphEditorMode.NORMAL)
+ self.ui.actionConnectionMode.setChecked(mode is GraphEditorMode.CONNECTION)
+ self._clear_connection_start()
+ component = self._component
+ if component is not None:
+ self.set_component(component, self._graph, self._icons)
+
+ def toggle_mode(self) -> None:
+ self.set_mode(GraphEditorMode.CONNECTION if self._mode is GraphEditorMode.NORMAL else GraphEditorMode.NORMAL)
+
def set_component(self, component: Component | None, graph: Graph | None = None, icons: dict[ComponentID, Icon] | None = None) -> None:
if component is not None and not isinstance(component.implementation, GraphImplementation):
raise TypeError("GraphEditorWidget only supports components with a graph implementation")
component_changed = component is not self._component
+ self._clear_connection_start()
self._component = component
self._graph = graph or Graph()
+ self._icons = icons or {}
self._component_items = {}
self._component_bounds = {}
self._connection_items = {}
@@ -251,11 +317,11 @@ class GraphEditorWidget(QWidget):
return
graph = self._graph
- icons = icons or {}
+ icons = self._icons
positions = {component_id: graph.component_positions.get(component_id, (index * FALLBACK_COMPONENT_SPACING, 0)) for index, component_id in enumerate(component.implementation.graph.components)}
for component_id, child in component.implementation.graph.components.items():
icon = icons.get(component_id, Icon())
- pixmap = render_icon(icon, child.interface.ports, COMPONENT_ICON_SIZE).pixmap(COMPONENT_ICON_SIZE)
+ pixmap = render_icon(icon, child.interface.ports, COMPONENT_ICON_SIZE, render_ports=self._mode is GraphEditorMode.CONNECTION).pixmap(COMPONENT_ICON_SIZE)
item = GraphComponentItem(component_id, pixmap, self)
item.setOffset(-pixmap.width() / 2, -pixmap.height() / 2)
item.setPos(*positions[component_id])
@@ -290,7 +356,9 @@ class GraphEditorWidget(QWidget):
return
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
for connection_id, item in self._connection_items.items():
- connection = component.implementation.graph.connections[connection_id]
+ connection = component.implementation.graph.connections.get(connection_id)
+ if connection is None:
+ continue
source_component = port_owners.get(connection.source)
target_component = port_owners.get(connection.target)
if source_component is None or target_component is None:
@@ -327,10 +395,14 @@ class GraphEditorWidget(QWidget):
def set_connection_points(self, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None:
if points is None:
self._graph.connections.pop(connection_id, None)
- interior_points = []
- else:
- self._graph.connections[connection_id] = GraphConnection(points=list(points))
- interior_points = points[1:-1]
+ for item in self._connection_point_items.pop(connection_id, []):
+ self.scene.removeItem(item)
+ connection_item = self._connection_items.pop(connection_id, None)
+ if connection_item is not None:
+ self.scene.removeItem(connection_item)
+ return
+ self._graph.connections[connection_id] = GraphConnection(points=list(points))
+ interior_points = points[1:-1]
items = self._connection_point_items.get(connection_id, [])
if len(items) == len(interior_points):
for item, position in zip(items, interior_points):
@@ -392,8 +464,11 @@ class GraphEditorWidget(QWidget):
if item is None:
return
if position is None:
+ self._graph.component_positions.pop(component_id, None)
index = list(self._component_items).index(component_id)
position = (index * FALLBACK_COMPONENT_SPACING, 0)
+ else:
+ self._graph.component_positions[component_id] = position
item.setPos(*position)
self.refresh_connections()
@@ -445,6 +520,112 @@ class GraphEditorWidget(QWidget):
def selected_component_ids(self) -> list[ComponentID]:
return [item.component_id for item in self.scene.selectedItems() if isinstance(item, GraphComponentItem)]
+ def selected_connection_ids(self) -> list[ConnectionID]:
+ return [item.connection_id for item in self.scene.selectedItems() if isinstance(item, GraphConnectionItem) and item.connection_id is not None]
+
+ def delete_selected_connections(self) -> None:
+ if self._component is not None:
+ self.connections_delete_requested.emit(self._component, self.selected_connection_ids())
+
+ def open_component(self, component_id: ComponentID) -> None:
+ self._clear_connection_start()
+ self.component_open_requested.emit(component_id)
+
+ def choose_connection_component(self, component_id: ComponentID, screen_position) -> None:
+ if self._component is None or self._mode is not GraphEditorMode.CONNECTION:
+ return
+ if self._connection_start is None:
+ self.scene.clearSelection()
+ self._component_items[component_id].setSelected(True)
+ self._connection_start = component_id
+ self._connection_preview = QGraphicsPathItem()
+ self._connection_preview.setPen(QPen(QColor("#606060"), CONNECTION_WIDTH, Qt.PenStyle.DashLine))
+ self._connection_preview.setZValue(-0.5)
+ self.scene.addItem(self._connection_preview)
+ return
+ start = self._connection_start
+ self._clear_connection_start()
+ self.scene.clearSelection()
+ if start == component_id:
+ return
+ options = self._connection_options(start, component_id)
+ if len(options) == 1:
+ self.connection_add_requested.emit(self._component, options[0][1])
+ return
+ if not options:
+ return
+ menu = QMenu(self)
+ actions = []
+ for label, connection, _preferred in options:
+ action = menu.addAction(label)
+ actions.append((action, connection))
+ menu.setActiveAction(actions[0][0])
+ selected = menu.exec(screen_position)
+ for action, connection in actions:
+ if selected is action:
+ self.connection_add_requested.emit(self._component, connection)
+ break
+
+ def _update_connection_preview(self, mouse_position: QPointF) -> None:
+ if self._connection_start is None or self._connection_preview is None:
+ return
+ source = self._item_position(self._connection_start)
+ if mouse_position == QPointF(*source):
+ self._connection_preview.setPath(QPainterPath(mouse_position))
+ return
+ bounds = self._component_bounds[self._connection_start].translated(*source)
+ start = self._bounding_box_edge(bounds, source, (mouse_position.x(), mouse_position.y()))
+ path = QPainterPath(QPointF(*start))
+ path.lineTo(mouse_position)
+ self._connection_preview.setPath(path)
+
+ def _clear_connection_start(self) -> None:
+ self._connection_start = None
+ if self._connection_preview is not None and self._connection_preview.scene() is self.scene:
+ self.scene.removeItem(self._connection_preview)
+ self._connection_preview = None
+
+ def _connection_options(self, first_id: ComponentID, second_id: ComponentID) -> list[tuple[str, Connection, bool]]:
+ if self._component is None:
+ return []
+ components = self._component.implementation.graph.components
+ first = components[first_id]
+ second = components[second_id]
+ used_ports = {port_id for connection in self._component.implementation.graph.connections.values() for port_id in (connection.source, connection.target)}
+ options = []
+ for first_port_id, first_port in first.interface.ports.items():
+ for second_port_id, second_port in second.interface.ports.items():
+ if not self._compatible_ports(first_port, second_port) or not self._port_available(first_port_id, first_port, used_ports) or not self._port_available(second_port_id, second_port, used_ports):
+ continue
+ preferred = first_port.direction is SignalDirection.OUTPUT and second_port.direction is SignalDirection.INPUT
+ reverse_preferred = second_port.direction is SignalDirection.OUTPUT and first_port.direction is SignalDirection.INPUT
+ if reverse_preferred:
+ source_id, source_port, source_name = second_port_id, second_port, second.name
+ target_id, target_port, target_name = first_port_id, first_port, first.name
+ else:
+ source_id, source_port, source_name = first_port_id, first_port, first.name
+ target_id, target_port, target_name = second_port_id, second_port, second.name
+ connection_type = SignalConnection if isinstance(source_port, SignalPort) else BondConnection
+ label = f"{source_name}.{source_port.name} → {target_name}.{target_port.name}"
+ options.append((label, connection_type(source=source_id, target=target_id), preferred or reverse_preferred))
+ return sorted(options, key=lambda option: not option[2])
+
+ @staticmethod
+ def _compatible_ports(first: Port, second: Port) -> bool:
+ if isinstance(first, SignalPort) and isinstance(second, SignalPort):
+ return first.direction is not second.direction
+ if isinstance(first, BondPort) and isinstance(second, BondPort):
+ return not first.domain or not second.domain or first.domain == second.domain
+ return False
+
+ @staticmethod
+ def _port_available(port_id: PortID, port: Port, used_ports: set[PortID]) -> bool:
+ if isinstance(port, SignalPort):
+ return port.direction is SignalDirection.OUTPUT or port_id not in used_ports
+ if isinstance(port, BondPort):
+ return port.multiplicity or port_id not in used_ports
+ return False
+
def paste_position(self) -> tuple[int, int]:
viewport = self.ui.graphicsView.viewport()
viewport_position = viewport.mapFromGlobal(QCursor.pos())
@@ -469,6 +650,9 @@ class GraphEditorWidget(QWidget):
self.ui.graphicsView.centerOn(bounds.center())
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
+ if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.MouseMove:
+ assert isinstance(event, QMouseEvent)
+ self._update_connection_preview(self.ui.graphicsView.mapToScene(event.position().toPoint()))
if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.Wheel:
assert isinstance(event, QWheelEvent)
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
diff --git a/src/bedit_gui/views/models/document_tree_model.py b/src/bedit_gui/views/models/document_tree_model.py
index 7579585..37740c0 100644
--- a/src/bedit_gui/views/models/document_tree_model.py
+++ b/src/bedit_gui/views/models/document_tree_model.py
@@ -131,6 +131,12 @@ class DocumentTreeModel(QAbstractItemModel):
node = index.internalPointer()
return node.value if isinstance(node, DocumentTreeNode) else None
+ def component_index(self, component_id: ComponentID) -> QModelIndex:
+ node = self._component_nodes.get(component_id)
+ if node is None or node.parent is None:
+ return QModelIndex()
+ return self.createIndex(node.parent.children.index(node), 0, node)
+
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
flags = super().flags(index)
diff --git a/src/bedit_simulation/simulation.py b/src/bedit_simulation/simulation.py
index 61df006..3bbf26d 100644
--- a/src/bedit_simulation/simulation.py
+++ b/src/bedit_simulation/simulation.py
@@ -374,6 +374,7 @@ class Simulation:
process = self._run_model_commands(
modelica,
[
+ f"checkModel({model_name})",
f'buildModel({model_name}, outputFormat="csv")',
"getErrorString()",
],
diff --git a/untitled.bedit.json b/untitled.bedit.json
index 3792448..28fcda2 100644
--- a/untitled.bedit.json
+++ b/untitled.bedit.json
@@ -360,20 +360,6 @@
"connection_type": "bond",
"source": "497b1f74-1186-471f-976a-36b07a451caf",
"target": "4a3f69f2-b305-4305-92e3-3998634ff226",
- "causality": "flow_out",
- "undesired": false
- },
- "8aaa96b3-45ed-4674-8f7a-06b28178a5c8": {
- "connection_type": "bond",
- "source": "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3",
- "target": "c87881f1-23b4-4a69-b586-8e3c7bf6e21e",
- "causality": "flow_out",
- "undesired": false
- },
- "ff606583-d31f-4638-93f7-926163db2665": {
- "connection_type": "bond",
- "source": "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3",
- "target": "0ac7d4ea-77f7-4c0d-8e37-406ef66e4740",
"causality": "effort_out",
"undesired": false
},
@@ -384,10 +370,24 @@
"causality": "effort_out",
"undesired": false
},
- "5af42b61-2317-48a8-b32b-0e2dfd88368e": {
+ "cfff2ad4-a4ec-4f37-aa36-2c37b01422f0": {
"connection_type": "bond",
"source": "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3",
"target": "497b1f74-1186-471f-976a-36b07a451caf",
+ "causality": "effort_out",
+ "undesired": false
+ },
+ "743e35a4-e736-407d-bf34-b4afbcde5a0b": {
+ "connection_type": "bond",
+ "source": "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3",
+ "target": "0ac7d4ea-77f7-4c0d-8e37-406ef66e4740",
+ "causality": "flow_out",
+ "undesired": false
+ },
+ "68727b23-888b-45b4-a0f4-801ac7b39601": {
+ "connection_type": "bond",
+ "source": "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3",
+ "target": "c87881f1-23b4-4a69-b586-8e3c7bf6e21e",
"causality": "flow_out",
"undesired": false
}
@@ -458,31 +458,6 @@
]
}
},
- "77701f68-b14c-4b98-8929-c5fba0261962": {
- "shapes": {
- "2a5bf670-c821-442b-9d46-1b07faa70722": {
- "layer": 0,
- "type": "text",
- "pos": [
- -32,
- -32
- ],
- "width": 64.0,
- "height": 64.0,
- "color": "#000000ff",
- "bold": true,
- "italic": false,
- "size": 64.0,
- "text": "R"
- }
- },
- "port_positions": {
- "4a3f69f2-b305-4305-92e3-3998634ff226": [
- -8,
- -8
- ]
- }
- },
"033b930e-bf79-403a-8b0b-a159f3c81ce9": {
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
@@ -586,6 +561,31 @@
-8
]
}
+ },
+ "77701f68-b14c-4b98-8929-c5fba0261962": {
+ "shapes": {
+ "2a5bf670-c821-442b-9d46-1b07faa70722": {
+ "layer": 0,
+ "type": "text",
+ "pos": [
+ -32,
+ -32
+ ],
+ "width": 64.0,
+ "height": 64.0,
+ "color": "#000000ff",
+ "bold": true,
+ "italic": false,
+ "size": 64.0,
+ "text": "R"
+ }
+ },
+ "port_positions": {
+ "4a3f69f2-b305-4305-92e3-3998634ff226": [
+ -8,
+ -8
+ ]
+ }
}
}
},
@@ -622,6 +622,10 @@
"033b930e-bf79-403a-8b0b-a159f3c81ce9": [
-192,
128
+ ],
+ "1455acad-931b-4caa-be71-aff4283507c6": [
+ 64,
+ 384
]
},
"connections": {
@@ -649,30 +653,6 @@
]
]
},
- "8aaa96b3-45ed-4674-8f7a-06b28178a5c8": {
- "points": [
- [
- -128,
- -128
- ],
- [
- 128,
- -256
- ]
- ]
- },
- "ff606583-d31f-4638-93f7-926163db2665": {
- "points": [
- [
- -128,
- -128
- ],
- [
- 128,
- -64
- ]
- ]
- },
"1f52dc5a-1847-4982-aabc-2538cf99a86f": {
"points": [
[
@@ -684,18 +664,6 @@
224
]
]
- },
- "5af42b61-2317-48a8-b32b-0e2dfd88368e": {
- "points": [
- [
- -128,
- -128
- ],
- [
- 128,
- 160
- ]
- ]
}
}
}