975 lines
42 KiB
Python
975 lines
42 KiB
Python
import json
|
||
from copy import deepcopy
|
||
from pathlib import Path
|
||
|
||
from PySide6.QtCore import QByteArray, QMimeData, Qt, Slot
|
||
from PySide6.QtGui import QCloseEvent
|
||
from PySide6.QtWidgets import (
|
||
QButtonGroup,
|
||
QApplication,
|
||
QFileDialog,
|
||
QMainWindow,
|
||
QMenu,
|
||
QMessageBox,
|
||
QTabWidget,
|
||
)
|
||
|
||
from bedit.core.model import Component, Connection
|
||
from bedit.core.application_log import get_logger
|
||
from bedit.core.serializer import DocumentSerializer
|
||
from bedit.core.simulation import Simulation
|
||
from bedit.gui.controllers.document import DocumentController
|
||
from bedit.gui.dialogs.component_options import ComponentOptionsDialog
|
||
from bedit.gui.dialogs.graph_parameters import GraphParametersDialog
|
||
from bedit.gui.dialogs.item_options import ItemOptionsDialog
|
||
from bedit.gui.models.library_repository import LibraryRepository
|
||
from bedit.gui.models.library_tree import (
|
||
COMPONENT_MIME_TYPE,
|
||
COMPONENT_ROLE,
|
||
COMPONENT_ID_ROLE,
|
||
COMPONENT_INSTANCE_ROLE,
|
||
ITEM_KIND_ROLE,
|
||
DocumentTreeModel,
|
||
LibraryTreeModel,
|
||
)
|
||
from bedit.gui.graphics.workspace import SELECTION_MIME_TYPE
|
||
from bedit.gui.dialogs.settings import SettingsDialog
|
||
from bedit.gui.dialogs.port_options import PortOptionsDialog
|
||
from bedit.gui.dialogs.parameter_options import ParameterOptionsDialog
|
||
from bedit.gui.dialogs.simulation_settings import SimulationSettingsDialog
|
||
from bedit.gui.simulation_window import SimulationWindow
|
||
from bedit.gui.preferences import application_settings
|
||
from bedit.gui.simulation_reload import reload_simulation
|
||
from bedit.gui.generated.ui_main_window import Ui_MainWindow
|
||
from bedit.gui.application_log import ApplicationLogHandler
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
"""Application shell and owner of the single active document."""
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.ui = Ui_MainWindow()
|
||
self.ui.setupUi(self)
|
||
self.application_logger = get_logger()
|
||
self.log = get_logger("ui")
|
||
self.log_handler = ApplicationLogHandler(self.ui.logOutput)
|
||
self.application_logger.addHandler(self.log_handler)
|
||
self.application_logger.setLevel("INFO")
|
||
self.ui.workspaceVerticalSplitter.setSizes([580, 160])
|
||
self.log.info("BEdit started")
|
||
self.settings = application_settings()
|
||
self._applying_text_definition = False
|
||
# Keep a Python-owned top-level window. Giving it MainWindow as its Qt
|
||
# parent makes some Linux window managers inherit the BEdit window icon.
|
||
self._simulation_window = SimulationWindow()
|
||
|
||
self.libraries = LibraryRepository(self)
|
||
self.simulation = Simulation(
|
||
openmodelica_path=SettingsDialog.openmodelica_path(self.settings)
|
||
)
|
||
self.document_controller = DocumentController(self, simulation=self.simulation)
|
||
self.library_tree_model = LibraryTreeModel(
|
||
self.libraries,
|
||
self.document_controller,
|
||
self,
|
||
)
|
||
self.document_tree_model = DocumentTreeModel(self.document_controller, self)
|
||
self._configure_models()
|
||
QApplication.clipboard().dataChanged.connect(self._update_edit_actions)
|
||
self._connect_actions()
|
||
self._populate_view_menu()
|
||
self._restore_window_geometry()
|
||
|
||
self.ui.leftDockHost.setWindowFlags(Qt.WindowType.Widget)
|
||
self.ui.leftDockHost.show()
|
||
self.ui.panel_libraries.show()
|
||
self.ui.panel_document.show()
|
||
self.ui.leftDockHost.setTabPosition(
|
||
Qt.DockWidgetArea.LeftDockWidgetArea, QTabWidget.TabPosition.North
|
||
)
|
||
self.ui.leftDockHost.tabifyDockWidget(self.ui.panel_document, self.ui.panel_libraries)
|
||
self.ui.panel_document.raise_()
|
||
self.ui.workspaceSplitter.setSizes([280, 720])
|
||
self.reload_libraries()
|
||
self._active_graph_changed()
|
||
self._update_title()
|
||
|
||
def _configure_models(self) -> None:
|
||
self.ui.treeView.setModel(self.library_tree_model)
|
||
self.ui.treeView.setHeaderHidden(True)
|
||
self.ui.treeView.setDragEnabled(True)
|
||
self.ui.treeView.setDragDropMode(self.ui.treeView.DragDropMode.DragOnly)
|
||
self.ui.treeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||
self.ui.treeView.customContextMenuRequested.connect(self.show_external_library_context_menu)
|
||
self.library_tree_model.rebuilt.connect(self.ui.treeView.expandAll)
|
||
self.ui.documentTreeView.setModel(self.document_tree_model)
|
||
self.ui.documentTreeView.setHeaderHidden(True)
|
||
self.ui.documentTreeView.setDragEnabled(True)
|
||
self.ui.documentTreeView.setDragDropMode(self.ui.documentTreeView.DragDropMode.DragOnly)
|
||
self.ui.documentTreeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||
self.ui.documentTreeView.customContextMenuRequested.connect(self.show_library_context_menu)
|
||
self.ui.documentTreeView.doubleClicked.connect(self.activate_tree_component)
|
||
self.ui.documentTreeView.clicked.connect(
|
||
lambda _index: self._update_edit_actions()
|
||
)
|
||
self.ui.treeView.clicked.connect(lambda _index: self._update_edit_actions())
|
||
self.document_tree_model.rebuilt.connect(self.ui.documentTreeView.expandAll)
|
||
self.ui.graphView.set_model(self.document_controller)
|
||
self.ui.graphView.componentOptionsRequested.connect(self.show_component_options)
|
||
self.ui.graphView.componentPortOptionsRequested.connect(self.show_component_port_options)
|
||
self.ui.graphView.componentParameterOptionsRequested.connect(
|
||
self.show_component_parameter_options
|
||
)
|
||
self.ui.graphView.portOptionsRequested.connect(self.show_port_options)
|
||
self.ui.graphView.connectionOptionsRequested.connect(self.show_connection_options)
|
||
self.ui.graphView.selectionAvailabilityChanged.connect(
|
||
lambda _available: self._update_edit_actions()
|
||
)
|
||
self.ui.graphView.toolModeShortcutRequested.connect(self.set_graph_tool)
|
||
self.mode_button_group = QButtonGroup(self)
|
||
self.mode_button_group.setExclusive(True)
|
||
self.mode_button_group.addButton(self.ui.pointerToolButton)
|
||
self.mode_button_group.addButton(self.ui.connectToolButton)
|
||
self.mode_button_group.addButton(self.ui.boxToolButton)
|
||
self.mode_button_group.addButton(self.ui.lineToolButton)
|
||
self.mode_button_group.addButton(self.ui.textToolButton)
|
||
self.ui.navigateUpButton.clicked.connect(self.navigate_up)
|
||
self.ui.navigateDownButton.clicked.connect(self.navigate_down)
|
||
self.ui.pointerToolButton.clicked.connect(lambda: self.set_graph_tool("pointer"))
|
||
self.ui.connectToolButton.clicked.connect(lambda: self.set_graph_tool("connect"))
|
||
self.ui.boxToolButton.clicked.connect(lambda: self.set_graph_tool("box"))
|
||
self.ui.lineToolButton.clicked.connect(lambda: self.set_graph_tool("line"))
|
||
self.ui.textToolButton.clicked.connect(lambda: self.set_graph_tool("text"))
|
||
self.ui.rotateToolButton.clicked.connect(self.ui.graphView.rotate_selected)
|
||
self.ui.textDefinitionEditor.definitionEdited.connect(
|
||
self.apply_text_definition
|
||
)
|
||
self.document_controller.activeGraphChanged.connect(self._active_graph_changed)
|
||
self.document_controller.textDefinitionChanged.connect(
|
||
self._text_definition_changed
|
||
)
|
||
|
||
def _connect_actions(self) -> None:
|
||
self.ui.actionNew.triggered.connect(self.new_document)
|
||
self.ui.actionOpen.triggered.connect(self.open_document)
|
||
self.ui.actionReloadLibraries.triggered.connect(self.reload_libraries)
|
||
self.ui.actionReloadSimulation.triggered.connect(self.reload_simulation_code)
|
||
self.ui.actionSave.triggered.connect(self.save_document)
|
||
self.ui.actionSaveAs.triggered.connect(self.save_document_as)
|
||
self.ui.actionClose.triggered.connect(self.close_document)
|
||
self.ui.actionExit.triggered.connect(self.close)
|
||
self.ui.actionSettings.triggered.connect(self.show_settings)
|
||
self.ui.actionAbout.triggered.connect(self.show_about)
|
||
self.ui.actionAboutQt.triggered.connect(
|
||
lambda: QMessageBox.aboutQt(self, "About Qt Framework")
|
||
)
|
||
self.ui.actionUndo.triggered.connect(self.document_controller.undo_stack.undo)
|
||
self.ui.actionRedo.triggered.connect(self.document_controller.undo_stack.redo)
|
||
self.ui.actionDelete.triggered.connect(self.ui.graphView.delete_selected)
|
||
self.ui.actionCopy.triggered.connect(self.copy_selection)
|
||
self.ui.actionCut.triggered.connect(self.cut_selection)
|
||
self.ui.actionPaste.triggered.connect(self.paste_selection)
|
||
self.ui.actionSelectAll.triggered.connect(self.ui.graphView.select_all)
|
||
self.ui.actionRotateClockwise.triggered.connect(self.ui.graphView.rotate_selected)
|
||
self.addAction(self.ui.actionRotateClockwise)
|
||
self.ui.actionZoomIn.triggered.connect(self.ui.graphView.zoom_in)
|
||
self.ui.actionZoomOut.triggered.connect(self.ui.graphView.zoom_out)
|
||
self.ui.actionCenterView.triggered.connect(self.ui.graphView.center_workspace)
|
||
self.ui.actionSimulationSettings.triggered.connect(
|
||
self.show_simulation_settings
|
||
)
|
||
self.ui.actionGraphParameters.triggered.connect(self.show_graph_parameters)
|
||
self.ui.actionCompose.triggered.connect(self.compose_active_graph)
|
||
self.ui.actionExportModel.triggered.connect(self.export_model)
|
||
self.ui.actionSimulationWindow.triggered.connect(self.show_simulation_window)
|
||
self.ui.actionRunSimulation.triggered.connect(self.run_simulation)
|
||
self.document_controller.undo_stack.canUndoChanged.connect(self.ui.actionUndo.setEnabled)
|
||
self.document_controller.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled)
|
||
self.document_controller.modifiedChanged.connect(lambda _modified: self._update_title())
|
||
self.document_controller.filePathChanged.connect(lambda _path: self._update_title())
|
||
self.document_controller.documentOpenedChanged.connect(self._document_opened_changed)
|
||
self.ui.actionUndo.setEnabled(False)
|
||
self.ui.actionRedo.setEnabled(False)
|
||
self._update_edit_actions()
|
||
self._document_opened_changed(False)
|
||
|
||
def _populate_view_menu(self) -> None:
|
||
for panel in (self.ui.panel_document, self.ui.panel_libraries):
|
||
self.ui.menuPanels.addAction(panel.toggleViewAction())
|
||
for toolbar in (
|
||
self.ui.fileToolbar,
|
||
self.ui.editToolbar,
|
||
self.ui.cameraToolbar,
|
||
self.ui.simulationToolbar,
|
||
):
|
||
self.ui.menuToolbars.addAction(toolbar.toggleViewAction())
|
||
|
||
def reload_libraries(self) -> None:
|
||
self.libraries.load_paths(SettingsDialog.library_paths(self.settings))
|
||
self.ui.treeView.expandAll()
|
||
self.log.info("Reloaded %d libraries", len(self.libraries.libraries))
|
||
if self.libraries.load_warnings:
|
||
for warning in self.libraries.load_warnings:
|
||
self.log.warning("Library load failed: %s", warning)
|
||
QMessageBox.warning(
|
||
self,
|
||
"Some libraries could not be loaded",
|
||
"\n".join(self.libraries.load_warnings),
|
||
)
|
||
|
||
@Slot()
|
||
def reload_simulation_code(self) -> None:
|
||
try:
|
||
replacement = reload_simulation(self.simulation)
|
||
except Exception as error:
|
||
self.log.exception("Could not reload simulation code")
|
||
QMessageBox.critical(self, "Could not reload simulation code", str(error))
|
||
return
|
||
self.simulation = replacement
|
||
self.document_controller.simulation = replacement
|
||
self.log.info("Reloaded simulation code")
|
||
|
||
@Slot()
|
||
def show_simulation_settings(self) -> None:
|
||
component = self.document_controller.active_component
|
||
if component is None or component.implementation_kind != "graph":
|
||
return
|
||
dialog = SimulationSettingsDialog(component.graph.simulation_settings, self)
|
||
if dialog.exec() == dialog.DialogCode.Accepted:
|
||
self.document_controller.edit_simulation_settings(dialog.settings)
|
||
|
||
@Slot()
|
||
def show_graph_parameters(self) -> None:
|
||
component = self.document_controller.active_component
|
||
if component is None or component.implementation_kind != "graph":
|
||
return
|
||
dialog = GraphParametersDialog(component, self)
|
||
if dialog.exec() == dialog.DialogCode.Accepted:
|
||
try:
|
||
self.document_controller.edit_graph_parameter_values(
|
||
component.id, dialog.parameter_values
|
||
)
|
||
except ValueError as error:
|
||
self.log.error("Could not change graph parameters: %s", error)
|
||
QMessageBox.warning(self, "Cannot change graph parameters", str(error))
|
||
|
||
@Slot()
|
||
def compose_active_graph(self) -> None:
|
||
try:
|
||
self.document_controller.compose_active_graph()
|
||
except ValueError as error:
|
||
self.log.error("Composition failed: %s", error)
|
||
QMessageBox.warning(self, "Cannot compose", str(error))
|
||
|
||
@Slot()
|
||
def export_model(self) -> None:
|
||
try:
|
||
model_name, source = self.document_controller.compose_active_graph_source()
|
||
except ValueError as error:
|
||
self.log.error("Model export composition failed: %s", error)
|
||
QMessageBox.warning(self, "Cannot Export Model", str(error))
|
||
return
|
||
file_name, _selected_filter = QFileDialog.getSaveFileName(
|
||
self,
|
||
"Export OpenModelica Model",
|
||
f"{model_name}.mo",
|
||
"Modelica Models (*.mo);;All Files (*)",
|
||
)
|
||
if not file_name:
|
||
return
|
||
path = Path(file_name)
|
||
if not path.suffix:
|
||
path = path.with_suffix(".mo")
|
||
temporary_path = path.with_suffix(path.suffix + ".tmp")
|
||
try:
|
||
temporary_path.write_text(source, encoding="utf-8")
|
||
temporary_path.replace(path)
|
||
except OSError as error:
|
||
self.log.error("Could not export model %s: %s", path, error)
|
||
QMessageBox.critical(self, "Cannot Export Model", str(error))
|
||
return
|
||
self.log.info("Exported OpenModelica model to %s", path)
|
||
|
||
@Slot()
|
||
def show_simulation_window(self) -> None:
|
||
self._simulation_window.show()
|
||
self._simulation_window.raise_()
|
||
self._simulation_window.activateWindow()
|
||
|
||
@Slot()
|
||
def run_simulation(self) -> None:
|
||
window = self._simulation_window
|
||
callbacks = window.begin_run()
|
||
self.show_simulation_window()
|
||
try:
|
||
self.document_controller.run_simulation(*callbacks)
|
||
window.prepare_run_model(self.simulation.model_name)
|
||
except Exception as error:
|
||
self.log.exception("Simulation run failed")
|
||
window.report_start_error(error)
|
||
|
||
def _restore_window_geometry(self) -> None:
|
||
geometry = self.settings.value("window/geometry")
|
||
if geometry is not None:
|
||
self.restoreGeometry(geometry)
|
||
|
||
def _update_title(self) -> None:
|
||
if self.document_controller.document is None:
|
||
self.setWindowTitle("BEdit")
|
||
return
|
||
name = (
|
||
self.document_controller.file_path.name
|
||
if self.document_controller.file_path
|
||
else "Untitled"
|
||
)
|
||
modified = "*" if not self.document_controller.undo_stack.isClean() else ""
|
||
self.setWindowTitle(f"{modified}{name} — BEdit")
|
||
|
||
def _active_graph_changed(self) -> None:
|
||
component = self.document_controller.active_component
|
||
if component is None:
|
||
self.ui.graphBreadcrumbLabel.setText("No component selected")
|
||
self.ui.navigateUpButton.setEnabled(False)
|
||
self.ui.workspaceModeLabel.setText("")
|
||
self.ui.workspaceStack.setCurrentWidget(self.ui.emptyPage)
|
||
self._set_graph_controls_visible(False)
|
||
self.ui.actionSimulationSettings.setEnabled(False)
|
||
self.ui.actionGraphParameters.setEnabled(False)
|
||
self.ui.actionCompose.setEnabled(False)
|
||
self.ui.actionExportModel.setEnabled(False)
|
||
self.ui.actionRunSimulation.setEnabled(False)
|
||
self._update_edit_actions()
|
||
return
|
||
self.ui.graphBreadcrumbLabel.setText(" › ".join(self.document_controller.breadcrumb()))
|
||
self.ui.navigateUpButton.setEnabled(
|
||
self.document_controller.document.find_parent(component.id) is not None
|
||
)
|
||
is_graph = component.implementation_kind == "graph"
|
||
self.ui.actionSimulationSettings.setEnabled(is_graph)
|
||
self.ui.actionGraphParameters.setEnabled(is_graph)
|
||
self.ui.actionCompose.setEnabled(is_graph)
|
||
self.ui.actionExportModel.setEnabled(is_graph)
|
||
self.ui.actionRunSimulation.setEnabled(is_graph)
|
||
self.ui.workspaceModeLabel.setText("Graph" if is_graph else "Text")
|
||
self.ui.workspaceStack.setCurrentWidget(
|
||
self.ui.graphPage if is_graph else self.ui.textPage
|
||
)
|
||
self._set_graph_controls_visible(is_graph)
|
||
if is_graph:
|
||
self.set_graph_tool("pointer")
|
||
else:
|
||
self._load_text_definition()
|
||
self._update_edit_actions()
|
||
|
||
def _update_edit_actions(self) -> None:
|
||
component = self.document_controller.active_component
|
||
is_graph = component is not None and component.implementation_kind == "graph"
|
||
has_selection = bool(
|
||
self.ui.graphView.scene() and self.ui.graphView.scene().selectedItems()
|
||
)
|
||
document_component_selected = bool(
|
||
self.ui.documentTreeView.currentIndex().data(COMPONENT_ID_ROLE)
|
||
)
|
||
library_component_selected = isinstance(
|
||
self.ui.treeView.currentIndex().data(COMPONENT_ROLE), dict
|
||
)
|
||
document_has_focus = self._view_has_focus(self.ui.documentTreeView)
|
||
library_has_focus = self._view_has_focus(self.ui.treeView)
|
||
self.ui.actionCopy.setEnabled(
|
||
(is_graph and has_selection)
|
||
or (document_has_focus and document_component_selected)
|
||
or (library_has_focus and library_component_selected)
|
||
)
|
||
self.ui.actionCut.setEnabled(
|
||
(is_graph and has_selection)
|
||
or (document_has_focus and document_component_selected)
|
||
)
|
||
self.ui.actionDelete.setEnabled(is_graph and has_selection)
|
||
self.ui.actionRotateClockwise.setEnabled(
|
||
is_graph and self.ui.graphView.has_selected_components()
|
||
)
|
||
self.ui.rotateToolButton.setEnabled(
|
||
is_graph and self.ui.graphView.has_selected_components()
|
||
)
|
||
self.ui.actionSelectAll.setEnabled(is_graph)
|
||
can_paste_component = QApplication.clipboard().mimeData().hasFormat(
|
||
COMPONENT_MIME_TYPE
|
||
) or QApplication.clipboard().mimeData().hasFormat(SELECTION_MIME_TYPE)
|
||
self.ui.actionPaste.setEnabled(
|
||
(is_graph and not document_has_focus and not library_has_focus)
|
||
or (
|
||
document_has_focus
|
||
and self.document_controller.document is not None
|
||
and can_paste_component
|
||
)
|
||
)
|
||
self.ui.navigateDownButton.setEnabled(
|
||
is_graph and self.ui.graphView.has_single_selected_component()
|
||
)
|
||
|
||
def copy_selection(self) -> bool:
|
||
if self._view_has_focus(self.ui.documentTreeView):
|
||
return self._copy_tree_component(self.ui.documentTreeView)
|
||
if self._view_has_focus(self.ui.treeView):
|
||
return self._copy_tree_component(self.ui.treeView)
|
||
return self.ui.graphView.copy_selection()
|
||
|
||
def cut_selection(self) -> None:
|
||
if self._view_has_focus(self.ui.documentTreeView):
|
||
index = self.ui.documentTreeView.currentIndex()
|
||
component_id = index.data(COMPONENT_ID_ROLE)
|
||
if component_id and self._copy_tree_component(self.ui.documentTreeView):
|
||
self.document_controller.delete_component(component_id)
|
||
return
|
||
if self._view_has_focus(self.ui.treeView):
|
||
self._copy_tree_component(self.ui.treeView)
|
||
return
|
||
self.ui.graphView.cut_selection()
|
||
|
||
def paste_selection(self) -> None:
|
||
if self._view_has_focus(self.ui.documentTreeView):
|
||
self._paste_into_document_tree()
|
||
return
|
||
self.ui.graphView.paste_selection()
|
||
|
||
def _copy_tree_component(self, tree) -> bool:
|
||
component = tree.currentIndex().data(COMPONENT_ROLE)
|
||
if not isinstance(component, dict):
|
||
return False
|
||
mime_data = QMimeData()
|
||
mime_data.setData(
|
||
COMPONENT_MIME_TYPE,
|
||
QByteArray(json.dumps(component).encode("utf-8")),
|
||
)
|
||
QApplication.clipboard().setMimeData(mime_data)
|
||
return True
|
||
|
||
def _paste_into_document_tree(self) -> None:
|
||
index = self.ui.documentTreeView.currentIndex()
|
||
kind = index.data(ITEM_KIND_ROLE)
|
||
owner_id = None
|
||
if kind == "current-component":
|
||
component_id = index.data(COMPONENT_ID_ROLE)
|
||
component = (
|
||
self.document_controller.document.find_component(component_id)
|
||
if self.document_controller.document
|
||
else None
|
||
)
|
||
if component is None or component.implementation_kind != "graph":
|
||
QMessageBox.warning(
|
||
self, "Cannot Paste", "Select a graph component or the document root."
|
||
)
|
||
return
|
||
owner_id = component.id
|
||
elif kind != "current-document":
|
||
QMessageBox.warning(
|
||
self, "Cannot Paste", "Select a graph component or the document root."
|
||
)
|
||
return
|
||
try:
|
||
components, connections = self._clipboard_components()
|
||
self.document_controller.paste_components_to(
|
||
owner_id, components, connections
|
||
)
|
||
except ValueError as error:
|
||
QMessageBox.warning(self, "Cannot Paste", str(error))
|
||
|
||
@staticmethod
|
||
def _clipboard_components() -> tuple[list[Component], list]:
|
||
mime_data = QApplication.clipboard().mimeData()
|
||
try:
|
||
if mime_data.hasFormat(SELECTION_MIME_TYPE):
|
||
payload = json.loads(
|
||
bytes(mime_data.data(SELECTION_MIME_TYPE)).decode("utf-8")
|
||
)
|
||
return (
|
||
[Component.from_dict(item) for item in payload.get("components", [])],
|
||
[Connection.from_dict(item) for item in payload.get("connections", [])],
|
||
)
|
||
if mime_data.hasFormat(COMPONENT_MIME_TYPE):
|
||
payload = json.loads(
|
||
bytes(mime_data.data(COMPONENT_MIME_TYPE)).decode("utf-8")
|
||
)
|
||
return [Component.from_dict(payload)], []
|
||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
|
||
raise ValueError("The clipboard does not contain a valid component") from error
|
||
raise ValueError("The clipboard does not contain a BEdit component")
|
||
|
||
@staticmethod
|
||
def _view_has_focus(view) -> bool:
|
||
focus = QApplication.focusWidget()
|
||
return focus is view or (focus is not None and view.isAncestorOf(focus))
|
||
|
||
@Slot()
|
||
def navigate_up(self) -> None:
|
||
if self._resolve_source_edits():
|
||
self.document_controller.navigate_up()
|
||
|
||
@Slot()
|
||
def navigate_down(self) -> None:
|
||
if self._resolve_source_edits():
|
||
self.ui.graphView.open_selected_component()
|
||
|
||
def _set_graph_controls_visible(self, visible: bool) -> None:
|
||
for widget in (
|
||
self.ui.pointerToolButton,
|
||
self.ui.connectToolButton,
|
||
self.ui.boxToolButton,
|
||
self.ui.lineToolButton,
|
||
self.ui.textToolButton,
|
||
self.ui.rotateToolButton,
|
||
):
|
||
widget.setVisible(visible)
|
||
|
||
def set_graph_tool(self, mode: str) -> None:
|
||
self.ui.graphView.set_tool_mode(mode)
|
||
{
|
||
"pointer": self.ui.pointerToolButton,
|
||
"connect": self.ui.connectToolButton,
|
||
"box": self.ui.boxToolButton,
|
||
"line": self.ui.lineToolButton,
|
||
"text": self.ui.textToolButton,
|
||
}[mode].setChecked(True)
|
||
|
||
def _load_text_definition(self) -> None:
|
||
component = self.document_controller.active_component
|
||
if component is None:
|
||
return
|
||
self.ui.textDefinitionEditor.set_definition(
|
||
component.source.get("declarations", ""),
|
||
component.source.get("initialEquations", ""),
|
||
component.source.get("equations", ""),
|
||
component.inputs,
|
||
component.outputs,
|
||
component.parameters,
|
||
)
|
||
|
||
def _resolve_source_edits(self) -> bool:
|
||
component = self.document_controller.active_component
|
||
if (
|
||
component is None
|
||
or component.implementation_kind != "text"
|
||
or not self.ui.textDefinitionEditor.is_modified
|
||
):
|
||
return True
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Apply text component changes?",
|
||
"The text component has unapplied declaration, initial-equation, "
|
||
"equation, port, or parameter changes.",
|
||
QMessageBox.StandardButton.Apply
|
||
| QMessageBox.StandardButton.Discard
|
||
| QMessageBox.StandardButton.Cancel,
|
||
)
|
||
if answer == QMessageBox.StandardButton.Apply:
|
||
return self.apply_text_definition()
|
||
return answer == QMessageBox.StandardButton.Discard
|
||
|
||
@Slot()
|
||
def apply_text_definition(self) -> bool:
|
||
try:
|
||
inputs, outputs = self.ui.textDefinitionEditor.ports
|
||
self._applying_text_definition = True
|
||
try:
|
||
self.document_controller.replace_active_text_definition(
|
||
inputs,
|
||
outputs,
|
||
self.ui.textDefinitionEditor.declarations,
|
||
self.ui.textDefinitionEditor.initial_equations,
|
||
self.ui.textDefinitionEditor.equations,
|
||
self.ui.textDefinitionEditor.parameters,
|
||
)
|
||
finally:
|
||
self._applying_text_definition = False
|
||
except (TypeError, ValueError) as error:
|
||
self.log.error("Text component update failed: %s", error)
|
||
QMessageBox.critical(self, "Invalid text component", str(error))
|
||
self._load_text_definition()
|
||
return False
|
||
self.ui.textDefinitionEditor.set_modified(False)
|
||
return True
|
||
|
||
@Slot(str)
|
||
def _text_definition_changed(self, component_id: str) -> None:
|
||
component = self.document_controller.active_component
|
||
if (
|
||
component is not None
|
||
and component.id == component_id
|
||
and not self._applying_text_definition
|
||
):
|
||
self._load_text_definition()
|
||
|
||
def _maybe_save(self) -> bool:
|
||
if self.document_controller.document is None:
|
||
return True
|
||
if self.document_controller.undo_stack.isClean():
|
||
return True
|
||
answer = QMessageBox.warning(
|
||
self,
|
||
"Unsaved changes",
|
||
"The current graph contains unsaved changes.",
|
||
QMessageBox.StandardButton.Save
|
||
| QMessageBox.StandardButton.Discard
|
||
| QMessageBox.StandardButton.Cancel,
|
||
)
|
||
if answer == QMessageBox.StandardButton.Save:
|
||
return self.save_document()
|
||
return answer == QMessageBox.StandardButton.Discard
|
||
|
||
@Slot()
|
||
def new_document(self) -> None:
|
||
if self._resolve_source_edits() and self._maybe_save():
|
||
self.document_controller.new_document()
|
||
self.log.info("Created a new document")
|
||
|
||
@Slot()
|
||
def close_document(self) -> None:
|
||
if self._resolve_source_edits() and self._maybe_save():
|
||
path = self.document_controller.file_path
|
||
self.document_controller.close_document()
|
||
self.log.info("Closed document%s", f" {path}" if path else "")
|
||
|
||
@Slot()
|
||
def open_document(self) -> None:
|
||
if not self._resolve_source_edits() or not self._maybe_save():
|
||
return
|
||
filename, _ = QFileDialog.getOpenFileName(
|
||
self,
|
||
"Open graph",
|
||
"",
|
||
"BEdit documents (*.bedit.json *.json *.beb);;"
|
||
"BEdit JSON (*.bedit.json *.json);;BEdit Binary (*.beb);;All files (*)",
|
||
)
|
||
if not filename:
|
||
return
|
||
try:
|
||
self.document_controller.load(Path(filename))
|
||
self.log.info("Opened document %s", filename)
|
||
except (OSError, ValueError) as error:
|
||
self.log.error("Could not open document %s: %s", filename, error)
|
||
QMessageBox.critical(self, "Could not open graph", str(error))
|
||
|
||
@Slot()
|
||
def save_document(self) -> bool:
|
||
if self.document_controller.document is None:
|
||
return False
|
||
if self.document_controller.file_path is None:
|
||
return self.save_document_as()
|
||
try:
|
||
path = self.document_controller.save()
|
||
self.log.info("Saved document %s", path)
|
||
except OSError as error:
|
||
self.log.error("Could not save document: %s", error)
|
||
QMessageBox.critical(self, "Could not save graph", str(error))
|
||
return False
|
||
return True
|
||
|
||
@Slot()
|
||
def save_document_as(self) -> bool:
|
||
if self.document_controller.document is None:
|
||
return False
|
||
filename, selected_filter = QFileDialog.getSaveFileName(
|
||
self,
|
||
"Save graph",
|
||
"untitled.beb",
|
||
"BEdit Binary (*.beb);;BEdit JSON (*.bedit.json *.json);;All files (*)",
|
||
)
|
||
if not filename:
|
||
return False
|
||
path = Path(filename)
|
||
if path.suffix.lower() not in {".json", ".beb"}:
|
||
path = path.with_suffix(
|
||
".beb" if "Binary" in selected_filter else ".bedit.json"
|
||
)
|
||
try:
|
||
path = self.document_controller.save(path)
|
||
self.log.info("Saved document %s", path)
|
||
except OSError as error:
|
||
self.log.error("Could not save document %s: %s", filename, error)
|
||
QMessageBox.critical(self, "Could not save graph", str(error))
|
||
return False
|
||
return True
|
||
|
||
@Slot()
|
||
def show_settings(self) -> None:
|
||
dialog = SettingsDialog(self)
|
||
dialog.settingsChanged.connect(self.reload_libraries)
|
||
dialog.settingsChanged.connect(self.refresh_editor_settings)
|
||
dialog.settingsChanged.connect(self.refresh_simulation_preferences)
|
||
dialog.exec()
|
||
|
||
def refresh_simulation_preferences(self) -> None:
|
||
self.simulation.openmodelica_path = SettingsDialog.openmodelica_path(
|
||
self.settings
|
||
)
|
||
|
||
def refresh_editor_settings(self) -> None:
|
||
scene = self.ui.graphView.scene()
|
||
if scene is not None:
|
||
scene.update()
|
||
self.ui.graphView.viewport().update()
|
||
self.ui.textDefinitionEditor.ui.declarationsEdit.reload_highlighting()
|
||
self.ui.textDefinitionEditor.ui.initialEquationsEdit.reload_highlighting()
|
||
self.ui.textDefinitionEditor.ui.equationsEdit.reload_highlighting()
|
||
|
||
def _document_opened_changed(self, opened: bool) -> None:
|
||
for action in (self.ui.actionClose, self.ui.actionSave, self.ui.actionSaveAs):
|
||
action.setEnabled(opened)
|
||
self._active_graph_changed()
|
||
|
||
@Slot(object)
|
||
def activate_tree_component(self, index) -> None:
|
||
if index.data(ITEM_KIND_ROLE) != "current-component":
|
||
return
|
||
component_id = index.data(COMPONENT_ID_ROLE)
|
||
if component_id:
|
||
self.document_controller.activate_component(component_id)
|
||
|
||
@Slot(object)
|
||
def show_library_context_menu(self, position) -> None:
|
||
tree_view = self.ui.documentTreeView
|
||
index = tree_view.indexAt(position)
|
||
if index.isValid():
|
||
tree_view.setCurrentIndex(index)
|
||
kind = index.data(ITEM_KIND_ROLE)
|
||
if kind == "current-component":
|
||
component_id = index.data(COMPONENT_ID_ROLE)
|
||
document = self.document_controller.document
|
||
component = document.find_component(component_id) if document else None
|
||
if component is None:
|
||
return
|
||
menu = QMenu(self)
|
||
graph_action = None
|
||
text_action = None
|
||
if component.implementation_kind == "graph":
|
||
graph_action = menu.addAction("New Graph Block")
|
||
text_action = menu.addAction("New Text Block")
|
||
menu.addSeparator()
|
||
options_action = menu.addAction("Component Options…")
|
||
ports_action = menu.addAction("Port Options…")
|
||
parameters_action = menu.addAction("Parameter Options…")
|
||
menu.addSeparator()
|
||
copy_action = menu.addAction("Copy")
|
||
cut_action = menu.addAction("Cut")
|
||
paste_action = menu.addAction("Paste")
|
||
paste_action.setEnabled(component.implementation_kind == "graph")
|
||
delete_action = menu.addAction("Delete")
|
||
selected = menu.exec(tree_view.viewport().mapToGlobal(position))
|
||
if graph_action is not None and selected is graph_action:
|
||
self.document_controller.add_child(component_id, "graph")
|
||
elif text_action is not None and selected is text_action:
|
||
self.document_controller.add_child(component_id, "text")
|
||
elif selected is options_action:
|
||
self.show_component_options(component_id)
|
||
elif selected is ports_action:
|
||
self.show_component_port_options(component_id)
|
||
elif selected is parameters_action:
|
||
self.show_component_parameter_options(component_id)
|
||
elif selected is copy_action:
|
||
self._copy_tree_component(tree_view)
|
||
elif selected is cut_action:
|
||
if self._copy_tree_component(tree_view):
|
||
self.document_controller.delete_component(component_id)
|
||
elif selected is paste_action:
|
||
self._paste_into_document_tree()
|
||
elif selected is delete_action:
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Delete component?",
|
||
f"Delete {component.name!r} and all of its contents?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if answer == QMessageBox.StandardButton.Yes:
|
||
self.document_controller.delete_component(component_id)
|
||
return
|
||
if kind != "current-document":
|
||
return
|
||
menu = QMenu(self)
|
||
graph_action = menu.addAction("New Graph Block")
|
||
text_action = menu.addAction("New Text Block")
|
||
menu.addSeparator()
|
||
paste_action = menu.addAction("Paste")
|
||
selected = menu.exec(tree_view.viewport().mapToGlobal(position))
|
||
if selected is graph_action:
|
||
self.document_controller.add_root("graph")
|
||
elif selected is text_action:
|
||
self.document_controller.add_root("text")
|
||
elif selected is paste_action:
|
||
self._paste_into_document_tree()
|
||
|
||
@Slot(object)
|
||
def show_external_library_context_menu(self, position) -> None:
|
||
tree = self.ui.treeView
|
||
index = tree.indexAt(position)
|
||
if index.isValid():
|
||
tree.setCurrentIndex(index)
|
||
component = index.data(COMPONENT_INSTANCE_ROLE)
|
||
if not isinstance(component, Component):
|
||
return
|
||
menu = QMenu(self)
|
||
copy_action = menu.addAction("Copy")
|
||
menu.addSeparator()
|
||
ports_action = menu.addAction("Port Options…")
|
||
parameters_action = menu.addAction("Parameter Options…")
|
||
selected = menu.exec(tree.viewport().mapToGlobal(position))
|
||
if selected is copy_action:
|
||
self._copy_tree_component(tree)
|
||
elif selected is ports_action:
|
||
dialog = PortOptionsDialog(component, self)
|
||
if dialog.exec() == dialog.DialogCode.Accepted:
|
||
old_inputs, old_outputs = deepcopy(component.inputs), deepcopy(component.outputs)
|
||
component.inputs = dialog.inputs
|
||
component.outputs = dialog.outputs
|
||
library = next(
|
||
(
|
||
library
|
||
for library in self.libraries.libraries
|
||
if any(item is component for item in library.document.all_components())
|
||
),
|
||
None,
|
||
)
|
||
try:
|
||
if library is not None:
|
||
library.document.validate()
|
||
DocumentSerializer.save(library.document, Path(library.source_path))
|
||
except (OSError, ValueError) as error:
|
||
component.inputs, component.outputs = old_inputs, old_outputs
|
||
self.log.error("Could not change library ports: %s", error)
|
||
QMessageBox.warning(self, "Cannot change library ports", str(error))
|
||
self.library_tree_model.rebuild()
|
||
elif selected is parameters_action:
|
||
dialog = ParameterOptionsDialog(component, self)
|
||
if dialog.exec() == dialog.DialogCode.Accepted:
|
||
old_parameters = deepcopy(component.parameters)
|
||
component.parameters = dialog.parameters
|
||
library = next(
|
||
(
|
||
library
|
||
for library in self.libraries.libraries
|
||
if any(item is component for item in library.document.all_components())
|
||
),
|
||
None,
|
||
)
|
||
try:
|
||
if library is not None:
|
||
library.document.validate()
|
||
DocumentSerializer.save(
|
||
library.document, Path(library.source_path)
|
||
)
|
||
except (OSError, ValueError) as error:
|
||
component.parameters = old_parameters
|
||
self.log.error("Could not change library parameters: %s", error)
|
||
QMessageBox.warning(
|
||
self, "Cannot change library parameters", str(error)
|
||
)
|
||
self.library_tree_model.rebuild()
|
||
|
||
@Slot(str)
|
||
def show_component_port_options(self, component_id: str) -> None:
|
||
document = self.document_controller.document
|
||
component = document.find_component(component_id) if document else None
|
||
if component is None:
|
||
return
|
||
dialog = PortOptionsDialog(component, self)
|
||
if dialog.exec() != dialog.DialogCode.Accepted:
|
||
return
|
||
try:
|
||
self.document_controller.edit_component_ports(
|
||
component_id, dialog.inputs, dialog.outputs
|
||
)
|
||
except ValueError as error:
|
||
self.log.error("Could not change component ports: %s", error)
|
||
QMessageBox.warning(self, "Cannot change ports", str(error))
|
||
|
||
@Slot(str)
|
||
def show_component_parameter_options(self, component_id: str) -> None:
|
||
document = self.document_controller.document
|
||
component = document.find_component(component_id) if document else None
|
||
if component is None:
|
||
return
|
||
dialog = ParameterOptionsDialog(component, self)
|
||
if dialog.exec() != dialog.DialogCode.Accepted:
|
||
return
|
||
try:
|
||
self.document_controller.edit_component_parameters(
|
||
component_id, dialog.parameters
|
||
)
|
||
except ValueError as error:
|
||
self.log.error("Could not change component parameters: %s", error)
|
||
QMessageBox.warning(self, "Cannot change parameters", str(error))
|
||
|
||
@Slot(str)
|
||
def show_component_options(self, component_id: str) -> None:
|
||
document = self.document_controller.document
|
||
component = document.find_component(component_id) if document else None
|
||
if component is None:
|
||
return
|
||
dialog = ComponentOptionsDialog(component, self)
|
||
if dialog.exec() == dialog.DialogCode.Accepted:
|
||
try:
|
||
self.document_controller.edit_component_appearance(
|
||
component_id,
|
||
dialog.ui.nameEdit.text().strip(),
|
||
dialog.edited_icon,
|
||
dialog.edited_inputs,
|
||
dialog.edited_outputs,
|
||
dialog.ui.showSubtreeCheckBox.isChecked(),
|
||
dialog.ui.showNameCheckBox.isChecked(),
|
||
)
|
||
except ValueError as error:
|
||
self.log.error("Could not rename component: %s", error)
|
||
QMessageBox.warning(self, "Cannot rename component", str(error))
|
||
|
||
@Slot(str, str)
|
||
def show_port_options(self, port_id: str, direction: str) -> None:
|
||
owner = self.document_controller.active_component
|
||
if owner is None:
|
||
return
|
||
ports = owner.inputs if direction == "input" else owner.outputs
|
||
port = next((item for item in ports if item.id == port_id), None)
|
||
if port is None:
|
||
return
|
||
dialog = ItemOptionsDialog(f"{direction.title()} Options", port.name, self)
|
||
if dialog.exec() == dialog.DialogCode.Accepted:
|
||
self.document_controller.rename_interface_port(port_id, dialog.name)
|
||
|
||
@Slot(str)
|
||
def show_connection_options(self, connection_id: str) -> None:
|
||
owner = self.document_controller.active_component
|
||
if owner is None or owner.implementation_kind != "graph":
|
||
return
|
||
connection = owner.graph.connections.get(connection_id)
|
||
if connection is None:
|
||
return
|
||
dialog = ItemOptionsDialog(
|
||
"Connection Options",
|
||
connection.name,
|
||
self,
|
||
name_required=False,
|
||
show_name=bool(connection.properties.get("showName", False)),
|
||
)
|
||
if dialog.exec() == dialog.DialogCode.Accepted:
|
||
self.document_controller.edit_connection_options(
|
||
connection_id, dialog.name, dialog.show_name
|
||
)
|
||
|
||
@Slot()
|
||
def show_about(self) -> None:
|
||
QMessageBox.about(
|
||
self,
|
||
"About BEdit",
|
||
"<h3>BEdit</h3><p>A graphical editor built with Python and Qt.</p>",
|
||
)
|
||
|
||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 (Qt API name)
|
||
if not self._resolve_source_edits() or not self._maybe_save():
|
||
event.ignore()
|
||
return
|
||
self.settings.setValue("window/geometry", self.saveGeometry())
|
||
self._simulation_window.close()
|
||
self.simulation.shutdown()
|
||
self.log.info("BEdit closed")
|
||
self.application_logger.removeHandler(self.log_handler)
|
||
event.accept()
|