Fixed some stuff
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from itertools import pairwise
|
||||
from math import hypot
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor, QCursor, QPainter, QPainterPath, QPen, QWheelEvent
|
||||
from PySide6.QtGui import QActionGroup, QBrush, QColor, QCursor, QKeySequence, QMouseEvent, QPainter, QPainterPath, QPen, QShortcut, QWheelEvent
|
||||
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsView, QMenu, QWidget
|
||||
|
||||
from bedit_core.models import BondCausality, BondConnection, Component, ComponentID, ConnectionID, GraphImplementation, SignalConnection
|
||||
from bedit_core.models import BondCausality, BondConnection, BondPort, Component, ComponentID, Connection, ConnectionID, GraphImplementation, Port, PortID, SignalConnection, SignalDirection, SignalPort
|
||||
from bedit_gui.models import Graph, GraphConnection, 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
|
||||
@@ -28,6 +29,11 @@ ARROW_HALF_WIDTH = 16.0
|
||||
CAUSALITY_TICK_HALF_LENGTH = 16.0
|
||||
|
||||
|
||||
class GraphEditorMode(Enum):
|
||||
NORMAL = "normal"
|
||||
CONNECTION = "connection"
|
||||
|
||||
|
||||
class GraphGraphicsScene(QGraphicsScene):
|
||||
"""Graph canvas with a lightweight dotted-line grid."""
|
||||
|
||||
@@ -59,15 +65,23 @@ class GraphConnectionItem(QGraphicsPathItem):
|
||||
self.setPath(self._connection_path(points, half_arrow, tick_at_source))
|
||||
pen = QPen(QColor("#202020"), CONNECTION_WIDTH)
|
||||
self.setPen(pen)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||
self.setZValue(-1)
|
||||
|
||||
def contextMenuEvent(self, event) -> None:
|
||||
if self.editor is None or self.connection_id is None:
|
||||
return
|
||||
if not self.isSelected():
|
||||
self.scene().clearSelection()
|
||||
self.setSelected(True)
|
||||
menu = QMenu(self.editor)
|
||||
add_point = menu.addAction("Add Point")
|
||||
if menu.exec(event.screenPos()) is add_point:
|
||||
delete_connection = menu.addAction("Delete Connection")
|
||||
selected = menu.exec(event.screenPos())
|
||||
if selected is add_point:
|
||||
self.editor.add_connection_point(self.connection_id, event.scenePos())
|
||||
elif selected is delete_connection:
|
||||
self.editor.delete_selected_connections()
|
||||
event.accept()
|
||||
|
||||
@staticmethod
|
||||
@@ -123,7 +137,7 @@ class GraphComponentItem(QGraphicsPixmapItem):
|
||||
self._drag_start = QPointF()
|
||||
self._dragging = False
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, editor.mode is GraphEditorMode.NORMAL)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
||||
|
||||
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
|
||||
@@ -136,11 +150,18 @@ class GraphComponentItem(QGraphicsPixmapItem):
|
||||
return result
|
||||
|
||||
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.editor.mode is GraphEditorMode.CONNECTION:
|
||||
self.editor.choose_connection_component(self.component_id, event.screenPos())
|
||||
event.accept()
|
||||
return
|
||||
self._drag_start = QPointF(self.pos())
|
||||
self._dragging = True
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
if self.editor.mode is GraphEditorMode.CONNECTION:
|
||||
event.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(event)
|
||||
self._dragging = False
|
||||
position = (round(self.pos().x()), round(self.pos().y()))
|
||||
@@ -148,6 +169,13 @@ class GraphComponentItem(QGraphicsPixmapItem):
|
||||
if self.pos() != self._drag_start:
|
||||
self.editor.finish_component_move(self.component_id, position)
|
||||
|
||||
def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.editor.open_component(self.component_id)
|
||||
event.accept()
|
||||
return
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
def contextMenuEvent(self, event) -> None:
|
||||
if not self.isSelected():
|
||||
self.scene().clearSelection()
|
||||
@@ -205,7 +233,10 @@ class GraphConnectionPointItem(QGraphicsEllipseItem):
|
||||
class GraphEditorWidget(QWidget):
|
||||
component_move_requested = Signal(object, object, object)
|
||||
component_context_menu_requested = Signal(object, object)
|
||||
component_open_requested = Signal(object)
|
||||
connection_points_change_requested = Signal(object, object, object, str)
|
||||
connection_add_requested = Signal(object, object)
|
||||
connections_delete_requested = Signal(object, object)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, snap_to_grid_size: int = 4) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -214,6 +245,10 @@ class GraphEditorWidget(QWidget):
|
||||
self.ui.setupUi(self)
|
||||
self._component: Component | None = None
|
||||
self._graph = Graph()
|
||||
self._icons: dict[ComponentID, Icon] = {}
|
||||
self._mode = GraphEditorMode.NORMAL
|
||||
self._connection_start: ComponentID | None = None
|
||||
self._connection_preview: QGraphicsPathItem | None = None
|
||||
self._component_items: dict[ComponentID, GraphComponentItem] = {}
|
||||
self._component_bounds: dict[ComponentID, QRectF] = {}
|
||||
self._connection_items: dict[ConnectionID, GraphConnectionItem] = {}
|
||||
@@ -224,7 +259,18 @@ class GraphEditorWidget(QWidget):
|
||||
self.ui.graphicsView.setScene(self.scene)
|
||||
self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
||||
self.ui.graphicsView.viewport().installEventFilter(self)
|
||||
self._mode_actions = QActionGroup(self)
|
||||
self._mode_actions.setExclusive(True)
|
||||
self._mode_actions.addAction(self.ui.actionMouseMode)
|
||||
self._mode_actions.addAction(self.ui.actionConnectionMode)
|
||||
self.ui.actionMouseMode.triggered.connect(lambda: self.set_mode(GraphEditorMode.NORMAL))
|
||||
self.ui.actionConnectionMode.triggered.connect(lambda: self.set_mode(GraphEditorMode.CONNECTION))
|
||||
self.ui.actionMouseMode.setChecked(True)
|
||||
self._mode_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Space), self)
|
||||
self._mode_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||
self._mode_shortcut.activated.connect(self.toggle_mode)
|
||||
self.ui.actionZoomToFit.triggered.connect(self.zoom_to_fit)
|
||||
self.ui.graphicsView.viewport().setMouseTracking(True)
|
||||
self.ui.graphicsView.centerOn(0, 0)
|
||||
|
||||
@property
|
||||
@@ -236,12 +282,32 @@ class GraphEditorWidget(QWidget):
|
||||
raise ValueError("snap-to-grid size must be positive")
|
||||
self._snap_to_grid_size = size
|
||||
|
||||
@property
|
||||
def mode(self) -> GraphEditorMode:
|
||||
return self._mode
|
||||
|
||||
def set_mode(self, mode: GraphEditorMode) -> None:
|
||||
if mode is self._mode:
|
||||
return
|
||||
self._mode = mode
|
||||
self.ui.actionMouseMode.setChecked(mode is GraphEditorMode.NORMAL)
|
||||
self.ui.actionConnectionMode.setChecked(mode is GraphEditorMode.CONNECTION)
|
||||
self._clear_connection_start()
|
||||
component = self._component
|
||||
if component is not None:
|
||||
self.set_component(component, self._graph, self._icons)
|
||||
|
||||
def toggle_mode(self) -> None:
|
||||
self.set_mode(GraphEditorMode.CONNECTION if self._mode is GraphEditorMode.NORMAL else GraphEditorMode.NORMAL)
|
||||
|
||||
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._clear_connection_start()
|
||||
self._component = component
|
||||
self._graph = graph or Graph()
|
||||
self._icons = icons or {}
|
||||
self._component_items = {}
|
||||
self._component_bounds = {}
|
||||
self._connection_items = {}
|
||||
@@ -251,11 +317,11 @@ class GraphEditorWidget(QWidget):
|
||||
return
|
||||
|
||||
graph = self._graph
|
||||
icons = icons or {}
|
||||
icons = self._icons
|
||||
positions = {component_id: graph.component_positions.get(component_id, (index * FALLBACK_COMPONENT_SPACING, 0)) for index, component_id in enumerate(component.implementation.graph.components)}
|
||||
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)
|
||||
pixmap = render_icon(icon, child.interface.ports, COMPONENT_ICON_SIZE, render_ports=self._mode is GraphEditorMode.CONNECTION).pixmap(COMPONENT_ICON_SIZE)
|
||||
item = GraphComponentItem(component_id, pixmap, self)
|
||||
item.setOffset(-pixmap.width() / 2, -pixmap.height() / 2)
|
||||
item.setPos(*positions[component_id])
|
||||
@@ -290,7 +356,9 @@ class GraphEditorWidget(QWidget):
|
||||
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]
|
||||
connection = component.implementation.graph.connections.get(connection_id)
|
||||
if connection is None:
|
||||
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:
|
||||
@@ -327,10 +395,14 @@ class GraphEditorWidget(QWidget):
|
||||
def set_connection_points(self, connection_id: ConnectionID, points: list[tuple[int, int]] | None) -> None:
|
||||
if points is None:
|
||||
self._graph.connections.pop(connection_id, None)
|
||||
interior_points = []
|
||||
else:
|
||||
self._graph.connections[connection_id] = GraphConnection(points=list(points))
|
||||
interior_points = points[1:-1]
|
||||
for item in self._connection_point_items.pop(connection_id, []):
|
||||
self.scene.removeItem(item)
|
||||
connection_item = self._connection_items.pop(connection_id, None)
|
||||
if connection_item is not None:
|
||||
self.scene.removeItem(connection_item)
|
||||
return
|
||||
self._graph.connections[connection_id] = GraphConnection(points=list(points))
|
||||
interior_points = points[1:-1]
|
||||
items = self._connection_point_items.get(connection_id, [])
|
||||
if len(items) == len(interior_points):
|
||||
for item, position in zip(items, interior_points):
|
||||
@@ -392,8 +464,11 @@ class GraphEditorWidget(QWidget):
|
||||
if item is None:
|
||||
return
|
||||
if position is None:
|
||||
self._graph.component_positions.pop(component_id, None)
|
||||
index = list(self._component_items).index(component_id)
|
||||
position = (index * FALLBACK_COMPONENT_SPACING, 0)
|
||||
else:
|
||||
self._graph.component_positions[component_id] = position
|
||||
item.setPos(*position)
|
||||
self.refresh_connections()
|
||||
|
||||
@@ -445,6 +520,112 @@ class GraphEditorWidget(QWidget):
|
||||
def selected_component_ids(self) -> list[ComponentID]:
|
||||
return [item.component_id for item in self.scene.selectedItems() if isinstance(item, GraphComponentItem)]
|
||||
|
||||
def selected_connection_ids(self) -> list[ConnectionID]:
|
||||
return [item.connection_id for item in self.scene.selectedItems() if isinstance(item, GraphConnectionItem) and item.connection_id is not None]
|
||||
|
||||
def delete_selected_connections(self) -> None:
|
||||
if self._component is not None:
|
||||
self.connections_delete_requested.emit(self._component, self.selected_connection_ids())
|
||||
|
||||
def open_component(self, component_id: ComponentID) -> None:
|
||||
self._clear_connection_start()
|
||||
self.component_open_requested.emit(component_id)
|
||||
|
||||
def choose_connection_component(self, component_id: ComponentID, screen_position) -> None:
|
||||
if self._component is None or self._mode is not GraphEditorMode.CONNECTION:
|
||||
return
|
||||
if self._connection_start is None:
|
||||
self.scene.clearSelection()
|
||||
self._component_items[component_id].setSelected(True)
|
||||
self._connection_start = component_id
|
||||
self._connection_preview = QGraphicsPathItem()
|
||||
self._connection_preview.setPen(QPen(QColor("#606060"), CONNECTION_WIDTH, Qt.PenStyle.DashLine))
|
||||
self._connection_preview.setZValue(-0.5)
|
||||
self.scene.addItem(self._connection_preview)
|
||||
return
|
||||
start = self._connection_start
|
||||
self._clear_connection_start()
|
||||
self.scene.clearSelection()
|
||||
if start == component_id:
|
||||
return
|
||||
options = self._connection_options(start, component_id)
|
||||
if len(options) == 1:
|
||||
self.connection_add_requested.emit(self._component, options[0][1])
|
||||
return
|
||||
if not options:
|
||||
return
|
||||
menu = QMenu(self)
|
||||
actions = []
|
||||
for label, connection, _preferred in options:
|
||||
action = menu.addAction(label)
|
||||
actions.append((action, connection))
|
||||
menu.setActiveAction(actions[0][0])
|
||||
selected = menu.exec(screen_position)
|
||||
for action, connection in actions:
|
||||
if selected is action:
|
||||
self.connection_add_requested.emit(self._component, connection)
|
||||
break
|
||||
|
||||
def _update_connection_preview(self, mouse_position: QPointF) -> None:
|
||||
if self._connection_start is None or self._connection_preview is None:
|
||||
return
|
||||
source = self._item_position(self._connection_start)
|
||||
if mouse_position == QPointF(*source):
|
||||
self._connection_preview.setPath(QPainterPath(mouse_position))
|
||||
return
|
||||
bounds = self._component_bounds[self._connection_start].translated(*source)
|
||||
start = self._bounding_box_edge(bounds, source, (mouse_position.x(), mouse_position.y()))
|
||||
path = QPainterPath(QPointF(*start))
|
||||
path.lineTo(mouse_position)
|
||||
self._connection_preview.setPath(path)
|
||||
|
||||
def _clear_connection_start(self) -> None:
|
||||
self._connection_start = None
|
||||
if self._connection_preview is not None and self._connection_preview.scene() is self.scene:
|
||||
self.scene.removeItem(self._connection_preview)
|
||||
self._connection_preview = None
|
||||
|
||||
def _connection_options(self, first_id: ComponentID, second_id: ComponentID) -> list[tuple[str, Connection, bool]]:
|
||||
if self._component is None:
|
||||
return []
|
||||
components = self._component.implementation.graph.components
|
||||
first = components[first_id]
|
||||
second = components[second_id]
|
||||
used_ports = {port_id for connection in self._component.implementation.graph.connections.values() for port_id in (connection.source, connection.target)}
|
||||
options = []
|
||||
for first_port_id, first_port in first.interface.ports.items():
|
||||
for second_port_id, second_port in second.interface.ports.items():
|
||||
if not self._compatible_ports(first_port, second_port) or not self._port_available(first_port_id, first_port, used_ports) or not self._port_available(second_port_id, second_port, used_ports):
|
||||
continue
|
||||
preferred = first_port.direction is SignalDirection.OUTPUT and second_port.direction is SignalDirection.INPUT
|
||||
reverse_preferred = second_port.direction is SignalDirection.OUTPUT and first_port.direction is SignalDirection.INPUT
|
||||
if reverse_preferred:
|
||||
source_id, source_port, source_name = second_port_id, second_port, second.name
|
||||
target_id, target_port, target_name = first_port_id, first_port, first.name
|
||||
else:
|
||||
source_id, source_port, source_name = first_port_id, first_port, first.name
|
||||
target_id, target_port, target_name = second_port_id, second_port, second.name
|
||||
connection_type = SignalConnection if isinstance(source_port, SignalPort) else BondConnection
|
||||
label = f"{source_name}.{source_port.name} → {target_name}.{target_port.name}"
|
||||
options.append((label, connection_type(source=source_id, target=target_id), preferred or reverse_preferred))
|
||||
return sorted(options, key=lambda option: not option[2])
|
||||
|
||||
@staticmethod
|
||||
def _compatible_ports(first: Port, second: Port) -> bool:
|
||||
if isinstance(first, SignalPort) and isinstance(second, SignalPort):
|
||||
return first.direction is not second.direction
|
||||
if isinstance(first, BondPort) and isinstance(second, BondPort):
|
||||
return not first.domain or not second.domain or first.domain == second.domain
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _port_available(port_id: PortID, port: Port, used_ports: set[PortID]) -> bool:
|
||||
if isinstance(port, SignalPort):
|
||||
return port.direction is SignalDirection.OUTPUT or port_id not in used_ports
|
||||
if isinstance(port, BondPort):
|
||||
return port.multiplicity or port_id not in used_ports
|
||||
return False
|
||||
|
||||
def paste_position(self) -> tuple[int, int]:
|
||||
viewport = self.ui.graphicsView.viewport()
|
||||
viewport_position = viewport.mapFromGlobal(QCursor.pos())
|
||||
@@ -469,6 +650,9 @@ class GraphEditorWidget(QWidget):
|
||||
self.ui.graphicsView.centerOn(bounds.center())
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.MouseMove:
|
||||
assert isinstance(event, QMouseEvent)
|
||||
self._update_connection_preview(self.ui.graphicsView.mapToScene(event.position().toPoint()))
|
||||
if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.Wheel:
|
||||
assert isinstance(event, QWheelEvent)
|
||||
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||
|
||||
Reference in New Issue
Block a user