diff --git a/src/bedit_gui/application.py b/src/bedit_gui/application.py
index 8c568b3..98c7b63 100644
--- a/src/bedit_gui/application.py
+++ b/src/bedit_gui/application.py
@@ -5,6 +5,7 @@ import argparse
from PySide6.QtWidgets import QApplication
+from bedit_gui.controllers.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, TextClipboardHandler
from bedit_gui.controllers.document_controller import DocumentController
from bedit_gui.controllers.log_controller import LogController
from bedit_gui.controllers.settings_controller import SettingsController
@@ -14,6 +15,7 @@ from bedit_gui.controllers.window_state_controller import WindowStateController
from bedit_gui.controllers.document_tree_controller import DocumentTreeController
from bedit_gui.documents import Document
from bedit_gui.services.application_settings import ApplicationSettings
+from bedit_gui.services.clipboard import ClipboardService
from bedit_gui.views.main_window import MainWindow
def parse_arguments():
@@ -46,7 +48,9 @@ def main() -> int:
SettingsController(window, settings)
UndoController(document, window)
ViewMenuController(window)
- DocumentTreeController(document, window)
+ document_tree_controller = DocumentTreeController(document, window)
+ clipboard = ClipboardService(app)
+ ClipboardController(window, clipboard, [TextClipboardHandler(clipboard), DocumentTreeClipboardHandler(document, window.ui.documentTree, document_tree_controller.model, clipboard)])
window_state_controller = WindowStateController(app, window)
window_state_controller.restore()
diff --git a/src/bedit_gui/commands/component_command.py b/src/bedit_gui/commands/component_command.py
index 847cf27..a9ee42a 100644
--- a/src/bedit_gui/commands/component_command.py
+++ b/src/bedit_gui/commands/component_command.py
@@ -1,6 +1,7 @@
from PySide6.QtGui import QUndoCommand
from bedit_core.models import Component, ComponentID, Connection, ConnectionID, EquationImplementation, Graph, GraphImplementation, Interface
+from bedit_gui.models import Icon
class AddEmptyGraphComponent(QUndoCommand):
@@ -104,3 +105,25 @@ class DeleteComponent(QUndoCommand):
values.insert(index, (item_id, item))
items.clear()
items.update(values)
+
+
+class PasteComponents(QUndoCommand):
+ def __init__(self, document: object, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None:
+ super().__init__("Paste components")
+ self.document = document
+ self.target = target
+ self.components = components
+ self.icons = icons
+
+ def redo(self) -> None:
+ self.target.update(self.components)
+ self.document.model_changed.emit(self.document.model)
+ for component_id, icon in self.icons.items():
+ self.document._set_component_icon(component_id, icon)
+
+ def undo(self) -> None:
+ for component_id in self.components:
+ self.target.pop(component_id, None)
+ self.document.model_changed.emit(self.document.model)
+ for component_id in self.icons:
+ self.document._set_component_icon(component_id, None)
diff --git a/src/bedit_gui/controllers/clipboard_controller.py b/src/bedit_gui/controllers/clipboard_controller.py
new file mode 100644
index 0000000..7a22359
--- /dev/null
+++ b/src/bedit_gui/controllers/clipboard_controller.py
@@ -0,0 +1,216 @@
+from __future__ import annotations
+
+from PySide6.QtCore import QEvent, QObject, QTimer, Signal
+from PySide6.QtWidgets import QApplication, QLineEdit, QMainWindow, QMessageBox, QPlainTextEdit, QTextEdit, QTreeView, QWidget
+
+from bedit_core.models import Component, GraphImplementation
+from bedit_core.models import Document as CoreDocument
+from bedit_gui.documents import Document
+from bedit_gui.services.clipboard import ClipboardService
+from bedit_gui.services.component_clipboard import export_components, import_components
+from bedit_gui.views.models.document_tree_model import DocumentTreeModel
+
+
+class ClipboardHandler(QObject):
+ """Base implementation for adding clipboard support to another editor."""
+
+ availability_changed = Signal()
+
+ def owns_focus(self, _widget: QWidget) -> bool:
+ return False
+
+ def can_copy(self) -> bool:
+ return False
+
+ def can_cut(self) -> bool:
+ return False
+
+ def can_paste(self) -> bool:
+ return False
+
+ def copy(self) -> None:
+ pass
+
+ def cut(self) -> None:
+ pass
+
+ def paste(self) -> None:
+ pass
+
+
+class TextClipboardHandler(ClipboardHandler):
+ def __init__(self, clipboard: ClipboardService, parent: QObject | None = None) -> None:
+ super().__init__(parent)
+ self.clipboard = clipboard
+
+ def owns_focus(self, widget: QWidget) -> bool:
+ return isinstance(widget, (QLineEdit, QTextEdit, QPlainTextEdit))
+
+ def can_copy(self) -> bool:
+ return self._has_selection()
+
+ def can_cut(self) -> bool:
+ widget = self._widget()
+ return widget is not None and not widget.isReadOnly() and self._has_selection()
+
+ def can_paste(self) -> bool:
+ widget = self._widget()
+ return widget is not None and not widget.isReadOnly() and self.clipboard.has_text()
+
+ def copy(self) -> None:
+ widget = self._widget()
+ if widget is not None:
+ widget.copy()
+
+ def cut(self) -> None:
+ widget = self._widget()
+ if widget is not None and not widget.isReadOnly():
+ widget.cut()
+
+ def paste(self) -> None:
+ widget = self._widget()
+ if widget is not None and not widget.isReadOnly():
+ widget.paste()
+
+ def _has_selection(self) -> bool:
+ widget = self._widget()
+ if isinstance(widget, QLineEdit):
+ return widget.hasSelectedText()
+ return widget.textCursor().hasSelection() if widget is not None else False
+
+ @staticmethod
+ def _widget() -> QLineEdit | QTextEdit | QPlainTextEdit | None:
+ widget = QApplication.focusWidget()
+ return widget if isinstance(widget, (QLineEdit, QTextEdit, QPlainTextEdit)) else None
+
+
+class DocumentTreeClipboardHandler(ClipboardHandler):
+ def __init__(self, document: Document, tree: QTreeView, model: DocumentTreeModel, clipboard: ClipboardService, parent: QObject | None = None) -> None:
+ super().__init__(parent)
+ self.document = document
+ self.tree = tree
+ self.model = model
+ self.clipboard = clipboard
+ self.tree.selectionModel().selectionChanged.connect(self.availability_changed)
+ self.tree.selectionModel().currentChanged.connect(self.availability_changed)
+
+ def owns_focus(self, widget: QWidget) -> bool:
+ return widget is self.tree or self.tree.isAncestorOf(widget)
+
+ def can_copy(self) -> bool:
+ return bool(self._selected_components())
+
+ def can_cut(self) -> bool:
+ return self.can_copy()
+
+ def can_paste(self) -> bool:
+ return self._target() is not None and self.clipboard.has_format(ClipboardService.COMPONENTS_MIME)
+
+ def copy(self) -> None:
+ components = self._selected_components()
+ if components:
+ self._write_components(components)
+
+ def cut(self) -> None:
+ components = self._selected_components()
+ if components:
+ self._write_components(components)
+ self.document.delete_components(components)
+
+ def paste(self) -> None:
+ target = self._target()
+ payload = self.clipboard.get_json(ClipboardService.COMPONENTS_MIME)
+ if target is None or payload is None:
+ return
+ try:
+ components, icons = import_components(payload)
+ except (TypeError, ValueError) as exc:
+ QMessageBox.critical(self.tree, "Could not paste components", str(exc))
+ return
+ self.document.paste_components(target, components, icons)
+
+ def _write_components(self, components: list[Component]) -> None:
+ payload = export_components(self.document, components)
+ self.clipboard.set_json(ClipboardService.COMPONENTS_MIME, payload, "\n".join(component.name for component in components))
+
+ def _selected_components(self) -> list[Component]:
+ indexes = self.tree.selectionModel().selectedRows(0)
+ selected = {id(component) for index in indexes if isinstance(component := self.model.value(index), Component)}
+ components: list[Component] = []
+ for index in indexes:
+ component = self.model.value(index)
+ if not isinstance(component, Component):
+ continue
+ parent = index.parent()
+ nested = False
+ while parent.isValid():
+ value = self.model.value(parent)
+ if isinstance(value, Component) and id(value) in selected:
+ nested = True
+ break
+ parent = parent.parent()
+ if not nested:
+ components.append(component)
+ return components
+
+ def _target(self) -> dict | None:
+ value = self.model.value(self.tree.currentIndex())
+ if isinstance(value, CoreDocument):
+ return value.root
+ if isinstance(value, Component) and isinstance(value.implementation, GraphImplementation):
+ return value.implementation.graph.components
+ return None
+
+
+class ClipboardController(QObject):
+ def __init__(self, window: QMainWindow, clipboard: ClipboardService, handlers: list[ClipboardHandler]) -> None:
+ super().__init__(window)
+ self.window = window
+ self.clipboard = clipboard
+ self.handlers = handlers
+ window.ui.actionCopy.triggered.connect(self.copy)
+ window.ui.actionCut.triggered.connect(self.cut)
+ window.ui.actionPaste.triggered.connect(self.paste)
+ application = QApplication.instance()
+ application.focusChanged.connect(self.update_actions)
+ application.installEventFilter(self)
+ clipboard.changed.connect(self.update_actions)
+ for handler in handlers:
+ handler.setParent(self)
+ handler.availability_changed.connect(self.update_actions)
+ self.update_actions()
+
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool:
+ if event.type() in (QEvent.Type.KeyRelease, QEvent.Type.MouseButtonRelease):
+ QTimer.singleShot(0, self.update_actions)
+ return super().eventFilter(watched, event)
+
+ def active_handler(self) -> ClipboardHandler | None:
+ widget = QApplication.focusWidget()
+ if widget is None:
+ return None
+ return next((handler for handler in self.handlers if handler.owns_focus(widget)), None)
+
+ def copy(self) -> None:
+ handler = self.active_handler()
+ if handler is not None and handler.can_copy():
+ handler.copy()
+ self.update_actions()
+
+ def cut(self) -> None:
+ handler = self.active_handler()
+ if handler is not None and handler.can_cut():
+ handler.cut()
+ self.update_actions()
+
+ def paste(self) -> None:
+ handler = self.active_handler()
+ if handler is not None and handler.can_paste():
+ handler.paste()
+ self.update_actions()
+
+ def update_actions(self, *_args: object) -> None:
+ handler = self.active_handler()
+ self.window.ui.actionCopy.setEnabled(handler is not None and handler.can_copy())
+ self.window.ui.actionCut.setEnabled(handler is not None and handler.can_cut())
+ self.window.ui.actionPaste.setEnabled(handler is not None and handler.can_paste())
diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py
index 5f4088e..ca3f8ca 100644
--- a/src/bedit_gui/controllers/document_tree_controller.py
+++ b/src/bedit_gui/controllers/document_tree_controller.py
@@ -3,7 +3,7 @@ from functools import partial
from typing import Protocol
from PySide6.QtCore import QObject, QPoint, QSize, Qt
-from PySide6.QtWidgets import QDialog, QHeaderView, QMenu
+from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu
from bedit_core.models import Component, ComponentID, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import Document as CoreDocument
@@ -69,6 +69,8 @@ class DocumentTreeController(QObject):
window.ui.actionEscape.triggered.connect(self.deselect)
window.ui.documentTree.setHeaderHidden(True)
+ window.ui.documentTree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
+ window.ui.documentTree.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
window.ui.documentTree.setIconSize(QSize(48, 48))
window.ui.documentTree.header().setStretchLastSection(False)
window.ui.documentTree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
@@ -177,11 +179,29 @@ class DocumentTreeController(QObject):
self.window.ui.documentTree.selectionModel().clear()
def delete_selected_component(self) -> None:
- index = self.window.ui.documentTree.currentIndex()
- component = self.model.value(index)
focused = self.window.ui.documentTree.hasFocus()
- if focused and isinstance(component, Component):
- self._delete_component(component)
+ if focused:
+ self.document.delete_components(self._selected_components())
def _delete_component(self, component: Component) -> None:
self.document.delete_component(component)
+
+ def _selected_components(self) -> list[Component]:
+ indexes = self.window.ui.documentTree.selectionModel().selectedRows(0)
+ selected = {id(component) for index in indexes if isinstance(component := self.model.value(index), Component)}
+ components: list[Component] = []
+ for index in indexes:
+ component = self.model.value(index)
+ if not isinstance(component, Component):
+ continue
+ parent = index.parent()
+ nested = False
+ while parent.isValid():
+ value = self.model.value(parent)
+ if isinstance(value, Component) and id(value) in selected:
+ nested = True
+ break
+ parent = parent.parent()
+ if not nested:
+ components.append(component)
+ return components
diff --git a/src/bedit_gui/documents/document.py b/src/bedit_gui/documents/document.py
index 11322a8..4295fe8 100644
--- a/src/bedit_gui/documents/document.py
+++ b/src/bedit_gui/documents/document.py
@@ -6,14 +6,14 @@ from pathlib import Path
from PySide6.QtCore import QObject, Signal
from PySide6.QtGui import QUndoStack
-from bedit_core.models import ID, Component, ComponentID, GraphImplementation, Port, PortID, Parameter, ParameterID, EquationImplementation
+from bedit_core.models import ID, Component, ComponentID, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import Document as CoreDocument
from bedit_gui.commands.change_icon_command import ChangeIconCommand
from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand
from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand
from bedit_gui.commands.rename_component_command import RenameComponentCommand
from bedit_gui.commands.rename_document_command import RenameDocumentCommand
-from bedit_gui.commands.component_command import AddEmptyGraphComponent, AddEmptyEquationComponent, DeleteComponent
+from bedit_gui.commands.component_command import AddEmptyEquationComponent, AddEmptyGraphComponent, DeleteComponent, PasteComponents
from bedit_gui.models import Icon, IconDatabase
from bedit_gui.services import document_files
@@ -199,11 +199,45 @@ class Document(QObject):
def add_empty_graph_component(self, component: Component) -> None:
if isinstance(component.implementation, GraphImplementation):
- self.undo_stack.push(AddEmptyGraphComponent(self, component))
+ command = AddEmptyGraphComponent(self, component)
+ command.component.name = self._unique_component_name(component.implementation.graph.components, command.component.name)
+ self.undo_stack.push(command)
def add_empty_equation_component(self, component: Component) -> None:
if isinstance(component.implementation, GraphImplementation):
- self.undo_stack.push(AddEmptyEquationComponent(self, component))
+ command = AddEmptyEquationComponent(self, component)
+ command.component.name = self._unique_component_name(component.implementation.graph.components, command.component.name)
+ self.undo_stack.push(command)
def delete_component(self, component: Component) -> None:
self.undo_stack.push(DeleteComponent(self, component))
+
+ def delete_components(self, components: list[Component]) -> None:
+ if not components:
+ return
+ self.undo_stack.beginMacro("Delete components")
+ for component in components:
+ self.undo_stack.push(DeleteComponent(self, component))
+ self.undo_stack.endMacro()
+
+ def paste_components(self, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None:
+ if not components:
+ return
+ names = {component.name for component in target.values()}
+ for component in components.values():
+ component.name = self._unique_name(names, component.name)
+ names.add(component.name)
+ self.undo_stack.push(PasteComponents(self, target, components, icons))
+
+ @staticmethod
+ def _unique_component_name(components: dict[ComponentID, Component], name: str) -> str:
+ return Document._unique_name({component.name for component in components.values()}, name)
+
+ @staticmethod
+ def _unique_name(names: set[str], name: str) -> str:
+ if name not in names:
+ return name
+ index = 0
+ while f"{name}_{index}" in names:
+ index += 1
+ return f"{name}_{index}"
diff --git a/src/bedit_gui/services/clipboard.py b/src/bedit_gui/services/clipboard.py
new file mode 100644
index 0000000..43d26ac
--- /dev/null
+++ b/src/bedit_gui/services/clipboard.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from PySide6.QtCore import QMimeData, QObject, Signal
+from PySide6.QtGui import QClipboard, QGuiApplication
+
+
+class ClipboardService(QObject):
+ COMPONENTS_MIME = "application/x-bedit-components+json"
+ changed = Signal()
+
+ def __init__(self, parent: QObject | None = None) -> None:
+ super().__init__(parent)
+ self._clipboard = QGuiApplication.clipboard()
+ self._clipboard.dataChanged.connect(self.changed)
+
+ def set_json(self, mime_type: str, data: dict[str, Any], text: str = "") -> None:
+ mime = QMimeData()
+ mime.setData(mime_type, json.dumps(data).encode("utf-8"))
+ if text:
+ mime.setText(text)
+ self._clipboard.setMimeData(mime, QClipboard.Mode.Clipboard)
+
+ def get_json(self, mime_type: str) -> dict[str, Any] | None:
+ mime = self._clipboard.mimeData(QClipboard.Mode.Clipboard)
+ if mime is None or not mime.hasFormat(mime_type):
+ return None
+ try:
+ data = json.loads(bytes(mime.data(mime_type)).decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ return None
+ return data if isinstance(data, dict) else None
+
+ def has_format(self, mime_type: str) -> bool:
+ mime = self._clipboard.mimeData(QClipboard.Mode.Clipboard)
+ return mime is not None and mime.hasFormat(mime_type)
+
+ def has_text(self) -> bool:
+ mime = self._clipboard.mimeData(QClipboard.Mode.Clipboard)
+ return mime is not None and mime.hasText()
diff --git a/src/bedit_gui/services/component_clipboard.py b/src/bedit_gui/services/component_clipboard.py
new file mode 100644
index 0000000..8a2edce
--- /dev/null
+++ b/src/bedit_gui/services/component_clipboard.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import Any
+
+from bedit_core.models import Component, ComponentID, ConnectionID, Document, GraphImplementation, ID, ParameterID, PortID
+from bedit_core.serialization.schema import document_from_data, document_to_data
+from bedit_gui.documents import Document as GuiDocument
+from bedit_gui.models import Icon, ShapeID
+
+FORMAT_VERSION = 1
+
+
+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}
+
+
+def import_components(payload: dict[str, Any]) -> tuple[dict[ComponentID, Component], dict[ComponentID, Icon]]:
+ if payload.get("format_version") != FORMAT_VERSION or payload.get("type") != "components":
+ raise ValueError("unsupported component clipboard format")
+ components = payload.get("components")
+ if not isinstance(components, dict):
+ raise TypeError("component clipboard payload must contain a components object")
+ clipboard_document = document_from_data({"format_version": 1, "id": str(ID()), "name": "Clipboard", "root": components, "metadata": None})
+ component_map: dict[ComponentID, ComponentID] = {}
+ port_map: dict[PortID, PortID] = {}
+ remapped = _remap_components(clipboard_document.root, component_map, port_map)
+ icon_data = payload.get("icons", {})
+ if not isinstance(icon_data, dict):
+ raise TypeError("component clipboard icons must be an object")
+ icons: dict[ComponentID, Icon] = {}
+ for old_id, data in icon_data.items():
+ new_component_id = component_map.get(ComponentID(old_id))
+ if new_component_id is None or not isinstance(data, dict):
+ continue
+ icon = Icon.from_data(data)
+ icon.shapes = {ShapeID(): shape for shape in icon.shapes.values()}
+ icon.port_positions = {port_map[port_id]: position for port_id, position in icon.port_positions.items() if port_id in port_map}
+ icons[new_component_id] = icon
+ return remapped, icons
+
+
+def _remap_components(components: dict[ComponentID, Component], component_map: dict[ComponentID, ComponentID], port_map: dict[PortID, PortID]) -> dict[ComponentID, Component]:
+ remapped: dict[ComponentID, Component] = {}
+ for old_component_id, original in components.items():
+ component = deepcopy(original)
+ new_component_id = ComponentID()
+ component_map[old_component_id] = new_component_id
+ component.interface.ports = {_new_port_id(old_id, port_map): port for old_id, port in component.interface.ports.items()}
+ component.parameters = {ParameterID(): parameter for parameter in component.parameters.values()}
+ if isinstance(component.implementation, GraphImplementation):
+ graph = component.implementation.graph
+ graph.components = _remap_components(graph.components, component_map, port_map)
+ graph.connections = {ConnectionID(): connection for connection in graph.connections.values()}
+ for connection in graph.connections.values():
+ connection.source = port_map.get(connection.source, connection.source)
+ connection.target = port_map.get(connection.target, connection.target)
+ remapped[new_component_id] = component
+ return remapped
+
+
+def _new_port_id(old_id: PortID, port_map: dict[PortID, PortID]) -> PortID:
+ new_id = PortID()
+ port_map[old_id] = new_id
+ return new_id
+
+
+def _all_component_ids(components: dict[ComponentID, Component]) -> list[ComponentID]:
+ component_ids: list[ComponentID] = []
+ for component_id, component in components.items():
+ component_ids.append(component_id)
+ if isinstance(component.implementation, GraphImplementation):
+ component_ids.extend(_all_component_ids(component.implementation.graph.components))
+ return component_ids
diff --git a/src/bedit_gui/ui/forms/main_window.ui b/src/bedit_gui/ui/forms/main_window.ui
index 96e7077..6c1a0fb 100644
--- a/src/bedit_gui/ui/forms/main_window.ui
+++ b/src/bedit_gui/ui/forms/main_window.ui
@@ -52,6 +52,10 @@
+
+
+
+
@@ -103,6 +107,9 @@
+
+
+
@@ -325,6 +332,60 @@
QAction::MenuRole::NoRole
+
+
+
+ :/icons/icons/edit-copy.png:/icons/icons/edit-copy.png
+
+
+ Copy
+
+
+ Copy selected
+
+
+ Ctrl+C
+
+
+ QAction::MenuRole::NoRole
+
+
+
+
+
+ :/icons/icons/edit-paste.png:/icons/icons/edit-paste.png
+
+
+ Paste
+
+
+ Paste selected
+
+
+ Ctrl+V
+
+
+ QAction::MenuRole::NoRole
+
+
+
+
+
+ :/icons/icons/edit-cut.png:/icons/icons/edit-cut.png
+
+
+ Cut
+
+
+ Cut selected
+
+
+ Ctrl+X
+
+
+ QAction::MenuRole::NoRole
+
+