Added new connection styles

This commit is contained in:
2026-07-20 12:45:47 +02:00
parent 8fa450d734
commit ee859c4311
9 changed files with 348 additions and 182 deletions

View File

@@ -225,7 +225,14 @@ class DocumentController(QObject):
RotateComponentsCommand(self, self.active_component_id, rotations)
)
def connect(self, source: Endpoint, target: Endpoint) -> str:
def connect(
self,
source: Endpoint,
target: Endpoint,
*,
routing: str = "spline",
waypoints: list[QPointF] | None = None,
) -> str:
if self.active_component_id is None:
raise ValueError("There is no active graph")
source_port = self._port_for_endpoint(source, "source")
@@ -236,7 +243,19 @@ class DocumentController(QObject):
raise ValueError(
f"Cannot connect {source_port.type!r} to {target_port.type!r}"
)
connection = Connection(str(uuid4()), source, target)
if routing not in {"direct", "angled", "spline"}:
raise ValueError(f"Unknown connection routing: {routing}")
connection = Connection(
str(uuid4()),
source,
target,
properties={
"routing": routing,
"waypoints": [
{"x": point.x(), "y": point.y()} for point in (waypoints or [])
],
},
)
self.undo_stack.push(
AddConnectionCommand(self, self.active_component_id, connection)
)
@@ -255,6 +274,10 @@ class DocumentController(QObject):
ports = component.outputs if role == "source" else component.inputs
return next((port for port in ports if port.id == (endpoint.interface or endpoint.port)), None)
def connection_port_type(self, connection: Connection) -> str:
port = self._port_for_endpoint(connection.source, "source")
return port.type if port is not None else "signal"
def add_interface_port(self, direction: str, position: QPointF) -> str:
component = self.active_component
if component is None or component.implementation_kind != "graph":
@@ -489,10 +512,17 @@ class DocumentController(QObject):
for source in source_connections:
if source.source.block not in id_map or source.target.block not in id_map:
continue
properties = deepcopy(source.properties)
for point in properties.get("waypoints", []):
if isinstance(point, dict):
point["x"] = float(point.get("x", 0)) + offset.x()
point["y"] = float(point.get("y", 0)) + offset.y()
connection = Connection(
id=str(uuid4()),
source=Endpoint(block=id_map[source.source.block], port=source.source.port),
target=Endpoint(block=id_map[source.target.block], port=source.target.port),
name=source.name,
properties=properties,
)
connections[connection.id] = connection
if blocks:

View File

@@ -182,6 +182,12 @@ class Ui_MainWindow(object):
self.workspaceHeaderLayout.addWidget(self.navigateUpButton)
self.navigateDownButton = QToolButton(self.workspaceHeader)
self.navigateDownButton.setObjectName(u"navigateDownButton")
self.navigateDownButton.setEnabled(False)
self.workspaceHeaderLayout.addWidget(self.navigateDownButton)
self.graphBreadcrumbLabel = QLabel(self.workspaceHeader)
self.graphBreadcrumbLabel.setObjectName(u"graphBreadcrumbLabel")
@@ -206,10 +212,39 @@ class Ui_MainWindow(object):
self.pointerToolButton.setObjectName(u"pointerToolButton")
self.pointerToolButton.setCheckable(True)
self.pointerToolButton.setChecked(True)
self.pointerToolButton.setAutoExclusive(True)
self.workspaceHeaderLayout.addWidget(self.pointerToolButton)
self.connectToolButton = QToolButton(self.workspaceHeader)
self.connectToolButton.setObjectName(u"connectToolButton")
self.connectToolButton.setCheckable(True)
self.workspaceHeaderLayout.addWidget(self.connectToolButton)
self.routingLabel = QLabel(self.workspaceHeader)
self.routingLabel.setObjectName(u"routingLabel")
self.workspaceHeaderLayout.addWidget(self.routingLabel)
self.directRoutingButton = QToolButton(self.workspaceHeader)
self.directRoutingButton.setObjectName(u"directRoutingButton")
self.directRoutingButton.setCheckable(True)
self.workspaceHeaderLayout.addWidget(self.directRoutingButton)
self.angledRoutingButton = QToolButton(self.workspaceHeader)
self.angledRoutingButton.setObjectName(u"angledRoutingButton")
self.angledRoutingButton.setCheckable(True)
self.workspaceHeaderLayout.addWidget(self.angledRoutingButton)
self.splineRoutingButton = QToolButton(self.workspaceHeader)
self.splineRoutingButton.setObjectName(u"splineRoutingButton")
self.splineRoutingButton.setCheckable(True)
self.splineRoutingButton.setChecked(True)
self.workspaceHeaderLayout.addWidget(self.splineRoutingButton)
self.workspaceEditorLayout.addWidget(self.workspaceHeader)
@@ -437,11 +472,20 @@ class Ui_MainWindow(object):
self.navigateUpButton.setText(QCoreApplication.translate("MainWindow", u"Up", None))
#if QT_CONFIG(tooltip)
self.navigateUpButton.setToolTip(QCoreApplication.translate("MainWindow", u"Open the containing graph", None))
#endif // QT_CONFIG(tooltip)
self.navigateDownButton.setText(QCoreApplication.translate("MainWindow", u"Down", None))
#if QT_CONFIG(tooltip)
self.navigateDownButton.setToolTip(QCoreApplication.translate("MainWindow", u"Open the selected block", None))
#endif // QT_CONFIG(tooltip)
self.graphBreadcrumbLabel.setText(QCoreApplication.translate("MainWindow", u"Untitled", None))
self.workspaceModeLabel.setText(QCoreApplication.translate("MainWindow", u"Graph", None))
self.applyJsonButton.setText(QCoreApplication.translate("MainWindow", u"Apply JSON", None))
self.pointerToolButton.setText(QCoreApplication.translate("MainWindow", u"Pointer", None))
self.connectToolButton.setText(QCoreApplication.translate("MainWindow", u"Connect", None))
self.routingLabel.setText(QCoreApplication.translate("MainWindow", u"Line:", None))
self.directRoutingButton.setText(QCoreApplication.translate("MainWindow", u"Direct", None))
self.angledRoutingButton.setText(QCoreApplication.translate("MainWindow", u"Angled", None))
self.splineRoutingButton.setText(QCoreApplication.translate("MainWindow", u"Spline", None))
self.jsonEditor.setPlaceholderText(QCoreApplication.translate("MainWindow", u"Component JSON", None))
self.emptyWorkspaceLabel.setText(QCoreApplication.translate("MainWindow", u"No document open", None))
self.menuFile.setTitle(QCoreApplication.translate("MainWindow", u"&File", None))

View File

@@ -0,0 +1,25 @@
from dataclasses import dataclass
from PySide6.QtCore import Qt
@dataclass(frozen=True)
class ConnectionStyle:
color: str = "#285f9e"
selected_color: str = "#f59e0b"
width: float = 2.5
selected_width: float = 4.0
line_style: Qt.PenStyle = Qt.PenStyle.SolidLine
arrow_at_source: bool = False
arrow_at_target: bool = True
arrow_size: float = 10.0
# This is the intentional code-level styling point for every port/connection type.
CONNECTION_STYLES: dict[str, ConnectionStyle] = {
"signal": ConnectionStyle(),
}
def connection_style(port_type: str) -> ConnectionStyle:
return CONNECTION_STYLES.get(port_type, ConnectionStyle())

View File

@@ -10,6 +10,7 @@ from PySide6.QtGui import (
QPainter,
QPainterPath,
QPen,
QPolygonF,
QTransform,
)
from PySide6.QtWidgets import (
@@ -32,6 +33,7 @@ from bedit.core.model import Component, Connection, Endpoint, Port
from bedit.gui.controllers.document import DocumentController
from bedit.gui.models.library_tree import COMPONENT_MIME_TYPE
from bedit.gui.graphics.icon_renderer import icon_bounds, paint_icon
from bedit.gui.graphics.connection_styles import ConnectionStyle, connection_style
from bedit.gui.preferences import application_settings
@@ -235,10 +237,20 @@ class InterfaceTerminalItem(QGraphicsObject):
class ConnectionGraphicsItem(QGraphicsPathItem):
def __init__(self, connection_id: str, name: str = "") -> None:
def __init__(
self,
connection_id: str,
name: str = "",
style: ConnectionStyle | None = None,
) -> None:
super().__init__()
self.connection_id = connection_id
self.name = name
self.style = style or ConnectionStyle()
self.start = QPointF()
self.end = QPointF()
self.start_direction = QPointF(1, 0)
self.end_direction = QPointF(1, 0)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self._update_pen()
self.setZValue(-1)
@@ -262,11 +274,40 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
def _update_pen(self) -> None:
self.setPen(
QPen(
QColor("#f59e0b") if self.isSelected() else QColor("#285f9e"),
4.0 if self.isSelected() else 2.5,
QColor(self.style.selected_color if self.isSelected() else self.style.color),
self.style.selected_width if self.isSelected() else self.style.width,
self.style.line_style,
)
)
def set_connection_path(
self,
path: QPainterPath,
points: list[QPointF],
) -> None:
self.setPath(path)
self.start, self.end = points[0], points[-1]
if len(points) > 1:
self.start_direction = points[1] - points[0]
self.end_direction = points[-1] - points[-2]
@staticmethod
def _arrow(end: QPointF, direction: QPointF, size: float) -> QPolygonF:
length = max(0.001, (direction.x() ** 2 + direction.y() ** 2) ** 0.5)
unit = QPointF(direction.x() / length, direction.y() / length)
normal = QPointF(-unit.y(), unit.x())
base = end - unit * size
return QPolygonF([end, base + normal * size * 0.45, base - normal * size * 0.45])
def paint(self, painter: QPainter, option, widget=None) -> None:
super().paint(painter, option, widget)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(self.pen().color())
if self.style.arrow_at_target:
painter.drawPolygon(self._arrow(self.end, self.end_direction, self.style.arrow_size))
if self.style.arrow_at_source:
painter.drawPolygon(self._arrow(self.start, -self.start_direction, self.style.arrow_size))
class GraphScene(QGraphicsScene):
componentOptionsRequested = Signal(str)
@@ -282,6 +323,10 @@ class GraphScene(QGraphicsScene):
self.output_items: dict[str, InterfaceTerminalItem] = {}
self.connection_items: dict[str, ConnectionGraphicsItem] = {}
self.pending_source: ConnectionPortItem | None = None
self.pending_waypoints: list[QPointF] = []
self.pending_preview: QGraphicsPathItem | None = None
self.interaction_mode = "pointer"
self.connection_routing = "spline"
self.setSceneRect(-2000, -2000, 4000, 4000)
controller.documentReset.connect(self.rebuild)
@@ -297,6 +342,8 @@ class GraphScene(QGraphicsScene):
self.output_items.clear()
self.connection_items.clear()
self.pending_source = None
self.pending_waypoints.clear()
self.pending_preview = None
owner = self.controller.active_component
if owner is None or owner.implementation_kind != "graph":
return
@@ -316,7 +363,11 @@ class GraphScene(QGraphicsScene):
item.setPos(component.x, component.y)
self.component_items[component.id] = item
for connection in owner.graph.connections.values():
item = ConnectionGraphicsItem(connection.id, connection.name)
item = ConnectionGraphicsItem(
connection.id,
connection.name,
connection_style(self.controller.connection_port_type(connection)),
)
self.addItem(item)
self.connection_items[connection.id] = item
self.update_connection(connection.id)
@@ -367,34 +418,115 @@ class GraphScene(QGraphicsScene):
if source is None or target is None:
return
start, end = source.scenePos(), target.scenePos()
distance = max(50.0, abs(end.x() - start.x()) * 0.5)
routing = str(connection.properties.get("routing", "spline"))
waypoints = [
QPointF(float(point["x"]), float(point["y"]))
for point in connection.properties.get("waypoints", [])
if isinstance(point, dict) and "x" in point and "y" in point
]
path, direction_points = self._route_path(start, end, routing, waypoints)
graphics.set_connection_path(path, direction_points)
@staticmethod
def _route_path(
start: QPointF,
end: QPointF,
routing: str,
waypoints: list[QPointF] | None = None,
) -> tuple[QPainterPath, list[QPointF]]:
waypoints = waypoints or []
path = QPainterPath(start)
path.cubicTo(start + QPointF(distance, 0), end - QPointF(distance, 0), end)
graphics.setPath(path)
if routing == "angled":
points = [start, *waypoints, end]
for point in points[1:]:
path.lineTo(point)
return path, points
if routing == "direct":
path.lineTo(end)
return path, [start, end]
distance = max(50.0, abs(end.x() - start.x()) * 0.5)
first_control = start + QPointF(distance, 0)
second_control = end - QPointF(distance, 0)
path.cubicTo(first_control, second_control, end)
return path, [start, first_control, second_control, end]
def set_interaction_mode(self, mode: str) -> None:
self.interaction_mode = mode
if mode != "connect":
self._clear_pending_source()
def set_connection_routing(self, routing: str) -> None:
self.connection_routing = routing
self._clear_pending_source()
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.interaction_mode != "connect":
self._clear_pending_source()
super().mousePressEvent(event)
return
if event.button() == Qt.MouseButton.RightButton:
self._clear_pending_source()
event.accept()
return
item = self.itemAt(event.scenePos(), QTransform())
if isinstance(item, ConnectionPortItem):
if item.role == "source":
self._clear_pending_source()
self.pending_source = item
self.pending_waypoints = []
item.setBrush(QColor("#f5b642"))
self._create_preview()
elif self.pending_source is not None:
if self.pending_source.endpoint != item.endpoint:
try:
self.controller.connect(self.pending_source.endpoint, item.endpoint)
self.controller.connect(
self.pending_source.endpoint,
item.endpoint,
routing=self.connection_routing,
waypoints=self.pending_waypoints,
)
except ValueError as error:
QToolTip.showText(event.screenPos(), str(error))
self._clear_pending_source()
event.accept()
return
self._clear_pending_source()
super().mousePressEvent(event)
if self.pending_source is not None and self.connection_routing == "angled":
self.pending_waypoints.append(_snapped(event.scenePos()))
self._update_preview(event.scenePos())
event.accept()
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.interaction_mode == "connect" and self.pending_source is not None:
self._update_preview(event.scenePos())
event.accept()
return
super().mouseMoveEvent(event)
def _create_preview(self) -> None:
self.pending_preview = QGraphicsPathItem()
self.pending_preview.setPen(QPen(QColor("#64748b"), 1.5, Qt.PenStyle.DashLine))
self.pending_preview.setZValue(-0.5)
self.addItem(self.pending_preview)
def _update_preview(self, cursor: QPointF) -> None:
if self.pending_source is None or self.pending_preview is None:
return
path, _points = self._route_path(
self.pending_source.scenePos(),
cursor,
self.connection_routing,
self.pending_waypoints,
)
self.pending_preview.setPath(path)
def _clear_pending_source(self) -> None:
if self.pending_source is not None:
self.pending_source.setBrush(QColor("#ffffff"))
if self.pending_preview is not None:
self.removeItem(self.pending_preview)
self.pending_source = None
self.pending_waypoints.clear()
self.pending_preview = None
class GraphWorkspaceView(QGraphicsView):
@@ -411,6 +543,7 @@ class GraphWorkspaceView(QGraphicsView):
self.tool_mode = "pointer"
self.paste_count = 0
self.setAcceptDrops(True)
self.setMouseTracking(True)
self.setRenderHint(QPainter.RenderHint.Antialiasing)
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
self.setBackgroundBrush(QColor("#f7f7f7"))
@@ -505,6 +638,29 @@ class GraphWorkspaceView(QGraphicsView):
and any(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems())
)
def has_single_selected_component(self) -> bool:
scene = self.scene()
return bool(
scene
and sum(
isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems()
)
== 1
)
def open_selected_component(self) -> bool:
if self.controller is None or self.scene() is None:
return False
selected = [
item
for item in self.scene().selectedItems()
if isinstance(item, ComponentGraphicsItem)
]
if len(selected) != 1:
return False
self.controller.activate_component(selected[0].component_id)
return True
def rotate_selected(self) -> None:
if self.controller is None or self.scene() is None:
return
@@ -578,6 +734,14 @@ class GraphWorkspaceView(QGraphicsView):
if mode == "pointer"
else QGraphicsView.DragMode.NoDrag
)
scene = self.scene()
if isinstance(scene, GraphScene):
scene.set_interaction_mode(mode)
def set_connection_routing(self, routing: str) -> None:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.set_connection_routing(routing)
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
super().mousePressEvent(event)

View File

@@ -4,7 +4,7 @@ from pathlib import Path
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu, QMessageBox
from PySide6.QtWidgets import QButtonGroup, QFileDialog, QMainWindow, QMenu, QMessageBox
from bedit.core.model import Component, Port
from bedit.core.serializer import JsonDocumentSerializer
@@ -93,8 +93,31 @@ class MainWindow(QMainWindow):
self.ui.graphView.selectionAvailabilityChanged.connect(
lambda _available: self._update_edit_actions()
)
self.mode_button_group = QButtonGroup(self)
self.mode_button_group.setExclusive(True)
self.mode_button_group.addButton(self.ui.pointerToolButton)
self.mode_button_group.addButton(self.ui.connectToolButton)
self.routing_button_group = QButtonGroup(self)
self.routing_button_group.setExclusive(True)
for button in (
self.ui.directRoutingButton,
self.ui.angledRoutingButton,
self.ui.splineRoutingButton,
):
self.routing_button_group.addButton(button)
self.ui.navigateUpButton.clicked.connect(self.navigate_up)
self.ui.navigateDownButton.clicked.connect(self.navigate_down)
self.ui.pointerToolButton.clicked.connect(lambda: self.set_graph_tool("pointer"))
self.ui.connectToolButton.clicked.connect(lambda: self.set_graph_tool("connect"))
self.ui.directRoutingButton.clicked.connect(
lambda: self.set_connection_routing("direct")
)
self.ui.angledRoutingButton.clicked.connect(
lambda: self.set_connection_routing("angled")
)
self.ui.splineRoutingButton.clicked.connect(
lambda: self.set_connection_routing("spline")
)
self.ui.applyJsonButton.clicked.connect(self.apply_json)
self.document_controller.activeGraphChanged.connect(self._active_graph_changed)
@@ -173,7 +196,7 @@ class MainWindow(QMainWindow):
self.ui.workspaceModeLabel.setText("")
self.ui.workspaceStack.setCurrentWidget(self.ui.emptyPage)
self.ui.applyJsonButton.setVisible(False)
self.ui.pointerToolButton.setVisible(False)
self._set_graph_controls_visible(False)
self._update_edit_actions()
return
self.ui.graphBreadcrumbLabel.setText(" ".join(self.document_controller.breadcrumb()))
@@ -184,7 +207,7 @@ class MainWindow(QMainWindow):
self.ui.workspaceModeLabel.setText("Graph" if is_graph else "Text")
self.ui.workspaceStack.setCurrentWidget(self.ui.graphPage if is_graph else self.ui.jsonPage)
self.ui.applyJsonButton.setVisible(not is_graph)
self.ui.pointerToolButton.setVisible(is_graph)
self._set_graph_controls_visible(is_graph)
if is_graph:
self.set_graph_tool("pointer")
else:
@@ -202,15 +225,43 @@ class MainWindow(QMainWindow):
)
self.ui.actionSelectAll.setEnabled(is_graph)
self.ui.actionPaste.setEnabled(is_graph)
self.ui.navigateDownButton.setEnabled(
is_graph and self.ui.graphView.has_single_selected_component()
)
@Slot()
def navigate_up(self) -> None:
if self._resolve_source_edits():
self.document_controller.navigate_up()
@Slot()
def navigate_down(self) -> None:
if self._resolve_source_edits():
self.ui.graphView.open_selected_component()
def _set_graph_controls_visible(self, visible: bool) -> None:
for widget in (
self.ui.pointerToolButton,
self.ui.connectToolButton,
self.ui.routingLabel,
self.ui.directRoutingButton,
self.ui.angledRoutingButton,
self.ui.splineRoutingButton,
):
widget.setVisible(visible)
def set_graph_tool(self, mode: str) -> None:
self.ui.graphView.set_tool_mode("pointer")
self.ui.pointerToolButton.setChecked(True)
self.ui.graphView.set_tool_mode(mode)
(self.ui.pointerToolButton if mode == "pointer" else self.ui.connectToolButton).setChecked(True)
def set_connection_routing(self, routing: str) -> None:
self.ui.graphView.set_connection_routing(routing)
buttons = {
"direct": self.ui.directRoutingButton,
"angled": self.ui.angledRoutingButton,
"spline": self.ui.splineRoutingButton,
}
buttons[routing].setChecked(True)
def _load_source_json(self) -> None:
component = self.document_controller.active_component