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())