Basic document handling

This commit is contained in:
2026-07-19 21:26:27 +02:00
parent c5ab3329ae
commit 76bd313f79
32 changed files with 4843 additions and 67 deletions

View File

@@ -0,0 +1,4 @@
from bedit.workspace.view import GraphWorkspaceView
__all__ = ["GraphWorkspaceView"]

View File

@@ -0,0 +1,547 @@
import json
from PySide6.QtCore import QMimeData, QPointF, QRectF, Qt, Signal
from PySide6.QtGui import (
QColor,
QDragEnterEvent,
QDropEvent,
QMouseEvent,
QPainter,
QPainterPath,
QPen,
QTransform,
)
from PySide6.QtWidgets import (
QGraphicsEllipseItem,
QGraphicsItem,
QGraphicsObject,
QGraphicsPathItem,
QGraphicsScene,
QGraphicsSceneContextMenuEvent,
QGraphicsSceneMouseEvent,
QGraphicsView,
QApplication,
QMenu,
QStyleOptionGraphicsItem,
QWidget,
)
from bedit.document.controller import DocumentController
from bedit.document.model import Component, Connection, Endpoint, Port
from bedit.library.tree_model import COMPONENT_MIME_TYPE
SELECTION_MIME_TYPE = "application/x-bedit-selection"
class ConnectionPortItem(QGraphicsEllipseItem):
def __init__(self, endpoint: Endpoint, role: str, label: str, parent: QGraphicsItem) -> None:
super().__init__(-6, -6, 12, 12, parent)
self.endpoint = endpoint
self.role = role
self.setBrush(QColor("#ffffff"))
self.setPen(QPen(QColor("#303030"), 1.5))
self.setZValue(2)
self.setToolTip(label)
class ComponentGraphicsItem(QGraphicsObject):
WIDTH = 120.0
HEIGHT = 72.0
def __init__(self, component: Component, controller: DocumentController) -> None:
super().__init__()
self.component_id = component.id
self.component = component
self.controller = controller
self.drag_start = QPointF()
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.input_ports = self._create_ports(component.inputs, "target", 0.0)
self.output_ports = self._create_ports(component.outputs, "source", self.WIDTH)
self.setTransformOriginPoint(self.WIDTH / 2, self.HEIGHT / 2)
self.setRotation(component.rotation)
def _create_ports(self, ports, role: str, x: float) -> dict[str, ConnectionPortItem]:
result = {}
spacing = self.HEIGHT / (len(ports) + 1)
for index, port in enumerate(ports, start=1):
endpoint = Endpoint(block=self.component_id, port=port.id)
item = ConnectionPortItem(endpoint, role, port.name, self)
item.setPos(x, spacing * index)
result[port.id] = item
return result
def boundingRect(self) -> QRectF: # noqa: N802
return QRectF(0, 0, self.WIDTH, self.HEIGHT)
def paint(
self,
painter: QPainter,
option: QStyleOptionGraphicsItem,
widget: QWidget | None = None,
) -> None:
del option, widget
icon = self.component.icon
fill = QColor("#dbeafe") if self.isSelected() else QColor(icon.fill)
painter.setBrush(fill)
painter.setPen(QPen(QColor(icon.border), 1.5))
if icon.shape == "ellipse":
painter.drawEllipse(self.boundingRect())
else:
painter.drawRoundedRect(self.boundingRect(), 5, 5)
painter.setPen(QColor("#202020"))
painter.drawText(
self.boundingRect(),
Qt.AlignmentFlag.AlignCenter,
icon.text or self.component.name,
)
def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.controller.activate_component(self.component_id)
event.accept()
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
if not self.isSelected():
scene = self.scene()
if scene is not None:
scene.clearSelection()
self.setSelected(True)
menu = QMenu()
options_action = menu.addAction("Component Options…")
if menu.exec(event.screenPos()) is options_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.componentOptionsRequested.emit(self.component_id)
event.accept()
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_component(self.component_id, self.drag_start, self.pos())
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.update_connections_for_block(self.component_id)
return super().itemChange(change, value)
class InterfaceTerminalItem(QGraphicsObject):
WIDTH = 110.0
HEIGHT = 36.0
def __init__(self, port: Port, direction: str, controller: DocumentController) -> None:
super().__init__()
self.port = port
self.direction = direction
self.controller = controller
self.drag_start = QPointF()
role = "source" if direction == "input" else "target"
self.connection_port = ConnectionPortItem(
Endpoint(interface=port.id), role, port.name, self
)
connection_x = self.WIDTH if direction == "input" else 0.0
self.connection_port.setPos(connection_x, self.HEIGHT / 2)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.setToolTip(f"Component {direction}: {port.name}")
def boundingRect(self) -> QRectF: # noqa: N802
return QRectF(0, 0, self.WIDTH, self.HEIGHT)
def paint(
self,
painter: QPainter,
option: QStyleOptionGraphicsItem,
widget: QWidget | None = None,
) -> None:
del option, widget
painter.setBrush(QColor("#e5e7eb"))
painter.setPen(QPen(QColor("#4b5563"), 1.5))
painter.drawRoundedRect(self.boundingRect(), 4, 4)
painter.setPen(QColor("#202020"))
marker = "IN" if self.direction == "input" else "OUT"
painter.drawText(
self.boundingRect().adjusted(8, 0, -8, 0),
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
f"{marker} {self.port.name}",
)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.drag_start = self.pos()
super().mousePressEvent(event)
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
menu = QMenu()
options_action = menu.addAction(f"{self.direction.title()} Options…")
if menu.exec(event.screenPos()) is options_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.portOptionsRequested.emit(self.port.id, self.direction)
event.accept()
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
self.controller.move_interface_port(self.port.id, self.drag_start, self.pos())
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.update_connections_for_interface(self.port.id)
return super().itemChange(change, value)
class ConnectionGraphicsItem(QGraphicsPathItem):
def __init__(self, connection_id: str, name: str = "") -> None:
super().__init__()
self.connection_id = connection_id
self.name = name
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self._update_pen()
self.setZValue(-1)
self.setToolTip(name or "Connection")
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
menu = QMenu()
options_action = menu.addAction("Connection Options…")
if menu.exec(event.screenPos()) is options_action:
scene = self.scene()
if isinstance(scene, GraphScene):
scene.connectionOptionsRequested.emit(self.connection_id)
event.accept()
def itemChange(self, change, value): # noqa: N802
result = super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
self._update_pen()
return result
def _update_pen(self) -> None:
self.setPen(
QPen(
QColor("#f59e0b") if self.isSelected() else QColor("#285f9e"),
4.0 if self.isSelected() else 2.5,
)
)
class GraphScene(QGraphicsScene):
componentOptionsRequested = Signal(str)
portOptionsRequested = Signal(str, str)
connectionOptionsRequested = Signal(str)
def __init__(self, controller: DocumentController, parent=None) -> None:
super().__init__(parent)
self.controller = controller
self.component_items: dict[str, ComponentGraphicsItem] = {}
self.input_items: dict[str, InterfaceTerminalItem] = {}
self.output_items: dict[str, InterfaceTerminalItem] = {}
self.connection_items: dict[str, ConnectionGraphicsItem] = {}
self.pending_source: ConnectionPortItem | None = None
self.setSceneRect(-2000, -2000, 4000, 4000)
controller.documentReset.connect(self.rebuild)
controller.activeGraphChanged.connect(self.rebuild)
controller.componentMoved.connect(self.set_component_position)
controller.componentRotated.connect(self.set_component_rotation)
self.rebuild()
def rebuild(self) -> None:
self.clear()
self.component_items.clear()
self.input_items.clear()
self.output_items.clear()
self.connection_items.clear()
self.pending_source = None
owner = self.controller.active_component
if owner is None or owner.implementation_kind != "graph":
return
for port in owner.inputs:
item = InterfaceTerminalItem(port, "input", self.controller)
self.addItem(item)
item.setPos(port.x, port.y)
self.input_items[port.id] = item
for port in owner.outputs:
item = InterfaceTerminalItem(port, "output", self.controller)
self.addItem(item)
item.setPos(port.x, port.y)
self.output_items[port.id] = item
for component in owner.graph.blocks.values():
item = ComponentGraphicsItem(component, self.controller)
self.addItem(item)
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)
self.addItem(item)
self.connection_items[connection.id] = item
self.update_connection(connection.id)
def set_component_position(self, component_id: str, position: QPointF) -> None:
item = self.component_items.get(component_id)
if item is not None and item.pos() != position:
item.setPos(position)
def set_component_rotation(self, component_id: str, rotation: float) -> None:
item = self.component_items.get(component_id)
if item is not None:
item.setRotation(rotation)
self.update_connections_for_block(component_id)
def update_connections_for_block(self, component_id: str) -> None:
for connection in self.controller.active_graph.connections.values():
if component_id in (connection.source.block, connection.target.block):
self.update_connection(connection.id)
def update_connections_for_interface(self, port_id: str) -> None:
for connection in self.controller.active_graph.connections.values():
if port_id in (connection.source.interface, connection.target.interface):
self.update_connection(connection.id)
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
painter.fillRect(rect, QColor("#e4e4e4"))
if self.controller.document is None or self.controller.active_component is None:
return
spacing = 32
left = int(rect.left()) - (int(rect.left()) % spacing)
top = int(rect.top()) - (int(rect.top()) % spacing)
painter.setPen(QPen(QColor("#b8b8b8"), 1))
for x in range(left, int(rect.right()) + spacing, spacing):
for y in range(top, int(rect.bottom()) + spacing, spacing):
painter.drawPoint(x, y)
def _endpoint_item(self, endpoint: Endpoint, role: str) -> ConnectionPortItem | None:
if endpoint.interface is not None:
terminals = self.input_items if role == "source" else self.output_items
terminal = terminals.get(endpoint.interface)
return terminal.connection_port if terminal else None
component = self.component_items.get(endpoint.block or "")
if component is None:
return None
ports = component.output_ports if role == "source" else component.input_ports
return ports.get(endpoint.port or "")
def update_connection(self, connection_id: str) -> None:
connection = self.controller.active_graph.connections.get(connection_id)
graphics = self.connection_items.get(connection_id)
if connection is None or graphics is None:
return
source = self._endpoint_item(connection.source, "source")
target = self._endpoint_item(connection.target, "target")
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)
path = QPainterPath(start)
path.cubicTo(start + QPointF(distance, 0), end - QPointF(distance, 0), end)
graphics.setPath(path)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
item = self.itemAt(event.scenePos(), QTransform())
if isinstance(item, ConnectionPortItem):
if item.role == "source":
self._clear_pending_source()
self.pending_source = item
item.setBrush(QColor("#f5b642"))
elif self.pending_source is not None:
if self.pending_source.endpoint != item.endpoint:
self.controller.connect(self.pending_source.endpoint, item.endpoint)
self._clear_pending_source()
event.accept()
return
self._clear_pending_source()
super().mousePressEvent(event)
def _clear_pending_source(self) -> None:
if self.pending_source is not None:
self.pending_source.setBrush(QColor("#ffffff"))
self.pending_source = None
class GraphWorkspaceView(QGraphicsView):
toolUsed = Signal()
componentOptionsRequested = Signal(str)
portOptionsRequested = Signal(str, str)
connectionOptionsRequested = Signal(str)
selectionAvailabilityChanged = Signal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.controller: DocumentController | None = None
self.tool_mode = "pointer"
self.paste_count = 0
self.setAcceptDrops(True)
self.setRenderHint(QPainter.RenderHint.Antialiasing)
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
self.setBackgroundBrush(QColor("#9a9a9a"))
def set_model(self, controller: DocumentController) -> None:
self.controller = controller
scene = GraphScene(controller, self)
scene.componentOptionsRequested.connect(self.componentOptionsRequested)
scene.portOptionsRequested.connect(self.portOptionsRequested)
scene.connectionOptionsRequested.connect(self.connectionOptionsRequested)
scene.selectionChanged.connect(
lambda: self.selectionAvailabilityChanged.emit(bool(scene.selectedItems()))
)
self.setScene(scene)
def select_all(self) -> None:
scene = self.scene()
if scene is None:
return
for item in scene.items():
if item.flags() & QGraphicsItem.GraphicsItemFlag.ItemIsSelectable:
item.setSelected(True)
def delete_selected(self) -> None:
if self.controller is None or not isinstance(self.scene(), GraphScene):
return
blocks: set[str] = set()
connections: set[str] = set()
inputs: set[str] = set()
outputs: set[str] = set()
for item in self.scene().selectedItems():
if isinstance(item, ComponentGraphicsItem):
blocks.add(item.component_id)
elif isinstance(item, ConnectionGraphicsItem):
connections.add(item.connection_id)
elif isinstance(item, InterfaceTerminalItem):
(inputs if item.direction == "input" else outputs).add(item.port.id)
self.controller.delete_selection(blocks, connections, inputs, outputs)
def has_selected_components(self) -> bool:
scene = self.scene()
return bool(
scene
and any(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems())
)
def rotate_selected(self) -> None:
if self.controller is None or self.scene() is None:
return
component_ids = {
item.component_id
for item in self.scene().selectedItems()
if isinstance(item, ComponentGraphicsItem)
}
self.controller.rotate_components(component_ids)
def copy_selection(self) -> bool:
if self.controller is None or not isinstance(self.scene(), GraphScene):
return False
selected_ids = {
item.component_id
for item in self.scene().selectedItems()
if isinstance(item, ComponentGraphicsItem)
}
if not selected_ids:
return False
graph = self.controller.active_graph
components = [graph.blocks[component_id].to_dict() for component_id in selected_ids]
connections = [
connection.to_dict()
for connection in graph.connections.values()
if connection.source.block in selected_ids and connection.target.block in selected_ids
]
mime_data = QMimeData()
mime_data.setData(
SELECTION_MIME_TYPE,
json.dumps({"components": components, "connections": connections}).encode("utf-8"),
)
QApplication.clipboard().setMimeData(mime_data)
self.paste_count = 0
return True
def cut_selection(self) -> None:
if self.copy_selection():
self.delete_selected()
def paste_selection(self) -> None:
if self.controller is None:
return
mime_data = QApplication.clipboard().mimeData()
if not mime_data.hasFormat(SELECTION_MIME_TYPE):
return
try:
payload = json.loads(bytes(mime_data.data(SELECTION_MIME_TYPE)).decode("utf-8"))
components = [Component.from_dict(item) for item in payload.get("components", [])]
connections = [Connection.from_dict(item) for item in payload.get("connections", [])]
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
return
self.paste_count += 1
new_ids = self.controller.paste_selection(
components,
connections,
QPointF(32 * self.paste_count, 32 * self.paste_count),
)
scene = self.scene()
if isinstance(scene, GraphScene):
scene.clearSelection()
for component_id in new_ids:
item = scene.component_items.get(component_id)
if item is not None:
item.setSelected(True)
def set_tool_mode(self, mode: str) -> None:
self.tool_mode = mode
self.setDragMode(
QGraphicsView.DragMode.RubberBandDrag
if mode == "pointer"
else QGraphicsView.DragMode.NoDrag
)
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
if (
self.controller is not None
and self.tool_mode in {"input", "output"}
and event.button() == Qt.MouseButton.LeftButton
):
self.controller.add_interface_port(self.tool_mode, self.mapToScene(event.position().toPoint()))
self.toolUsed.emit()
event.accept()
return
super().mousePressEvent(event)
def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802
if (
event.mimeData().hasFormat(COMPONENT_MIME_TYPE)
and self.controller is not None
and self.controller.active_component is not None
and self.controller.active_component.implementation_kind == "graph"
):
event.acceptProposedAction()
return
super().dragEnterEvent(event)
def dragMoveEvent(self, event) -> None: # noqa: N802
if (
event.mimeData().hasFormat(COMPONENT_MIME_TYPE)
and self.controller is not None
and self.controller.active_component is not None
and self.controller.active_component.implementation_kind == "graph"
):
event.acceptProposedAction()
return
super().dragMoveEvent(event)
def dropEvent(self, event: QDropEvent) -> None: # noqa: N802
if self.controller is None or not event.mimeData().hasFormat(COMPONENT_MIME_TYPE):
super().dropEvent(event)
return
data = json.loads(bytes(event.mimeData().data(COMPONENT_MIME_TYPE)).decode("utf-8"))
source = Component.from_dict(data)
self.controller.add_component_copy(source, self.mapToScene(event.position().toPoint()))
event.acceptProposedAction()