Library drag-in

This commit is contained in:
2026-08-17 17:52:12 +02:00
parent b1f453a6df
commit e88c58f095
14 changed files with 138 additions and 198 deletions

View File

@@ -182,6 +182,7 @@ class GraphEditorClipboardHandler(ClipboardHandler):
self.editor = editor
self.clipboard = clipboard
editor.scene.selectionChanged.connect(self.availability_changed)
editor.component_drop_requested.connect(self.drop_components)
def owns_focus(self, widget: QWidget) -> bool:
return widget is self.editor or self.editor.isAncestorOf(widget)
@@ -214,12 +215,19 @@ class GraphEditorClipboardHandler(ClipboardHandler):
payload = self.clipboard.get_json(ClipboardService.COMPONENTS_MIME)
if graph_component is None or not isinstance(graph_component.implementation, GraphImplementation) or payload is None:
return
x, y = self.editor.paste_position()
self._paste_payload(graph_component, payload, (x, y))
def drop_components(self, graph_component: Component, payload: dict, position: tuple[int, int]) -> None:
self._paste_payload(graph_component, payload, position)
def _paste_payload(self, graph_component: Component, payload: dict, position: tuple[int, int]) -> None:
try:
components, icons = import_components(payload)
except (TypeError, ValueError) as exc:
QMessageBox.critical(self.editor, "Could not paste components", str(exc))
return
x, y = self.editor.paste_position()
x, y = position
spacing = self.editor.snap_to_grid_size * 4
positions = {component_id: (x + index * spacing, y + index * spacing) for index, component_id in enumerate(components)}
self.document.paste_graph_components(graph_component, components, icons, positions)

View File

@@ -1,13 +1,14 @@
from PySide6.QtCore import QObject, QSize
from PySide6.QtCore import QObject, QSize, Qt
from PySide6.QtWidgets import QAbstractItemView, QHeaderView
from bedit_core.models import Component, ComponentID, GraphImplementation
from bedit_gui.models import Icon, IconDatabase
from bedit_gui.services.application_settings import ApplicationSettings
from bedit_gui.services.component_clipboard import export_component_data
from bedit_gui.services.libraries import load_library_documents
from bedit_gui.utils.icon import render_fitted_icon
from bedit_gui.views.main_window import MainWindow
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
from bedit_gui.views.models.library_tree_model import LibraryTreeModel
ICON_SIZE = QSize(32, 32)
@@ -17,13 +18,17 @@ class LibraryController(QObject):
super().__init__(window)
self.window = window
self.settings = settings
self.model = DocumentTreeModel(editable=False)
self._component_sources: dict[int, tuple[ComponentID, dict[ComponentID, Icon]]] = {}
self.model = LibraryTreeModel(self._component_payload)
tree = window.ui.libraryTree
tree.setModel(self.model)
tree.setHeaderHidden(True)
tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
tree.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
tree.setDragEnabled(True)
tree.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
tree.setDefaultDropAction(Qt.DropAction.CopyAction)
tree.setIconSize(QSize(48, 48))
tree.header().setStretchLastSection(False)
tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
@@ -34,6 +39,11 @@ class LibraryController(QObject):
def reload(self) -> None:
libraries = load_library_documents(self.settings.library_paths)
self._component_sources = {}
for library in libraries:
database = library.document.metadata.get("icon_database") if library.document.metadata is not None else None
icons = database.icons if isinstance(database, IconDatabase) else {}
self._collect_component_sources(library.document.root, icons)
self.model.set_documents([library.document for library in libraries])
for library in libraries:
database = library.document.metadata.get("icon_database") if library.document.metadata is not None else None
@@ -41,6 +51,24 @@ class LibraryController(QObject):
self._set_component_icons(library.document.root, icons)
self.window.ui.libraryTree.expandAll()
def _component_payload(self, components: list[Component]) -> dict:
roots = {}
icons = {}
for component in components:
source = self._component_sources.get(id(component))
if source is None:
continue
component_id, source_icons = source
roots[component_id] = component
icons.update(source_icons)
return export_component_data(roots, icons)
def _collect_component_sources(self, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None:
for component_id, component in components.items():
self._component_sources[id(component)] = (component_id, icons)
if isinstance(component.implementation, GraphImplementation):
self._collect_component_sources(component.implementation.graph.components, icons)
def _set_component_icons(self, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None:
for component_id, component in components.items():
self.model.set_component_icon(component_id, render_fitted_icon(icons.get(component_id, Icon()), component.interface.ports, ICON_SIZE))

View File

@@ -6,9 +6,11 @@ from typing import Any
from PySide6.QtCore import QMimeData, QObject, Signal
from PySide6.QtGui import QClipboard, QGuiApplication
from bedit_gui.services.component_clipboard import COMPONENTS_MIME
class ClipboardService(QObject):
COMPONENTS_MIME = "application/x-bedit-components+json"
COMPONENTS_MIME = COMPONENTS_MIME
changed = Signal()
def __init__(self, parent: QObject | None = None) -> None:

View File

@@ -9,18 +9,25 @@ from bedit_gui.documents import Document as GuiDocument
from bedit_gui.models import Icon, ShapeID
FORMAT_VERSION = 1
COMPONENTS_MIME = "application/x-bedit-components+json"
def export_components(document: GuiDocument, components: list[Component]) -> dict[str, Any]:
roots = {document.component_id(component): component for component in components}
serialized = document_to_data(Document(format_version=1, id=ID(), name="Clipboard", root=roots))
component_ids = _all_component_ids(roots)
icons = {}
for component_id in component_ids:
icon = document.stored_component_icon(component_id)
if icon is not None:
icons[str(component_id)] = icon.to_data()
return {"format_version": FORMAT_VERSION, "type": "components", "components": serialized["root"], "icons": icons}
icons[component_id] = icon
return export_component_data(roots, icons)
def export_component_data(components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> dict[str, Any]:
serialized = document_to_data(Document(format_version=1, id=ID(), name="Clipboard", root=components))
component_ids = set(_all_component_ids(components))
icon_data = {str(component_id): icon.to_data() for component_id, icon in icons.items() if component_id in component_ids}
return {"format_version": FORMAT_VERSION, "type": "components", "components": serialized["root"], "icons": icon_data}
def import_components(payload: dict[str, Any]) -> tuple[dict[ComponentID, Component], dict[ComponentID, Icon]]:

View File

@@ -1,15 +1,17 @@
from __future__ import annotations
import json
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 QActionGroup, QBrush, QColor, QCursor, QKeySequence, QMouseEvent, QPainter, QPainterPath, QPen, QShortcut, QWheelEvent
from PySide6.QtGui import QActionGroup, QBrush, QColor, QCursor, QDragEnterEvent, QDragMoveEvent, QDropEvent, QKeySequence, QMouseEvent, QPainter, QPainterPath, QPen, QShortcut, QWheelEvent
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsTextItem, QGraphicsView, QMenu, QWidget
from bedit_core.models import BondCausality, BondConnection, BondPort, Component, ComponentID, Connection, ConnectionID, GraphImplementation, Port, PortID, SignalConnection, SignalDirection, SignalPort
from bedit_gui.models import Graph, GraphComponentLabel, GraphConnection, Icon
from bedit_gui.services.component_clipboard import COMPONENTS_MIME
from bedit_gui.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget
from bedit_gui.utils.icon import get_pixmap_bounding_box, render_icon
@@ -291,6 +293,7 @@ class GraphEditorWidget(QWidget):
connection_points_change_requested = Signal(object, object, object, str)
connection_add_requested = Signal(object, object)
connections_delete_requested = Signal(object, object)
component_drop_requested = Signal(object, object, object)
def __init__(self, parent: QWidget | None = None, snap_to_grid_size: int = 4) -> None:
super().__init__(parent)
@@ -314,6 +317,7 @@ class GraphEditorWidget(QWidget):
self.ui.graphicsView.setScene(self.scene)
self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
self.ui.graphicsView.viewport().installEventFilter(self)
self.ui.graphicsView.viewport().setAcceptDrops(True)
self._mode_actions = QActionGroup(self)
self._mode_actions.setExclusive(True)
self._mode_actions.addAction(self.ui.actionMouseMode)
@@ -736,6 +740,25 @@ 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() in (QEvent.Type.DragEnter, QEvent.Type.DragMove):
assert isinstance(event, (QDragEnterEvent, QDragMoveEvent))
if self._component is not None and event.mimeData().hasFormat(COMPONENTS_MIME):
event.setDropAction(Qt.DropAction.CopyAction)
event.accept()
return True
if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.Drop:
assert isinstance(event, QDropEvent)
if self._component is not None and event.mimeData().hasFormat(COMPONENTS_MIME):
try:
payload = json.loads(bytes(event.mimeData().data(COMPONENTS_MIME)).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return True
if isinstance(payload, dict):
position = self._snap_position(self.ui.graphicsView.mapToScene(event.position().toPoint()))
self.component_drop_requested.emit(self._component, payload, position)
event.setDropAction(Qt.DropAction.CopyAction)
event.accept()
return True
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()))

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import json
from collections.abc import Callable
from PySide6.QtCore import QMimeData, QModelIndex, Qt
from bedit_core.models import Component
from bedit_gui.services.component_clipboard import COMPONENTS_MIME
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
PayloadFactory = Callable[[list[Component]], dict]
class LibraryTreeModel(DocumentTreeModel):
def __init__(self, payload_factory: PayloadFactory) -> None:
super().__init__(editable=False)
self._payload_factory = payload_factory
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
flags = super().flags(index)
if isinstance(self.value(index), Component):
flags |= Qt.ItemFlag.ItemIsDragEnabled
return flags
def mimeTypes(self) -> list[str]:
return [COMPONENTS_MIME]
def mimeData(self, indexes: list[QModelIndex]) -> QMimeData:
rows = [index for index in indexes if index.column() == 0 and isinstance(self.value(index), Component)]
selected = {id(self.value(index)) for index in rows}
components = []
added = set()
for index in rows:
parent = index.parent()
if any(id(self.value(parent_index)) in selected for parent_index in self._parents(parent)):
continue
component = self.value(index)
if isinstance(component, Component) and id(component) not in added:
components.append(component)
added.add(id(component))
mime = QMimeData()
if components:
mime.setData(COMPONENTS_MIME, json.dumps(self._payload_factory(components)).encode("utf-8"))
mime.setText("\n".join(component.name for component in components))
return mime
def supportedDragActions(self) -> Qt.DropAction:
return Qt.DropAction.CopyAction
@staticmethod
def _parents(index: QModelIndex):
while index.isValid():
yield index
index = index.parent()