Compare commits
4 Commits
54788583a4
...
346a963acc
| Author | SHA1 | Date | |
|---|---|---|---|
| 346a963acc | |||
| 5df9e53d58 | |||
| 51a1537c9c | |||
| a4daaa8798 |
2
.vscode/tasks.json
vendored
2
.vscode/tasks.json
vendored
@@ -41,7 +41,7 @@
|
|||||||
"reveal": "always",
|
"reveal": "always",
|
||||||
"panel": "dedicated"
|
"panel": "dedicated"
|
||||||
},
|
},
|
||||||
"problemMatcher": []
|
"problemMatcher": [],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
25
Se.icon.json
Normal file
25
Se.icon.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"shapes": {
|
||||||
|
"2a5bf670-c821-442b-9d46-1b07faa70722": {
|
||||||
|
"layer": 0,
|
||||||
|
"type": "text",
|
||||||
|
"pos": [
|
||||||
|
-48,
|
||||||
|
-32
|
||||||
|
],
|
||||||
|
"width": 96.0,
|
||||||
|
"height": 64.0,
|
||||||
|
"color": "#000000ff",
|
||||||
|
"bold": true,
|
||||||
|
"italic": false,
|
||||||
|
"size": 64.0,
|
||||||
|
"text": "Se"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"port_positions": {
|
||||||
|
"5b6a8a0c-0875-402c-bd74-11d086aac372": [
|
||||||
|
-8,
|
||||||
|
-8
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
129
src/bedit_gui/commands/component_command.py
Normal file
129
src/bedit_gui/commands/component_command.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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):
|
||||||
|
def __init__(self, document: object, parent: Component) -> None:
|
||||||
|
super().__init__("Add graph component")
|
||||||
|
if not isinstance(parent.implementation, GraphImplementation):
|
||||||
|
raise TypeError("parent component must have a graph implementation")
|
||||||
|
self.document = document
|
||||||
|
self.graph = parent.implementation.graph
|
||||||
|
self.component_id = ComponentID()
|
||||||
|
self.component = Component(name="New Graph Component", interface=Interface(), parameters={}, implementation=GraphImplementation(Graph()))
|
||||||
|
|
||||||
|
def redo(self) -> None:
|
||||||
|
self.graph.components[self.component_id] = self.component
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
def undo(self) -> None:
|
||||||
|
del self.graph.components[self.component_id]
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
|
||||||
|
class AddEmptyEquationComponent(QUndoCommand):
|
||||||
|
def __init__(self, document: object, parent: Component) -> None:
|
||||||
|
super().__init__("Add equation component")
|
||||||
|
if not isinstance(parent.implementation, GraphImplementation):
|
||||||
|
raise TypeError("parent component must have a graph implementation")
|
||||||
|
self.document = document
|
||||||
|
self.graph = parent.implementation.graph
|
||||||
|
self.component_id = ComponentID()
|
||||||
|
self.component = Component(name="New Equation Component", interface=Interface(), parameters={}, implementation=EquationImplementation())
|
||||||
|
|
||||||
|
def redo(self) -> None:
|
||||||
|
self.graph.components[self.component_id] = self.component
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
def undo(self) -> None:
|
||||||
|
del self.graph.components[self.component_id]
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteComponent(QUndoCommand):
|
||||||
|
def __init__(self, document: object, component: Component) -> None:
|
||||||
|
super().__init__("Delete component")
|
||||||
|
self.document = document
|
||||||
|
self.component = component
|
||||||
|
self.components, self.component_id, self.parent_graph = self._find_component(document.model.root, component)
|
||||||
|
self.component_index = list(self.components).index(self.component_id)
|
||||||
|
port_ids = set(component.interface.ports)
|
||||||
|
self.connections: list[tuple[int, ConnectionID, Connection]] = []
|
||||||
|
if self.parent_graph is not None:
|
||||||
|
for index, (connection_id, connection) in enumerate(self.parent_graph.connections.items()):
|
||||||
|
if connection.source in port_ids or connection.target in port_ids:
|
||||||
|
self.connections.append((index, connection_id, connection))
|
||||||
|
self.icons = {}
|
||||||
|
for component_id in self._component_ids(self.component_id, component):
|
||||||
|
icon = document.stored_component_icon(component_id)
|
||||||
|
if icon is not None:
|
||||||
|
self.icons[component_id] = icon
|
||||||
|
|
||||||
|
def redo(self) -> None:
|
||||||
|
if self.parent_graph is not None:
|
||||||
|
for _, connection_id, _ in self.connections:
|
||||||
|
self.parent_graph.connections.pop(connection_id, None)
|
||||||
|
self.components.pop(self.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)
|
||||||
|
|
||||||
|
def undo(self) -> None:
|
||||||
|
self._restore_item(self.components, self.component_id, self.component, self.component_index)
|
||||||
|
if self.parent_graph is not None:
|
||||||
|
for index, connection_id, connection in self.connections:
|
||||||
|
self._restore_item(self.parent_graph.connections, connection_id, connection, index)
|
||||||
|
self.document.model_changed.emit(self.document.model)
|
||||||
|
for component_id, icon in self.icons.items():
|
||||||
|
self.document._set_component_icon(component_id, icon)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _find_component(cls, components: dict[ComponentID, Component], target: Component, parent_graph: Graph | None = None) -> tuple[dict[ComponentID, Component], ComponentID, Graph | None]:
|
||||||
|
for component_id, component in components.items():
|
||||||
|
if component is target:
|
||||||
|
return components, component_id, parent_graph
|
||||||
|
if isinstance(component.implementation, GraphImplementation):
|
||||||
|
try:
|
||||||
|
return cls._find_component(component.implementation.graph.components, target, component.implementation.graph)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
raise ValueError("component is not part of this document")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _component_ids(cls, component_id: ComponentID, component: Component) -> list[ComponentID]:
|
||||||
|
component_ids = [component_id]
|
||||||
|
if isinstance(component.implementation, GraphImplementation):
|
||||||
|
for child_id, child in component.implementation.graph.components.items():
|
||||||
|
component_ids.extend(cls._component_ids(child_id, child))
|
||||||
|
return component_ids
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _restore_item(items: dict, item_id: object, item: object, index: int) -> None:
|
||||||
|
values = list(items.items())
|
||||||
|
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)
|
||||||
216
src/bedit_gui/controllers/clipboard_controller.py
Normal file
216
src/bedit_gui/controllers/clipboard_controller.py
Normal 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())
|
||||||
@@ -2,18 +2,21 @@ from collections.abc import Callable
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, QPoint, Qt
|
from PySide6.QtCore import QObject, QPoint, QSize, Qt
|
||||||
from PySide6.QtWidgets import QDialog, QMenu
|
from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu
|
||||||
|
|
||||||
from bedit_core.models import Component, 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
|
||||||
from bedit_gui.documents import Document
|
from bedit_gui.documents import Document
|
||||||
|
from bedit_gui.models import Icon
|
||||||
from bedit_gui.views.dialogs.interface_editor_dialog import InterfaceEditorDialog
|
from bedit_gui.views.dialogs.interface_editor_dialog import InterfaceEditorDialog
|
||||||
from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog
|
from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog
|
||||||
from bedit_gui.views.icon_editor_window import IconEditorWindow
|
from bedit_gui.views.icon_editor_window import IconEditorWindow
|
||||||
from bedit_gui.views.main_window import MainWindow
|
from bedit_gui.views.main_window import MainWindow
|
||||||
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
|
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
|
||||||
|
from bedit_gui.utils.icon import render_icon
|
||||||
|
|
||||||
|
ICON_SIZE = QSize(32, 32)
|
||||||
|
|
||||||
class InterfaceEditorLike(Protocol):
|
class InterfaceEditorLike(Protocol):
|
||||||
def exec(self) -> int: ...
|
def exec(self) -> int: ...
|
||||||
@@ -51,13 +54,28 @@ class DocumentTreeController(QObject):
|
|||||||
self.interface_editor_factory = interface_editor_factory
|
self.interface_editor_factory = interface_editor_factory
|
||||||
self.param_editor_factory = param_editor_factory
|
self.param_editor_factory = param_editor_factory
|
||||||
self._icon_editors: list[IconEditorWindow] = []
|
self._icon_editors: list[IconEditorWindow] = []
|
||||||
|
self._components: dict[ComponentID, Component] = {}
|
||||||
|
|
||||||
window.ui.documentTree.setModel(self.model)
|
window.ui.documentTree.setModel(self.model)
|
||||||
document.model_changed.connect(self._on_document_changed)
|
document.model_changed.connect(self._on_document_changed)
|
||||||
|
document.icon_changed.connect(self._on_icon_changed)
|
||||||
self.model.rename_document_requested.connect(self.document.rename)
|
self.model.rename_document_requested.connect(self.document.rename)
|
||||||
self.model.rename_component_requested.connect(self.document.rename_component)
|
self.model.rename_component_requested.connect(self.document.rename_component)
|
||||||
|
window.ui.actionDelete.triggered.connect(self.delete_selected_component)
|
||||||
|
|
||||||
|
# Add deselection with esc to this widget
|
||||||
|
window.ui.actionEscape.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||||
|
window.ui.documentTree.addAction(window.ui.actionEscape)
|
||||||
|
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.header().setStretchLastSection(False)
|
||||||
|
window.ui.documentTree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||||
|
window.ui.documentTree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||||
|
window.ui.documentTree.setColumnWidth(1, 56)
|
||||||
window.ui.documentTree.setContextMenuPolicy(
|
window.ui.documentTree.setContextMenuPolicy(
|
||||||
Qt.ContextMenuPolicy.CustomContextMenu
|
Qt.ContextMenuPolicy.CustomContextMenu
|
||||||
)
|
)
|
||||||
@@ -70,11 +88,28 @@ class DocumentTreeController(QObject):
|
|||||||
def _on_document_changed(self, model: CoreDocument) -> None:
|
def _on_document_changed(self, model: CoreDocument) -> None:
|
||||||
"""Rebuild the tree whenever New/Open replaces the core document."""
|
"""Rebuild the tree whenever New/Open replaces the core document."""
|
||||||
self.model.set_document(model)
|
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_icon(icon, component.interface.ports, ICON_SIZE))
|
||||||
|
|
||||||
# Optional presentation behavior. Later, you could instead remember
|
# Optional presentation behavior. Later, you could instead remember
|
||||||
# expanded component IDs and restore only those nodes.
|
# expanded component IDs and restore only those nodes.
|
||||||
self.window.ui.documentTree.expandAll()
|
self.window.ui.documentTree.expandAll()
|
||||||
|
|
||||||
|
def _on_icon_changed(self, component_id: ComponentID, icon: object) -> None:
|
||||||
|
component = self._components.get(component_id)
|
||||||
|
if component is None:
|
||||||
|
return
|
||||||
|
self.model.set_component_icon(component_id, render_icon(icon if isinstance(icon, Icon) else Icon(), component.interface.ports, ICON_SIZE))
|
||||||
|
|
||||||
|
def _collect_components(self, components: dict[ComponentID, Component]) -> None:
|
||||||
|
for component_id, component in components.items():
|
||||||
|
self._components[component_id] = component
|
||||||
|
if isinstance(component.implementation, GraphImplementation):
|
||||||
|
self._collect_components(component.implementation.graph.components)
|
||||||
|
|
||||||
def _show_context_menu(self, position: QPoint) -> None:
|
def _show_context_menu(self, position: QPoint) -> None:
|
||||||
index = self.window.ui.documentTree.indexAt(position)
|
index = self.window.ui.documentTree.indexAt(position)
|
||||||
component = self.model.value(index)
|
component = self.model.value(index)
|
||||||
@@ -85,6 +120,11 @@ class DocumentTreeController(QObject):
|
|||||||
edit_interface = menu.addAction("Edit Interface")
|
edit_interface = menu.addAction("Edit Interface")
|
||||||
edit_params = menu.addAction("Edit Parameters")
|
edit_params = menu.addAction("Edit Parameters")
|
||||||
edit_icon = menu.addAction("Edit Icon")
|
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()
|
||||||
|
delete_component = menu.addAction("Delete Component")
|
||||||
selected = menu.exec(
|
selected = menu.exec(
|
||||||
self.window.ui.documentTree.viewport().mapToGlobal(position)
|
self.window.ui.documentTree.viewport().mapToGlobal(position)
|
||||||
)
|
)
|
||||||
@@ -94,6 +134,12 @@ class DocumentTreeController(QObject):
|
|||||||
self._edit_params(component)
|
self._edit_params(component)
|
||||||
elif selected is edit_icon:
|
elif selected is edit_icon:
|
||||||
self._edit_icon(component)
|
self._edit_icon(component)
|
||||||
|
elif selected is add_graph_component:
|
||||||
|
self._add_graph_component(component)
|
||||||
|
elif selected is add_equation_component:
|
||||||
|
self._add_equation_component(component)
|
||||||
|
elif selected is delete_component:
|
||||||
|
self._delete_component(component)
|
||||||
|
|
||||||
def _edit_interface(self, component: Component) -> None:
|
def _edit_interface(self, component: Component) -> None:
|
||||||
dialog = self.interface_editor_factory(
|
dialog = self.interface_editor_factory(
|
||||||
@@ -122,3 +168,40 @@ class DocumentTreeController(QObject):
|
|||||||
def _icon_editor_closed(self, editor: IconEditorWindow, *_args: object) -> None:
|
def _icon_editor_closed(self, editor: IconEditorWindow, *_args: object) -> None:
|
||||||
if editor in self._icon_editors:
|
if editor in self._icon_editors:
|
||||||
self._icon_editors.remove(editor)
|
self._icon_editors.remove(editor)
|
||||||
|
|
||||||
|
def _add_graph_component(self, component: Component) -> None:
|
||||||
|
self.document.add_empty_graph_component(component)
|
||||||
|
|
||||||
|
def _add_equation_component(self, component: Component) -> None:
|
||||||
|
self.document.add_empty_equation_component(component)
|
||||||
|
|
||||||
|
def deselect(self) -> None:
|
||||||
|
self.window.ui.documentTree.selectionModel().clear()
|
||||||
|
|
||||||
|
def delete_selected_component(self) -> None:
|
||||||
|
focused = self.window.ui.documentTree.hasFocus()
|
||||||
|
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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand,
|
|||||||
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 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
|
||||||
|
|
||||||
@@ -195,3 +196,48 @@ class Document(QObject):
|
|||||||
for command in commands:
|
for command in commands:
|
||||||
self.undo_stack.push(command)
|
self.undo_stack.push(command)
|
||||||
self.undo_stack.endMacro()
|
self.undo_stack.endMacro()
|
||||||
|
|
||||||
|
def add_empty_graph_component(self, component: Component) -> None:
|
||||||
|
if isinstance(component.implementation, GraphImplementation):
|
||||||
|
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):
|
||||||
|
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}"
|
||||||
|
|||||||
BIN
src/bedit_gui/resources/icons/edit-delete.png
Normal file
BIN
src/bedit_gui/resources/icons/edit-delete.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,5 +1,6 @@
|
|||||||
<RCC>
|
<RCC>
|
||||||
<qresource prefix="icons">
|
<qresource prefix="icons">
|
||||||
|
<file>icons/edit-delete.png</file>
|
||||||
<file>icons/dialog-close.png</file>
|
<file>icons/dialog-close.png</file>
|
||||||
<file>icons/list-remove.png</file>
|
<file>icons/list-remove.png</file>
|
||||||
<file>icons/list-add.png</file>
|
<file>icons/list-add.png</file>
|
||||||
|
|||||||
42
src/bedit_gui/services/clipboard.py
Normal file
42
src/bedit_gui/services/clipboard.py
Normal 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()
|
||||||
82
src/bedit_gui/services/component_clipboard.py
Normal file
82
src/bedit_gui/services/component_clipboard.py
Normal 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
|
||||||
@@ -52,6 +52,12 @@
|
|||||||
<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="separator"/>
|
||||||
<addaction name="actionSettings"/>
|
<addaction name="actionSettings"/>
|
||||||
</widget>
|
</widget>
|
||||||
<widget class="QMenu" name="menuView">
|
<widget class="QMenu" name="menuView">
|
||||||
@@ -101,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">
|
||||||
@@ -294,6 +303,89 @@
|
|||||||
<string>Settings</string>
|
<string>Settings</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
|
<action name="actionDelete">
|
||||||
|
<property name="icon">
|
||||||
|
<iconset resource="../../resources/resources.qrc">
|
||||||
|
<normaloff>:/icons/icons/edit-delete.png</normaloff>:/icons/icons/edit-delete.png</iconset>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Delete</string>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Delete selected</string>
|
||||||
|
</property>
|
||||||
|
<property name="shortcut">
|
||||||
|
<string>Del</string>
|
||||||
|
</property>
|
||||||
|
<property name="menuRole">
|
||||||
|
<enum>QAction::MenuRole::NoRole</enum>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionEscape">
|
||||||
|
<property name="text">
|
||||||
|
<string>Escape</string>
|
||||||
|
</property>
|
||||||
|
<property name="shortcut">
|
||||||
|
<string>Esc</string>
|
||||||
|
</property>
|
||||||
|
<property name="menuRole">
|
||||||
|
<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>
|
</widget>
|
||||||
<resources>
|
<resources>
|
||||||
<include location="../../resources/resources.qrc"/>
|
<include location="../../resources/resources.qrc"/>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import QRectF
|
from PySide6.QtCore import QRectF, QSize, Qt
|
||||||
|
from PySide6.QtGui import QBrush, QColor, QFont, QIcon, QPainter, QPen, QPixmap
|
||||||
|
|
||||||
from bedit_gui.models import Icon, Line, Rectangle, Text
|
from bedit_core.models import Port, PortID, SignalDirection
|
||||||
|
from bedit_gui.models import Icon, Line, LineType, Rectangle, Text
|
||||||
|
|
||||||
PORT_SIZE = 16
|
PORT_SIZE = 16
|
||||||
|
DEFAULT_ICON_SIZE = QSize(48, 48)
|
||||||
|
|
||||||
|
|
||||||
def get_bounding_box(icon: Icon) -> QRectF:
|
def get_bounding_box(icon: Icon) -> QRectF:
|
||||||
@@ -30,3 +33,64 @@ def get_bounding_box(icon: Icon) -> QRectF:
|
|||||||
right = max(point[0] for point in points)
|
right = max(point[0] for point in points)
|
||||||
bottom = max(point[1] for point in points)
|
bottom = max(point[1] for point in points)
|
||||||
return QRectF(left, top, right - left, bottom - top)
|
return QRectF(left, top, right - left, bottom - top)
|
||||||
|
|
||||||
|
|
||||||
|
def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICON_SIZE, render_ports: bool = False) -> QIcon:
|
||||||
|
pixmap = QPixmap(size)
|
||||||
|
pixmap.fill(Qt.GlobalColor.transparent)
|
||||||
|
bounds = get_bounding_box(icon)
|
||||||
|
if not icon.shapes and not icon.port_positions:
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
available_width = max(1, size.width() - 4)
|
||||||
|
available_height = max(1, size.height() - 4)
|
||||||
|
scale = min(available_width / max(1, bounds.width()), available_height / max(1, bounds.height()))
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
painter.translate(size.width() / 2, size.height() / 2)
|
||||||
|
painter.scale(scale, scale)
|
||||||
|
painter.translate(-bounds.center())
|
||||||
|
|
||||||
|
for shape in sorted(icon.shapes.values(), key=lambda item: item.layer):
|
||||||
|
if isinstance(shape, Rectangle):
|
||||||
|
painter.setPen(_line_pen(shape.line_type, shape.line_thickness, shape.line_color))
|
||||||
|
painter.setBrush(QBrush(_color(shape.fill_color)))
|
||||||
|
painter.drawRoundedRect(QRectF(shape.pos[0], shape.pos[1], shape.width, shape.height), shape.corner_radius, shape.corner_radius)
|
||||||
|
elif isinstance(shape, Text):
|
||||||
|
font = QFont()
|
||||||
|
font.setPixelSize(max(1, round(shape.size)))
|
||||||
|
font.setBold(shape.bold)
|
||||||
|
font.setItalic(shape.italic)
|
||||||
|
painter.setFont(font)
|
||||||
|
painter.setPen(_color(shape.color))
|
||||||
|
painter.drawText(QRectF(shape.pos[0], shape.pos[1], shape.width, shape.height), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, shape.text)
|
||||||
|
elif isinstance(shape, Line):
|
||||||
|
painter.setPen(_line_pen(shape.line_type, shape.line_thickness, shape.line_color))
|
||||||
|
painter.drawLine(shape.pos[0], shape.pos[1], shape.end[0], shape.end[1])
|
||||||
|
|
||||||
|
if render_ports:
|
||||||
|
painter.setPen(QPen(QColor("#000000")))
|
||||||
|
for port_id, position in icon.port_positions.items():
|
||||||
|
port = ports.get(port_id)
|
||||||
|
if port is None:
|
||||||
|
continue
|
||||||
|
color = QColor("#000000") if port.direction is SignalDirection.INPUT else QColor("#ffffff")
|
||||||
|
painter.setBrush(QBrush(color))
|
||||||
|
painter.drawRect(position[0], position[1], PORT_SIZE, PORT_SIZE)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
def _line_pen(line_type: LineType, thickness: float, color: str) -> QPen:
|
||||||
|
styles = {LineType.SOLID: Qt.PenStyle.SolidLine, LineType.DASHED: Qt.PenStyle.DashLine, LineType.DOTTED: Qt.PenStyle.DotLine, LineType.DASH_DOT: Qt.PenStyle.DashDotLine}
|
||||||
|
if line_type is LineType.NONE:
|
||||||
|
return QPen(Qt.PenStyle.NoPen)
|
||||||
|
return QPen(_color(color), thickness, styles[line_type])
|
||||||
|
|
||||||
|
|
||||||
|
def _color(value: str) -> QColor:
|
||||||
|
color = value.removeprefix("#")
|
||||||
|
if len(color) == 8:
|
||||||
|
return QColor(int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), int(color[6:8], 16))
|
||||||
|
return QColor(value)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal
|
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal
|
||||||
|
from PySide6.QtGui import QIcon
|
||||||
|
|
||||||
from bedit_core.models import Component, ComponentID, GraphImplementation
|
from bedit_core.models import Component, ComponentID, GraphImplementation
|
||||||
from bedit_core.models import Document as CoreDocument
|
from bedit_core.models import Document as CoreDocument
|
||||||
@@ -12,6 +13,7 @@ from bedit_core.models import Document as CoreDocument
|
|||||||
class DocumentTreeNode:
|
class DocumentTreeNode:
|
||||||
name: str
|
name: str
|
||||||
value: object
|
value: object
|
||||||
|
component_id: ComponentID | None
|
||||||
parent: DocumentTreeNode | None
|
parent: DocumentTreeNode | None
|
||||||
children: list[DocumentTreeNode]
|
children: list[DocumentTreeNode]
|
||||||
|
|
||||||
@@ -23,23 +25,40 @@ class DocumentTreeModel(QAbstractItemModel):
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._document: CoreDocument | None = None
|
self._document: CoreDocument | None = None
|
||||||
self._root = DocumentTreeNode("Document", None, None, [])
|
self._root = DocumentTreeNode("Document", None, None, None, [])
|
||||||
|
self._component_icons: dict[ComponentID, QIcon] = {}
|
||||||
|
self._component_nodes: dict[ComponentID, DocumentTreeNode] = {}
|
||||||
|
|
||||||
def set_document(self, document: CoreDocument) -> None:
|
def set_document(self, document: CoreDocument) -> None:
|
||||||
self.beginResetModel()
|
self.beginResetModel()
|
||||||
self._document = document
|
self._document = document
|
||||||
|
self._component_icons = {}
|
||||||
|
self._component_nodes = {}
|
||||||
self._root = self._build_tree(document)
|
self._root = self._build_tree(document)
|
||||||
self.endResetModel()
|
self.endResetModel()
|
||||||
|
|
||||||
|
def set_component_icon(self, component_id: ComponentID, icon: QIcon) -> None:
|
||||||
|
node = self._component_nodes.get(component_id)
|
||||||
|
if node is None or node.parent is None:
|
||||||
|
return
|
||||||
|
self._component_icons[component_id] = icon
|
||||||
|
row = node.parent.children.index(node)
|
||||||
|
index = self.createIndex(row, 1, node)
|
||||||
|
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DecorationRole])
|
||||||
|
|
||||||
def rowCount(self, parent: QModelIndex | None = None) -> int:
|
def rowCount(self, parent: QModelIndex | None = None) -> int:
|
||||||
|
if parent is not None and parent.isValid() and parent.column() != 0:
|
||||||
|
return 0
|
||||||
return len(self._node(parent).children)
|
return len(self._node(parent).children)
|
||||||
|
|
||||||
def columnCount(self, _parent: QModelIndex | None = None) -> int:
|
def columnCount(self, _parent: QModelIndex | None = None) -> int:
|
||||||
return 1
|
return 2
|
||||||
|
|
||||||
def index(self, row: int, column: int, parent: QModelIndex | None = None) -> QModelIndex:
|
def index(self, row: int, column: int, parent: QModelIndex | None = None) -> QModelIndex:
|
||||||
|
if parent is not None and parent.isValid() and parent.column() != 0:
|
||||||
|
return QModelIndex()
|
||||||
parent_node = self._node(parent)
|
parent_node = self._node(parent)
|
||||||
if column != 0 or row < 0 or row >= len(parent_node.children):
|
if column not in (0,1) or row < 0 or row >= len(parent_node.children):
|
||||||
return QModelIndex()
|
return QModelIndex()
|
||||||
return self.createIndex(row, column, parent_node.children[row])
|
return self.createIndex(row, column, parent_node.children[row])
|
||||||
|
|
||||||
@@ -69,13 +88,19 @@ class DocumentTreeModel(QAbstractItemModel):
|
|||||||
if not isinstance(node, DocumentTreeNode):
|
if not isinstance(node, DocumentTreeNode):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if index.column() == 0:
|
||||||
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
|
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
|
||||||
return node.name
|
return node.name
|
||||||
|
elif index.column() == 1:
|
||||||
|
if role == Qt.ItemDataRole.DecorationRole:
|
||||||
|
return self._component_icons.get(node.component_id)
|
||||||
|
if role == Qt.ItemDataRole.TextAlignmentRole:
|
||||||
|
return Qt.AlignmentFlag.AlignCenter
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def setData(self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole) -> bool:
|
def setData(self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole) -> bool:
|
||||||
if role != Qt.ItemDataRole.EditRole or not index.isValid():
|
if role != Qt.ItemDataRole.EditRole or not index.isValid() or index.column() != 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
node = index.internalPointer()
|
node = index.internalPointer()
|
||||||
@@ -115,7 +140,7 @@ class DocumentTreeModel(QAbstractItemModel):
|
|||||||
node = index.internalPointer()
|
node = index.internalPointer()
|
||||||
|
|
||||||
# Make the document root node editable
|
# Make the document root node editable
|
||||||
if isinstance(node, DocumentTreeNode) and isinstance(node.value, (CoreDocument, Component)):
|
if index.column() == 0 and isinstance(node, DocumentTreeNode) and isinstance(node.value, (CoreDocument, Component)):
|
||||||
flags |= Qt.ItemFlag.ItemIsEditable
|
flags |= Qt.ItemFlag.ItemIsEditable
|
||||||
|
|
||||||
return flags
|
return flags
|
||||||
@@ -125,17 +150,19 @@ class DocumentTreeModel(QAbstractItemModel):
|
|||||||
root = DocumentTreeNode(
|
root = DocumentTreeNode(
|
||||||
name="",
|
name="",
|
||||||
value=None,
|
value=None,
|
||||||
|
component_id=None,
|
||||||
parent=None,
|
parent=None,
|
||||||
children=[],
|
children=[],
|
||||||
)
|
)
|
||||||
# Add itself as a child so the document root is visible in the tree
|
# Add itself as a child so the document root is visible in the tree
|
||||||
document_root = DocumentTreeNode(name=document.name, value=document, parent=root, children=[])
|
document_root = DocumentTreeNode(name=document.name, value=document, component_id=None, parent=root, children=[])
|
||||||
root.children.append(document_root)
|
root.children.append(document_root)
|
||||||
|
|
||||||
def _list_children(root: DocumentTreeNode, components: dict[ComponentID, Component]) -> None:
|
def _list_children(root: DocumentTreeNode, components: dict[ComponentID, Component]) -> None:
|
||||||
for component in components.values():
|
for component_id, component in components.items():
|
||||||
component_node = DocumentTreeNode(name=component.name, value=component, parent=root, children=[])
|
component_node = DocumentTreeNode(name=component.name, value=component, component_id=component_id, parent=root, children=[])
|
||||||
root.children.append(component_node)
|
root.children.append(component_node)
|
||||||
|
self._component_nodes[component_id] = component_node
|
||||||
|
|
||||||
if isinstance(component.implementation, GraphImplementation):
|
if isinstance(component.implementation, GraphImplementation):
|
||||||
_list_children(component_node, component.implementation.graph.components)
|
_list_children(component_node, component.implementation.graph.components)
|
||||||
|
|||||||
@@ -438,8 +438,8 @@
|
|||||||
-32,
|
-32,
|
||||||
-32
|
-32
|
||||||
],
|
],
|
||||||
"width": 64,
|
"width": 64.0,
|
||||||
"height": 64,
|
"height": 64.0,
|
||||||
"color": "#000000ff",
|
"color": "#000000ff",
|
||||||
"bold": true,
|
"bold": true,
|
||||||
"italic": false,
|
"italic": false,
|
||||||
@@ -492,8 +492,8 @@
|
|||||||
-32,
|
-32,
|
||||||
-32
|
-32
|
||||||
],
|
],
|
||||||
"width": 64,
|
"width": 64.0,
|
||||||
"height": 64,
|
"height": 64.0,
|
||||||
"color": "#000000ff",
|
"color": "#000000ff",
|
||||||
"bold": true,
|
"bold": true,
|
||||||
"italic": false,
|
"italic": false,
|
||||||
@@ -521,8 +521,8 @@
|
|||||||
-32,
|
-32,
|
||||||
-32
|
-32
|
||||||
],
|
],
|
||||||
"width": 64,
|
"width": 64.0,
|
||||||
"height": 64,
|
"height": 64.0,
|
||||||
"color": "#000000ff",
|
"color": "#000000ff",
|
||||||
"bold": true,
|
"bold": true,
|
||||||
"italic": false,
|
"italic": false,
|
||||||
@@ -546,8 +546,8 @@
|
|||||||
-32,
|
-32,
|
||||||
-32
|
-32
|
||||||
],
|
],
|
||||||
"width": 64,
|
"width": 64.0,
|
||||||
"height": 64,
|
"height": 64.0,
|
||||||
"color": "#000000ff",
|
"color": "#000000ff",
|
||||||
"bold": true,
|
"bold": true,
|
||||||
"italic": false,
|
"italic": false,
|
||||||
@@ -561,6 +561,31 @@
|
|||||||
-8
|
-8
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"2804f2f1-6123-4a53-aff1-87a3a8202711": {
|
||||||
|
"shapes": {
|
||||||
|
"2a5bf670-c821-442b-9d46-1b07faa70722": {
|
||||||
|
"layer": 0,
|
||||||
|
"type": "text",
|
||||||
|
"pos": [
|
||||||
|
-48,
|
||||||
|
-32
|
||||||
|
],
|
||||||
|
"width": 96.0,
|
||||||
|
"height": 64.0,
|
||||||
|
"color": "#000000ff",
|
||||||
|
"bold": true,
|
||||||
|
"italic": false,
|
||||||
|
"size": 64.0,
|
||||||
|
"text": "Se"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"port_positions": {
|
||||||
|
"5b6a8a0c-0875-402c-bd74-11d086aac372": [
|
||||||
|
-8,
|
||||||
|
-8
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user