From 0bb2141673ecda5bf79d47e0371d1fb8f7f39983 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Mon, 17 Aug 2026 12:12:57 +0200 Subject: [PATCH] Copy/paste on canvas --- src/bedit_gui/application.py | 4 +- src/bedit_gui/commands/component_command.py | 10 ++- .../controllers/clipboard_controller.py | 88 +++++++++++++++++++ .../controllers/document_tree_controller.py | 30 ++++--- src/bedit_gui/documents/document.py | 10 +++ src/bedit_gui/ui/forms/graph_editor_widget.ui | 33 ++++--- .../ui/forms/graph_editor_widget_ui.py | 33 ++++--- src/bedit_gui/views/graph_editor_widget.py | 23 ++++- 8 files changed, 182 insertions(+), 49 deletions(-) diff --git a/src/bedit_gui/application.py b/src/bedit_gui/application.py index 6064024..f51f090 100644 --- a/src/bedit_gui/application.py +++ b/src/bedit_gui/application.py @@ -5,7 +5,7 @@ import argparse from PySide6.QtWidgets import QApplication, QMessageBox -from bedit_gui.controllers.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, TextClipboardHandler +from bedit_gui.controllers.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, GraphEditorClipboardHandler, 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 @@ -59,7 +59,7 @@ def main() -> int: ViewMenuController(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)]) + ClipboardController(window, clipboard, [TextClipboardHandler(clipboard), DocumentTreeClipboardHandler(document, window.ui.documentTree, document_tree_controller.model, clipboard), GraphEditorClipboardHandler(document, window.graph_editor, 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 a9ee42a..712572a 100644 --- a/src/bedit_gui/commands/component_command.py +++ b/src/bedit_gui/commands/component_command.py @@ -108,15 +108,20 @@ class DeleteComponent(QUndoCommand): class PasteComponents(QUndoCommand): - def __init__(self, document: object, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon]) -> None: + def __init__(self, document: object, target: dict[ComponentID, Component], components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], graph_id: ComponentID | None = None, positions: dict[ComponentID, tuple[int, int]] | None = None) -> None: super().__init__("Paste components") self.document = document self.target = target self.components = components self.icons = icons + self.graph_id = graph_id + self.positions = positions or {} def redo(self) -> None: self.target.update(self.components) + if self.graph_id is not None: + for component_id, position in self.positions.items(): + self.document._set_graph_component_position(self.graph_id, component_id, position) self.document.model_changed.emit(self.document.model) for component_id, icon in self.icons.items(): self.document._set_component_icon(component_id, icon) @@ -124,6 +129,9 @@ class PasteComponents(QUndoCommand): def undo(self) -> None: for component_id in self.components: self.target.pop(component_id, None) + if self.graph_id is not None: + for component_id in self.positions: + self.document._set_graph_component_position(self.graph_id, 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 index 7a22359..1a46847 100644 --- a/src/bedit_gui/controllers/clipboard_controller.py +++ b/src/bedit_gui/controllers/clipboard_controller.py @@ -8,6 +8,7 @@ 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 @@ -28,6 +29,9 @@ class ClipboardHandler(QObject): def can_paste(self) -> bool: return False + def can_delete(self) -> bool: + return False + def copy(self) -> None: pass @@ -37,6 +41,9 @@ class ClipboardHandler(QObject): def paste(self) -> None: pass + def delete(self) -> None: + pass + class TextClipboardHandler(ClipboardHandler): def __init__(self, clipboard: ClipboardService, parent: QObject | None = None) -> None: @@ -106,6 +113,9 @@ class DocumentTreeClipboardHandler(ClipboardHandler): 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: @@ -129,6 +139,9 @@ class DocumentTreeClipboardHandler(ClipboardHandler): 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)) @@ -162,6 +175,73 @@ class DocumentTreeClipboardHandler(ClipboardHandler): 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 self.can_copy() + + 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: + 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]: + 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) @@ -171,6 +251,7 @@ class ClipboardController(QObject): 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) @@ -209,8 +290,15 @@ class ClipboardController(QObject): 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()) diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py index 11ef119..b242194 100644 --- a/src/bedit_gui/controllers/document_tree_controller.py +++ b/src/bedit_gui/controllers/document_tree_controller.py @@ -66,8 +66,8 @@ class DocumentTreeController(QObject): document.equation_text_changed.connect(self._on_equation_text_changed) self.model.rename_document_requested.connect(self.document.rename) self.model.rename_component_requested.connect(self.document.rename_component) - window.ui.actionDelete.triggered.connect(self.delete_selected_component) window.graph_editor.component_move_requested.connect(self.document.move_graph_component) + window.graph_editor.component_context_menu_requested.connect(self._show_graph_component_context_menu) # Add deselection with esc to this widget window.ui.actionEscape.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) @@ -98,13 +98,14 @@ class DocumentTreeController(QObject): def _on_document_changed(self, model: CoreDocument) -> None: """Rebuild the tree whenever New/Open replaces the core document.""" - self._show_component(None) + displayed_component = self.window.graph_editor.component() or self.window.equation_editor.component() self.model.set_document(model) self._components = {} self._collect_components(model.root) for component_id, component in self._components.items(): icon = self.document.component_icon(component_id) self.model.set_component_icon(component_id, render_fitted_icon(icon, component.interface.ports, ICON_SIZE)) + self._show_component(displayed_component if any(component is displayed_component for component in self._components.values()) else None) # Optional presentation behavior. Later, you could instead remember # expanded component IDs and restore only those nodes. @@ -161,27 +162,36 @@ class DocumentTreeController(QObject): if not isinstance(component, Component): return + self._show_component_context_menu(component, self.window.ui.documentTree.viewport().mapToGlobal(position)) + + def _show_graph_component_context_menu(self, component_id: ComponentID, global_position: QPoint) -> None: + component = self._components.get(component_id) + if component is not None: + self._show_component_context_menu(component, global_position) + + def _show_component_context_menu(self, component: Component, global_position: QPoint) -> None: menu = QMenu(self.window.ui.documentTree) edit_interface = menu.addAction("Edit Interface") edit_params = menu.addAction("Edit Parameters") edit_icon = menu.addAction("Edit Icon") menu.addSeparator() - add_graph_component = menu.addAction("Add Graph Component") - add_equation_component = menu.addAction("Add Equation Component") - menu.addSeparator() + add_graph_component = None + add_equation_component = None + if isinstance(component.implementation, GraphImplementation): + add_graph_component = menu.addAction("Add Graph Component") + add_equation_component = menu.addAction("Add Equation Component") + menu.addSeparator() delete_component = menu.addAction("Delete Component") - selected = menu.exec( - self.window.ui.documentTree.viewport().mapToGlobal(position) - ) + selected = menu.exec(global_position) if selected is edit_interface: self._edit_interface(component) elif selected is edit_params: self._edit_params(component) elif selected is edit_icon: self._edit_icon(component) - elif selected is add_graph_component: + elif add_graph_component is not None and selected is add_graph_component: self._add_graph_component(component) - elif selected is add_equation_component: + elif add_equation_component is not None and selected is add_equation_component: self._add_equation_component(component) elif selected is delete_component: self._delete_component(component) diff --git a/src/bedit_gui/documents/document.py b/src/bedit_gui/documents/document.py index d82efe7..38b8691 100644 --- a/src/bedit_gui/documents/document.py +++ b/src/bedit_gui/documents/document.py @@ -322,6 +322,16 @@ class Document(QObject): names.add(component.name) self.undo_stack.push(PasteComponents(self, target, components, icons)) + def paste_graph_components(self, graph_component: Component, components: dict[ComponentID, Component], icons: dict[ComponentID, Icon], positions: dict[ComponentID, tuple[int, int]]) -> None: + if not isinstance(graph_component.implementation, GraphImplementation) or not components: + return + target = graph_component.implementation.graph.components + 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, self.component_id(graph_component), positions)) + @staticmethod def _unique_component_name(components: dict[ComponentID, Component], name: str) -> str: return Document._unique_name({component.name for component in components.values()}, name) diff --git a/src/bedit_gui/ui/forms/graph_editor_widget.ui b/src/bedit_gui/ui/forms/graph_editor_widget.ui index f1ecac3..0dd6d1e 100644 --- a/src/bedit_gui/ui/forms/graph_editor_widget.ui +++ b/src/bedit_gui/ui/forms/graph_editor_widget.ui @@ -41,9 +41,8 @@ false - - - + + @@ -72,28 +71,28 @@ Zoom canvas to fit - + + + + :/icons/icons/edit-select.png:/icons/icons/edit-select.png + - Add Component + Mouse Mode - Add a component + Mouse Mode - + + + + :/icons/icons/network-connect.png:/icons/icons/network-connect.png + - Add Connection + Connection Mode - Add a connection - - - - - Delete - - - Delete selected graph items + Connection Mode diff --git a/src/bedit_gui/ui/forms/graph_editor_widget_ui.py b/src/bedit_gui/ui/forms/graph_editor_widget_ui.py index 1450483..9d65f34 100644 --- a/src/bedit_gui/ui/forms/graph_editor_widget_ui.py +++ b/src/bedit_gui/ui/forms/graph_editor_widget_ui.py @@ -30,12 +30,16 @@ class Ui_graphEditorWidget(object): icon = QIcon() icon.addFile(u":/icons/icons/zoom-original.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) self.actionZoomToFit.setIcon(icon) - self.actionAddComponent = QAction(graphEditorWidget) - self.actionAddComponent.setObjectName(u"actionAddComponent") - self.actionAddConnection = QAction(graphEditorWidget) - self.actionAddConnection.setObjectName(u"actionAddConnection") - self.actionDelete = QAction(graphEditorWidget) - self.actionDelete.setObjectName(u"actionDelete") + self.actionMouseMode = QAction(graphEditorWidget) + self.actionMouseMode.setObjectName(u"actionMouseMode") + icon1 = QIcon() + icon1.addFile(u":/icons/icons/edit-select.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) + self.actionMouseMode.setIcon(icon1) + self.actionConnectionMode = QAction(graphEditorWidget) + self.actionConnectionMode.setObjectName(u"actionConnectionMode") + icon2 = QIcon() + icon2.addFile(u":/icons/icons/network-connect.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) + self.actionConnectionMode.setIcon(icon2) self.verticalLayout = QVBoxLayout(graphEditorWidget) self.verticalLayout.setSpacing(0) self.verticalLayout.setObjectName(u"verticalLayout") @@ -57,9 +61,8 @@ class Ui_graphEditorWidget(object): self.graphToolBar.addAction(self.actionZoomToFit) - self.graphToolBar.addAction(self.actionAddComponent) - self.graphToolBar.addAction(self.actionAddConnection) - self.graphToolBar.addAction(self.actionDelete) + self.graphToolBar.addAction(self.actionMouseMode) + self.graphToolBar.addAction(self.actionConnectionMode) self.retranslateUi(graphEditorWidget) @@ -72,17 +75,13 @@ class Ui_graphEditorWidget(object): #if QT_CONFIG(tooltip) self.actionZoomToFit.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Zoom canvas to fit", None)) #endif // QT_CONFIG(tooltip) - self.actionAddComponent.setText(QCoreApplication.translate("graphEditorWidget", u"Add Component", None)) + self.actionMouseMode.setText(QCoreApplication.translate("graphEditorWidget", u"Mouse Mode", None)) #if QT_CONFIG(tooltip) - self.actionAddComponent.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Add a component", None)) + self.actionMouseMode.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Mouse Mode", None)) #endif // QT_CONFIG(tooltip) - self.actionAddConnection.setText(QCoreApplication.translate("graphEditorWidget", u"Add Connection", None)) + self.actionConnectionMode.setText(QCoreApplication.translate("graphEditorWidget", u"Connection Mode", None)) #if QT_CONFIG(tooltip) - self.actionAddConnection.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Add a connection", None)) -#endif // QT_CONFIG(tooltip) - self.actionDelete.setText(QCoreApplication.translate("graphEditorWidget", u"Delete", None)) -#if QT_CONFIG(tooltip) - self.actionDelete.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Delete selected graph items", None)) + self.actionConnectionMode.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Connection Mode", None)) #endif // QT_CONFIG(tooltip) self.graphToolBar.setWindowTitle(QCoreApplication.translate("graphEditorWidget", u"Graph tools", None)) # retranslateUi diff --git a/src/bedit_gui/views/graph_editor_widget.py b/src/bedit_gui/views/graph_editor_widget.py index f3f47ec..8cd31c8 100644 --- a/src/bedit_gui/views/graph_editor_widget.py +++ b/src/bedit_gui/views/graph_editor_widget.py @@ -3,7 +3,7 @@ from __future__ import annotations from math import hypot from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt, Signal -from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QWheelEvent +from PySide6.QtGui import QColor, QCursor, QPainter, QPainterPath, QPen, QWheelEvent from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsView, QWidget from bedit_core.models import BondCausality, BondConnection, Component, ComponentID, ConnectionID, GraphImplementation, SignalConnection @@ -55,7 +55,6 @@ class GraphConnectionItem(QGraphicsPathItem): super().__init__() self.setPath(self._connection_path(points, half_arrow, tick_at_source)) pen = QPen(QColor("#202020"), CONNECTION_WIDTH) - pen.setCosmetic(True) self.setPen(pen) self.setZValue(-1) @@ -137,9 +136,17 @@ class GraphComponentItem(QGraphicsPixmapItem): if self.pos() != self._drag_start: self.editor.finish_component_move(self.component_id, position) + def contextMenuEvent(self, event) -> None: + if not self.isSelected(): + self.scene().clearSelection() + self.setSelected(True) + self.editor.component_context_menu_requested.emit(self.component_id, event.screenPos()) + event.accept() + class GraphEditorWidget(QWidget): component_move_requested = Signal(object, object, object) + component_context_menu_requested = Signal(object, object) def __init__(self, parent: QWidget | None = None, snap_to_grid_size: int = 4) -> None: super().__init__(parent) @@ -298,6 +305,18 @@ class GraphEditorWidget(QWidget): def component(self) -> Component | None: return self._component + def selected_component_ids(self) -> list[ComponentID]: + return [item.component_id for item in self.scene.selectedItems() if isinstance(item, GraphComponentItem)] + + def paste_position(self) -> tuple[int, int]: + viewport = self.ui.graphicsView.viewport() + viewport_position = viewport.mapFromGlobal(QCursor.pos()) + if not viewport.rect().contains(viewport_position): + viewport_position = viewport.rect().center() + scene_position = self.ui.graphicsView.mapToScene(viewport_position) + size = self.snap_to_grid_size + return round(scene_position.x() / size) * size, round(scene_position.y() / size) * size + def zoom_to_fit(self) -> None: bounds = self.scene.itemsBoundingRect() if bounds.isEmpty():