AI'ed clipboard

This commit is contained in:
2026-07-27 16:35:02 +02:00
parent 5df9e53d58
commit 346a963acc
8 changed files with 492 additions and 10 deletions

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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}"

View File

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

View File

@@ -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

View File

@@ -52,6 +52,10 @@
<addaction name="actionUndo"/>
<addaction name="actionRedo"/>
<addaction name="separator"/>
<addaction name="actionCopy"/>
<addaction name="actionCut"/>
<addaction name="actionPaste"/>
<addaction name="separator"/>
<addaction name="actionDelete"/>
<addaction name="separator"/>
<addaction name="actionSettings"/>
@@ -103,6 +107,9 @@
</attribute>
<addaction name="actionUndo"/>
<addaction name="actionRedo"/>
<addaction name="actionCopy"/>
<addaction name="actionCut"/>
<addaction name="actionPaste"/>
</widget>
<widget class="QDockWidget" name="documentTreeWidget">
<property name="minimumSize">
@@ -325,6 +332,60 @@
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionCopy">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-copy.png</normaloff>:/icons/icons/edit-copy.png</iconset>
</property>
<property name="text">
<string>Copy</string>
</property>
<property name="toolTip">
<string>Copy selected</string>
</property>
<property name="shortcut">
<string>Ctrl+C</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionPaste">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-paste.png</normaloff>:/icons/icons/edit-paste.png</iconset>
</property>
<property name="text">
<string>Paste</string>
</property>
<property name="toolTip">
<string>Paste selected</string>
</property>
<property name="shortcut">
<string>Ctrl+V</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionCut">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/edit-cut.png</normaloff>:/icons/icons/edit-cut.png</iconset>
</property>
<property name="text">
<string>Cut</string>
</property>
<property name="toolTip">
<string>Cut selected</string>
</property>
<property name="shortcut">
<string>Ctrl+X</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
</widget>
<resources>
<include location="../../resources/resources.qrc"/>