Files
bedit/src/bedit_gui/controllers/clipboard_controller.py
T
2026-08-17 12:55:42 +02:00

311 lines
12 KiB
Python

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.graph_editor_widget import GraphEditorWidget
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 can_delete(self) -> bool:
return False
def copy(self) -> None:
pass
def cut(self) -> None:
pass
def paste(self) -> None:
pass
def delete(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 can_delete(self) -> bool:
return bool(self._selected_components())
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 delete(self) -> None:
self.document.delete_components(self._selected_components())
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 GraphEditorClipboardHandler(ClipboardHandler):
def __init__(self, document: Document, editor: GraphEditorWidget, clipboard: ClipboardService, parent: QObject | None = None) -> None:
super().__init__(parent)
self.document = document
self.editor = editor
self.clipboard = clipboard
editor.scene.selectionChanged.connect(self.availability_changed)
def owns_focus(self, widget: QWidget) -> bool:
return widget is self.editor or self.editor.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 can_delete(self) -> bool:
return bool(self._selected_components() or self.editor.selected_connection_ids())
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:
graph_component = self.editor.component()
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
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()
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)
def delete(self) -> None:
components = self._selected_components()
connection_ids = self.editor.selected_connection_ids()
if components:
self.document.delete_components(components)
graph_component = self.editor.component()
if graph_component is not None and connection_ids:
self.document.delete_graph_connections(graph_component, connection_ids)
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]:
target = self._target()
if target is None:
return []
return [target[component_id] for component_id in self.editor.selected_component_ids() if component_id in target]
def _target(self) -> dict | None:
component = self.editor.component()
return component.implementation.graph.components if component is not None and isinstance(component.implementation, GraphImplementation) else 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)
window.ui.actionDelete.triggered.connect(self.delete)
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 delete(self) -> None:
handler = self.active_handler()
if handler is not None and handler.can_delete():
handler.delete()
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())
self.window.ui.actionDelete.setEnabled(handler is not None and handler.can_delete())