diff --git a/lib/icons/0.icon.json b/lib/icons/0.icon.json deleted file mode 100644 index 5575d4c..0000000 --- a/lib/icons/0.icon.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "shapes": { - "2a5bf670-c821-442b-9d46-1b07faa70722": { - "layer": 0, - "type": "text", - "pos": [ - -32, - -32 - ], - "width": 64, - "height": 64, - "color": "#000000ff", - "bold": true, - "italic": false, - "size": 64.0, - "text": "0" - } - }, - "port_positions": { - "497b1f74-1186-471f-976a-36b07a451caf": [ - -8, - -8 - ] - } -} diff --git a/lib/icons/1.icon.json b/lib/icons/1.icon.json deleted file mode 100644 index f6c0182..0000000 --- a/lib/icons/1.icon.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "shapes": { - "2a5bf670-c821-442b-9d46-1b07faa70722": { - "layer": 0, - "type": "text", - "pos": [ - -32, - -32 - ], - "width": 64, - "height": 64, - "color": "#000000ff", - "bold": true, - "italic": false, - "size": 64.0, - "text": "1" - } - }, - "port_positions": { - "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3": [ - -8, - -8 - ] - } -} diff --git a/lib/icons/C.icon.json b/lib/icons/C.icon.json deleted file mode 100644 index 8122bbb..0000000 --- a/lib/icons/C.icon.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "shapes": { - "2a5bf670-c821-442b-9d46-1b07faa70722": { - "layer": 0, - "type": "text", - "pos": [ - -32, - -32 - ], - "width": 64.0, - "height": 64.0, - "color": "#000000ff", - "bold": true, - "italic": false, - "size": 64.0, - "text": "C" - } - }, - "port_positions": { - "c87881f1-23b4-4a69-b586-8e3c7bf6e21e": [ - -8, - -8 - ], - "c62de8eb-e13b-4849-bea5-c8cc5332269e": [ - 16, - -32 - ] - } -} diff --git a/lib/icons/I.icon.json b/lib/icons/I.icon.json deleted file mode 100644 index ed73013..0000000 --- a/lib/icons/I.icon.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "shapes": { - "2a5bf670-c821-442b-9d46-1b07faa70722": { - "layer": 0, - "type": "text", - "pos": [ - -32, - -32 - ], - "width": 64, - "height": 64, - "color": "#000000ff", - "bold": true, - "italic": false, - "size": 64.0, - "text": "I" - } - }, - "port_positions": { - "0b9036b1-e4e4-437e-8c35-f5bb391b6cbd": [ - -8, - -8 - ], - "fb25f35d-4c0c-4cfa-92d4-1a18a71e2c11": [ - 16, - -32 - ] - } -} diff --git a/lib/icons/R.icon.json b/lib/icons/R.icon.json deleted file mode 100644 index a1a464b..0000000 --- a/lib/icons/R.icon.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "shapes": { - "2a5bf670-c821-442b-9d46-1b07faa70722": { - "layer": 0, - "type": "text", - "pos": [ - -32, - -32 - ], - "width": 64, - "height": 64, - "color": "#000000ff", - "bold": true, - "italic": false, - "size": 64.0, - "text": "R" - } - }, - "port_positions": { - "0ac7d4ea-77f7-4c0d-8e37-406ef66e4740": [ - -8, - -8 - ], - "d1f5aab2-7bff-4b1e-8697-98fb6c3d8f02": [ - 16, - -32 - ] - } -} diff --git a/lib/icons/Se.icon.json b/lib/icons/Se.icon.json deleted file mode 100644 index f0f6c76..0000000 --- a/lib/icons/Se.icon.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "shapes": { - "2a5bf670-c821-442b-9d46-1b07faa70722": { - "layer": 0, - "type": "text", - "pos": [ - -48, - -32 - ], - "width": 96.0, - "height": 64.0, - "color": "#000000ff", - "bold": true, - "italic": false, - "size": 64.0, - "text": "Se" - } - }, - "port_positions": { - "5b6a8a0c-0875-402c-bd74-11d086aac372": [ - -8, - -8 - ] - } -} diff --git a/lib/icons/Sf.icon.json b/lib/icons/Sf.icon.json deleted file mode 100644 index e2fe944..0000000 --- a/lib/icons/Sf.icon.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "shapes": { - "f1a38ee1-8a53-454d-a398-12c14032ba79": { - "layer": 0, - "type": "text", - "pos": [ - -48, - -32 - ], - "width": 96.0, - "height": 64.0, - "color": "#000000ff", - "bold": true, - "italic": false, - "size": 64.0, - "text": "Sf" - } - }, - "port_positions": { - "856b4152-e18f-46d7-889d-626cb98474aa": [ - -8, - -8 - ] - } -} diff --git a/src/bedit_gui/controllers/clipboard_controller.py b/src/bedit_gui/controllers/clipboard_controller.py index d792791..473d102 100644 --- a/src/bedit_gui/controllers/clipboard_controller.py +++ b/src/bedit_gui/controllers/clipboard_controller.py @@ -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) diff --git a/src/bedit_gui/controllers/library_controller.py b/src/bedit_gui/controllers/library_controller.py index f5cdb1c..8243578 100644 --- a/src/bedit_gui/controllers/library_controller.py +++ b/src/bedit_gui/controllers/library_controller.py @@ -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)) diff --git a/src/bedit_gui/services/clipboard.py b/src/bedit_gui/services/clipboard.py index 43d26ac..e753f8c 100644 --- a/src/bedit_gui/services/clipboard.py +++ b/src/bedit_gui/services/clipboard.py @@ -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: diff --git a/src/bedit_gui/services/component_clipboard.py b/src/bedit_gui/services/component_clipboard.py index 8a2edce..1c3fa6d 100644 --- a/src/bedit_gui/services/component_clipboard.py +++ b/src/bedit_gui/services/component_clipboard.py @@ -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]]: diff --git a/src/bedit_gui/views/graph_editor_widget.py b/src/bedit_gui/views/graph_editor_widget.py index e2c37e0..3185375 100644 --- a/src/bedit_gui/views/graph_editor_widget.py +++ b/src/bedit_gui/views/graph_editor_widget.py @@ -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())) diff --git a/src/bedit_gui/views/models/library_tree_model.py b/src/bedit_gui/views/models/library_tree_model.py new file mode 100644 index 0000000..9da3853 --- /dev/null +++ b/src/bedit_gui/views/models/library_tree_model.py @@ -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() diff --git a/untitled.bedit.json b/untitled.bedit.json index 3bbe50a..6595062 100644 --- a/untitled.bedit.json +++ b/untitled.bedit.json @@ -374,14 +374,14 @@ "connection_type": "bond", "source": "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3", "target": "497b1f74-1186-471f-976a-36b07a451caf", - "causality": "effort_out", + "causality": "flow_out", "undesired": false }, "743e35a4-e736-407d-bf34-b4afbcde5a0b": { "connection_type": "bond", "source": "4306701a-6b1b-4d19-b8ff-45dbfa04f2d3", "target": "0ac7d4ea-77f7-4c0d-8e37-406ef66e4740", - "causality": "flow_out", + "causality": "effort_out", "undesired": false }, "68727b23-888b-45b4-a0f4-801ac7b39601": { @@ -626,6 +626,10 @@ "1455acad-931b-4caa-be71-aff4283507c6": [ 64, 384 + ], + "015a0697-69a9-43bf-9410-d2c8b44303f8": [ + 64, + 384 ] }, "component_labels": {