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 - ] - ] } } }