Compare commits

...

3 Commits

Author SHA1 Message Date
4591e6b7b0 Fixed some stuff 2026-08-17 12:55:42 +02:00
cb9e03a6bf Connection points 2026-08-17 12:16:59 +02:00
0bb2141673 Copy/paste on canvas 2026-08-17 12:12:57 +02:00
16 changed files with 767 additions and 143 deletions

View File

@@ -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)

View File

@@ -5,7 +5,7 @@ import argparse
from PySide6.QtWidgets import QApplication, QMessageBox
from bedit_gui.controllers.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, TextClipboardHandler
from bedit_gui.controllers.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, GraphEditorClipboardHandler, TextClipboardHandler
from bedit_gui.controllers.document_controller import DocumentController
from bedit_gui.controllers.log_controller import LogController
from bedit_gui.controllers.settings_controller import SettingsController
@@ -59,7 +59,7 @@ def main() -> int:
ViewMenuController(window)
document_tree_controller = DocumentTreeController(document, window)
clipboard = ClipboardService(app)
ClipboardController(window, clipboard, [TextClipboardHandler(clipboard), DocumentTreeClipboardHandler(document, window.ui.documentTree, document_tree_controller.model, clipboard)])
ClipboardController(window, clipboard, [TextClipboardHandler(clipboard), DocumentTreeClipboardHandler(document, window.ui.documentTree, document_tree_controller.model, clipboard), GraphEditorClipboardHandler(document, window.graph_editor, clipboard)])
window_state_controller = WindowStateController(app, window)
window_state_controller.restore()

View File

@@ -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)

View File

@@ -108,15 +108,20 @@ class DeleteComponent(QUndoCommand):
class PasteComponents(QUndoCommand):
def __init__(self, document: object, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None:
def __init__(self, document: object, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], graph_id: ComponentID | None = None, positions: dict[ComponentID, tuple[int, int]] | None = None) -> None:
super().__init__("Paste components")
self.document = document
self.target = target
self.components = components
self.icons = icons
self.graph_id = graph_id
self.positions = positions or {}
def redo(self) -> None:
self.target.update(self.components)
if self.graph_id is not None:
for component_id, position in self.positions.items():
self.document._set_graph_component_position(self.graph_id, component_id, position)
self.document.model_changed.emit(self.document.model)
for component_id, icon in self.icons.items():
self.document._set_component_icon(component_id, icon)
@@ -124,6 +129,9 @@ class PasteComponents(QUndoCommand):
def undo(self) -> None:
for component_id in self.components:
self.target.pop(component_id, None)
if self.graph_id is not None:
for component_id in self.positions:
self.document._set_graph_component_position(self.graph_id, component_id, None)
self.document.model_changed.emit(self.document.model)
for component_id in self.icons:
self.document._set_component_icon(component_id, None)

View File

@@ -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)

View File

@@ -0,0 +1,24 @@
from copy import deepcopy
from PySide6.QtGui import QUndoCommand
from bedit_core.models import ComponentID, ConnectionID
class ChangeGraphConnectionPointsCommand(QUndoCommand):
def __init__(self, document: object, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None:
super().__init__(text)
self.document = document
self.graph_id = graph_id
self.connection_id = connection_id
database = document._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
connection = graph.connections.get(connection_id) if graph is not None else None
self.old_points = deepcopy(connection.points) if connection is not None else None
self.new_points = deepcopy(points)
def redo(self) -> None:
self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.new_points)
def undo(self) -> None:
self.document._set_graph_connection_points(self.graph_id, self.connection_id, self.old_points)

View File

@@ -8,6 +8,7 @@ from bedit_core.models import Document as CoreDocument
from bedit_gui.documents import Document
from bedit_gui.services.clipboard import ClipboardService
from bedit_gui.services.component_clipboard import export_components, import_components
from bedit_gui.views.graph_editor_widget import GraphEditorWidget
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
@@ -28,6 +29,9 @@ class ClipboardHandler(QObject):
def can_paste(self) -> bool:
return False
def can_delete(self) -> bool:
return False
def copy(self) -> None:
pass
@@ -37,6 +41,9 @@ class ClipboardHandler(QObject):
def paste(self) -> None:
pass
def delete(self) -> None:
pass
class TextClipboardHandler(ClipboardHandler):
def __init__(self, clipboard: ClipboardService, parent: QObject | None = None) -> None:
@@ -106,6 +113,9 @@ class DocumentTreeClipboardHandler(ClipboardHandler):
def can_paste(self) -> bool:
return self._target() is not None and self.clipboard.has_format(ClipboardService.COMPONENTS_MIME)
def can_delete(self) -> bool:
return bool(self._selected_components())
def copy(self) -> None:
components = self._selected_components()
if components:
@@ -129,6 +139,9 @@ class DocumentTreeClipboardHandler(ClipboardHandler):
return
self.document.paste_components(target, components, icons)
def delete(self) -> None:
self.document.delete_components(self._selected_components())
def _write_components(self, components: list[Component]) -> None:
payload = export_components(self.document, components)
self.clipboard.set_json(ClipboardService.COMPONENTS_MIME, payload, "\n".join(component.name for component in components))
@@ -162,6 +175,79 @@ class DocumentTreeClipboardHandler(ClipboardHandler):
return None
class GraphEditorClipboardHandler(ClipboardHandler):
def __init__(self, document: Document, editor: GraphEditorWidget, clipboard: ClipboardService, parent: QObject | None = None) -> None:
super().__init__(parent)
self.document = document
self.editor = editor
self.clipboard = clipboard
editor.scene.selectionChanged.connect(self.availability_changed)
def owns_focus(self, widget: QWidget) -> bool:
return widget is self.editor or self.editor.isAncestorOf(widget)
def can_copy(self) -> bool:
return bool(self._selected_components())
def can_cut(self) -> bool:
return self.can_copy()
def can_paste(self) -> bool:
return self._target() is not None and self.clipboard.has_format(ClipboardService.COMPONENTS_MIME)
def can_delete(self) -> bool:
return bool(self._selected_components() or self.editor.selected_connection_ids())
def copy(self) -> None:
components = self._selected_components()
if components:
self._write_components(components)
def cut(self) -> None:
components = self._selected_components()
if components:
self._write_components(components)
self.document.delete_components(components)
def paste(self) -> None:
graph_component = self.editor.component()
payload = self.clipboard.get_json(ClipboardService.COMPONENTS_MIME)
if graph_component is None or not isinstance(graph_component.implementation, GraphImplementation) or payload is None:
return
try:
components, icons = import_components(payload)
except (TypeError, ValueError) as exc:
QMessageBox.critical(self.editor, "Could not paste components", str(exc))
return
x, y = self.editor.paste_position()
spacing = self.editor.snap_to_grid_size * 4
positions = {component_id: (x + index * spacing, y + index * spacing) for index, component_id in enumerate(components)}
self.document.paste_graph_components(graph_component, components, icons, positions)
def delete(self) -> None:
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)
self.clipboard.set_json(ClipboardService.COMPONENTS_MIME, payload, "\n".join(component.name for component in components))
def _selected_components(self) -> list[Component]:
target = self._target()
if target is None:
return []
return [target[component_id] for component_id in self.editor.selected_component_ids() if component_id in target]
def _target(self) -> dict | None:
component = self.editor.component()
return component.implementation.graph.components if component is not None and isinstance(component.implementation, GraphImplementation) else None
class ClipboardController(QObject):
def __init__(self, window: QMainWindow, clipboard: ClipboardService, handlers: list[ClipboardHandler]) -> None:
super().__init__(window)
@@ -171,6 +257,7 @@ class ClipboardController(QObject):
window.ui.actionCopy.triggered.connect(self.copy)
window.ui.actionCut.triggered.connect(self.cut)
window.ui.actionPaste.triggered.connect(self.paste)
window.ui.actionDelete.triggered.connect(self.delete)
application = QApplication.instance()
application.focusChanged.connect(self.update_actions)
application.installEventFilter(self)
@@ -209,8 +296,15 @@ class ClipboardController(QObject):
handler.paste()
self.update_actions()
def delete(self) -> None:
handler = self.active_handler()
if handler is not None and handler.can_delete():
handler.delete()
self.update_actions()
def update_actions(self, *_args: object) -> None:
handler = self.active_handler()
self.window.ui.actionCopy.setEnabled(handler is not None and handler.can_copy())
self.window.ui.actionCut.setEnabled(handler is not None and handler.can_cut())
self.window.ui.actionPaste.setEnabled(handler is not None and handler.can_paste())
self.window.ui.actionDelete.setEnabled(handler is not None and handler.can_delete())

View File

@@ -2,11 +2,11 @@ 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
from bedit_core.models import Component, ComponentID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import Component, ComponentID, ConnectionID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import Document as CoreDocument
from bedit_gui.documents import Document
from bedit_gui.models import Graph, Icon
@@ -63,11 +63,16 @@ class DocumentTreeController(QObject):
document.model_changed.connect(self._on_document_changed)
document.icon_changed.connect(self._on_icon_changed)
document.graph_component_position_changed.connect(self._on_graph_component_position_changed)
document.graph_connection_points_changed.connect(self._on_graph_connection_points_changed)
document.equation_text_changed.connect(self._on_equation_text_changed)
self.model.rename_document_requested.connect(self.document.rename)
self.model.rename_component_requested.connect(self.document.rename_component)
window.ui.actionDelete.triggered.connect(self.delete_selected_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)
@@ -98,13 +103,14 @@ class DocumentTreeController(QObject):
def _on_document_changed(self, model: CoreDocument) -> None:
"""Rebuild the tree whenever New/Open replaces the core document."""
self._show_component(None)
displayed_component = self.window.graph_editor.component() or self.window.equation_editor.component()
self.model.set_document(model)
self._components = {}
self._collect_components(model.root)
for component_id, component in self._components.items():
icon = self.document.component_icon(component_id)
self.model.set_component_icon(component_id, render_fitted_icon(icon, component.interface.ports, ICON_SIZE))
self._show_component(displayed_component if any(component is displayed_component for component in self._components.values()) else None)
# Optional presentation behavior. Later, you could instead remember
# expanded component IDs and restore only those nodes.
@@ -149,6 +155,11 @@ class DocumentTreeController(QObject):
if graph_component is not None and self.document.component_id(graph_component) == graph_id:
self.window.graph_editor.set_component_position(component_id, position)
def _on_graph_connection_points_changed(self, graph_id: ComponentID, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None:
graph_component = self.window.graph_editor.component()
if graph_component is not None and self.document.component_id(graph_component) == graph_id:
self.window.graph_editor.set_connection_points(connection_id, points)
def _collect_components(self, components: dict[ComponentID, Component]) -> None:
for component_id, component in components.items():
self._components[component_id] = component
@@ -161,27 +172,42 @@ class DocumentTreeController(QObject):
if not isinstance(component, Component):
return
self._show_component_context_menu(component, self.window.ui.documentTree.viewport().mapToGlobal(position))
def _show_graph_component_context_menu(self, component_id: ComponentID, global_position: QPoint) -> None:
component = self._components.get(component_id)
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")
edit_params = menu.addAction("Edit Parameters")
edit_icon = menu.addAction("Edit Icon")
menu.addSeparator()
add_graph_component = None
add_equation_component = None
if isinstance(component.implementation, GraphImplementation):
add_graph_component = menu.addAction("Add Graph Component")
add_equation_component = menu.addAction("Add Equation Component")
menu.addSeparator()
delete_component = menu.addAction("Delete Component")
selected = menu.exec(
self.window.ui.documentTree.viewport().mapToGlobal(position)
)
selected = menu.exec(global_position)
if selected is edit_interface:
self._edit_interface(component)
elif selected is edit_params:
self._edit_params(component)
elif selected is edit_icon:
self._edit_icon(component)
elif selected is add_graph_component:
elif add_graph_component is not None and selected is add_graph_component:
self._add_graph_component(component)
elif selected is add_equation_component:
elif add_equation_component is not None and selected is add_equation_component:
self._add_equation_component(component)
elif selected is delete_component:
self._delete_component(component)

View File

@@ -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)

View File

@@ -6,18 +6,22 @@ from pathlib import Path
from PySide6.QtCore import QObject, Signal
from PySide6.QtGui import QUndoStack
from bedit_core.models import ID, Component, ComponentID, 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
from bedit_gui.commands.rename_document_command import RenameDocumentCommand
from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand
from bedit_gui.commands.component_command import AddEmptyEquationComponent, AddEmptyGraphComponent, DeleteComponent, PasteComponents
from bedit_gui.models import Graph, GraphDatabase, Icon, IconDatabase, Simulation, SimulationDatabase
from bedit_gui.models import Graph, GraphConnection, GraphDatabase, Icon, IconDatabase, Simulation, SimulationDatabase
from bedit_gui.services import document_files
@@ -31,6 +35,7 @@ class Document(QObject):
equation_text_changed = Signal(object, str)
simulation_database_changed = Signal(object)
graph_component_position_changed = Signal(object, object, object)
graph_connection_points_changed = Signal(object, object, object)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
@@ -100,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():
@@ -163,6 +174,36 @@ class Document(QObject):
metadata["graph_database"] = database
return database
def change_graph_connection_points(self, graph_component: Component, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None:
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)
graph = database.graphs.get(graph_id) if database is not None else None
if graph is not None:
graph.connections.pop(connection_id, None)
else:
database = self._graph_database(True)
graph = database.graphs.setdefault(graph_id, Graph())
graph.connections[connection_id] = GraphConnection(points=list(points))
self.graph_connection_points_changed.emit(graph_id, connection_id, points)
def change_icon(self, component_id: ComponentID, icon: Icon) -> None:
self.undo_stack.push(ChangeIconCommand(self, component_id, icon))
@@ -322,6 +363,16 @@ class Document(QObject):
names.add(component.name)
self.undo_stack.push(PasteComponents(self, target, components, icons))
def paste_graph_components(self, graph_component: Component, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], positions: dict[ComponentID, tuple[int, int]]) -> None:
if not isinstance(graph_component.implementation, GraphImplementation) or not components:
return
target = graph_component.implementation.graph.components
names = {component.name for component in target.values()}
for component in components.values():
component.name = self._unique_name(names, component.name)
names.add(component.name)
self.undo_stack.push(PasteComponents(self, target, components, icons, self.component_id(graph_component), positions))
@staticmethod
def _unique_component_name(components: dict[ComponentID, Component], name: str) -> str:
return Document._unique_name({component.name for component in components.values()}, name)

View File

@@ -41,9 +41,8 @@
<bool>false</bool>
</property>
<addaction name="actionZoomToFit"/>
<addaction name="actionAddComponent"/>
<addaction name="actionAddConnection"/>
<addaction name="actionDelete"/>
<addaction name="actionMouseMode"/>
<addaction name="actionConnectionMode"/>
</widget>
</item>
<item>
@@ -72,28 +71,34 @@
<string>Zoom canvas to fit</string>
</property>
</action>
<action name="actionAddComponent">
<action name="actionMouseMode">
<property name="checkable">
<bool>true</bool>
</property>
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-select.png</normaloff>:/icons/icons/edit-select.png</iconset>
</property>
<property name="text">
<string>Add Component</string>
<string>Mouse Mode</string>
</property>
<property name="toolTip">
<string>Add a component</string>
<string>Mouse Mode</string>
</property>
</action>
<action name="actionAddConnection">
<action name="actionConnectionMode">
<property name="checkable">
<bool>true</bool>
</property>
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/network-connect.png</normaloff>:/icons/icons/network-connect.png</iconset>
</property>
<property name="text">
<string>Add Connection</string>
<string>Connection Mode</string>
</property>
<property name="toolTip">
<string>Add a connection</string>
</property>
</action>
<action name="actionDelete">
<property name="text">
<string>Delete</string>
</property>
<property name="toolTip">
<string>Delete selected graph items</string>
<string>Connection Mode</string>
</property>
</action>
</widget>

View File

@@ -30,12 +30,18 @@ class Ui_graphEditorWidget(object):
icon = QIcon()
icon.addFile(u":/icons/icons/zoom-original.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionZoomToFit.setIcon(icon)
self.actionAddComponent = QAction(graphEditorWidget)
self.actionAddComponent.setObjectName(u"actionAddComponent")
self.actionAddConnection = QAction(graphEditorWidget)
self.actionAddConnection.setObjectName(u"actionAddConnection")
self.actionDelete = QAction(graphEditorWidget)
self.actionDelete.setObjectName(u"actionDelete")
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)
self.verticalLayout = QVBoxLayout(graphEditorWidget)
self.verticalLayout.setSpacing(0)
self.verticalLayout.setObjectName(u"verticalLayout")
@@ -57,9 +63,8 @@ class Ui_graphEditorWidget(object):
self.graphToolBar.addAction(self.actionZoomToFit)
self.graphToolBar.addAction(self.actionAddComponent)
self.graphToolBar.addAction(self.actionAddConnection)
self.graphToolBar.addAction(self.actionDelete)
self.graphToolBar.addAction(self.actionMouseMode)
self.graphToolBar.addAction(self.actionConnectionMode)
self.retranslateUi(graphEditorWidget)
@@ -72,17 +77,13 @@ class Ui_graphEditorWidget(object):
#if QT_CONFIG(tooltip)
self.actionZoomToFit.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Zoom canvas to fit", None))
#endif // QT_CONFIG(tooltip)
self.actionAddComponent.setText(QCoreApplication.translate("graphEditorWidget", u"Add Component", None))
self.actionMouseMode.setText(QCoreApplication.translate("graphEditorWidget", u"Mouse Mode", None))
#if QT_CONFIG(tooltip)
self.actionAddComponent.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Add a component", None))
self.actionMouseMode.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Mouse Mode", None))
#endif // QT_CONFIG(tooltip)
self.actionAddConnection.setText(QCoreApplication.translate("graphEditorWidget", u"Add Connection", None))
self.actionConnectionMode.setText(QCoreApplication.translate("graphEditorWidget", u"Connection Mode", None))
#if QT_CONFIG(tooltip)
self.actionAddConnection.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Add a connection", None))
#endif // QT_CONFIG(tooltip)
self.actionDelete.setText(QCoreApplication.translate("graphEditorWidget", u"Delete", None))
#if QT_CONFIG(tooltip)
self.actionDelete.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Delete selected graph items", None))
self.actionConnectionMode.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Connection Mode", None))
#endif // QT_CONFIG(tooltip)
self.graphToolBar.setWindowTitle(QCoreApplication.translate("graphEditorWidget", u"Graph tools", None))
# retranslateUi

View File

@@ -1,13 +1,15 @@
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 QColor, QPainter, QPainterPath, QPen, QWheelEvent
from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsView, QWidget
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_gui.models import Graph, Icon
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
@@ -27,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."""
@@ -51,14 +58,32 @@ class GraphGraphicsScene(QGraphicsScene):
class GraphConnectionItem(QGraphicsPathItem):
"""A routed connection with a full signal arrow or half bond arrow."""
def __init__(self, points: list[tuple[float, float]], *, half_arrow: bool, tick_at_source: bool | None = None) -> None:
def __init__(self, points: list[tuple[float, float]], *, half_arrow: bool, tick_at_source: bool | None = None, connection_id: ConnectionID | None = None, editor: GraphEditorWidget | None = None) -> None:
super().__init__()
self.connection_id = connection_id
self.editor = editor
self.setPath(self._connection_path(points, half_arrow, tick_at_source))
pen = QPen(QColor("#202020"), CONNECTION_WIDTH)
pen.setCosmetic(True)
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")
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
def _connection_path(points: list[tuple[float, float]], half_arrow: bool, tick_at_source: bool | None = None) -> QPainterPath:
path = QPainterPath(QPointF(*points[0]))
@@ -112,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:
@@ -124,6 +149,66 @@ class GraphComponentItem(QGraphicsPixmapItem):
self.editor.refresh_connections()
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()))
self.setPos(*position)
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()
self.setSelected(True)
self.editor.component_context_menu_requested.emit(self.component_id, event.screenPos())
event.accept()
class GraphConnectionPointItem(QGraphicsEllipseItem):
def __init__(self, connection_id: ConnectionID, index: int, position: tuple[int, int], editor: GraphEditorWidget) -> None:
radius = CONNECTION_WIDTH
super().__init__(-radius, -radius, radius * 2, radius * 2)
self.connection_id = connection_id
self.index = index
self.editor = editor
self._drag_start = QPointF()
self._dragging = False
self.setPos(*position)
self.setPen(QPen(Qt.PenStyle.NoPen))
self.setBrush(QBrush(QColor("#202020")))
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self._dragging and isinstance(value, QPointF):
size = self.editor.snap_to_grid_size
value = QPointF(round(value.x() / size) * size, round(value.y() / size) * size)
result = super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.editor.refresh_connections()
return result
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
self._drag_start = QPointF(self.pos())
self._dragging = True
@@ -135,11 +220,23 @@ class GraphComponentItem(QGraphicsPixmapItem):
position = (round(self.pos().x()), round(self.pos().y()))
self.setPos(*position)
if self.pos() != self._drag_start:
self.editor.finish_component_move(self.component_id, position)
self.editor.finish_connection_point_move(self.connection_id)
def contextMenuEvent(self, event) -> None:
menu = QMenu(self.editor)
delete_point = menu.addAction("Delete Point")
if menu.exec(event.screenPos()) is delete_point:
self.editor.delete_connection_point(self.connection_id, self.index)
event.accept()
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)
@@ -148,16 +245,32 @@ 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] = {}
self._connection_point_items: dict[ConnectionID, list[GraphConnectionPointItem]] = {}
self.set_snap_to_grid_size(snap_to_grid_size)
self.scene = GraphGraphicsScene(self)
self.scene.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE)
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
@@ -169,25 +282,46 @@ 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 = {}
self._connection_point_items = {}
self.scene.clear()
if component is None:
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])
@@ -206,10 +340,12 @@ class GraphEditorWidget(QWidget):
tick_at_source = False
elif connection.causality is BondCausality.FLOW_OUT:
tick_at_source = True
connection_item = GraphConnectionItem([(0, 0), (1, 0)], half_arrow=isinstance(connection, BondConnection), tick_at_source=tick_at_source)
connection_item = GraphConnectionItem([(0, 0), (1, 0)], half_arrow=isinstance(connection, BondConnection), tick_at_source=tick_at_source, connection_id=connection_id, editor=self)
connection_item.setData(0, str(connection_id))
self._connection_items[connection_id] = connection_item
self.scene.addItem(connection_item)
visual_connection = graph.connections.get(connection_id)
self._create_connection_point_items(connection_id, visual_connection.points[1:-1] if visual_connection is not None and len(visual_connection.points) >= 2 else [])
self.refresh_connections()
if component_changed:
QTimer.singleShot(0, self.ui.actionZoomToFit.trigger)
@@ -220,16 +356,17 @@ 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:
continue
source_position = self._item_position(source_component)
target_position = self._item_position(target_component)
visual_connection = self._graph.connections.get(connection_id)
points = list(visual_connection.points) if visual_connection is not None else []
points = [source_position, target_position] if len(points) < 2 else [source_position, *points[1:-1], target_position]
point_items = self._connection_point_items.get(connection_id, [])
points = [source_position, *((point.pos().x(), point.pos().y()) for point in point_items), target_position]
points = self._straighten_direct_connection(points)
source_bounds = self._component_bounds[source_component].translated(*source_position)
target_bounds = self._component_bounds[target_component].translated(*target_position)
@@ -239,6 +376,85 @@ class GraphEditorWidget(QWidget):
tick_at_source = False if connection.causality is BondCausality.EFFORT_OUT else True if connection.causality is BondCausality.FLOW_OUT else None
item.setPath(item._connection_path(points, isinstance(connection, BondConnection), tick_at_source))
def add_connection_point(self, connection_id: ConnectionID, scene_position: QPointF) -> None:
points = self._connection_metadata_points(connection_id)
position = self._snap_position(scene_position)
index = self._nearest_segment_index(points, position) + 1
points.insert(index, position)
self._request_connection_points_change(connection_id, points, "Add connection point")
def delete_connection_point(self, connection_id: ConnectionID, index: int) -> None:
points = self._connection_metadata_points(connection_id)
if 0 < index < len(points) - 1:
points.pop(index)
self._request_connection_points_change(connection_id, points, "Delete connection point")
def finish_connection_point_move(self, connection_id: ConnectionID) -> None:
self._request_connection_points_change(connection_id, self._connection_metadata_points(connection_id), "Move connection point")
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)
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):
item.setPos(*position)
else:
for item in items:
self.scene.removeItem(item)
self._create_connection_point_items(connection_id, interior_points)
self.refresh_connections()
def _create_connection_point_items(self, connection_id: ConnectionID, positions: list[tuple[int, int]]) -> None:
items = [GraphConnectionPointItem(connection_id, index, position, self) for index, position in enumerate(positions, 1)]
self._connection_point_items[connection_id] = items
for item in items:
self.scene.addItem(item)
def _connection_metadata_points(self, connection_id: ConnectionID) -> list[tuple[int, int]]:
component = self._component
if component is None:
return []
connection = component.implementation.graph.connections[connection_id]
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
source = self._item_position(port_owners[connection.source])
target = self._item_position(port_owners[connection.target])
interior = [(round(item.pos().x()), round(item.pos().y())) for item in self._connection_point_items.get(connection_id, [])]
return [(round(source[0]), round(source[1])), *interior, (round(target[0]), round(target[1]))]
def _request_connection_points_change(self, connection_id: ConnectionID, points: list[tuple[int, int]], text: str) -> None:
if self._component is not None:
self.connection_points_change_requested.emit(self._component, connection_id, points, text)
def _snap_position(self, position: QPointF) -> tuple[int, int]:
size = self.snap_to_grid_size
return round(position.x() / size) * size, round(position.y() / size) * size
@staticmethod
def _nearest_segment_index(points: list[tuple[int, int]], position: tuple[int, int]) -> int:
best_index = 0
best_distance = float("inf")
for index, (start, end) in enumerate(pairwise(points)):
dx = end[0] - start[0]
dy = end[1] - start[1]
length_squared = dx * dx + dy * dy
ratio = 0.0 if not length_squared else max(0.0, min(1.0, ((position[0] - start[0]) * dx + (position[1] - start[1]) * dy) / length_squared))
closest_x = start[0] + ratio * dx
closest_y = start[1] + ratio * dy
distance = (position[0] - closest_x) ** 2 + (position[1] - closest_y) ** 2
if distance < best_distance:
best_index = index
best_distance = distance
return best_index
def finish_component_move(self, component_id: ComponentID, position: tuple[int, int]) -> None:
if self._component is not None:
self.component_move_requested.emit(self._component, component_id, position)
@@ -248,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()
@@ -298,6 +517,124 @@ class GraphEditorWidget(QWidget):
def component(self) -> Component | None:
return self._component
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())
if not viewport.rect().contains(viewport_position):
viewport_position = viewport.rect().center()
scene_position = self.ui.graphicsView.mapToScene(viewport_position)
size = self.snap_to_grid_size
return round(scene_position.x() / size) * size, round(scene_position.y() / size) * size
def zoom_to_fit(self) -> None:
bounds = self.scene.itemsBoundingRect()
if bounds.isEmpty():
@@ -313,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:

View File

@@ -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)

View File

@@ -374,6 +374,7 @@ class Simulation:
process = self._run_model_commands(
modelica,
[
f"checkModel({model_name})",
f'buildModel({model_name}, outputFormat="csv")',
"getErrorString()",
],

View File

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