Added array ports and junctions

This commit is contained in:
2026-07-20 15:00:06 +02:00
parent a067c85994
commit 495edd42f4
15 changed files with 1686 additions and 72 deletions

View File

@@ -1,7 +1,7 @@
from PySide6.QtCore import QPointF
from PySide6.QtGui import QUndoCommand
from bedit.core.model import Annotation, Component, Connection, Port
from bedit.core.model import Annotation, Component, Connection, Junction, Port
class AddComponentCommand(QUndoCommand):
@@ -76,6 +76,32 @@ class AddConnectionCommand(QUndoCommand):
self.controller._remove_connection(self.owner_id, self.connection.id)
class SplitConnectionCommand(QUndoCommand):
def __init__(
self,
controller,
owner_id: str,
original: Connection,
junction: Junction,
first: Connection,
second: Connection,
) -> None:
super().__init__("Add connection junction")
self.controller, self.owner_id = controller, owner_id
self.original, self.junction = original, junction
self.first, self.second = first, second
def redo(self) -> None:
self.controller._split_connection(
self.owner_id, self.original.id, self.junction, self.first, self.second
)
def undo(self) -> None:
self.controller._restore_split_connection(
self.owner_id, self.original, self.junction.id, self.first.id, self.second.id
)
class AddAnnotationCommand(QUndoCommand):
def __init__(self, controller, owner_id: str, annotation: Annotation) -> None:
super().__init__(f"Draw {annotation.kind}")

View File

@@ -23,6 +23,7 @@ from bedit.gui.controllers.commands import (
RenameInterfacePortCommand,
ReplaceSourceCommand,
RotateComponentsCommand,
SplitConnectionCommand,
)
from bedit.core.model import (
Annotation,
@@ -31,6 +32,7 @@ from bedit.core.model import (
Endpoint,
GraphDocument,
Icon,
Junction,
Parameter,
Port,
clone_component,
@@ -228,6 +230,20 @@ class DocumentController(QObject):
MoveComponentCommand(self, self.active_component_id, component_id, old, new)
)
def move_junction(self, junction_id: str, old: QPointF, new: QPointF) -> None:
if old != new and self.active_component_id is not None:
self.undo_stack.push(
EditGraphItemCommand(
self,
self.active_component_id,
"junction_geometry",
junction_id,
{"x": old.x(), "y": old.y()},
{"x": new.x(), "y": new.y()},
"Move connection junction",
)
)
def rotate_components(self, component_ids: set[str]) -> None:
if self.active_component is None or self.active_component_id is None:
return
@@ -254,6 +270,14 @@ class DocumentController(QObject):
raise ValueError("A connection endpoint no longer exists")
if not PortTypeRegistry.compatible(source_port.type, target_port.type):
raise ValueError(f"Cannot connect {source_port.type!r} to {target_port.type!r}")
if not self.endpoint_accepts_connection(source, "source"):
raise ValueError(
f"Port {source_port.name!r} already has a connection; enable multiple connections first"
)
if not self.endpoint_accepts_connection(target, "target"):
raise ValueError(
f"Port {target_port.name!r} already has a connection; enable multiple connections first"
)
connection = Connection(
str(uuid4()),
source,
@@ -265,6 +289,53 @@ class DocumentController(QObject):
self.undo_stack.push(AddConnectionCommand(self, self.active_component_id, connection))
return connection.id
def split_connection(
self,
connection_id: str,
position: QPointF,
first_waypoints: list[QPointF],
second_waypoints: list[QPointF],
) -> str:
if self.active_component_id is None:
raise ValueError("There is no active graph")
original = self.active_graph.connections.get(connection_id)
if original is None:
raise ValueError("The connection no longer exists")
port_type = self.connection_port_type(original)
junction = Junction(str(uuid4()), position.x(), position.y(), port_type)
first_properties = deepcopy(original.properties)
first_properties["waypoints"] = [
{"x": point.x(), "y": point.y()} for point in first_waypoints
]
second_properties = {
"waypoints": [{"x": point.x(), "y": point.y()} for point in second_waypoints]
}
first = Connection(
str(uuid4()),
original.source,
Endpoint(junction=junction.id),
original.name,
first_properties,
)
second = Connection(
str(uuid4()),
Endpoint(junction=junction.id),
original.target,
"",
second_properties,
)
self.undo_stack.push(
SplitConnectionCommand(
self,
self.active_component_id,
original,
junction,
first,
second,
)
)
return junction.id
def add_annotation(
self,
kind: str,
@@ -405,6 +476,16 @@ class DocumentController(QObject):
owner = self.active_component
if owner is None:
return None
if endpoint.junction is not None:
junction = owner.graph.junctions.get(endpoint.junction)
if junction is None:
return None
return Port(
junction.id,
"Junction",
type=junction.type,
allows_multiple_connections=role == "source",
)
if endpoint.interface is not None:
ports = owner.inputs if role == "source" else owner.outputs
else:
@@ -420,6 +501,17 @@ class DocumentController(QObject):
port = self._port_for_endpoint(connection.source, "source")
return port.type if port is not None else "signal"
def endpoint_accepts_connection(self, endpoint: Endpoint, role: str) -> bool:
port = self._port_for_endpoint(endpoint, role)
if port is None:
return False
if role == "source" or port.allows_multiple_connections:
return True
return not any(
endpoint == (connection.source if role == "source" else connection.target)
for connection in self.active_graph.connections.values()
)
def add_interface_port(self, direction: str, position: QPointF) -> str:
component = self.active_component
if component is None or component.implementation_kind != "graph":
@@ -526,6 +618,12 @@ class DocumentController(QObject):
},
}
if old != new:
candidate = deepcopy(self.document)
candidate_component = candidate.find_component(component.id)
candidate_component.inputs = deepcopy(inputs)
candidate_component.outputs = deepcopy(outputs)
candidate_component.source = deepcopy(new["source"])
candidate.validate()
self.undo_stack.push(EditTextDefinitionCommand(self, component.id, old, new))
def edit_component_appearance(
@@ -849,6 +947,36 @@ class DocumentController(QObject):
self.connectionAdded.emit(connection.id)
self.documentReset.emit()
def _split_connection(
self,
owner_id: str,
original_id: str,
junction: Junction,
first: Connection,
second: Connection,
) -> None:
graph = self._graph_for(owner_id)
graph.connections.pop(original_id, None)
graph.junctions[junction.id] = junction
graph.connections[first.id] = first
graph.connections[second.id] = second
self.documentReset.emit()
def _restore_split_connection(
self,
owner_id: str,
original: Connection,
junction_id: str,
first_id: str,
second_id: str,
) -> None:
graph = self._graph_for(owner_id)
graph.connections.pop(first_id, None)
graph.connections.pop(second_id, None)
graph.junctions.pop(junction_id, None)
graph.connections[original.id] = original
self.documentReset.emit()
def _remove_connection(self, owner_id: str, connection_id: str) -> None:
self._graph_for(owner_id).connections.pop(connection_id, None)
if owner_id == self.active_component_id:
@@ -871,7 +999,11 @@ class DocumentController(QObject):
self, owner_id: str, item_kind: str, item_id: str, values: dict
) -> None:
graph = self._graph_for(owner_id)
if item_kind == "connection_data":
if item_kind == "junction_geometry":
item = graph.junctions.get(item_id)
if item is not None:
item.x, item.y = float(values["x"]), float(values["y"])
elif item_kind == "connection_data":
item = graph.connections.get(item_id)
if item is not None:
item.name = values["name"]

View File

@@ -36,6 +36,7 @@ class PortOptionsDialog(QDialog):
self.ui.nameEdit.textEdited.connect(self._store_current)
self.ui.typeCombo.currentIndexChanged.connect(self._store_current)
self.ui.orientationCombo.currentIndexChanged.connect(self._store_current)
self.ui.multipleConnectionsCheckBox.toggled.connect(self._store_current)
self.ui.portSplitter.setSizes([250, 370])
if read_only:
self.ui.addPortButton.setEnabled(False)
@@ -43,6 +44,7 @@ class PortOptionsDialog(QDialog):
self.ui.nameEdit.setReadOnly(True)
self.ui.typeCombo.setEnabled(False)
self.ui.orientationCombo.setEnabled(False)
self.ui.multipleConnectionsCheckBox.setEnabled(False)
self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText("Close")
self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).hide()
self._rebuild_list(0 if self.ports else -1)
@@ -72,6 +74,9 @@ class PortOptionsDialog(QDialog):
self.ui.nameEdit.setText(port.name)
self.ui.typeCombo.setCurrentIndex(self.ui.typeCombo.findData(port.type))
self.ui.orientationCombo.setCurrentIndex(self.ui.orientationCombo.findData(orientation))
self.ui.multipleConnectionsCheckBox.setChecked(
port.allows_multiple_connections
)
else:
self.ui.nameEdit.clear()
self._loading = False
@@ -83,6 +88,7 @@ class PortOptionsDialog(QDialog):
self.ui.nameEdit.setEnabled(enabled)
self.ui.typeCombo.setEnabled(enabled)
self.ui.orientationCombo.setEnabled(enabled)
self.ui.multipleConnectionsCheckBox.setEnabled(enabled)
def _store_current(self) -> None:
row = self.ui.portList.currentRow()
@@ -91,6 +97,7 @@ class PortOptionsDialog(QDialog):
port, _orientation = self.ports[row]
port.name = self.ui.nameEdit.text()
port.type = self.ui.typeCombo.currentData()
port.allows_multiple_connections = self.ui.multipleConnectionsCheckBox.isChecked()
self.ports[row] = (port, self.ui.orientationCombo.currentData())
self.ui.portList.item(row).setText(
f"{port.name} [{self.ports[row][1]}, {port.type}]"

View File

@@ -2,7 +2,7 @@ from copy import deepcopy
from uuid import uuid4
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QComboBox, QHeaderView, QTableWidgetItem, QWidget
from PySide6.QtWidgets import QCheckBox, QComboBox, QHeaderView, QTableWidgetItem, QWidget
from bedit.core.model import Parameter, Port
from bedit.core.port_types import PortTypeRegistry
@@ -60,11 +60,13 @@ class TextDefinitionEditor(QWidget):
name_item = self.ui.portsTable.item(row, 0)
type_combo = self.ui.portsTable.cellWidget(row, 1)
orientation_combo = self.ui.portsTable.cellWidget(row, 2)
multiple_check = self.ui.portsTable.cellWidget(row, 3)
port = Port(
id=name_item.data(ID_ROLE),
name=name_item.text().strip(),
type=type_combo.currentData(),
properties=deepcopy(name_item.data(PROPERTIES_ROLE) or {}),
allows_multiple_connections=multiple_check.isChecked(),
)
target = inputs if orientation_combo.currentData() == "input" else outputs
target.append(port)
@@ -137,6 +139,10 @@ class TextDefinitionEditor(QWidget):
2,
self._new_combo([("Input", "input"), ("Output", "output")], orientation),
)
multiple = QCheckBox("Any", self)
multiple.setChecked(port.allows_multiple_connections)
multiple.toggled.connect(self._mark_modified)
table.setCellWidget(row, 3, multiple)
def _append_parameter(self, parameter: Parameter) -> None:
table = self.ui.parametersTable

View File

@@ -15,10 +15,11 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QAbstractButton, QApplication, QComboBox, QDialog,
QDialogButtonBox, QFormLayout, QHBoxLayout, QLabel,
QLineEdit, QListWidget, QListWidgetItem, QPushButton,
QSizePolicy, QSplitter, QVBoxLayout, QWidget)
from PySide6.QtWidgets import (QAbstractButton, QApplication, QCheckBox, QComboBox,
QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
QLabel, QLineEdit, QListWidget, QListWidgetItem,
QPushButton, QSizePolicy, QSplitter, QVBoxLayout,
QWidget)
class Ui_PortOptionsDialog(object):
def setupUi(self, PortOptionsDialog):
@@ -94,11 +95,16 @@ class Ui_PortOptionsDialog(object):
self.portDetailsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.orientationCombo)
self.multipleConnectionsCheckBox = QCheckBox(self.portDetailsPanel)
self.multipleConnectionsCheckBox.setObjectName(u"multipleConnectionsCheckBox")
self.portDetailsForm.setWidget(3, QFormLayout.ItemRole.SpanningRole, self.multipleConnectionsCheckBox)
self.positionHintLabel = QLabel(self.portDetailsPanel)
self.positionHintLabel.setObjectName(u"positionHintLabel")
self.positionHintLabel.setWordWrap(True)
self.portDetailsForm.setWidget(3, QFormLayout.ItemRole.SpanningRole, self.positionHintLabel)
self.portDetailsForm.setWidget(4, QFormLayout.ItemRole.SpanningRole, self.positionHintLabel)
self.portSplitter.addWidget(self.portDetailsPanel)
@@ -130,6 +136,7 @@ class Ui_PortOptionsDialog(object):
self.orientationCombo.setItemText(0, QCoreApplication.translate("PortOptionsDialog", u"Input", None))
self.orientationCombo.setItemText(1, QCoreApplication.translate("PortOptionsDialog", u"Output", None))
self.multipleConnectionsCheckBox.setText(QCoreApplication.translate("PortOptionsDialog", u"Allow multiple connections", None))
self.positionHintLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"New ports start at (0, 0) in the icon editor.", None))
# retranslateUi

View File

@@ -52,18 +52,20 @@ class Ui_TextDefinitionEditor(object):
self.portsLayout = QVBoxLayout(self.portsGroup)
self.portsLayout.setObjectName(u"portsLayout")
self.portsTable = QTableWidget(self.portsGroup)
if (self.portsTable.columnCount() < 3):
self.portsTable.setColumnCount(3)
if (self.portsTable.columnCount() < 4):
self.portsTable.setColumnCount(4)
__qtablewidgetitem = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(0, __qtablewidgetitem)
__qtablewidgetitem1 = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(1, __qtablewidgetitem1)
__qtablewidgetitem2 = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(2, __qtablewidgetitem2)
__qtablewidgetitem3 = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(3, __qtablewidgetitem3)
self.portsTable.setObjectName(u"portsTable")
self.portsTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.portsTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.portsTable.setColumnCount(3)
self.portsTable.setColumnCount(4)
self.portsLayout.addWidget(self.portsTable)
@@ -94,12 +96,12 @@ class Ui_TextDefinitionEditor(object):
self.parametersTable = QTableWidget(self.parametersGroup)
if (self.parametersTable.columnCount() < 3):
self.parametersTable.setColumnCount(3)
__qtablewidgetitem3 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(0, __qtablewidgetitem3)
__qtablewidgetitem4 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(1, __qtablewidgetitem4)
self.parametersTable.setHorizontalHeaderItem(0, __qtablewidgetitem4)
__qtablewidgetitem5 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(2, __qtablewidgetitem5)
self.parametersTable.setHorizontalHeaderItem(1, __qtablewidgetitem5)
__qtablewidgetitem6 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(2, __qtablewidgetitem6)
self.parametersTable.setObjectName(u"parametersTable")
self.parametersTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.parametersTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
@@ -147,15 +149,17 @@ class Ui_TextDefinitionEditor(object):
___qtablewidgetitem1.setText(QCoreApplication.translate("TextDefinitionEditor", u"Type", None))
___qtablewidgetitem2 = self.portsTable.horizontalHeaderItem(2)
___qtablewidgetitem2.setText(QCoreApplication.translate("TextDefinitionEditor", u"Orientation", None))
___qtablewidgetitem3 = self.portsTable.horizontalHeaderItem(3)
___qtablewidgetitem3.setText(QCoreApplication.translate("TextDefinitionEditor", u"Multiple", None))
self.addPortButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Add Port", None))
self.removePortButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Remove Port", None))
self.parametersGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Parameters", None))
___qtablewidgetitem3 = self.parametersTable.horizontalHeaderItem(0)
___qtablewidgetitem3.setText(QCoreApplication.translate("TextDefinitionEditor", u"Name", None))
___qtablewidgetitem4 = self.parametersTable.horizontalHeaderItem(1)
___qtablewidgetitem4.setText(QCoreApplication.translate("TextDefinitionEditor", u"Type", None))
___qtablewidgetitem5 = self.parametersTable.horizontalHeaderItem(2)
___qtablewidgetitem5.setText(QCoreApplication.translate("TextDefinitionEditor", u"Value", None))
___qtablewidgetitem4 = self.parametersTable.horizontalHeaderItem(0)
___qtablewidgetitem4.setText(QCoreApplication.translate("TextDefinitionEditor", u"Name", None))
___qtablewidgetitem5 = self.parametersTable.horizontalHeaderItem(1)
___qtablewidgetitem5.setText(QCoreApplication.translate("TextDefinitionEditor", u"Type", None))
___qtablewidgetitem6 = self.parametersTable.horizontalHeaderItem(2)
___qtablewidgetitem6.setText(QCoreApplication.translate("TextDefinitionEditor", u"Value", None))
self.addParameterButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Add Parameter", None))
self.removeParameterButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Remove Parameter", None))
pass

View File

@@ -33,7 +33,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from bedit.core.model import Annotation, Component, Connection, Endpoint, Port
from bedit.core.model import Annotation, Component, Connection, Endpoint, Junction, Port
from bedit.gui.controllers.document import DocumentController
from bedit.gui.dialogs.connection_chooser import ConnectionChooserDialog
from bedit.gui.models.library_tree import COMPONENT_MIME_TYPE
@@ -79,6 +79,42 @@ class ConnectionPortItem(QGraphicsEllipseItem):
self.setToolTip(label)
class JunctionGraphicsItem(QGraphicsEllipseItem):
def __init__(self, junction: Junction, controller: DocumentController) -> None:
super().__init__(-5, -5, 10, 10)
self.junction = junction
self.controller = controller
self.endpoint = Endpoint(junction=junction.id)
self.drag_start = QPointF(junction.x, junction.y)
self.setPos(junction.x, junction.y)
self.setBrush(QColor("#303030"))
self.setPen(QPen(QColor("#ffffff"), 1))
self.setZValue(3)
self.setToolTip("Connection junction")
self.setCursor(Qt.CursorShape.SizeAllCursor)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.drag_start = self.pos()
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
self.controller.move_junction(self.junction.id, self.drag_start, self.pos())
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
return _snapped(value)
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.update_connections_for_junction(self.junction.id)
return super().itemChange(change, value)
class NameLabelItem(QGraphicsSimpleTextItem):
"""Movable italic name label whose position is stored by its owner."""
@@ -248,8 +284,8 @@ class ComponentGraphicsItem(QGraphicsObject):
class InterfaceTerminalItem(QGraphicsObject):
WIDTH = 110.0
HEIGHT = 36.0
WIDTH = 128.0
HEIGHT = 32.0
def __init__(self, port: Port, direction: str, controller: DocumentController) -> None:
super().__init__()
@@ -330,6 +366,8 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
super().__init__()
self.connection_id = connection.id
self.name = connection.name
self.source_is_junction = connection.source.junction is not None
self.target_is_junction = connection.target.junction is not None
self.controller = controller
self.style = style or ConnectionStyle()
self.start = QPointF()
@@ -374,6 +412,7 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
self.setSelected(True)
menu = QMenu()
add_node_action = menu.addAction("Add Node")
add_junction_action = menu.addAction("Add Junction")
menu.addSeparator()
options_action = menu.addAction("Connection Options…")
selected = menu.exec(event.screenPos())
@@ -381,6 +420,10 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
scene = self.scene()
if isinstance(scene, GraphScene):
scene.add_route_node("connection", self.connection_id, event.scenePos())
elif selected is add_junction_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.add_connection_junction(self.connection_id, event.scenePos())
elif selected is options_action:
scene = self.scene()
if isinstance(scene, GraphScene):
@@ -448,9 +491,9 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
super().paint(painter, option, widget)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(self.pen().color())
if self.style.arrow_at_target:
if self.style.arrow_at_target and not self.target_is_junction:
painter.drawPolygon(self._arrow(self.end, self.end_direction, self.style.arrow_size))
if self.style.arrow_at_source:
if self.style.arrow_at_source and not self.source_is_junction:
painter.drawPolygon(
self._arrow(self.start, -self.start_direction, self.style.arrow_size)
)
@@ -799,8 +842,11 @@ class GraphScene(QGraphicsScene):
self.input_items: dict[str, InterfaceTerminalItem] = {}
self.output_items: dict[str, InterfaceTerminalItem] = {}
self.connection_items: dict[str, ConnectionGraphicsItem] = {}
self.junction_items: dict[str, JunctionGraphicsItem] = {}
self.annotation_items: dict[str, QGraphicsItem] = {}
self.pending_connection_item: ComponentGraphicsItem | ConnectionPortItem | None = None
self.pending_connection_item: (
ComponentGraphicsItem | ConnectionPortItem | JunctionGraphicsItem | None
) = None
self.pending_waypoints: list[QPointF] = []
self.pending_preview: QGraphicsPathItem | None = None
self.interaction_mode = "pointer"
@@ -822,6 +868,7 @@ class GraphScene(QGraphicsScene):
self.input_items.clear()
self.output_items.clear()
self.connection_items.clear()
self.junction_items.clear()
self.annotation_items.clear()
self.pending_connection_item = None
self.pending_waypoints.clear()
@@ -844,6 +891,10 @@ class GraphScene(QGraphicsScene):
self.addItem(item)
item.setPos(component.x, component.y)
self.component_items[component.id] = item
for junction in owner.graph.junctions.values():
item = JunctionGraphicsItem(junction, self.controller)
self.addItem(item)
self.junction_items[junction.id] = item
for connection in owner.graph.connections.values():
item = ConnectionGraphicsItem(
connection,
@@ -892,6 +943,15 @@ class GraphScene(QGraphicsScene):
if item_kind in {"connection", "connection_data"}:
self.update_connection(item_id)
return
if item_kind == "junction_geometry":
junction = self.controller.active_graph.junctions.get(item_id)
graphics = self.junction_items.get(item_id)
if junction is not None and graphics is not None:
position = QPointF(junction.x, junction.y)
if graphics.pos() != position:
graphics.setPos(position)
self.update_connections_for_junction(item_id)
return
annotation = self.controller.active_graph.annotations.get(item_id)
graphics = self.annotation_items.get(item_id)
if annotation is None or graphics is None:
@@ -945,11 +1005,20 @@ class GraphScene(QGraphicsScene):
if port_id in (connection.source.interface, connection.target.interface):
self.update_connection(connection.id)
def update_connections_for_junction(self, junction_id: str) -> None:
for connection in self.controller.active_graph.connections.values():
if junction_id in (connection.source.junction, connection.target.junction):
self.update_connection(connection.id)
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
# The view paints the grid so it always covers the complete viewport.
del painter, rect
def _endpoint_item(self, endpoint: Endpoint, role: str) -> ConnectionPortItem | None:
def _endpoint_item(
self, endpoint: Endpoint, role: str
) -> ConnectionPortItem | JunctionGraphicsItem | None:
if endpoint.junction is not None:
return self.junction_items.get(endpoint.junction)
if endpoint.interface is not None:
terminals = self.input_items if role == "source" else self.output_items
terminal = terminals.get(endpoint.interface)
@@ -979,6 +1048,9 @@ class GraphScene(QGraphicsScene):
if endpoint.block is not None:
component = self.component_items.get(endpoint.block)
return component.mapToScene(component.hitbox.center()) if component else None
if endpoint.junction is not None:
junction = self.junction_items.get(endpoint.junction)
return junction.scenePos() if junction else None
port = self._endpoint_item(endpoint, role)
return port.scenePos() if port else None
@@ -988,6 +1060,9 @@ class GraphScene(QGraphicsScene):
if endpoint.block is not None:
component = self.component_items.get(endpoint.block)
return self._hitbox_intersection(component, reference) if component else None
if endpoint.junction is not None:
junction = self.junction_items.get(endpoint.junction)
return junction.scenePos() if junction else None
port = self._endpoint_item(endpoint, role)
return port.scenePos() if port else None
@@ -1076,17 +1151,43 @@ class GraphScene(QGraphicsScene):
return item.parentItem()
return None
@staticmethod
def _clicked_terminal(item: QGraphicsItem | None) -> InterfaceTerminalItem | None:
if isinstance(item, InterfaceTerminalItem):
return item
if isinstance(item, ConnectionPortItem) and isinstance(
item.parentItem(), InterfaceTerminalItem
):
return item.parentItem()
return None
@classmethod
def _connection_click_item(
cls, item: QGraphicsItem | None
) -> ComponentGraphicsItem | ConnectionPortItem | JunctionGraphicsItem | None:
if isinstance(item, JunctionGraphicsItem):
return item
component = cls._clicked_component(item)
if component is not None:
return item if isinstance(item, ConnectionPortItem) else component
terminal = cls._clicked_terminal(item)
return terminal.connection_port if terminal is not None else None
@staticmethod
def _click_endpoint(item: QGraphicsItem | None) -> Endpoint | None:
return item.endpoint if isinstance(item, ConnectionPortItem) else None
return (
item.endpoint
if isinstance(item, (ConnectionPortItem, JunctionGraphicsItem))
else None
)
@staticmethod
def _connection_anchor(
item: ComponentGraphicsItem | ConnectionPortItem,
item: ComponentGraphicsItem | ConnectionPortItem | JunctionGraphicsItem,
) -> QPointF:
return (
item.scenePos()
if isinstance(item, ConnectionPortItem)
if isinstance(item, (ConnectionPortItem, JunctionGraphicsItem))
else item.sceneBoundingRect().center()
)
@@ -1104,10 +1205,16 @@ class GraphScene(QGraphicsScene):
for input_port in target_item.component.inputs:
if not PortTypeRegistry.compatible(output.type, input_port.type):
continue
source = Endpoint(block=source_item.component_id, port=output.id)
target = Endpoint(block=target_item.component_id, port=input_port.id)
if not self.controller.endpoint_accepts_connection(source, "source"):
continue
if not self.controller.endpoint_accepts_connection(target, "target"):
continue
choices.append(
ConnectionChoice(
Endpoint(block=source_item.component_id, port=output.id),
Endpoint(block=target_item.component_id, port=input_port.id),
source,
target,
f"{source_item.component.name}.{output.name}"
f"{target_item.component.name}.{input_port.name}",
)
@@ -1117,11 +1224,92 @@ class GraphScene(QGraphicsScene):
add_pairs(second, first)
return choices
def _interface_connection_choices(
self,
terminal: InterfaceTerminalItem,
component: ComponentGraphicsItem,
) -> list[ConnectionChoice]:
choices: list[ConnectionChoice] = []
interface = Endpoint(interface=terminal.port.id)
if terminal.direction == "input":
for port in component.component.inputs:
target = Endpoint(block=component.component_id, port=port.id)
if not PortTypeRegistry.compatible(terminal.port.type, port.type):
continue
if not self.controller.endpoint_accepts_connection(interface, "source"):
continue
if not self.controller.endpoint_accepts_connection(target, "target"):
continue
choices.append(
ConnectionChoice(
interface,
target,
f"IN.{terminal.port.name}{component.component.name}.{port.name}",
)
)
else:
for port in component.component.outputs:
source = Endpoint(block=component.component_id, port=port.id)
if not PortTypeRegistry.compatible(port.type, terminal.port.type):
continue
if not self.controller.endpoint_accepts_connection(source, "source"):
continue
if not self.controller.endpoint_accepts_connection(interface, "target"):
continue
choices.append(
ConnectionChoice(
source,
interface,
f"{component.component.name}.{port.name} → OUT.{terminal.port.name}",
)
)
return choices
def _junction_connection_choices(
self,
junction: JunctionGraphicsItem,
component: ComponentGraphicsItem,
) -> list[ConnectionChoice]:
choices: list[ConnectionChoice] = []
source = junction.endpoint
for port in component.component.inputs:
target = Endpoint(block=component.component_id, port=port.id)
if not PortTypeRegistry.compatible(junction.junction.type, port.type):
continue
if not self.controller.endpoint_accepts_connection(target, "target"):
continue
choices.append(
ConnectionChoice(
source,
target,
f"Junction → {component.component.name}.{port.name}",
)
)
return choices
def _junction_interface_choices(
self,
junction: JunctionGraphicsItem,
terminal: InterfaceTerminalItem,
) -> list[ConnectionChoice]:
if terminal.direction != "output":
return []
target = Endpoint(interface=terminal.port.id)
if not PortTypeRegistry.compatible(junction.junction.type, terminal.port.type):
return []
if not self.controller.endpoint_accepts_connection(target, "target"):
return []
return [
ConnectionChoice(
junction.endpoint,
target,
f"Junction → OUT.{terminal.port.name}",
)
]
@staticmethod
def _default_choice_index(
choices: list[ConnectionChoice],
first_component: ComponentGraphicsItem,
second_component: ComponentGraphicsItem,
first_endpoint: Endpoint | None,
second_endpoint: Endpoint | None,
) -> int:
@@ -1131,10 +1319,6 @@ class GraphScene(QGraphicsScene):
value += 8
if second_endpoint is not None and second_endpoint in (choice.source, choice.target):
value += 8
if choice.source.block == first_component.component_id:
value += 2
if choice.target.block == second_component.component_id:
value += 1
return value
return max(range(len(choices)), key=lambda index: score(choices[index]))
@@ -1148,27 +1332,41 @@ class GraphScene(QGraphicsScene):
return
first_component = self._clicked_component(first_item)
second_component = self._clicked_component(second_item)
if (
first_component is None
or second_component is None
or first_component is second_component
):
first_terminal = self._clicked_terminal(first_item)
second_terminal = self._clicked_terminal(second_item)
first_junction = first_item if isinstance(first_item, JunctionGraphicsItem) else None
second_junction = second_item if isinstance(second_item, JunctionGraphicsItem) else None
if first_component is not None and second_component is not None:
if first_component is second_component:
self._clear_pending_connection()
return
choices = self._connection_choices(first_component, second_component)
elif first_terminal is not None and second_component is not None:
choices = self._interface_connection_choices(first_terminal, second_component)
elif first_component is not None and second_terminal is not None:
choices = self._interface_connection_choices(second_terminal, first_component)
elif first_junction is not None and second_component is not None:
choices = self._junction_connection_choices(first_junction, second_component)
elif first_component is not None and second_junction is not None:
choices = self._junction_connection_choices(second_junction, first_component)
elif first_junction is not None and second_terminal is not None:
choices = self._junction_interface_choices(first_junction, second_terminal)
elif first_terminal is not None and second_junction is not None:
choices = self._junction_interface_choices(second_junction, first_terminal)
else:
self._clear_pending_connection()
return
choices = self._connection_choices(first_component, second_component)
if not choices:
QToolTip.showText(
self.views()[0].mapToGlobal(self.views()[0].viewport().rect().center())
if self.views()
else QPointF().toPoint(),
"These blocks have no compatible input/output pairs",
"These items have no available compatible input/output pairs",
)
self._clear_pending_connection()
return
default = self._default_choice_index(
choices,
first_component,
second_component,
self._click_endpoint(first_item),
self._click_endpoint(second_item),
)
@@ -1200,9 +1398,8 @@ class GraphScene(QGraphicsScene):
event.accept()
return
item = self.itemAt(event.scenePos(), QTransform())
component = self._clicked_component(item)
if component is not None:
clicked = item if isinstance(item, ConnectionPortItem) else component
clicked = self._connection_click_item(item)
if clicked is not None:
if self.pending_connection_item is None:
self.pending_connection_item = clicked
self.pending_waypoints = []
@@ -1351,6 +1548,44 @@ class GraphScene(QGraphicsScene):
points.insert(insertion, snapped)
self.controller.set_route_waypoints(item_kind, item_id, points)
def add_connection_junction(self, connection_id: str, position: QPointF) -> None:
connection, points = self._route_values("connection", connection_id)
graphics = self.connection_items.get(connection_id)
if connection is None or graphics is None:
return
snapped = _snapped(position)
anchors = [graphics.start, *points, graphics.end]
def segment_distance(point: QPointF, first: QPointF, second: QPointF) -> float:
delta = second - first
length_squared = delta.x() ** 2 + delta.y() ** 2
if length_squared == 0:
return (point - first).manhattanLength()
ratio = max(
0.0,
min(
1.0,
((point.x() - first.x()) * delta.x()
+ (point.y() - first.y()) * delta.y())
/ length_squared,
),
)
nearest = first + delta * ratio
return (point.x() - nearest.x()) ** 2 + (point.y() - nearest.y()) ** 2
insertion = min(
range(len(anchors) - 1),
key=lambda index: segment_distance(
snapped, anchors[index], anchors[index + 1]
),
)
self.controller.split_connection(
connection_id,
snapped,
points[:insertion],
points[insertion:],
)
def move_route_node(self, item_kind: str, item_id: str, index: int, position: QPointF) -> None:
item, points = self._route_values(item_kind, item_id)
if item is None:

View File

@@ -315,6 +315,7 @@ class MainWindow(QMainWindow):
self._applying_text_definition = False
except (TypeError, ValueError) as error:
QMessageBox.critical(self, "Invalid text component", str(error))
self._load_text_definition()
return False
self.ui.textDefinitionEditor.set_modified(False)
return True

View File

@@ -86,7 +86,9 @@ class DocumentTreeModel(LibraryTreeModel):
self.clear()
self.setHorizontalHeaderLabels(["Document"])
if self.controller.document is not None:
current_root = QStandardItem("Current Document")
current_root = QStandardItem(
str(self.controller.document.metadata.get("name") or "Current Document")
)
current_root.setDragEnabled(False)
current_root.setData("current-document", ITEM_KIND_ROLE)
for component in self.controller.document.roots.values():