From 346a963acc5ba2ddea815c8eea6fcb069757efd5 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Mon, 27 Jul 2026 16:35:02 +0200 Subject: [PATCH] AI'ed clipboard --- src/bedit_gui/application.py | 6 +- src/bedit_gui/commands/component_command.py | 23 ++ .../controllers/clipboard_controller.py | 216 ++++++++++++++++++ .../controllers/document_tree_controller.py | 30 ++- src/bedit_gui/documents/document.py | 42 +++- src/bedit_gui/services/clipboard.py | 42 ++++ src/bedit_gui/services/component_clipboard.py | 82 +++++++ src/bedit_gui/ui/forms/main_window.ui | 61 +++++ 8 files changed, 492 insertions(+), 10 deletions(-) create mode 100644 src/bedit_gui/controllers/clipboard_controller.py create mode 100644 src/bedit_gui/services/clipboard.py create mode 100644 src/bedit_gui/services/component_clipboard.py 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 + +