Moving items in the graph

This commit is contained in:
Joppe Blondel
2026-08-17 11:49:13 +02:00
parent 8e7d2d3efd
commit fcee2ac8b6
11 changed files with 270 additions and 68 deletions
+144 -47
View File
@@ -2,28 +2,29 @@ from __future__ import annotations
from math import hypot
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt, Signal
from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QWheelEvent
from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsView, QWidget
from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsView, QWidget
from bedit_core.models import BondCausality, BondConnection, Component, ComponentID, GraphImplementation, SignalConnection
from bedit_core.models import BondCausality, BondConnection, Component, ComponentID, ConnectionID, GraphImplementation, SignalConnection
from bedit_gui.models import Graph, Icon
from bedit_gui.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget
from bedit_gui.utils.icon import get_pixmap_bounding_box, render_icon
GRID_SPACING = 32
GRID_SPACING = 64
SCENE_SIZE = 10000
MIN_ZOOM = 0.2
MAX_ZOOM = 4.0
ZOOM_STEP = 1.15
ZOOM_TO_FIT_PADDING = 32.0
COMPONENT_ICON_SIZE = QSize(96, 96)
COMPONENT_ICON_SIZE = QSize(128, 128)
FALLBACK_COMPONENT_SPACING = 128
CONNECTION_WIDTH = 2.0
CONNECTION_BOUNDING_BOX_SPACING = 8.0
ARROW_LENGTH = 14.0
ARROW_HALF_WIDTH = 7.0
CAUSALITY_TICK_HALF_LENGTH = 8.0
CONNECTION_WIDTH = 4.0
CONNECTION_BOUNDING_BOX_SPACING = 16.0
CONNECTION_STRAIGHTEN_TOLERANCE = 8.0
ARROW_LENGTH = 32.0
ARROW_HALF_WIDTH = 16.0
CAUSALITY_TICK_HALF_LENGTH = 16.0
class GraphGraphicsScene(QGraphicsScene):
@@ -34,15 +35,16 @@ class GraphGraphicsScene(QGraphicsScene):
pen = QPen(QColor(205, 205, 205), 0, Qt.PenStyle.DotLine)
painter.setPen(pen)
scene_rect = self.sceneRect()
left = int(rect.left()) - int(rect.left()) % GRID_SPACING
top = int(rect.top()) - int(rect.top()) % GRID_SPACING
x = left
while x <= rect.right():
painter.drawLine(x, rect.top(), x, rect.bottom())
painter.drawLine(x, scene_rect.top(), x, scene_rect.bottom())
x += GRID_SPACING
y = top
while y <= rect.bottom():
painter.drawLine(rect.left(), y, rect.right(), y)
painter.drawLine(scene_rect.left(), y, scene_rect.right(), y)
y += GRID_SPACING
@@ -102,13 +104,54 @@ class GraphConnectionItem(QGraphicsPathItem):
path.lineTo(endpoint.x() + perpendicular_x, endpoint.y() + perpendicular_y)
class GraphComponentItem(QGraphicsPixmapItem):
def __init__(self, component_id: ComponentID, pixmap, editor: GraphEditorWidget) -> None:
super().__init__(pixmap)
self.component_id = component_id
self.editor = editor
self._drag_start = QPointF()
self._dragging = False
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self._dragging and isinstance(value, QPointF):
grid_size = self.editor.snap_to_grid_size
value = QPointF(round(value.x() / grid_size) * grid_size, round(value.y() / grid_size) * grid_size)
result = super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.editor.refresh_connections()
return result
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
self._drag_start = QPointF(self.pos())
self._dragging = True
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
super().mouseReleaseEvent(event)
self._dragging = False
position = (round(self.pos().x()), round(self.pos().y()))
self.setPos(*position)
if self.pos() != self._drag_start:
self.editor.finish_component_move(self.component_id, position)
class GraphEditorWidget(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
component_move_requested = Signal(object, object, object)
def __init__(self, parent: QWidget | None = None, snap_to_grid_size: int = 4) -> None:
super().__init__(parent)
self.ui = Ui_graphEditorWidget()
self.ui.setupUi(self)
self._component: Component | None = None
self._graph = Graph()
self._component_items: dict[ComponentID, GraphComponentItem] = {}
self._component_bounds: dict[ComponentID, QRectF] = {}
self._connection_items: dict[ConnectionID, GraphConnectionItem] = {}
self.set_snap_to_grid_size(snap_to_grid_size)
self.scene = GraphGraphicsScene(self)
self.scene.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE)
self.ui.graphicsView.setScene(self.scene)
@@ -117,65 +160,120 @@ class GraphEditorWidget(QWidget):
self.ui.actionZoomToFit.triggered.connect(self.zoom_to_fit)
self.ui.graphicsView.centerOn(0, 0)
@property
def snap_to_grid_size(self) -> int:
return self._snap_to_grid_size
def set_snap_to_grid_size(self, size: int) -> None:
if size < 1:
raise ValueError("snap-to-grid size must be positive")
self._snap_to_grid_size = size
def set_component(self, component: Component | None, graph: Graph | None = None, icons: dict[ComponentID, Icon] | None = None) -> None:
if component is not None and not isinstance(component.implementation, GraphImplementation):
raise TypeError("GraphEditorWidget only supports components with a graph implementation")
component_changed = component is not self._component
self._component = component
self._graph = graph or Graph()
self._component_items = {}
self._component_bounds = {}
self._connection_items = {}
self.scene.clear()
if component is None:
return
graph = graph or Graph()
graph = self._graph
icons = icons or {}
positions = {component_id: graph.component_positions.get(component_id, (index * FALLBACK_COMPONENT_SPACING, 0)) for index, component_id in enumerate(component.implementation.graph.components)}
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
component_items: dict[ComponentID, QGraphicsPixmapItem] = {}
component_bounds: dict[ComponentID, QRectF] = {}
for component_id, child in component.implementation.graph.components.items():
icon = icons.get(component_id, Icon())
pixmap = render_icon(icon, child.interface.ports, COMPONENT_ICON_SIZE).pixmap(COMPONENT_ICON_SIZE)
item = QGraphicsPixmapItem(pixmap)
item = GraphComponentItem(component_id, pixmap, self)
item.setOffset(-pixmap.width() / 2, -pixmap.height() / 2)
item.setPos(*positions[component_id])
item.setToolTip(child.name)
item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
component_items[component_id] = item
bounds = get_pixmap_bounding_box(pixmap)
component_bounds[component_id] = bounds.translated(item.pos().x() - pixmap.width() / 2, item.pos().y() - pixmap.height() / 2)
self._component_items[component_id] = item
self._component_bounds[component_id] = bounds.translated(-pixmap.width() / 2, -pixmap.height() / 2)
self.scene.addItem(item)
for connection_id, connection in component.implementation.graph.connections.items():
if not isinstance(connection, (SignalConnection, BondConnection)):
continue
source_component = port_owners.get(connection.source)
target_component = port_owners.get(connection.target)
if source_component is None or target_component is None:
continue
visual_connection = graph.connections.get(connection_id)
points = list(visual_connection.points) if visual_connection is not None else []
if len(points) < 2:
points = [positions[source_component], positions[target_component]]
else:
points = [positions[source_component], *points[1:-1], positions[target_component]]
points = self._clip_connection(points, component_bounds[source_component], component_bounds[target_component])
tick_at_source = None
if isinstance(connection, BondConnection):
if connection.causality is BondCausality.EFFORT_OUT:
tick_at_source = False
elif connection.causality is BondCausality.FLOW_OUT:
tick_at_source = True
connection_item = GraphConnectionItem(points, half_arrow=isinstance(connection, BondConnection), tick_at_source=tick_at_source)
connection_item = GraphConnectionItem([(0, 0), (1, 0)], half_arrow=isinstance(connection, BondConnection), tick_at_source=tick_at_source)
connection_item.setData(0, str(connection_id))
self._connection_items[connection_id] = connection_item
self.scene.addItem(connection_item)
for item in component_items.values():
self.scene.addItem(item)
self.refresh_connections()
if component_changed:
QTimer.singleShot(0, self.ui.actionZoomToFit.trigger)
def refresh_connections(self) -> None:
component = self._component
if component is None or not self._connection_items:
return
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
for connection_id, item in self._connection_items.items():
connection = component.implementation.graph.connections[connection_id]
source_component = port_owners.get(connection.source)
target_component = port_owners.get(connection.target)
if source_component is None or target_component is None:
continue
source_position = self._item_position(source_component)
target_position = self._item_position(target_component)
visual_connection = self._graph.connections.get(connection_id)
points = list(visual_connection.points) if visual_connection is not None else []
points = [source_position, target_position] if len(points) < 2 else [source_position, *points[1:-1], target_position]
points = self._straighten_direct_connection(points)
source_bounds = self._component_bounds[source_component].translated(*source_position)
target_bounds = self._component_bounds[target_component].translated(*target_position)
points = self._clip_connection(points, source_bounds, target_bounds)
tick_at_source = None
if isinstance(connection, BondConnection):
tick_at_source = False if connection.causality is BondCausality.EFFORT_OUT else True if connection.causality is BondCausality.FLOW_OUT else None
item.setPath(item._connection_path(points, isinstance(connection, BondConnection), tick_at_source))
def finish_component_move(self, component_id: ComponentID, position: tuple[int, int]) -> None:
if self._component is not None:
self.component_move_requested.emit(self._component, component_id, position)
def set_component_position(self, component_id: ComponentID, position: tuple[int, int] | None) -> None:
item = self._component_items.get(component_id)
if item is None:
return
if position is None:
index = list(self._component_items).index(component_id)
position = (index * FALLBACK_COMPONENT_SPACING, 0)
item.setPos(*position)
self.refresh_connections()
def _item_position(self, component_id: ComponentID) -> tuple[float, float]:
position = self._component_items[component_id].pos()
return position.x(), position.y()
@staticmethod
def _clip_connection(points: list[tuple[int, int]], source_bounds: QRectF, target_bounds: QRectF) -> list[tuple[float, float]]:
def _straighten_direct_connection(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
if len(points) != 2:
return points
source, target = points
dx = target[0] - source[0]
dy = target[1] - source[1]
if abs(dx) <= CONNECTION_STRAIGHTEN_TOLERANCE and abs(dx) < abs(dy):
x = (source[0] + target[0]) / 2
return [(x, source[1]), (x, target[1])]
if abs(dy) <= CONNECTION_STRAIGHTEN_TOLERANCE and abs(dy) < abs(dx):
y = (source[1] + target[1]) / 2
return [(source[0], y), (target[0], y)]
return points
@staticmethod
def _clip_connection(points: list[tuple[float, float]], source_bounds: QRectF, target_bounds: QRectF) -> list[tuple[float, float]]:
source = points[0]
target = points[-1]
source_direction = next((point for point in points[1:] if point != source), None)
@@ -183,20 +281,19 @@ class GraphEditorWidget(QWidget):
if source_direction is None or target_direction is None:
return points
clipped = list(points)
clipped[0] = GraphEditorWidget._bounding_box_edge(source_bounds, source_direction)
clipped[-1] = GraphEditorWidget._bounding_box_edge(target_bounds, target_direction)
clipped[0] = GraphEditorWidget._bounding_box_edge(source_bounds, source, source_direction)
clipped[-1] = GraphEditorWidget._bounding_box_edge(target_bounds, target, target_direction)
return clipped
@staticmethod
def _bounding_box_edge(bounds: QRectF, toward: tuple[int, int]) -> tuple[float, float]:
def _bounding_box_edge(bounds: QRectF, origin: tuple[float, float], toward: tuple[float, float]) -> tuple[float, float]:
bounds = bounds.adjusted(-CONNECTION_BOUNDING_BOX_SPACING, -CONNECTION_BOUNDING_BOX_SPACING, CONNECTION_BOUNDING_BOX_SPACING, CONNECTION_BOUNDING_BOX_SPACING)
center = bounds.center()
dx = toward[0] - center.x()
dy = toward[1] - center.y()
horizontal_scale = bounds.width() / 2 / abs(dx) if dx else float("inf")
vertical_scale = bounds.height() / 2 / abs(dy) if dy else float("inf")
dx = toward[0] - origin[0]
dy = toward[1] - origin[1]
horizontal_scale = (bounds.right() - origin[0]) / dx if dx > 0 else (bounds.left() - origin[0]) / dx if dx < 0 else float("inf")
vertical_scale = (bounds.bottom() - origin[1]) / dy if dy > 0 else (bounds.top() - origin[1]) / dy if dy < 0 else float("inf")
scale = min(horizontal_scale, vertical_scale)
return center.x() + dx * scale, center.y() + dy * scale
return origin[0] + dx * scale, origin[1] + dy * scale
def component(self) -> Component | None:
return self._component