From 495edd42f4da364bf70fa8aba3a46c8a9bd3d17c Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Mon, 20 Jul 2026 15:00:06 +0200 Subject: [PATCH] Added array ports and junctions --- BEdit/AGENTS.md | 3 + BEdit/src/bedit/core/model.py | 132 ++- BEdit/src/bedit/data/libraries/default.json | 228 ++++- BEdit/src/bedit/gui/controllers/commands.py | 28 +- BEdit/src/bedit/gui/controllers/document.py | 134 ++- BEdit/src/bedit/gui/dialogs/port_options.py | 7 + .../src/bedit/gui/editors/text_definition.py | 8 +- .../gui/generated/ui_port_options_dialog.py | 17 +- .../generated/ui_text_definition_editor.py | 30 +- BEdit/src/bedit/gui/graphics/workspace.py | 295 +++++- BEdit/src/bedit/gui/main_window.py | 1 + BEdit/src/bedit/gui/models/library_tree.py | 4 +- BEdit/ui/port_options_dialog.ui | 3 +- BEdit/ui/text_definition_editor.ui | 2 +- BEdit/untitled.bedit.json | 866 +++++++++++++++++- 15 files changed, 1686 insertions(+), 72 deletions(-) diff --git a/BEdit/AGENTS.md b/BEdit/AGENTS.md index 64240e4..f0ea503 100644 --- a/BEdit/AGENTS.md +++ b/BEdit/AGENTS.md @@ -60,6 +60,9 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant - Port removal or reorientation must be rejected when it would invalidate an existing connection. - Connections reference port IDs, never port names. +- Connection junctions are explicit typed graph objects. Splitting a connection + creates one incoming and one outgoing segment; the junction can source further + branches without overlapping full connection paths. - Graph interaction has separate Pointer and Connect modes. Port hints are only visible in Connect mode. Connecting two blocks opens the compatible port-pair chooser; explicit port clicks determine its default selection. Connections diff --git a/BEdit/src/bedit/core/model.py b/BEdit/src/bedit/core/model.py index 52da2f4..3e8f761 100644 --- a/BEdit/src/bedit/core/model.py +++ b/BEdit/src/bedit/core/model.py @@ -16,6 +16,7 @@ class Port: y: float = 0.0 properties: dict[str, Any] = field(default_factory=dict) type: str = "signal" + allows_multiple_connections: bool = False def to_dict(self) -> dict[str, Any]: return { @@ -24,6 +25,7 @@ class Port: "position": {"x": self.x, "y": self.y}, "properties": self.properties, "type": self.type, + "multipleConnections": self.allows_multiple_connections, } @classmethod @@ -36,6 +38,7 @@ class Port: y=float(position.get("y", 0.0)), properties=dict(data.get("properties", {})), type=str(data.get("type", "signal")), + allows_multiple_connections=bool(data.get("multipleConnections", False)), ) @@ -109,10 +112,13 @@ class Endpoint: block: str | None = None port: str | None = None interface: str | None = None + junction: str | None = None def to_dict(self) -> dict[str, str]: if self.interface is not None: return {"interface": self.interface} + if self.junction is not None: + return {"junction": self.junction} if self.block is None or self.port is None: raise ValueError("A block endpoint requires both block and port") return {"block": self.block, "port": self.port} @@ -121,6 +127,8 @@ class Endpoint: def from_dict(cls, data: dict[str, Any]) -> "Endpoint": if "interface" in data: return cls(interface=str(data["interface"])) + if "junction" in data: + return cls(junction=str(data["junction"])) return cls(block=str(data["block"]), port=str(data["port"])) @@ -176,6 +184,31 @@ class Parameter: ) +@dataclass +class Junction: + id: str + x: float + y: float + type: str = "signal" + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "position": {"x": self.x, "y": self.y}, + "type": self.type, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Junction": + position = data.get("position", {}) + return cls( + id=str(data["id"]), + x=float(position.get("x", 0)), + y=float(position.get("y", 0)), + type=str(data.get("type", "signal")), + ) + + @dataclass class Annotation: id: str @@ -221,12 +254,14 @@ class Graph: blocks: dict[str, Component] = field(default_factory=dict) connections: dict[str, Connection] = field(default_factory=dict) annotations: dict[str, Annotation] = field(default_factory=dict) + junctions: dict[str, Junction] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: return { "blocks": [block.to_dict() for block in self.blocks.values()], "connections": [connection.to_dict() for connection in self.connections.values()], "annotations": [item.to_dict() for item in self.annotations.values()], + "junctions": [junction.to_dict() for junction in self.junctions.values()], } @classmethod @@ -235,18 +270,22 @@ class Graph: blocks = [Component.from_dict(item) for item in data.get("blocks", [])] connections = [Connection.from_dict(item) for item in data.get("connections", [])] annotations = [Annotation.from_dict(item) for item in data.get("annotations", [])] + junctions = [Junction.from_dict(item) for item in data.get("junctions", [])] if len({block.id for block in blocks}) != len(blocks): raise ValueError("A graph contains duplicate component IDs") if len({connection.id for connection in connections}) != len(connections): raise ValueError("A graph contains duplicate connection IDs") if len({item.id for item in annotations}) != len(annotations): raise ValueError("A graph contains duplicate annotation IDs") + if len({item.id for item in junctions}) != len(junctions): + raise ValueError("A graph contains duplicate junction IDs") if any(item.kind not in {"box", "line", "text"} for item in annotations): raise ValueError("A graph contains an unknown annotation kind") return cls( blocks={block.id: block for block in blocks}, connections={connection.id: connection for connection in connections}, annotations={item.id: item for item in annotations}, + junctions={junction.id: junction for junction in junctions}, ) @@ -332,7 +371,7 @@ class GraphDocument: @classmethod def empty(cls) -> "GraphDocument": - return cls(metadata={"name": "Untitled"}) + return cls(metadata={"name": "Current Document"}) def to_dict(self) -> dict[str, Any]: return { @@ -409,8 +448,18 @@ class GraphDocument: raise ValueError(f"Component {owner.name} contains duplicate port IDs") for port in (*owner.inputs, *owner.outputs): PortTypeRegistry.get(port.type) + for junction in owner.graph.junctions.values(): + PortTypeRegistry.get(junction.type) + endpoint_counts: dict[tuple[str, str, str], int] = {} for connection in owner.graph.connections.values(): - if connection.source.interface is not None: + if connection.source.junction is not None: + junction = owner.graph.junctions.get(connection.source.junction) + if junction is None: + raise ValueError( + f"Connection {connection.id} uses an unknown source junction" + ) + source_port = Port(junction.id, "Junction", type=junction.type) + elif connection.source.interface is not None: if connection.source.interface not in input_ids: raise ValueError(f"Connection {connection.id} uses an unknown interface input") source_port = next(p for p in owner.inputs if p.id == connection.source.interface) @@ -419,7 +468,19 @@ class GraphDocument: if source is None or connection.source.port not in {p.id for p in source.outputs}: raise ValueError(f"Connection {connection.id} uses an unknown block output") source_port = next(p for p in source.outputs if p.id == connection.source.port) - if connection.target.interface is not None: + if connection.target.junction is not None: + junction = owner.graph.junctions.get(connection.target.junction) + if junction is None: + raise ValueError( + f"Connection {connection.id} uses an unknown target junction" + ) + target_port = Port( + junction.id, + "Junction", + type=junction.type, + allows_multiple_connections=False, + ) + elif connection.target.interface is not None: if connection.target.interface not in output_ids: raise ValueError(f"Connection {connection.id} uses an unknown interface output") target_port = next(p for p in owner.outputs if p.id == connection.target.interface) @@ -430,6 +491,40 @@ class GraphDocument: target_port = next(p for p in target.inputs if p.id == connection.target.port) if not PortTypeRegistry.compatible(source_port.type, target_port.type): raise ValueError(f"Connection {connection.id} joins incompatible port types") + source_key = ( + "source-junction" + if connection.source.junction is not None + else "source-interface" + if connection.source.interface is not None + else "source-block", + connection.source.block or "", + connection.source.junction + or connection.source.interface + or connection.source.port + or "", + ) + target_key = ( + "target-junction" + if connection.target.junction is not None + else "target-interface" + if connection.target.interface is not None + else "target-block", + connection.target.block or "", + connection.target.junction + or connection.target.interface + or connection.target.port + or "", + ) + endpoint_counts[source_key] = endpoint_counts.get(source_key, 0) + 1 + endpoint_counts[target_key] = endpoint_counts.get(target_key, 0) + 1 + if ( + endpoint_counts[target_key] > 1 + and not target_port.allows_multiple_connections + ): + raise ValueError( + f"Input {target_port.name!r} has multiple incoming connections " + "but does not allow them" + ) def clone_component(source: Component) -> Component: @@ -438,10 +533,15 @@ def clone_component(source: Component) -> Component: def clone(current: Component) -> Component: child_pairs = [(child, clone(child)) for child in current.graph.blocks.values()] child_ids = {old.id: new.id for old, new in child_pairs} + junction_ids = { + junction.id: str(uuid4()) for junction in current.graph.junctions.values() + } def remap(endpoint: Endpoint) -> Endpoint: if endpoint.interface is not None: return endpoint + if endpoint.junction is not None: + return Endpoint(junction=junction_ids[endpoint.junction]) return Endpoint(block=child_ids[endpoint.block or ""], port=endpoint.port) graph = Graph( @@ -472,6 +572,12 @@ def clone_component(source: Component) -> Component: for annotation in current.graph.annotations.values() for new_id in [str(uuid4())] }, + junctions={ + junction_ids[junction.id]: Junction( + junction_ids[junction.id], junction.x, junction.y, junction.type + ) + for junction in current.graph.junctions.values() + }, ) return Component( id=str(uuid4()), @@ -479,11 +585,27 @@ def clone_component(source: Component) -> Component: x=current.x, y=current.y, inputs=[ - Port(port.id, port.name, port.x, port.y, deepcopy(port.properties), port.type) + Port( + port.id, + port.name, + port.x, + port.y, + deepcopy(port.properties), + port.type, + port.allows_multiple_connections, + ) for port in current.inputs ], outputs=[ - Port(port.id, port.name, port.x, port.y, deepcopy(port.properties), port.type) + Port( + port.id, + port.name, + port.x, + port.y, + deepcopy(port.properties), + port.type, + port.allows_multiple_connections, + ) for port in current.outputs ], icon=Icon.from_dict(current.icon.to_dict()), diff --git a/BEdit/src/bedit/data/libraries/default.json b/BEdit/src/bedit/data/libraries/default.json index bcf84c9..7eb0e76 100644 --- a/BEdit/src/bedit/data/libraries/default.json +++ b/BEdit/src/bedit/data/libraries/default.json @@ -28,7 +28,8 @@ "y": 64.0 } }, - "type": "signal" + "type": "signal", + "multipleConnections": false } ], "outputs": [ @@ -45,7 +46,8 @@ "y": 40.0 } }, - "type": "signal" + "type": "signal", + "multipleConnections": false } ] }, @@ -131,7 +133,8 @@ "y": 64.0 } }, - "type": "signal" + "type": "signal", + "multipleConnections": false } ], "outputs": [ @@ -148,7 +151,8 @@ "y": 40.0 } }, - "type": "signal" + "type": "signal", + "multipleConnections": false } ] }, @@ -210,6 +214,222 @@ ] } } + }, + { + "id": "afdc7696-622b-47e4-b2c3-28ec60786bf2", + "name": "integrate", + "position": { + "x": 0.0, + "y": 0.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [ + { + "id": "port-12903207", + "name": "in", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 64.0, + "y": 64.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ], + "outputs": [ + { + "id": "port-b3370b1a", + "name": "out", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 88.0, + "y": 40.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text", + "size": { + "width": 128.0, + "height": 128.0 + }, + "elements": [ + { + "type": "rectangle", + "x": 32.0, + "y": 32.0, + "width": 64.0, + "height": 64.0, + "fill": "#f4f4f4", + "stroke": "#303030", + "lineWidth": 1.5, + "lineStyle": "solid", + "cornerRadius": 5.0 + }, + { + "type": "text", + "x": 64.0, + "y": 40.0, + "width": 28.0, + "height": 48.0, + "text": "dt", + "color": "#00007f", + "fontSize": 18.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#00007f", + "fill": "#ffffff" + }, + { + "type": "text", + "x": 40.0, + "y": 32.0, + "width": 28.0, + "height": 54.0, + "fill": "none", + "stroke": "#00007f", + "lineWidth": 1.5, + "lineStyle": "none", + "text": "\u222b", + "fontSize": 32.0, + "color": "#00007f" + } + ] + }, + "properties": { + "showName": false + }, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": "initial out = initial;\nder(out) = in;", + "parameters": [ + { + "id": "parameter-6bdc1c76", + "name": "initial", + "type": "real", + "value": "0" + } + ] + } + } + }, + { + "id": "f6b8e5c9-4d13-4f7b-b574-e00ca96e42c3", + "name": "add", + "position": { + "x": 0.0, + "y": 0.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [ + { + "id": "port-c995845c", + "name": "in", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 64.0, + "y": 64.0 + } + }, + "type": "signal", + "multipleConnections": true + } + ], + "outputs": [ + { + "id": "port-a2f0dea6", + "name": "out", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 80.0, + "y": 48.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text", + "size": { + "width": 128.0, + "height": 128.0 + }, + "elements": [ + { + "type": "circle", + "x": 40.0, + "y": 40.0, + "width": 48.0, + "height": 48.0, + "fill": "#f4f4f4", + "stroke": "#303030", + "lineWidth": 1.5, + "lineStyle": "solid" + }, + { + "type": "text", + "x": 48.0, + "y": 48.0, + "width": 32.0, + "height": 32.0, + "fill": "none", + "stroke": "#00007f", + "lineWidth": 1.5, + "lineStyle": "none", + "text": "+", + "fontSize": 18.0, + "color": "#00007f" + } + ] + }, + "properties": { + "showName": false + }, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": "out = sum(in[i] for i in 1:in.N);", + "parameters": [] + } + } } ] } diff --git a/BEdit/src/bedit/gui/controllers/commands.py b/BEdit/src/bedit/gui/controllers/commands.py index eda5285..b856f9d 100644 --- a/BEdit/src/bedit/gui/controllers/commands.py +++ b/BEdit/src/bedit/gui/controllers/commands.py @@ -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}") diff --git a/BEdit/src/bedit/gui/controllers/document.py b/BEdit/src/bedit/gui/controllers/document.py index 87a11c2..c0ca3e7 100644 --- a/BEdit/src/bedit/gui/controllers/document.py +++ b/BEdit/src/bedit/gui/controllers/document.py @@ -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"] diff --git a/BEdit/src/bedit/gui/dialogs/port_options.py b/BEdit/src/bedit/gui/dialogs/port_options.py index 5595d9c..212f835 100644 --- a/BEdit/src/bedit/gui/dialogs/port_options.py +++ b/BEdit/src/bedit/gui/dialogs/port_options.py @@ -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}]" diff --git a/BEdit/src/bedit/gui/editors/text_definition.py b/BEdit/src/bedit/gui/editors/text_definition.py index df2d0cc..6493478 100644 --- a/BEdit/src/bedit/gui/editors/text_definition.py +++ b/BEdit/src/bedit/gui/editors/text_definition.py @@ -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 diff --git a/BEdit/src/bedit/gui/generated/ui_port_options_dialog.py b/BEdit/src/bedit/gui/generated/ui_port_options_dialog.py index 804a3b9..4266200 100644 --- a/BEdit/src/bedit/gui/generated/ui_port_options_dialog.py +++ b/BEdit/src/bedit/gui/generated/ui_port_options_dialog.py @@ -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 diff --git a/BEdit/src/bedit/gui/generated/ui_text_definition_editor.py b/BEdit/src/bedit/gui/generated/ui_text_definition_editor.py index 2feed68..4c23d7a 100644 --- a/BEdit/src/bedit/gui/generated/ui_text_definition_editor.py +++ b/BEdit/src/bedit/gui/generated/ui_text_definition_editor.py @@ -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 diff --git a/BEdit/src/bedit/gui/graphics/workspace.py b/BEdit/src/bedit/gui/graphics/workspace.py index 10b1697..7e1dea8 100644 --- a/BEdit/src/bedit/gui/graphics/workspace.py +++ b/BEdit/src/bedit/gui/graphics/workspace.py @@ -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: diff --git a/BEdit/src/bedit/gui/main_window.py b/BEdit/src/bedit/gui/main_window.py index 1e35e36..8e0a18b 100644 --- a/BEdit/src/bedit/gui/main_window.py +++ b/BEdit/src/bedit/gui/main_window.py @@ -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 diff --git a/BEdit/src/bedit/gui/models/library_tree.py b/BEdit/src/bedit/gui/models/library_tree.py index c08fe2d..1be91c2 100644 --- a/BEdit/src/bedit/gui/models/library_tree.py +++ b/BEdit/src/bedit/gui/models/library_tree.py @@ -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(): diff --git a/BEdit/ui/port_options_dialog.ui b/BEdit/ui/port_options_dialog.ui index de0e011..752b231 100644 --- a/BEdit/ui/port_options_dialog.ui +++ b/BEdit/ui/port_options_dialog.ui @@ -27,7 +27,8 @@ Signal Orientation: InputOutput - New ports start at (0, 0) in the icon editor.true + Allow multiple connections + New ports start at (0, 0) in the icon editor.true diff --git a/BEdit/ui/text_definition_editor.ui b/BEdit/ui/text_definition_editor.ui index 20f0cb5..3342287 100644 --- a/BEdit/ui/text_definition_editor.ui +++ b/BEdit/ui/text_definition_editor.ui @@ -24,7 +24,7 @@ Ports - QAbstractItemView::SelectionBehavior::SelectRowsQAbstractItemView::SelectionMode::SingleSelection3NameTypeOrientation + QAbstractItemView::SelectionBehavior::SelectRowsQAbstractItemView::SelectionMode::SingleSelection4NameTypeOrientationMultiple Add PortRemove PortQt::Orientation::Horizontal4020 diff --git a/BEdit/untitled.bedit.json b/BEdit/untitled.bedit.json index 1fe0870..ee96c86 100644 --- a/BEdit/untitled.bedit.json +++ b/BEdit/untitled.bedit.json @@ -6,16 +6,50 @@ }, "roots": [ { - "id": "b53c8186-0926-46b1-80ec-51a22ac5e0c6", - "name": "New Graph Block 1", + "id": "57fe8127-ea9d-4a8b-8d09-da430ffbbd92", + "name": "PID", "position": { "x": 0.0, "y": 0.0 }, "rotation": 0.0, "interface": { - "inputs": [], - "outputs": [] + "inputs": [ + { + "id": "port-1342716d", + "name": "in", + "position": { + "x": -384.0, + "y": -112.0 + }, + "properties": { + "iconPosition": { + "x": 0.0, + "y": 0.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ], + "outputs": [ + { + "id": "port-c75d0f16", + "name": "out", + "position": { + "x": 320.0, + "y": -112.0 + }, + "properties": { + "iconPosition": { + "x": 0.0, + "y": 0.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ] }, "icon": { "shape": "rectangle", @@ -51,16 +85,830 @@ } ] }, - "properties": {}, + "properties": { + "showName": false + }, "library": { - "showSubtree": true + "showSubtree": false }, "implementation": { "kind": "graph", "graph": { - "blocks": [], - "connections": [], - "annotations": [] + "blocks": [ + { + "id": "cbfb5d1f-38cc-412e-b8e3-53aa82e46eb8", + "name": "g_D", + "position": { + "x": -128.0, + "y": -160.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [ + { + "id": "port-df34ce84", + "name": "in", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 64.0, + "y": 64.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ], + "outputs": [ + { + "id": "port-fe9e6486", + "name": "out", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 88.0, + "y": 40.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text", + "size": { + "width": 128.0, + "height": 128.0 + }, + "elements": [ + { + "cornerRadius": 5.0, + "fill": "#f4f4f4", + "height": 64.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#303030", + "type": "rectangle", + "width": 64.0, + "x": 32.0, + "y": 32.0 + }, + { + "color": "#00007f", + "fill": "#ffffff", + "fontSize": 24.0, + "height": 48.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#00007f", + "text": "K", + "type": "text", + "width": 48.0, + "x": 40.0, + "y": 40.0 + } + ] + }, + "properties": { + "showName": true + }, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": "out = k*in;", + "parameters": [ + { + "id": "parameter-61a43a86", + "name": "k", + "type": "real", + "value": "1" + } + ] + } + } + }, + { + "id": "478e7ff8-baa1-467f-8707-e375b99395f6", + "name": "differentiate0", + "position": { + "x": 0.0, + "y": -160.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [ + { + "id": "port-1cbabc8f", + "name": "in", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 64.0, + "y": 64.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ], + "outputs": [ + { + "id": "port-4eeea4e7", + "name": "out", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 88.0, + "y": 40.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text", + "size": { + "width": 128.0, + "height": 128.0 + }, + "elements": [ + { + "cornerRadius": 5.0, + "fill": "#f4f4f4", + "height": 64.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#303030", + "type": "rectangle", + "width": 64.0, + "x": 32.0, + "y": 32.0 + }, + { + "color": "#00007f", + "fill": "#ffffff", + "fontSize": 18.0, + "height": 48.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#00007f", + "text": "d/dt", + "type": "text", + "width": 48.0, + "x": 40.0, + "y": 40.0 + } + ] + }, + "properties": { + "showName": false + }, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": "initial out = initial;\nout = der(in);", + "parameters": [ + { + "id": "parameter-331e38be", + "name": "initial", + "type": "real", + "value": "0" + } + ] + } + } + }, + { + "id": "9011e8f6-940a-40ca-8bdf-773f0ed1c9f3", + "name": "g_P", + "position": { + "x": -64.0, + "y": -288.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [ + { + "id": "port-df34ce84", + "name": "in", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 64.0, + "y": 64.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ], + "outputs": [ + { + "id": "port-fe9e6486", + "name": "out", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 88.0, + "y": 40.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text", + "size": { + "width": 128.0, + "height": 128.0 + }, + "elements": [ + { + "cornerRadius": 5.0, + "fill": "#f4f4f4", + "height": 64.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#303030", + "type": "rectangle", + "width": 64.0, + "x": 32.0, + "y": 32.0 + }, + { + "color": "#00007f", + "fill": "#ffffff", + "fontSize": 24.0, + "height": 48.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#00007f", + "text": "K", + "type": "text", + "width": 48.0, + "x": 40.0, + "y": 40.0 + } + ] + }, + "properties": { + "showName": true + }, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": "out = k*in;", + "parameters": [ + { + "id": "parameter-61a43a86", + "name": "k", + "type": "real", + "value": "1" + } + ] + } + } + }, + { + "id": "1995074c-4580-4ca3-8691-6923aedc5833", + "name": "g_I", + "position": { + "x": -128.0, + "y": -32.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [ + { + "id": "port-df34ce84", + "name": "in", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 64.0, + "y": 64.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ], + "outputs": [ + { + "id": "port-fe9e6486", + "name": "out", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 88.0, + "y": 40.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text", + "size": { + "width": 128.0, + "height": 128.0 + }, + "elements": [ + { + "cornerRadius": 5.0, + "fill": "#f4f4f4", + "height": 64.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#303030", + "type": "rectangle", + "width": 64.0, + "x": 32.0, + "y": 32.0 + }, + { + "color": "#00007f", + "fill": "#ffffff", + "fontSize": 24.0, + "height": 48.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#00007f", + "text": "K", + "type": "text", + "width": 48.0, + "x": 40.0, + "y": 40.0 + } + ] + }, + "properties": { + "showName": true + }, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": "out = k*in;", + "parameters": [ + { + "id": "parameter-61a43a86", + "name": "k", + "type": "real", + "value": "1" + } + ] + } + } + }, + { + "id": "c7440f4d-f206-4c27-9fd2-5722950f207f", + "name": "add0", + "position": { + "x": 128.0, + "y": -160.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [ + { + "id": "port-c995845c", + "name": "in", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 64.0, + "y": 64.0 + } + }, + "type": "signal", + "multipleConnections": true + } + ], + "outputs": [ + { + "id": "port-a2f0dea6", + "name": "out", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 80.0, + "y": 48.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text", + "size": { + "width": 128.0, + "height": 128.0 + }, + "elements": [ + { + "fill": "#f4f4f4", + "height": 48.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#303030", + "type": "circle", + "width": 48.0, + "x": 40.0, + "y": 40.0 + }, + { + "color": "#00007f", + "fill": "none", + "fontSize": 18.0, + "height": 32.0, + "lineStyle": "none", + "lineWidth": 1.5, + "stroke": "#00007f", + "text": "+", + "type": "text", + "width": 32.0, + "x": 48.0, + "y": 48.0 + } + ] + }, + "properties": { + "showName": false + }, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": "out = sum(in[i] for i in 1:in.N);", + "parameters": [] + } + } + }, + { + "id": "48af4110-839a-45f7-a105-7e7dbc51676b", + "name": "integrate0", + "position": { + "x": 0.0, + "y": -32.0 + }, + "rotation": 0.0, + "interface": { + "inputs": [ + { + "id": "port-12903207", + "name": "in", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 64.0, + "y": 64.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ], + "outputs": [ + { + "id": "port-b3370b1a", + "name": "out", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 88.0, + "y": 40.0 + } + }, + "type": "signal", + "multipleConnections": false + } + ] + }, + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text", + "size": { + "width": 128.0, + "height": 128.0 + }, + "elements": [ + { + "cornerRadius": 5.0, + "fill": "#f4f4f4", + "height": 64.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#303030", + "type": "rectangle", + "width": 64.0, + "x": 32.0, + "y": 32.0 + }, + { + "color": "#00007f", + "fill": "#ffffff", + "fontSize": 18.0, + "height": 48.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#00007f", + "text": "dt", + "type": "text", + "width": 28.0, + "x": 64.0, + "y": 40.0 + }, + { + "color": "#00007f", + "fill": "none", + "fontSize": 32.0, + "height": 54.0, + "lineStyle": "none", + "lineWidth": 1.5, + "stroke": "#00007f", + "text": "\u222b", + "type": "text", + "width": 28.0, + "x": 40.0, + "y": 32.0 + } + ] + }, + "properties": { + "showName": false + }, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": "initial out = initial;\nder(out) = in;", + "parameters": [ + { + "id": "parameter-6bdc1c76", + "name": "initial", + "type": "real", + "value": "0" + } + ] + } + } + } + ], + "connections": [ + { + "id": "f89a8edb-b662-4a55-aa00-6d681b0bf6ae", + "source": { + "block": "9011e8f6-940a-40ca-8bdf-773f0ed1c9f3", + "port": "port-fe9e6486" + }, + "target": { + "block": "c7440f4d-f206-4c27-9fd2-5722950f207f", + "port": "port-c995845c" + }, + "name": "", + "properties": { + "waypoints": [ + { + "x": 192.0, + "y": -224.0 + } + ] + } + }, + { + "id": "c1a2f8e2-b98f-45ee-9ef6-fad8cd42b71b", + "source": { + "block": "478e7ff8-baa1-467f-8707-e375b99395f6", + "port": "port-4eeea4e7" + }, + "target": { + "block": "c7440f4d-f206-4c27-9fd2-5722950f207f", + "port": "port-c995845c" + }, + "name": "", + "properties": { + "waypoints": [] + } + }, + { + "id": "e889f7bd-c684-4e59-8e9d-1693bd2ba87f", + "source": { + "block": "cbfb5d1f-38cc-412e-b8e3-53aa82e46eb8", + "port": "port-fe9e6486" + }, + "target": { + "block": "478e7ff8-baa1-467f-8707-e375b99395f6", + "port": "port-1cbabc8f" + }, + "name": "", + "properties": { + "waypoints": [] + } + }, + { + "id": "1cebb01c-7da4-48c7-b7ea-5585f64cd524", + "source": { + "block": "1995074c-4580-4ca3-8691-6923aedc5833", + "port": "port-fe9e6486" + }, + "target": { + "block": "48af4110-839a-45f7-a105-7e7dbc51676b", + "port": "port-12903207" + }, + "name": "", + "properties": { + "waypoints": [] + } + }, + { + "id": "6d16540e-d902-4ebf-a6a1-dcdda0cc8458", + "source": { + "block": "48af4110-839a-45f7-a105-7e7dbc51676b", + "port": "port-b3370b1a" + }, + "target": { + "block": "c7440f4d-f206-4c27-9fd2-5722950f207f", + "port": "port-c995845c" + }, + "name": "", + "properties": { + "waypoints": [ + { + "x": 192.0, + "y": 32.0 + } + ] + } + }, + { + "id": "206f1501-4509-4ba7-a28e-156e35de51ea", + "source": { + "block": "c7440f4d-f206-4c27-9fd2-5722950f207f", + "port": "port-a2f0dea6" + }, + "target": { + "interface": "port-c75d0f16" + }, + "name": "", + "properties": { + "waypoints": [] + } + }, + { + "id": "5d234bec-8754-46e8-b9e8-1c802bb9f9ba", + "source": { + "interface": "port-1342716d" + }, + "target": { + "junction": "7a552e5e-b15d-46ca-a891-37e5129f88d1" + }, + "name": "", + "properties": { + "waypoints": [] + } + }, + { + "id": "33ba6f27-6f3a-4290-baac-b3cddcaa6b50", + "source": { + "junction": "7a552e5e-b15d-46ca-a891-37e5129f88d1" + }, + "target": { + "block": "cbfb5d1f-38cc-412e-b8e3-53aa82e46eb8", + "port": "port-df34ce84" + }, + "name": "", + "properties": { + "waypoints": [] + } + }, + { + "id": "ee4a3409-fc46-485c-b475-b456dddb93b5", + "source": { + "junction": "7a552e5e-b15d-46ca-a891-37e5129f88d1" + }, + "target": { + "block": "9011e8f6-940a-40ca-8bdf-773f0ed1c9f3", + "port": "port-df34ce84" + }, + "name": "", + "properties": { + "waypoints": [ + { + "x": -192.0, + "y": -224.0 + } + ] + } + }, + { + "id": "8d1a5f3c-b706-4671-873a-2121f55f748b", + "source": { + "junction": "7a552e5e-b15d-46ca-a891-37e5129f88d1" + }, + "target": { + "block": "1995074c-4580-4ca3-8691-6923aedc5833", + "port": "port-df34ce84" + }, + "name": "", + "properties": { + "waypoints": [ + { + "x": -192.0, + "y": 32.0 + } + ] + } + } + ], + "annotations": [], + "junctions": [ + { + "id": "7a552e5e-b15d-46ca-a891-37e5129f88d1", + "position": { + "x": -192.0, + "y": -96.0 + }, + "type": "signal" + } + ] } } }