From feba2c979836497fe2612c38b27fff1c44395b4b Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Sun, 2 Aug 2026 11:54:38 +0200 Subject: [PATCH] Plot settings and version --- src/bedit_gui/application.py | 7 +- .../commands/simulation_plot_commands.py | 21 ++- .../controllers/simulation_plot_controller.py | 23 ++- src/bedit_gui/simulation_application.py | 5 + src/bedit_gui/simulation_models.py | 72 ++++++++- src/bedit_gui/ui/forms/plot_settings.ui | 102 +++++++++++++ src/bedit_gui/versions.py | 4 + .../views/dialogs/plot_settings_dialog.py | 139 ++++++++++++++++++ src/bedit_gui/views/simulation_plot_widget.py | 38 ++++- untitled.besim.json | 76 +++++++++- 10 files changed, 469 insertions(+), 18 deletions(-) create mode 100644 src/bedit_gui/ui/forms/plot_settings.ui create mode 100644 src/bedit_gui/versions.py create mode 100644 src/bedit_gui/views/dialogs/plot_settings_dialog.py diff --git a/src/bedit_gui/application.py b/src/bedit_gui/application.py index c2ea6fb..68af787 100644 --- a/src/bedit_gui/application.py +++ b/src/bedit_gui/application.py @@ -3,7 +3,7 @@ from __future__ import annotations import sys import argparse -from PySide6.QtWidgets import QApplication +from PySide6.QtWidgets import QApplication, QMessageBox from bedit_gui.controllers.clipboard_controller import ClipboardController, DocumentTreeClipboardHandler, TextClipboardHandler from bedit_gui.controllers.document_controller import DocumentController @@ -19,6 +19,7 @@ from bedit_gui.documents import Document 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.versions import BEDIT_VERSION def parse_arguments(): parser = argparse.ArgumentParser(exit_on_error=False) @@ -26,6 +27,7 @@ def parse_arguments(): parser.add_argument( "-f", "--file", type=str, help="Path to a file to open", default=None, required=False ) + parser.add_argument("--version", action="version", version=f"BEdit {BEDIT_VERSION}") return parser.parse_args() @@ -40,10 +42,13 @@ def main() -> int: app.setOrganizationName("BEdit") app.setApplicationName("BEdit") + app.setApplicationVersion(BEDIT_VERSION) settings = ApplicationSettings() document = Document(app) window = MainWindow() + window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEdit", f"BEdit {BEDIT_VERSION}")) + window.ui.actionAbout_QT.triggered.connect(app.aboutQt) LogController(window, settings.log_level) DocumentController(document, window) diff --git a/src/bedit_gui/commands/simulation_plot_commands.py b/src/bedit_gui/commands/simulation_plot_commands.py index a1b4a73..5244218 100644 --- a/src/bedit_gui/commands/simulation_plot_commands.py +++ b/src/bedit_gui/commands/simulation_plot_commands.py @@ -1,10 +1,11 @@ from __future__ import annotations from collections.abc import Callable +from copy import deepcopy from PySide6.QtGui import QUndoCommand -from bedit_gui.simulation_models import SimulationPlotTab, SimulationRoot +from bedit_gui.simulation_models import SimulationPlotSettings, SimulationPlotTab, SimulationRoot ChangedCallback = Callable[[int], None] @@ -95,3 +96,21 @@ class ChangeSimulationPlotXAxisCommand(QUndoCommand): def undo(self) -> None: self.root.plot_tabs[self.index].x_axis = self.old_x_axis self.changed(self.index) + + +class ChangeSimulationPlotSettingsCommand(QUndoCommand): + def __init__(self, root: SimulationRoot, index: int, settings: SimulationPlotSettings, changed: ChangedCallback) -> None: + super().__init__("Change plot settings") + self.root = root + self.index = index + self.old_settings = deepcopy(root.plot_tabs[index].settings) + self.settings = deepcopy(settings) + self.changed = changed + + def redo(self) -> None: + self.root.plot_tabs[self.index].settings = deepcopy(self.settings) + self.changed(self.index) + + def undo(self) -> None: + self.root.plot_tabs[self.index].settings = deepcopy(self.old_settings) + self.changed(self.index) diff --git a/src/bedit_gui/controllers/simulation_plot_controller.py b/src/bedit_gui/controllers/simulation_plot_controller.py index 4552d38..6c7b458 100644 --- a/src/bedit_gui/controllers/simulation_plot_controller.py +++ b/src/bedit_gui/controllers/simulation_plot_controller.py @@ -2,13 +2,14 @@ from __future__ import annotations from PySide6.QtCore import QObject, QPoint, Qt from PySide6.QtGui import QUndoStack -from PySide6.QtWidgets import QInputDialog, QMenu, QTreeWidgetItem, QWidget +from PySide6.QtWidgets import QDialog, QInputDialog, QMenu, QTreeWidgetItem, QWidget -from bedit_gui.commands.simulation_plot_commands import AddSimulationPlotTabCommand, ChangeSimulationPlotSignalsCommand, ChangeSimulationPlotXAxisCommand, RemoveSimulationPlotTabCommand, RenameSimulationPlotTabCommand +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__) @@ -109,8 +110,10 @@ class SimulationPlotController(QObject): while tabs.count(): tabs.removeTab(0) if root is not None: - for tab in root.plot_tabs: - tabs.addTab(SimulationPlotWidget(tabs), tab.name) + 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: @@ -222,6 +225,16 @@ class SimulationPlotController(QObject): 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: @@ -242,7 +255,7 @@ class SimulationPlotController(QObject): 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) + 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") diff --git a/src/bedit_gui/simulation_application.py b/src/bedit_gui/simulation_application.py index 527f80f..16bd78d 100644 --- a/src/bedit_gui/simulation_application.py +++ b/src/bedit_gui/simulation_application.py @@ -12,10 +12,12 @@ from bedit_gui.controllers.simulation_run_controller import SimulationRunControl from bedit_gui.simulation_models import CompiledModel from bedit_gui.services.application_settings import SimulationApplicationSettings from bedit_gui.views.simulation_window import SimulationWindow +from bedit_gui.versions import BESIM_VERSION def parse_arguments(arguments: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Open and run BEdit simulations") + parser.add_argument("--version", action="version", version=f"BEsim {BESIM_VERSION}") parser.add_argument("file", nargs="?", help="BEdit (.beb/.json) or simulation (.bes/.json) file") parser.add_argument("-f", "--file", dest="file_option", help=argparse.SUPPRESS) parser.add_argument("--handoff", action="store_true", help=argparse.SUPPRESS) @@ -38,8 +40,11 @@ def main(arguments: list[str] | None = None) -> int: app.setOrganizationName("BEsim") app.setApplicationName("BEsim") + app.setApplicationVersion(BESIM_VERSION) window = SimulationWindow() + window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEsim", f"BEsim {BESIM_VERSION}")) + window.ui.actionAbout_QT.triggered.connect(app.aboutQt) controller = SimulationFileController(window) settings = SimulationApplicationSettings() diff --git a/src/bedit_gui/simulation_models.py b/src/bedit_gui/simulation_models.py index b57b8cd..dd64b25 100644 --- a/src/bedit_gui/simulation_models.py +++ b/src/bedit_gui/simulation_models.py @@ -25,21 +25,89 @@ class CompiledModel: return {"model_name": self.model_name, "executable": self.executable, "working_directory": self.working_directory, "output": self.output, "errors": self.errors} +@dataclass +class SimulationTraceSettings: + visible: bool = True + label: str = "" + color: str = "" + line_style: str = "-" + line_width: float = 1.5 + marker: str = "" + marker_size: float = 6.0 + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> SimulationTraceSettings: + return cls(visible=bool(data.get("visible", True)), label=str(data.get("label", "")), color=str(data.get("color", "")), line_style=str(data.get("line_style", "-")), line_width=float(data.get("line_width", 1.5)), marker=str(data.get("marker", "")), marker_size=float(data.get("marker_size", 6.0))) + + def to_data(self) -> dict[str, Any]: + return {"visible": self.visible, "label": self.label, "color": self.color, "line_style": self.line_style, "line_width": self.line_width, "marker": self.marker, "marker_size": self.marker_size} + + +@dataclass +class SimulationPlotSettings: + title: str = "" + x_label: str = "" + y_label: str = "" + x_scale: str = "linear" + y_scale: str = "linear" + x_auto: bool = True + y_auto: bool = True + x_min: float = 0.0 + x_max: float = 1.0 + y_min: float = 0.0 + y_max: float = 1.0 + grid_visible: bool = True + grid_axis: str = "both" + grid_style: str = "-" + grid_alpha: float = 0.5 + legend_visible: bool = True + legend_location: str = "best" + traces: dict[str, SimulationTraceSettings] = field(default_factory=dict) + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> SimulationPlotSettings: + raw_traces = data.get("traces", {}) + if not isinstance(raw_traces, Mapping): + raise TypeError("plot trace settings must be a mapping") + return cls( + title=str(data.get("title", "")), x_label=str(data.get("x_label", "")), y_label=str(data.get("y_label", "")), + x_scale=str(data.get("x_scale", "linear")), y_scale=str(data.get("y_scale", "linear")), + x_auto=bool(data.get("x_auto", True)), y_auto=bool(data.get("y_auto", True)), + x_min=float(data.get("x_min", 0.0)), x_max=float(data.get("x_max", 1.0)), y_min=float(data.get("y_min", 0.0)), y_max=float(data.get("y_max", 1.0)), + grid_visible=bool(data.get("grid_visible", True)), grid_axis=str(data.get("grid_axis", "both")), grid_style=str(data.get("grid_style", "-")), grid_alpha=float(data.get("grid_alpha", 0.5)), + legend_visible=bool(data.get("legend_visible", True)), legend_location=str(data.get("legend_location", "best")), + traces={str(signal): SimulationTraceSettings.from_data(trace) for signal, trace in raw_traces.items() if isinstance(trace, Mapping)}, + ) + + def to_data(self) -> dict[str, Any]: + return { + "title": self.title, "x_label": self.x_label, "y_label": self.y_label, "x_scale": self.x_scale, "y_scale": self.y_scale, + "x_auto": self.x_auto, "y_auto": self.y_auto, "x_min": self.x_min, "x_max": self.x_max, "y_min": self.y_min, "y_max": self.y_max, + "grid_visible": self.grid_visible, "grid_axis": self.grid_axis, "grid_style": self.grid_style, "grid_alpha": self.grid_alpha, + "legend_visible": self.legend_visible, "legend_location": self.legend_location, + "traces": {signal: trace.to_data() for signal, trace in self.traces.items()}, + } + + @dataclass class SimulationPlotTab: name: str signals: list[str] = field(default_factory=list) x_axis: str | None = None + settings: SimulationPlotSettings = field(default_factory=SimulationPlotSettings) @classmethod def from_data(cls, data: Mapping[str, Any]) -> SimulationPlotTab: raw_signals = data.get("signals", []) if not isinstance(raw_signals, list): raise TypeError("plot tab signals must be a list") - return cls(name=str(data.get("name", "Plot")), signals=[str(signal) for signal in raw_signals], x_axis=str(data["x_axis"]) if data.get("x_axis") is not None else None) + raw_settings = data.get("settings", {}) + if not isinstance(raw_settings, Mapping): + raise TypeError("plot tab settings must be a mapping") + return cls(name=str(data.get("name", "Plot")), signals=[str(signal) for signal in raw_signals], x_axis=str(data["x_axis"]) if data.get("x_axis") is not None else None, settings=SimulationPlotSettings.from_data(raw_settings)) def to_data(self) -> dict[str, Any]: - return {"name": self.name, "signals": self.signals, "x_axis": self.x_axis} + return {"name": self.name, "signals": self.signals, "x_axis": self.x_axis, "settings": self.settings.to_data()} @dataclass diff --git a/src/bedit_gui/ui/forms/plot_settings.ui b/src/bedit_gui/ui/forms/plot_settings.ui new file mode 100644 index 0000000..22564a5 --- /dev/null +++ b/src/bedit_gui/ui/forms/plot_settings.ui @@ -0,0 +1,102 @@ + + + PlotSettingsDialog + + 00520430 + Plot Settings + + + + 0 + + General + + Labels + Title: + Optional plot title + X-axis label: + Automatic + Y-axis label: + Optional + + Qt::Orientation::Vertical2040 + + + + Axes + + X axis + Scale: + LinearLogarithmic + Automatic limitstrue + Minimum: + 8 + Maximum: + 81.000000000000000 + + Y axis + Scale: + LinearLogarithmic + Automatic limitstrue + Minimum: + 8 + Maximum: + 81.000000000000000 + + Qt::Orientation::Vertical2020 + + + + Traces + + + Trace: + + + Appearance + Visibletrue + Legend label: + Signal name + Line color: + AutomaticReset + Line style: + SolidDashedDottedDash-dotNo line + Line width: + 0.10000000000000020.0000000000000000.2500000000000001.500000000000000 + Marker: + + Marker size: + 0.10000000000000050.0000000000000000.5000000000000006.000000000000000 + + Qt::Orientation::Vertical2020 + + + + Grid & Legend + + Gridtruetrue + Grid lines: + Both axesX axis onlyY axis only + Line style: + SolidDashedDottedDash-dot + Opacity: + Qt::Orientation::Horizontal10050 + + Legendtruetrue + Position: + + + Qt::Orientation::Vertical2030 + + + + + Qt::Orientation::HorizontalQDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + buttonBoxaccepted()PlotSettingsDialogaccept() + buttonBoxrejected()PlotSettingsDialogreject() + + diff --git a/src/bedit_gui/versions.py b/src/bedit_gui/versions.py new file mode 100644 index 0000000..7200d68 --- /dev/null +++ b/src/bedit_gui/versions.py @@ -0,0 +1,4 @@ +"""Independent versions for the BEdit desktop applications.""" + +BEDIT_VERSION = "0.2.0" +BESIM_VERSION = "0.1.0" diff --git a/src/bedit_gui/views/dialogs/plot_settings_dialog.py b/src/bedit_gui/views/dialogs/plot_settings_dialog.py new file mode 100644 index 0000000..1eda147 --- /dev/null +++ b/src/bedit_gui/views/dialogs/plot_settings_dialog.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from copy import deepcopy + +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QColorDialog, QDialog, QMessageBox, QWidget + +from bedit_gui.simulation_models import SimulationPlotSettings, SimulationTraceSettings +from bedit_gui.ui.generated.ui_plot_settings import Ui_PlotSettingsDialog + + +class PlotSettingsDialog(QDialog): + def __init__(self, settings: SimulationPlotSettings, signals: list[str], parent: QWidget | None = None) -> None: + super().__init__(parent) + self.ui = Ui_PlotSettingsDialog() + self.ui.setupUi(self) + self._trace_settings = deepcopy(settings.traces) + self._current_trace: str | None = None + self._trace_color = "" + self._configure_controls() + self._load(settings) + self.ui.traceCombo.addItems(signals) + self.ui.traceCombo.currentIndexChanged.connect(self._trace_changed) + self.ui.traceColorButton.clicked.connect(self._choose_trace_color) + self.ui.traceColorResetButton.clicked.connect(self._reset_trace_color) + self.ui.xAutoCheck.toggled.connect(self._update_limit_controls) + self.ui.yAutoCheck.toggled.connect(self._update_limit_controls) + self._update_limit_controls() + self._trace_changed(self.ui.traceCombo.currentIndex()) + + def settings(self) -> SimulationPlotSettings: + self._store_trace() + return SimulationPlotSettings( + title=self.ui.titleEdit.text().strip(), x_label=self.ui.xLabelEdit.text().strip(), y_label=self.ui.yLabelEdit.text().strip(), + x_scale=self.ui.xScaleCombo.currentData(), y_scale=self.ui.yScaleCombo.currentData(), + x_auto=self.ui.xAutoCheck.isChecked(), y_auto=self.ui.yAutoCheck.isChecked(), + x_min=self.ui.xMinSpin.value(), x_max=self.ui.xMaxSpin.value(), y_min=self.ui.yMinSpin.value(), y_max=self.ui.yMaxSpin.value(), + grid_visible=self.ui.gridGroup.isChecked(), grid_axis=self.ui.gridAxisCombo.currentData(), grid_style=self.ui.gridStyleCombo.currentData(), grid_alpha=self.ui.gridOpacitySlider.value() / 100.0, + legend_visible=self.ui.legendGroup.isChecked(), legend_location=self.ui.legendLocationCombo.currentData(), + traces=deepcopy(self._trace_settings), + ) + + def accept(self) -> None: + settings = self.settings() + if not settings.x_auto and settings.x_min >= settings.x_max: + QMessageBox.warning(self, "Invalid x-axis limits", "The x-axis maximum must be greater than its minimum.") + return + if not settings.y_auto and settings.y_min >= settings.y_max: + QMessageBox.warning(self, "Invalid y-axis limits", "The y-axis maximum must be greater than its minimum.") + return + if settings.x_scale == "log" and not settings.x_auto and settings.x_min <= 0: + QMessageBox.warning(self, "Invalid x-axis limits", "A logarithmic x axis requires a positive minimum.") + return + if settings.y_scale == "log" and not settings.y_auto and settings.y_min <= 0: + QMessageBox.warning(self, "Invalid y-axis limits", "A logarithmic y axis requires a positive minimum.") + return + super().accept() + + def _configure_controls(self) -> None: + for combo in (self.ui.xScaleCombo, self.ui.yScaleCombo): + combo.setItemData(0, "linear") + combo.setItemData(1, "log") + for label, value in (("Both axes", "both"), ("X axis only", "x"), ("Y axis only", "y")): + self.ui.gridAxisCombo.setItemData(self.ui.gridAxisCombo.findText(label), value) + for index, value in enumerate(("-", "--", ":", "-.")): + self.ui.gridStyleCombo.setItemData(index, value) + self.ui.traceLineStyleCombo.setItemData(index, value) + self.ui.traceLineStyleCombo.setItemData(4, "None") + for label, value in (("None", ""), ("Point", "."), ("Circle", "o"), ("Square", "s"), ("Triangle up", "^"), ("Triangle down", "v"), ("Diamond", "D"), ("Plus", "+"), ("Cross", "x"), ("Star", "*")): + self.ui.traceMarkerCombo.addItem(label, value) + for label, value in (("Automatic", "best"), ("Upper right", "upper right"), ("Upper left", "upper left"), ("Lower right", "lower right"), ("Lower left", "lower left"), ("Center right", "center right"), ("Center left", "center left"), ("Upper center", "upper center"), ("Lower center", "lower center"), ("Center", "center")): + self.ui.legendLocationCombo.addItem(label, value) + for spin in (self.ui.xMinSpin, self.ui.xMaxSpin, self.ui.yMinSpin, self.ui.yMaxSpin): + spin.setRange(-1e100, 1e100) + + def _load(self, settings: SimulationPlotSettings) -> None: + self.ui.titleEdit.setText(settings.title) + self.ui.xLabelEdit.setText(settings.x_label) + self.ui.yLabelEdit.setText(settings.y_label) + self.ui.xScaleCombo.setCurrentIndex(self.ui.xScaleCombo.findData(settings.x_scale)) + self.ui.yScaleCombo.setCurrentIndex(self.ui.yScaleCombo.findData(settings.y_scale)) + self.ui.xAutoCheck.setChecked(settings.x_auto) + self.ui.yAutoCheck.setChecked(settings.y_auto) + self.ui.xMinSpin.setValue(settings.x_min) + self.ui.xMaxSpin.setValue(settings.x_max) + self.ui.yMinSpin.setValue(settings.y_min) + self.ui.yMaxSpin.setValue(settings.y_max) + self.ui.gridGroup.setChecked(settings.grid_visible) + self.ui.gridAxisCombo.setCurrentIndex(self.ui.gridAxisCombo.findData(settings.grid_axis)) + self.ui.gridStyleCombo.setCurrentIndex(self.ui.gridStyleCombo.findData(settings.grid_style)) + self.ui.gridOpacitySlider.setValue(round(settings.grid_alpha * 100)) + self.ui.legendGroup.setChecked(settings.legend_visible) + self.ui.legendLocationCombo.setCurrentIndex(self.ui.legendLocationCombo.findData(settings.legend_location)) + + def _update_limit_controls(self) -> None: + for widget in (self.ui.xMinLabel, self.ui.xMinSpin, self.ui.xMaxLabel, self.ui.xMaxSpin): + widget.setEnabled(not self.ui.xAutoCheck.isChecked()) + for widget in (self.ui.yMinLabel, self.ui.yMinSpin, self.ui.yMaxLabel, self.ui.yMaxSpin): + widget.setEnabled(not self.ui.yAutoCheck.isChecked()) + + def _trace_changed(self, index: int) -> None: + self._store_trace() + self._current_trace = self.ui.traceCombo.itemText(index) if index >= 0 else None + self.ui.traceGroup.setEnabled(self._current_trace is not None) + trace = self._trace_settings.get(self._current_trace, SimulationTraceSettings()) + self.ui.traceVisibleCheck.setChecked(trace.visible) + self.ui.traceLegendEdit.setText(trace.label) + self.ui.traceLineStyleCombo.setCurrentIndex(self.ui.traceLineStyleCombo.findData(trace.line_style)) + self.ui.traceLineWidthSpin.setValue(trace.line_width) + self.ui.traceMarkerCombo.setCurrentIndex(self.ui.traceMarkerCombo.findData(trace.marker)) + self.ui.traceMarkerSizeSpin.setValue(trace.marker_size) + self._set_trace_color(trace.color) + + def _store_trace(self) -> None: + if self._current_trace is None: + return + trace = SimulationTraceSettings( + visible=self.ui.traceVisibleCheck.isChecked(), label=self.ui.traceLegendEdit.text().strip(), color=self._trace_color, + line_style=self.ui.traceLineStyleCombo.currentData(), line_width=self.ui.traceLineWidthSpin.value(), + marker=self.ui.traceMarkerCombo.currentData(), marker_size=self.ui.traceMarkerSizeSpin.value(), + ) + if trace == SimulationTraceSettings(): + self._trace_settings.pop(self._current_trace, None) + else: + self._trace_settings[self._current_trace] = trace + + def _choose_trace_color(self) -> None: + initial = QColor(self._trace_color) if self._trace_color else QColor("black") + color = QColorDialog.getColor(initial, self, "Select trace color") + if color.isValid(): + self._set_trace_color(color.name(QColor.NameFormat.HexRgb)) + + def _reset_trace_color(self) -> None: + self._set_trace_color("") + + def _set_trace_color(self, color: str) -> None: + self._trace_color = color + self.ui.traceColorButton.setText(color or "Automatic") + self.ui.traceColorButton.setStyleSheet(f"background-color: {color};" if color else "") diff --git a/src/bedit_gui/views/simulation_plot_widget.py b/src/bedit_gui/views/simulation_plot_widget.py index 67cd0b7..3d1138d 100644 --- a/src/bedit_gui/views/simulation_plot_widget.py +++ b/src/bedit_gui/views/simulation_plot_widget.py @@ -2,26 +2,41 @@ from __future__ import annotations from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT from matplotlib.figure import Figure +from PySide6.QtCore import Signal from PySide6.QtWidgets import QVBoxLayout, QWidget +from bedit_gui.simulation_models import SimulationPlotSettings from bedit_simulation import SimulationResult class SimulationPlotWidget(QWidget): + settings_requested = Signal() + def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.figure = Figure(layout="constrained") self.canvas = FigureCanvasQTAgg(self.figure) self.toolbar = NavigationToolbar2QT(self.canvas, self) + for action in self.toolbar.actions(): + if action.text() in ("Customize", "Subplots"): + self.toolbar.removeAction(action) + self.toolbar.addSeparator() + self.settings_action = self.toolbar.addAction("Plot settings…") + self.settings_action.setToolTip("Edit plot title, axes, traces, grid, and legend") + self.settings_action.triggered.connect(self.settings_requested) self.axes = self.figure.add_subplot() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.toolbar) layout.addWidget(self.canvas) - def set_plot(self, results: list[SimulationResult], signals: list[str], x_axis: str | None = None) -> None: + def set_plot(self, results: list[SimulationResult], signals: list[str], x_axis: str | None = None, settings: SimulationPlotSettings | None = None) -> None: + settings = settings or SimulationPlotSettings() self.axes.clear() for signal in signals: + trace = settings.traces.get(signal) + if trace is not None and not trace.visible: + continue x_values: list[float] = [] values: list[float] = [] sample_offset = 0 @@ -39,9 +54,20 @@ class SimulationPlotWidget(QWidget): values.extend(result_values) sample_offset += len(result_values) if values: - self.axes.plot(x_values, values, label=signal) - self.axes.set_xlabel(x_axis or "Time") - self.axes.grid(True) - if self.axes.lines: - self.axes.legend() + options = {"label": trace.label or signal, "linestyle": trace.line_style, "linewidth": trace.line_width, "marker": trace.marker or None, "markersize": trace.marker_size} if trace is not None else {"label": signal} + if trace is not None and trace.color: + options["color"] = trace.color + self.axes.plot(x_values, values, **options) + self.axes.set_title(settings.title) + self.axes.set_xlabel(settings.x_label or x_axis or "Time") + self.axes.set_ylabel(settings.y_label) + self.axes.set_xscale(settings.x_scale) + self.axes.set_yscale(settings.y_scale) + if not settings.x_auto: + self.axes.set_xlim(settings.x_min, settings.x_max) + if not settings.y_auto: + self.axes.set_ylim(settings.y_min, settings.y_max) + self.axes.grid(settings.grid_visible, axis=settings.grid_axis, linestyle=settings.grid_style, alpha=settings.grid_alpha) + if self.axes.lines and settings.legend_visible: + self.axes.legend(loc=settings.legend_location) self.canvas.draw_idle() diff --git a/untitled.besim.json b/untitled.besim.json index 86196a6..6e17c94 100644 --- a/untitled.besim.json +++ b/untitled.besim.json @@ -7909,21 +7909,91 @@ "j0.e", "j1.f" ], - "x_axis": null + "x_axis": null, + "settings": { + "title": "Some title here", + "x_label": "", + "y_label": "", + "x_scale": "linear", + "y_scale": "linear", + "x_auto": true, + "y_auto": true, + "x_min": 0.0, + "x_max": 1.0, + "y_min": 0.0, + "y_max": 1.0, + "grid_visible": true, + "grid_axis": "both", + "grid_style": "-", + "grid_alpha": 0.5, + "legend_visible": true, + "legend_location": "best", + "traces": {} + } }, { "name": "R power", "signals": [ "R.power" ], - "x_axis": null + "x_axis": null, + "settings": { + "title": "", + "x_label": "", + "y_label": "", + "x_scale": "linear", + "y_scale": "linear", + "x_auto": true, + "y_auto": true, + "x_min": 0.0, + "x_max": 1.0, + "y_min": 0.0, + "y_max": 1.0, + "grid_visible": true, + "grid_axis": "both", + "grid_style": "-", + "grid_alpha": 0.5, + "legend_visible": true, + "legend_location": "best", + "traces": {} + } }, { "name": "Eh idk?", "signals": [ "j1.f" ], - "x_axis": "j0.e" + "x_axis": "j0.e", + "settings": { + "title": "", + "x_label": "", + "y_label": "j1.f", + "x_scale": "linear", + "y_scale": "linear", + "x_auto": true, + "y_auto": true, + "x_min": 0.0, + "x_max": 1.0, + "y_min": 0.0, + "y_max": 1.0, + "grid_visible": true, + "grid_axis": "both", + "grid_style": "-", + "grid_alpha": 0.5, + "legend_visible": true, + "legend_location": "best", + "traces": { + "j1.f": { + "visible": true, + "label": "", + "color": "#ff0000", + "line_style": ":", + "line_width": 1.5, + "marker": "", + "marker_size": 6.0 + } + } + } } ] }