Moving items in the graph
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user