"""
Minimal Qt GUI starter for ARTS plots integration.
Usage:
from pyarts3.qt_plot_gui import start_plot_gui
start_plot_gui(plot_kwarg_func)
Where plot_kwarg_func is a callable (with __call__(self, **kwargs)) that returns a dict of kwargs for plots.plot().
"""
import os
import sys
import platform as _platform
import pyarts3
_headless = os.environ.get("ARTS_HEADLESS") == "1"
# macOS-specific: ensure platform.mac_ver() reports a version for Qt/Matplotlib compatibility
if sys.platform == "darwin" and not _headless:
os.environ.setdefault("SYSTEM_VERSION_COMPAT", "1")
os.environ.setdefault("MPLBACKEND", "Qt5Agg")
os.environ.setdefault("QT_API", "PyQt5")
# Work around matplotlib parsing of platform.mac_ver() returning '' on some setups
try:
if not _platform.mac_ver()[0]:
_platform.mac_ver = lambda: ("10.16", ("", "", ""), "") # type: ignore
except Exception:
pass
import matplotlib
matplotlib.use("Agg" if _headless else "Qt5Agg", force=True)
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
from matplotlib.patches import Rectangle
from PyQt5.QtWidgets import (QApplication, QVBoxLayout, QHBoxLayout, QPushButton,
QWidget, QListWidget, QLabel, QDialog, QLineEdit,
QDialogButtonBox, QFormLayout, QListWidgetItem, QMenu, QAction,
QMessageBox, QTextEdit, QTabWidget, QTabBar)
from PyQt5.QtCore import Qt
import numpy as np
from . import edit as editors
[docs]
class PlotGui(QWidget):
[docs]
def __init__(self, plot_kwarg_func, plot_func, simulation_settings=None, post=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.plot_kwarg_func = plot_kwarg_func
self.plot_func = plot_func
self.simulation_settings = simulation_settings or {}
self.simulation_results = {} # Results from running simulation
self.selected_plot_key = None # Selected key when results is a dict
self.plot_settings = {} # Additional kwargs for plot function
self.post_settings = {} # Additional kwargs for post function
self.last_added_option = None # Track last added option for error recovery
self.post = post
self.init_ui()
[docs]
def init_ui(self):
self.setWindowTitle("ARTS GUI")
self.fig = Figure(figsize=(16, 12), constrained_layout=True)
self.canvas = FigureCanvas(self.fig)
self.canvas.setMinimumSize(800, 600)
# Enable native matplotlib pan/zoom interactions
self.canvas.mpl_connect('scroll_event', self.on_scroll)
self.canvas.mpl_connect('button_press_event', self.on_press)
self.canvas.mpl_connect('button_release_event', self.on_release)
self.canvas.mpl_connect('motion_notify_event', self.on_motion)
self._panning = False
self._pan_start = None
self._zooming = False
self._zoom_start = None
self._zoom_rect = None
self._zoom_axes = None
self._original_limits = {} # Store original axis limits for reset
self.ax = None
# Create main horizontal layout
main_layout = QHBoxLayout()
main_layout.setContentsMargins(10, 10, 10, 10)
# Left side: selection tab bar above canvas
left_container = QWidget()
left_layout = QVBoxLayout()
left_layout.setContentsMargins(0, 0, 0, 0)
self.plot_tabbar = QTabBar()
self.plot_tabbar.setExpanding(False)
self.plot_tabbar.setMovable(False)
self.plot_tabbar.setTabsClosable(False)
self.plot_tabbar.currentChanged.connect(self.on_plot_tab_changed)
self.plot_tabbar.hide() # Hidden unless we have multiple results
left_layout.addWidget(self.plot_tabbar)
left_layout.addWidget(self.canvas, stretch=1)
left_container.setLayout(left_layout)
main_layout.addWidget(left_container, stretch=1)
# Right side: control panel with tabs
right_panel = QVBoxLayout()
self.run_btn = QPushButton("Run Simulation")
self.run_btn.clicked.connect(self.run_simulation)
right_panel.addWidget(self.run_btn)
# Create tab widget
self.tabs = QTabWidget()
# Tab 1: Simulation Settings
sim_tab = QWidget()
sim_layout = QVBoxLayout()
self.simulation_list = QListWidget()
self.simulation_list.itemDoubleClicked.connect(lambda item: self.on_item_double_clicked("simulation", item))
sim_layout.addWidget(self.simulation_list)
sim_tab.setLayout(sim_layout)
self.tabs.addTab(sim_tab, "Simulation Settings")
# Tab 2: Simulation Results
results_tab = QWidget()
results_layout = QVBoxLayout()
self.results_list = QListWidget()
self.results_list.itemDoubleClicked.connect(lambda item: self.on_item_double_clicked("results", item))
results_layout.addWidget(self.results_list)
results_tab.setLayout(results_layout)
self.tabs.addTab(results_tab, "Simulation Results")
# Tab 4: Plot Settings
plot_tab = QWidget()
plot_layout = QVBoxLayout()
self.plot_settings_list = QListWidget()
self.plot_settings_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.plot_settings_list.customContextMenuRequested.connect(lambda pos: self.show_options_context_menu(pos, "plot"))
self.plot_settings_list.itemDoubleClicked.connect(lambda item: self.on_item_double_clicked("plot", item))
self.plot_settings_list.itemClicked.connect(lambda item: self.on_settings_item_clicked(item, "plot"))
plot_layout.addWidget(self.plot_settings_list)
plot_tab.setLayout(plot_layout)
self.tabs.addTab(plot_tab, "Plot Settings")
# Tab 5: Post Settings
post_tab = QWidget()
post_layout = QVBoxLayout()
self.post_settings_list = QListWidget()
self.post_settings_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.post_settings_list.customContextMenuRequested.connect(lambda pos: self.show_options_context_menu(pos, "post"))
self.post_settings_list.itemDoubleClicked.connect(lambda item: self.on_item_double_clicked("post", item))
self.post_settings_list.itemClicked.connect(lambda item: self.on_settings_item_clicked(item, "post"))
post_layout.addWidget(self.post_settings_list)
post_tab.setLayout(post_layout)
self.tabs.addTab(post_tab, "Post Settings")
right_panel.addWidget(self.tabs)
main_layout.addLayout(right_panel)
self.setLayout(main_layout)
# Initialize settings lists with "+ Add option..." placeholder
self.update_settings_display("plot")
self.update_settings_display("post")
[docs]
def run_simulation(self):
"""Run the simulation function to generate results."""
try:
# Populate Simulation Settings list
self.simulation_list.clear()
for key, value in self.simulation_settings.items():
type_name = type(value).__name__
self.simulation_list.addItem(f"{key}: <{type_name}>")
# Run simulation to get results
self.simulation_results = self.plot_kwarg_func(**self.simulation_settings)
# Populate the Results list
self.results_list.clear()
if isinstance(self.simulation_results, dict):
for key, value in self.simulation_results.items():
type_name = type(value).__name__
self.results_list.addItem(f"{key}: <{type_name}>")
# Populate top tab bar and preserve previous selection if possible
prev = self.selected_plot_key
# Clear all tabs
while self.plot_tabbar.count() > 0:
self.plot_tabbar.removeTab(0)
keys = list(self.simulation_results.keys())
index_for_prev = -1
for i, key in enumerate(keys):
self.plot_tabbar.addTab(str(key))
if key == prev:
index_for_prev = i
# Determine new index
if index_for_prev == -1:
index_for_prev = 0 if keys else -1
if index_for_prev != -1:
self.plot_tabbar.setCurrentIndex(index_for_prev)
self.selected_plot_key = keys[index_for_prev]
self.plot_tabbar.show()
else:
self.selected_plot_key = None
self.plot_tabbar.hide()
else:
# Single result
type_name = type(self.simulation_results).__name__
self.results_list.addItem(f"data: <{type_name}>")
# Hide the tab bar for single-result mode
self.selected_plot_key = None
self.plot_tabbar.hide()
# Automatically run plot after simulation
self.run_plot()
except Exception as e:
import traceback
QMessageBox.critical(self, "Simulation Error", f"Error running simulation:\n{traceback.format_exc()}")
[docs]
def run_plot(self):
"""Run the plot function with current results and settings."""
try:
# Clear figure completely
self.fig.clear()
self.ax = None
self._original_limits.clear()
if not self.simulation_results:
QMessageBox.information(self, "No Results", "Please run simulation first")
return
# Check if results is a dict (multiple data) and plot the selected one only
if isinstance(self.simulation_results, dict):
if len(self.simulation_results) == 0:
return
if not self.selected_plot_key or self.selected_plot_key not in self.simulation_results:
# No selection yet - do not plot until user selects
return
data = self.simulation_results[self.selected_plot_key]
plot_kwargs = {**self.plot_settings}
plot_kwargs['data'] = data
plot_kwargs['fig'] = self.fig
plot_kwargs['ax'] = None
_, self.ax = self.plot_func(**plot_kwargs)
else:
# Single result - pass as data parameter
plot_kwargs = {**self.plot_settings}
plot_kwargs['data'] = self.simulation_results
plot_kwargs['fig'] = self.fig
plot_kwargs['ax'] = None
_, self.ax = self.plot_func(**plot_kwargs)
# Call post processing if provided
if callable(self.post):
self.post(self.fig, self.ax, **self.post_settings)
# Remove any empty/unused axes that might have been created
for ax in self.fig.get_axes():
if not ax.has_data() and not ax.get_title() and not ax.get_xlabel() and not ax.get_ylabel():
self.fig.delaxes(ax)
# Store original limits for all axes
for ax in self.fig.get_axes():
self._original_limits[ax] = {
'xlim': ax.get_xlim(),
'ylim': ax.get_ylim()
}
self.canvas.draw()
except Exception as e:
# Show error dialog with scrollable text
error_msg = f"{type(e).__name__}: {str(e)}" # Extract last line for main display
error_lines = error_msg.split('\n')
last_line = error_lines[-1].strip() if error_lines else error_msg
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Plot Error")
msg_box.setText(f"An error occurred while plotting:\n\n{last_line}")
# Use detailed text which is automatically scrollable
msg_box.setDetailedText(error_msg)
msg_box.addButton(QMessageBox.Ok)
# Set a reasonable size for the dialog
msg_box.setStyleSheet("QTextEdit { min-width: 600px; min-height: 300px; }")
msg_box.exec_()
[docs]
def on_plot_tab_changed(self, index):
"""Handle change of the plot selection tab above the canvas."""
if index is None or index < 0:
return
# Guard against race conditions where results changed
try:
key = self.plot_tabbar.tabText(index)
except Exception:
return
self.selected_plot_key = key
self.run_plot()
[docs]
def on_item_double_clicked(self, list_type, item):
"""Handle double-click on an item from any of the lists."""
# Parse the item text to extract key and value
text = item.text()
# Check if it's the "+ Add option..." item
if text.startswith("+ Add"):
if list_type == "plot":
self.add_option("plot")
elif list_type == "post":
self.add_option("post")
return
# Parse "key: value" or "key: value (type)" format
if ": " not in text:
return
key, rest = text.split(": ", 1)
# Get the actual value from the appropriate dict
if list_type == "simulation":
value = self.simulation_settings.get(key)
elif list_type == "results":
# Results are read-only, just view
if isinstance(self.simulation_results, dict):
value = self.simulation_results.get(key)
else:
return # Can't edit single result
elif list_type == "plot":
# For plot settings list, the key is stored in UserRole
key = item.data(Qt.UserRole)
if key is None: # "+ Add option..." item
self.add_option("plot")
return
value = self.plot_settings.get(key)
elif list_type == "post":
# For post settings list, the key is stored in UserRole
key = item.data(Qt.UserRole)
if key is None: # "+ Add option..." item
self.add_option("post")
return
value = self.post_settings.get(key)
else:
return
if value is None:
return
# Open editor (will automatically detect type and fall back to generic viewer)
new_value = editors.edit(value, self)
# Update the value if changed
if new_value is not None:
if list_type == "simulation":
self.simulation_settings[key] = new_value
# Update display - show only type name
self.simulation_list.clear()
for k, v in self.simulation_settings.items():
type_name = type(v).__name__
self.simulation_list.addItem(f"{k}: <{type_name}>")
# Re-run simulation to regenerate results
self.run_simulation()
elif list_type == "plot":
self.plot_settings[key] = new_value
self.update_settings_display("plot")
# Replot with updated plot settings
self.run_plot()
elif list_type == "post":
self.post_settings[key] = new_value
self.update_settings_display("post")
# Replot with updated post settings
self.run_plot()
[docs]
def update_settings_display(self, settings_type):
"""Refresh the Plot Settings or Post Settings display.
Args:
settings_type: Either "plot" or "post"
"""
if settings_type == "plot":
list_widget = self.plot_settings_list
settings_dict = self.plot_settings
else: # "post"
list_widget = self.post_settings_list
settings_dict = self.post_settings
list_widget.clear()
for key, value in settings_dict.items():
type_name = type(value).__name__
item = QListWidgetItem(f"{key}: {value} ({type_name})")
item.setData(Qt.UserRole, key) # Store key for removal
list_widget.addItem(item)
# Add a "+ Add option..." item at the end
add_item = QListWidgetItem("+ Add option...")
add_item.setData(Qt.UserRole, None) # Mark as the add button
add_item.setForeground(Qt.gray)
list_widget.addItem(add_item)
[docs]
def on_settings_item_clicked(self, item, settings_type):
"""Handle click on settings list items.
Args:
item: The clicked item
settings_type: Either "plot" or "post"
"""
# Check if it's the "+ Add option..." item
if item.data(Qt.UserRole) is None:
self.add_option(settings_type)
[docs]
def add_option(self, settings_type):
"""Show dialog to add a new Plot or Post Setting.
Args:
settings_type: Either "plot" or "post"
"""
settings_dict = self.plot_settings if settings_type == "plot" else self.post_settings
dialog = AddOptionDialog(self, existing_keys=settings_dict.keys())
if dialog.exec_() == QDialog.Accepted:
key, value = dialog.get_values()
if key:
settings_dict[key] = value
self.last_added_option = (settings_type, key) # Track for error recovery
self.update_settings_display(settings_type)
# Replot with new settings
self.run_plot()
[docs]
def edit_option(self, settings_type):
"""Edit an existing Plot or Post Setting.
Args:
settings_type: Either "plot" or "post"
"""
list_widget = self.plot_settings_list if settings_type == "plot" else self.post_settings_list
settings_dict = self.plot_settings if settings_type == "plot" else self.post_settings
current_item = list_widget.currentItem()
if current_item:
key = current_item.data(Qt.UserRole)
# Don't edit if it's the add button
if key is not None and key in settings_dict:
current_value = settings_dict[key]
# Pass existing keys except the one being edited
other_keys = [k for k in settings_dict.keys() if k != key]
dialog = AddOptionDialog(
self,
existing_keys=other_keys,
edit_mode=True,
initial_key=key,
initial_value=current_value
)
result = dialog.exec_()
if dialog.deleted:
# Delete was clicked
del settings_dict[key]
# Clear last_added_option if we deleted it
if self.last_added_option == (settings_type, key):
self.last_added_option = None
self.update_settings_display(settings_type)
# Replot after deletion
self.run_plot()
elif result == QDialog.Accepted:
new_key, new_value = dialog.get_values()
if new_key:
# Remove old key if it changed
if new_key != key:
del settings_dict[key]
# Update last_added_option if key changed
if self.last_added_option == (settings_type, key):
self.last_added_option = (settings_type, new_key)
settings_dict[new_key] = new_value
self.update_settings_display(settings_type)
# Replot with modified settings
self.run_plot()
[docs]
def remove_option(self, settings_type):
"""Remove the selected Plot or Post Setting.
Args:
settings_type: Either "plot" or "post"
"""
list_widget = self.plot_settings_list if settings_type == "plot" else self.post_settings_list
settings_dict = self.plot_settings if settings_type == "plot" else self.post_settings
current_item = list_widget.currentItem()
if current_item:
key = current_item.data(Qt.UserRole)
# Don't remove if it's the add button
if key is not None and key in settings_dict:
del settings_dict[key]
# Clear last_added_option if we removed it
if self.last_added_option == (settings_type, key):
self.last_added_option = None
self.update_settings_display(settings_type)
# Replot after removal
self.run_plot()
[docs]
def on_press(self, event):
"""Handle mouse button press for panning, zooming, and reset."""
if event.inaxes is None:
return
# Double-click to restore original view
if event.dblclick and event.inaxes in self._original_limits:
ax = event.inaxes
limits = self._original_limits[ax]
ax.set_xlim(limits['xlim'])
ax.set_ylim(limits['ylim'])
self.canvas.draw_idle()
return
# Left-click for panning
if event.button == 1:
self._panning = True
self._pan_start = (event.xdata, event.ydata)
self._pan_axes = event.inaxes
# Right-click for zoom-to-rectangle
elif event.button == 3:
self._zooming = True
self._zoom_start = (event.xdata, event.ydata)
self._zoom_axes = event.inaxes
# Create a rectangle patch for visual feedback
self._zoom_rect = Rectangle(
(event.xdata, event.ydata), 0, 0,
fill=False, edgecolor='red', linewidth=2, linestyle='--'
)
event.inaxes.add_patch(self._zoom_rect)
self.canvas.draw_idle()
[docs]
def on_release(self, event):
"""Handle mouse button release."""
# Handle zoom rectangle completion
if self._zooming and self._zoom_rect is not None and event.inaxes == self._zoom_axes:
# Remove the rectangle
self._zoom_rect.remove()
self._zoom_rect = None
# Apply zoom if we have valid coordinates
if (event.xdata is not None and event.ydata is not None and
self._zoom_start is not None):
x0, y0 = self._zoom_start
x1, y1 = event.xdata, event.ydata
# Make sure we have a proper rectangle (not just a click)
if abs(x1 - x0) > 0.01 * (self._zoom_axes.get_xlim()[1] - self._zoom_axes.get_xlim()[0]) and \
abs(y1 - y0) > 0.01 * (self._zoom_axes.get_ylim()[1] - self._zoom_axes.get_ylim()[0]):
# Set new limits (ensure proper ordering)
self._zoom_axes.set_xlim([min(x0, x1), max(x0, x1)])
self._zoom_axes.set_ylim([min(y0, y1), max(y0, y1)])
self.canvas.draw_idle()
self._zooming = False
self._zoom_start = None
self._zoom_axes = None
# Handle panning release
self._panning = False
self._pan_start = None
[docs]
def on_motion(self, event):
"""Handle mouse motion for panning and zoom rectangle."""
# Handle zoom rectangle drawing
if self._zooming and self._zoom_rect is not None:
if event.xdata is not None and event.ydata is not None and self._zoom_start is not None:
x0, y0 = self._zoom_start
width = event.xdata - x0
height = event.ydata - y0
self._zoom_rect.set_width(width)
self._zoom_rect.set_height(height)
self.canvas.draw_idle()
return
# Handle panning
if not self._panning or event.inaxes != self._pan_axes:
return
if event.xdata is None or event.ydata is None or self._pan_start is None:
return
# Calculate delta
dx = self._pan_start[0] - event.xdata
dy = self._pan_start[1] - event.ydata
# Update limits
ax = self._pan_axes
xlim = ax.get_xlim()
ylim = ax.get_ylim()
ax.set_xlim([xlim[0] + dx, xlim[1] + dx])
ax.set_ylim([ylim[0] + dy, ylim[1] + dy])
self.canvas.draw_idle()
class AddOptionDialog(QDialog):
"""Dialog for adding a new Additional Option with type inference."""
def __init__(self, parent=None, existing_keys=None, edit_mode=False, initial_key=None, initial_value=None, allow_name_edit=False):
super().__init__(parent)
self.existing_keys = set(existing_keys) if existing_keys else set()
self.edit_mode = edit_mode
self.initial_key = initial_key
self.allow_name_edit = allow_name_edit
if edit_mode:
self.setWindowTitle("Edit Additional Option")
else:
self.setWindowTitle("Add Additional Option")
layout = QFormLayout()
self.key_input = QLineEdit()
self.key_input.setPlaceholderText("Option name")
if edit_mode and initial_key:
self.key_input.setText(initial_key)
# Only gray out if not allowing name edit
if not allow_name_edit:
self.key_input.setEnabled(False) # Gray out in edit mode
self.key_input.setStyleSheet("QLineEdit { background-color: #f0f0f0; color: #888; }")
self.key_input.textChanged.connect(self.check_duplicate)
layout.addRow("Name:", self.key_input)
self.warning_label = QLabel("")
self.warning_label.setStyleSheet("QLabel { color: red; font-size: 10px; }")
layout.addRow("", self.warning_label)
self.value_input = QLineEdit()
self.value_input.setPlaceholderText("Value (auto-typed; empty → None)")
if edit_mode and initial_value is not None:
self.value_input.setText(str(initial_value))
layout.addRow("Value:", self.value_input)
# Buttons
if edit_mode:
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
delete_btn = self.buttons.addButton("Delete", QDialogButtonBox.DestructiveRole)
delete_btn.clicked.connect(self.delete_option)
else:
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.ok_button = self.buttons.button(QDialogButtonBox.Ok)
self.buttons.accepted.connect(self.accept)
self.buttons.rejected.connect(self.reject)
layout.addRow(self.buttons)
self.setLayout(layout)
self.deleted = False
# Initial duplicate check if in edit mode
if not edit_mode:
self.check_duplicate()
def check_duplicate(self):
"""Check if the key already exists and show warning."""
key = self.key_input.text().strip()
if key in self.existing_keys:
self.warning_label.setText("Duplicate name")
self.key_input.setStyleSheet("QLineEdit { border: 1px solid red; }")
self.ok_button.setEnabled(False)
else:
self.warning_label.setText("")
self.key_input.setStyleSheet("")
self.ok_button.setEnabled(True)
def delete_option(self):
"""Mark option for deletion and close dialog."""
self.deleted = True
self.reject()
def get_values(self):
"""Return the key and inferred-type value."""
if self.deleted:
return None, None
key = self.key_input.text().strip()
value_str = self.value_input.text().strip()
# Infer type
value = self._infer_type(value_str)
return key, value
def _infer_type(self, value_str):
"""Infer type using eval with fallback to string; empty -> None.
Behavior:
- Empty string: return None
- Non-empty: try eval(value_str); on any error, return the original string
"""
# Empty string -> None
if value_str == "":
return None
try:
# Use eval to allow Python literals/expressions (e.g., lists, dicts, None, True, False)
# Intentionally not restricting builtins here as this is user-entered config in a local GUI.
return eval(value_str)
except Exception:
# Fallback to raw string if eval fails
return value_str
[docs]
def start_gui(plot_kwarg_func, plot_func=pyarts3.plots.plot, simulation_settings=None, post=None):
app = QApplication.instance() or QApplication(sys.argv)
gui = PlotGui(plot_kwarg_func, plot_func, simulation_settings=simulation_settings, post=post)
gui.show()
app.exec_()