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 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.document_controller import DocumentController
from bedit_gui.controllers.log_controller import LogController from bedit_gui.controllers.log_controller import LogController
from bedit_gui.controllers.settings_controller import SettingsController 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.controllers.document_tree_controller import DocumentTreeController
from bedit_gui.documents import Document from bedit_gui.documents import Document
from bedit_gui.services.application_settings import ApplicationSettings from bedit_gui.services.application_settings import ApplicationSettings
from bedit_gui.services.clipboard import ClipboardService
from bedit_gui.views.main_window import MainWindow from bedit_gui.views.main_window import MainWindow
def parse_arguments(): def parse_arguments():
@@ -46,7 +48,9 @@ def main() -> int:
SettingsController(window, settings) SettingsController(window, settings)
UndoController(document, window) UndoController(document, window)
ViewMenuController(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 = WindowStateController(app, window)
window_state_controller.restore() window_state_controller.restore()

View File

@@ -1,6 +1,7 @@
from PySide6.QtGui import QUndoCommand from PySide6.QtGui import QUndoCommand
from bedit_core.models import Component, ComponentID, Connection, ConnectionID, EquationImplementation, Graph, GraphImplementation, Interface from bedit_core.models import Component, ComponentID, Connection, ConnectionID, EquationImplementation, Graph, GraphImplementation, Interface
from bedit_gui.models import Icon
class AddEmptyGraphComponent(QUndoCommand): class AddEmptyGraphComponent(QUndoCommand):
@@ -104,3 +105,25 @@ class DeleteComponent(QUndoCommand):
values.insert(index, (item_id, item)) values.insert(index, (item_id, item))
items.clear() items.clear()
items.update(values) 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 typing import Protocol
from PySide6.QtCore import QObject, QPoint, QSize, Qt 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 Component, ComponentID, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import Document as CoreDocument from bedit_core.models import Document as CoreDocument
@@ -69,6 +69,8 @@ class DocumentTreeController(QObject):
window.ui.actionEscape.triggered.connect(self.deselect) window.ui.actionEscape.triggered.connect(self.deselect)
window.ui.documentTree.setHeaderHidden(True) 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.setIconSize(QSize(48, 48))
window.ui.documentTree.header().setStretchLastSection(False) window.ui.documentTree.header().setStretchLastSection(False)
window.ui.documentTree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) window.ui.documentTree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
@@ -177,11 +179,29 @@ class DocumentTreeController(QObject):
self.window.ui.documentTree.selectionModel().clear() self.window.ui.documentTree.selectionModel().clear()
def delete_selected_component(self) -> None: def delete_selected_component(self) -> None:
index = self.window.ui.documentTree.currentIndex()
component = self.model.value(index)
focused = self.window.ui.documentTree.hasFocus() focused = self.window.ui.documentTree.hasFocus()
if focused and isinstance(component, Component): if focused:
self._delete_component(component) self.document.delete_components(self._selected_components())
def _delete_component(self, component: Component) -> None: def _delete_component(self, component: Component) -> None:
self.document.delete_component(component) 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.QtCore import QObject, Signal
from PySide6.QtGui import QUndoStack 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_core.models import Document as CoreDocument
from bedit_gui.commands.change_icon_command import ChangeIconCommand from bedit_gui.commands.change_icon_command import ChangeIconCommand
from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand
from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand
from bedit_gui.commands.rename_component_command import RenameComponentCommand from bedit_gui.commands.rename_component_command import RenameComponentCommand
from bedit_gui.commands.rename_document_command import RenameDocumentCommand 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.models import Icon, IconDatabase
from bedit_gui.services import document_files from bedit_gui.services import document_files
@@ -199,11 +199,45 @@ class Document(QObject):
def add_empty_graph_component(self, component: Component) -> None: def add_empty_graph_component(self, component: Component) -> None:
if isinstance(component.implementation, GraphImplementation): 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: def add_empty_equation_component(self, component: Component) -> None:
if isinstance(component.implementation, GraphImplementation): 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: def delete_component(self, component: Component) -> None:
self.undo_stack.push(DeleteComponent(self, component)) 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="actionUndo"/>
<addaction name="actionRedo"/> <addaction name="actionRedo"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionCopy"/>
<addaction name="actionCut"/>
<addaction name="actionPaste"/>
<addaction name="separator"/>
<addaction name="actionDelete"/> <addaction name="actionDelete"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionSettings"/> <addaction name="actionSettings"/>
@@ -103,6 +107,9 @@
</attribute> </attribute>
<addaction name="actionUndo"/> <addaction name="actionUndo"/>
<addaction name="actionRedo"/> <addaction name="actionRedo"/>
<addaction name="actionCopy"/>
<addaction name="actionCut"/>
<addaction name="actionPaste"/>
</widget> </widget>
<widget class="QDockWidget" name="documentTreeWidget"> <widget class="QDockWidget" name="documentTreeWidget">
<property name="minimumSize"> <property name="minimumSize">
@@ -325,6 +332,60 @@
<enum>QAction::MenuRole::NoRole</enum> <enum>QAction::MenuRole::NoRole</enum>
</property> </property>
</action> </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> </widget>
<resources> <resources>
<include location="../../resources/resources.qrc"/> <include location="../../resources/resources.qrc"/>