from __future__ import annotations from PySide6.QtCore import QObject, QPoint, Qt from PySide6.QtGui import QUndoStack from PySide6.QtWidgets import QDialog, QInputDialog, QMenu, QTreeWidgetItem, QWidget from bedit_gui.commands.simulation_plot_commands import AddSimulationPlotTabCommand, ChangeSimulationPlotSettingsCommand, ChangeSimulationPlotSignalsCommand, ChangeSimulationPlotXAxisCommand, RemoveSimulationPlotTabCommand, RenameSimulationPlotTabCommand from bedit_gui.controllers.simulation_file_controller import SimulationFileController from bedit_gui.services.application_logging import get_logger from bedit_gui.simulation_models import SimulationPlotTab from bedit_gui.views.simulation_plot_widget import SimulationPlotWidget from bedit_gui.views.dialogs.plot_settings_dialog import PlotSettingsDialog from bedit_gui.views.simulation_window import SimulationWindow logger = get_logger(__name__) def _signal_tree_parts(signal: str) -> list[str]: if signal.startswith("der(") and signal.endswith(")"): parts = signal[4:-1].split(".") if len(parts) > 1: return [*parts[:-1], f"der({parts[-1]})"] return signal.split(".") class SimulationPlotController(QObject): def __init__(self, window: SimulationWindow, files: SimulationFileController) -> None: super().__init__(window) self.window = window self.files = files self.undo_stack = QUndoStack(self) self._updating = False self._active_tab = -1 tabs = window.ui.resultsTabWidget tree = window.ui.simulationTree tabs.currentChanged.connect(self._tab_changed) tabs.tabBar().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) tabs.tabBar().customContextMenuRequested.connect(self._show_tab_menu) tree.itemChanged.connect(self._signal_changed) tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) tree.customContextMenuRequested.connect(self._show_signal_menu) files.runtime_changed.connect(self.load_root) window.ui.actionUndo.triggered.connect(self.undo) window.ui.actionRedo.triggered.connect(self.redo) self.undo_stack.canUndoChanged.connect(window.ui.actionUndo.setEnabled) self.undo_stack.canRedoChanged.connect(window.ui.actionRedo.setEnabled) self.undo_stack.undoTextChanged.connect(self._set_undo_text) self.undo_stack.redoTextChanged.connect(self._set_redo_text) window.ui.actionUndo.setEnabled(False) window.ui.actionRedo.setEnabled(False) self.load_root() def load_root(self) -> None: self.undo_stack.clear() self._active_tab = 0 if self.files.root is not None and self.files.root.plot_tabs else -1 self._rebuild_tabs(self._active_tab) self._rebuild_tree() def refresh_results(self) -> None: self._rebuild_tree() self._refresh_plots() def add_tab(self) -> None: root = self.files.root if root is None: return used_names = {tab.name for tab in root.plot_tabs} number = 1 while f"Plot {number}" in used_names: number += 1 index = len(root.plot_tabs) self.undo_stack.push(AddSimulationPlotTabCommand(root, SimulationPlotTab(f"Plot {number}"), index, self._tabs_changed)) def rename_tab(self, index: int, name: str) -> None: root = self.files.root name = name.strip() if root is None or not 0 <= index < len(root.plot_tabs) or not name or root.plot_tabs[index].name == name: return self.undo_stack.push(RenameSimulationPlotTabCommand(root, index, name, self._tabs_changed)) def remove_tab(self, index: int) -> None: root = self.files.root if root is None or not 0 <= index < len(root.plot_tabs): return self.undo_stack.push(RemoveSimulationPlotTabCommand(root, index, self._tabs_changed)) def undo(self) -> None: command = self.undo_stack.undoText() self.undo_stack.undo() logger.info("Undo: %s", command) def redo(self) -> None: command = self.undo_stack.redoText() self.undo_stack.redo() logger.info("Redo: %s", command) def _tabs_changed(self, selected_index: int) -> None: root = self.files.root if root is None or not root.plot_tabs: self._active_tab = -1 else: self._active_tab = max(0, min(selected_index, len(root.plot_tabs) - 1)) self._rebuild_tabs(self._active_tab) self._rebuild_tree() def _selection_changed(self, index: int) -> None: self._active_tab = index self._sync_tree_checks() self._refresh_plot(index) def _rebuild_tabs(self, selected_index: int) -> None: tabs = self.window.ui.resultsTabWidget root = self.files.root self._updating = True try: while tabs.count(): tabs.removeTab(0) if root is not None: for index, tab in enumerate(root.plot_tabs): widget = SimulationPlotWidget(tabs) widget.settings_requested.connect(lambda checked=False, tab_index=index: self._open_plot_settings(tab_index)) tabs.addTab(widget, tab.name) tabs.addTab(QWidget(tabs), "+") tabs.setCurrentIndex(selected_index if selected_index >= 0 else tabs.count() - 1) finally: self._updating = False self._refresh_plots() def _rebuild_tree(self) -> None: tree = self.window.ui.simulationTree signals = self._available_signals() self._updating = True try: tree.clear() nodes: dict[tuple[str, ...], QTreeWidgetItem] = {} for signal in signals: parent = tree.invisibleRootItem() parts = _signal_tree_parts(signal) for depth, part in enumerate(parts, start=1): path = tuple(parts[:depth]) item = nodes.get(path) if item is None: item = QTreeWidgetItem(parent, [part]) nodes[path] = item parent = item item.setData(0, Qt.ItemDataRole.UserRole, signal) item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) self._sync_tree_checks() finally: self._updating = False def _sync_tree_checks(self) -> None: tree = self.window.ui.simulationTree root = self.files.root selected = set(root.plot_tabs[self._active_tab].signals) if root is not None and 0 <= self._active_tab < len(root.plot_tabs) else set() previous = self._updating self._updating = True try: iterator = tree.invisibleRootItem() pending = [iterator.child(index) for index in range(iterator.childCount())] while pending: item = pending.pop() signal = item.data(0, Qt.ItemDataRole.UserRole) if signal is not None: item.setCheckState(0, Qt.CheckState.Checked if signal in selected else Qt.CheckState.Unchecked) pending.extend(item.child(index) for index in range(item.childCount())) finally: self._updating = previous def _signal_changed(self, item: QTreeWidgetItem, _column: int) -> None: if self._updating: return root = self.files.root signal = item.data(0, Qt.ItemDataRole.UserRole) if root is None or signal is None or not 0 <= self._active_tab < len(root.plot_tabs): return selected = set(root.plot_tabs[self._active_tab].signals) if item.checkState(0) == Qt.CheckState.Checked: selected.add(signal) else: selected.discard(signal) ordered = [available for available in self._available_signals() if available in selected] if ordered != root.plot_tabs[self._active_tab].signals: self.undo_stack.push(ChangeSimulationPlotSignalsCommand(root, self._active_tab, ordered, self._selection_changed)) def _tab_changed(self, index: int) -> None: if self._updating: return root = self.files.root real_tab_count = len(root.plot_tabs) if root is not None else 0 if index == real_tab_count: self.add_tab() return if 0 <= index < real_tab_count: self._active_tab = index self._sync_tree_checks() def _show_tab_menu(self, position: QPoint) -> None: root = self.files.root tab_bar = self.window.ui.resultsTabWidget.tabBar() index = tab_bar.tabAt(position) if root is None or not 0 <= index < len(root.plot_tabs): return menu = QMenu(tab_bar) rename_action = menu.addAction("Rename") remove_action = menu.addAction("Remove") selected = menu.exec(tab_bar.mapToGlobal(position)) if selected is rename_action: name, accepted = QInputDialog.getText(self.window, "Rename Plot", "Name:", text=root.plot_tabs[index].name) if accepted: self.rename_tab(index, name) elif selected is remove_action: self.remove_tab(index) def _show_signal_menu(self, position: QPoint) -> None: root = self.files.root tree = self.window.ui.simulationTree item = tree.itemAt(position) signal = item.data(0, Qt.ItemDataRole.UserRole) if item is not None else None if root is None or signal is None or not 0 <= self._active_tab < len(root.plot_tabs): return tab = root.plot_tabs[self._active_tab] menu = QMenu(tree) use_signal = menu.addAction("Use as x axis") use_signal.setEnabled(tab.x_axis != signal) use_time = menu.addAction("Use time as x axis") use_time.setEnabled(tab.x_axis is not None) selected = menu.exec(tree.viewport().mapToGlobal(position)) if selected is use_signal: self.undo_stack.push(ChangeSimulationPlotXAxisCommand(root, self._active_tab, signal, self._selection_changed)) elif selected is use_time: self.undo_stack.push(ChangeSimulationPlotXAxisCommand(root, self._active_tab, None, self._selection_changed)) def _open_plot_settings(self, index: int) -> None: root = self.files.root if root is None or not 0 <= index < len(root.plot_tabs): return dialog = PlotSettingsDialog(root.plot_tabs[index].settings, root.plot_tabs[index].signals, self.window) if dialog.exec() == QDialog.DialogCode.Accepted: settings = dialog.settings() if settings != root.plot_tabs[index].settings: self.undo_stack.push(ChangeSimulationPlotSettingsCommand(root, index, settings, self._selection_changed)) def _available_signals(self) -> list[str]: root = self.files.root if root is None: return [] return sorted({signal for result in root.results for signal in result.data}) def _refresh_plots(self) -> None: root = self.files.root if root is None: return for index in range(len(root.plot_tabs)): self._refresh_plot(index) def _refresh_plot(self, index: int) -> None: root = self.files.root if root is None or not 0 <= index < len(root.plot_tabs): return widget = self.window.ui.resultsTabWidget.widget(index) if isinstance(widget, SimulationPlotWidget): tab = root.plot_tabs[index] widget.set_plot(root.results, tab.signals, tab.x_axis, tab.settings) def _set_undo_text(self, command: str) -> None: self.window.ui.actionUndo.setText(f"Undo {command}" if command else "Undo") def _set_redo_text(self, command: str) -> None: self.window.ui.actionRedo.setText(f"Redo {command}" if command else "Redo")