Source code for xraylabtool.gui.main_window

from __future__ import annotations

import csv
from pathlib import Path
import re
from typing import Any

from PySide6.QtCore import (
    QObject,
    QStandardPaths,
    Qt,
    QThreadPool,
    QTimer,
)
from PySide6.QtWidgets import (
    QCheckBox,
    QComboBox,
    QDoubleSpinBox,
    QFileDialog,
    QGridLayout,
    QGroupBox,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QLineEdit,
    QMainWindow,
    QMessageBox,
    QProgressBar,
    QPushButton,
    QScrollArea,
    QSizePolicy,
    QSpinBox,
    QStatusBar,
    QTableWidget,
    QTableWidgetItem,
    QTabWidget,
    QVBoxLayout,
    QWidget,
)

from xraylabtool.logging_utils import get_log_file_path, get_logger
from xraylabtool.utils import energy_to_wavelength, wavelength_to_energy

from .services import EnergyConfig, compute_multiple, compute_single
from .table_formatter import TableFormatter
from .widgets.material_form import MaterialInputForm
from .widgets.material_table import MaterialTable
from .widgets.plot_canvas import PlotCanvas
from .widgets.scrollbar_helper import OverlayScrollbarMarginHelper
from .widgets.sweep_plots import F1F2Plot, MultiF1F2Plot
from .workers import CalculationWorker


[docs] class Toast(QLabel): """Lightweight, non-blocking toast overlay."""
[docs] def __init__(self, parent: QWidget) -> None: super().__init__(parent) self.setStyleSheet( "background: rgba(15,23,42,0.92); color: white; padding: 8px 12px;" "border-radius: 8px;" ) self.setVisible(False) self._timer = QTimer(self) self._timer.setSingleShot(True) self._timer.timeout.connect(self.hide) self._kind_colors = { "info": "#00f0ff", "success": "#00ff9d", "error": "#ff9d00", } self._durations = {"info": 2000, "success": 2400, "error": 3500}
[docs] def show_toast( self, message: str, kind: str = "info", duration_ms: int | None = None ) -> None: color = self._kind_colors.get(kind, "#2563eb") self.setStyleSheet( f"background: rgba(15,23,42,0.92); color: white; padding: 8px 12px;" f"border: 1px solid {color}; border-radius: 8px;" ) self.setText(message) self.adjustSize() self._reposition() self.show() self.raise_() duration = ( duration_ms if duration_ms is not None else self._durations.get(kind, 2200) ) self._timer.start(duration)
def _reposition(self) -> None: parent = self.parentWidget() if not parent: return x = max(8, (parent.width() - self.width()) // 2) y = max(8, parent.height() - self.height() - 24) self.move(x, y)
PROPERTIES = [ "attenuation_length_cm", "dispersion_delta", "absorption_beta", "critical_angle_degrees", "real_sld_per_ang2", "imaginary_sld_per_ang2", ] logger = get_logger(__name__)
[docs] class MainWindow(QMainWindow):
[docs] def __init__(self, theme_manager: Any | None = None) -> None: super().__init__() self.theme_manager = theme_manager self.setWindowTitle("XRayLabTool GUI") self.resize(1100, 720) self.setMinimumSize(900, 620) self.threadpool: QThreadPool | None = ( None # Assigned on first use to avoid import cycles ) self.status_bar = QStatusBar() self.progress = QProgressBar() self.progress.setMaximumHeight(18) self.progress.setRange(0, 1) self.progress.setValue(0) self.progress.setVisible(False) self.progress.setTextVisible(True) self.progress.setFormat("%p%") self.status_bar.addPermanentWidget(self.progress) self.log_path_label = QLabel() self.log_path_label.setVisible(False) self.status_bar.addPermanentWidget(self.log_path_label) self.log_path_toggle = QPushButton("Log path") self.log_path_toggle.setProperty("class", "secondary") self.log_path_toggle.setToolTip("Show or hide the current log file path") self.log_path_toggle.setCheckable(True) self.log_path_toggle.setChecked(False) self.log_path_toggle.clicked.connect(self._toggle_log_path) self.status_bar.addPermanentWidget(self.log_path_toggle) self.theme_toggle = QPushButton("Light Mode") self.theme_toggle.setProperty("class", "secondary") self.theme_toggle.setCheckable(True) if self.theme_manager: curr = self.theme_manager.get_theme() is_dark = curr == "dark" self.theme_toggle.setChecked(is_dark) self.theme_toggle.setText("Dark Mode" if is_dark else "Light Mode") self.theme_toggle.clicked.connect(self._handle_theme_toggle_click) self.theme_manager.theme_changed.connect(self._on_theme_changed) else: self.theme_toggle.setEnabled(False) self.status_bar.addPermanentWidget(self.theme_toggle) self.status_bar.setSizeGripEnabled(True) self.setStatusBar(self.status_bar) self.toast = Toast(self) self.single_result = None self.multi_results = None self.multi_comparison = None self._workers: list[Any] = [] self.material_presets = { "Si": 2.33, "SiO2": 2.2, "Al2O3": 3.95, "C": 3.52, "Au": 19.3, "Pt": 21.45, "Rh": 12.4, "Pd": 12.0, "CaCO3": 2.71, } self.energy_presets = { "10 keV": (10.0, 10.0, 1, False), "Cu Kalpha (8.048 keV)": (8.048, 8.048, 1, False), "1-30 keV log (100)": (1.0, 30.0, 100, True), "5-25 keV log (50)": (5.0, 25.0, 50, True), } self.main_tabs = QTabWidget() self.main_tabs.addTab(self._build_single_tab(), "Single Material") self.main_tabs.addTab(self._build_multi_tab(), "Multiple Materials") self.setCentralWidget(self.main_tabs) self._set_tab_order() self._tune_table_headers()
def _handle_theme_toggle_click(self) -> None: if self.theme_manager: self.theme_manager.toggle_theme() def _on_theme_changed(self, mode: str) -> None: is_dark = mode == "dark" self.theme_toggle.setChecked(is_dark) self.theme_toggle.setText("Dark Mode" if is_dark else "Light Mode") self._refresh_plots() def _refresh_plots(self) -> None: """Force update of all plots to match new theme.""" # Find all widgets with update_theme capability (PlotCanvas, F1F2Plot, etc.) # We search recursively for widget in self.findChildren(QWidget): if hasattr(widget, "update_theme"): widget.update_theme() # ------------------------------------------------------------------ # Single tab def _build_single_tab(self) -> QWidget: container = QWidget() outer = QVBoxLayout(container) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(8) # Inputs self.single_form = MaterialInputForm() self.single_form.compute_button.clicked.connect(self._run_single) # Presets self.single_preset = QComboBox() self.single_preset.addItem("Select material preset") for name in self.material_presets: self.single_preset.addItem(name) self.single_preset.currentTextChanged.connect(self._apply_single_preset) self.single_preset.setToolTip("Apply a common material formula and density") self.energy_preset = QComboBox() self.energy_preset.addItem("Select energy preset") for name in self.energy_presets: self.energy_preset.addItem(name) self.energy_preset.currentTextChanged.connect(self._apply_energy_preset) self.energy_preset.setToolTip( "Pick a frequently used energy sweep or single energy" ) # Property chooser + export buttons self.single_property = QComboBox() self.single_property.addItems(PROPERTIES) self.single_property.currentTextChanged.connect(self._refresh_single_views) self.single_logx = QCheckBox("Log X") self.single_logy = QCheckBox("Log Y") self.single_logx.stateChanged.connect(self._refresh_single_views) self.single_logy.stateChanged.connect(self._refresh_single_views) self.single_property.setToolTip("Select which property to plot and export") self.single_logx.setToolTip("Toggle logarithmic X axis for plots") self.single_logy.setToolTip("Toggle logarithmic Y axis for plots") self.single_save_png = QPushButton("Save plot PNG") self.single_save_png.setProperty("class", "secondary") self.single_save_png.setShortcut("Ctrl+Shift+S") self.single_save_png.setToolTip("Export the current plot to PNG (Ctrl+Shift+S)") self.single_save_png.clicked.connect(self._save_single_png) self.single_export_csv = QPushButton("Export CSV") self.single_export_csv.setProperty("class", "secondary") self.single_export_csv.setShortcut("Ctrl+Shift+E") self.single_export_csv.setToolTip("Export table data to CSV (Ctrl+Shift+E)") self.single_export_csv.clicked.connect(self._export_single_csv) plot_header = QHBoxLayout() plot_header.setSpacing(12) plot_header.addWidget(QLabel("Property")) plot_header.addWidget(self.single_property) plot_header.addWidget(self.single_logx) plot_header.addWidget(self.single_logy) plot_header.addStretch(1) plot_header.addWidget(self.single_save_png) plot_header.addWidget(self.single_export_csv) self.single_summary = QTableWidget(1, 5) self.single_summary.setHorizontalHeaderLabels( [ "Formula", "MW (g/mol)", "Density (g/cm³)", "Electron density (e/ų)", "Total electrons", ] ) self.single_summary.verticalHeader().setVisible(False) self.single_summary.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.single_summary.setMaximumHeight(64) self.single_summary.setMinimumHeight(48) self.single_summary.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) # Table # 12 columns: energy, wavelength, delta, beta, critical angles, attenuation, mu, f1/f2, SLDs self.single_table = QTableWidget(0, 12) self.single_table.setAlternatingRowColors(True) self.single_table.setHorizontalHeaderLabels( [ "Energy (keV)", "Wavelength (Å)", "δ", "β", "θc (deg)", "θc (mrad)", "Atten. length (cm)", "μ (1/cm)", "f1 (e)", "f2 (e)", "Re SLD (Å⁻²)", "Im SLD (Å⁻²)", ] ) self.single_table.verticalHeader().setVisible(False) # Plot tabs self.single_plot = PlotCanvas() self.single_f1f2 = F1F2Plot() # Converter converter = QGroupBox("Energy ↔ Wavelength") conv_layout = QHBoxLayout() self.conv_energy = QDoubleSpinBox() self.conv_energy.setRange(0.01, 100.0) self.conv_energy.setDecimals(4) self.conv_energy.setSuffix(" keV") self.conv_wavelength = QDoubleSpinBox() self.conv_wavelength.setRange(0.01, 10_000.0) self.conv_wavelength.setDecimals(4) self.conv_wavelength.setSuffix(" Å") btn_e2w = QPushButton("E→λ") btn_w2e = QPushButton("λ→E") btn_e2w.clicked.connect(self._convert_e2w) btn_w2e.clicked.connect(self._convert_w2e) conv_layout.addWidget(QLabel("Energy")) conv_layout.addWidget(self.conv_energy) conv_layout.addWidget(btn_e2w) conv_layout.addWidget(QLabel("Wavelength")) conv_layout.addWidget(self.conv_wavelength) conv_layout.addWidget(btn_w2e) converter.setLayout(conv_layout) presets_box = QGroupBox("Presets") presets_row = QHBoxLayout() presets_row.addWidget(QLabel("Material")) presets_row.addWidget(self.single_preset) presets_row.addWidget(QLabel("Energy")) presets_row.addWidget(self.energy_preset) presets_row.addStretch(1) presets_box.setLayout(presets_row) input_box = QGroupBox("Material input") input_layout = QVBoxLayout() self.single_form.compute_button.setProperty("class", "primary") self.single_form.compute_button.setShortcut("Ctrl+Return") self.single_form.compute_button.setToolTip( "Compute properties for this material (Ctrl+Enter)" ) input_layout.addWidget(self.single_form) input_box.setLayout(input_layout) left_panel = QWidget() left_panel.setMinimumWidth(380) left_panel.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding ) left_layout = QVBoxLayout(left_panel) left_layout.setSpacing(24) left_layout.addWidget(presets_box) left_layout.addWidget(input_box) left_layout.addStretch(1) self.single_plot_tabs = QTabWidget() self.single_plot_tabs.setMinimumHeight(260) self.single_plot_tabs.addTab(self.single_plot, "Property plot") self.single_plot_tabs.addTab(self.single_f1f2, "f1 / f2") single_plot_container = QWidget() # Give the scroll area real overflow so the scrollbar can actually scroll. single_plot_container.setMinimumHeight(720) single_plot_layout = QVBoxLayout(single_plot_container) single_plot_layout.setContentsMargins(0, 0, 0, 0) single_plot_layout.setSpacing(0) single_plot_layout.addWidget(self.single_plot_tabs) self.single_plot_scroll = QScrollArea() self.single_plot_scroll.setWidgetResizable(True) self.single_plot_scroll.setHorizontalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) self.single_plot_scroll.setVerticalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOn ) self.single_plot_scroll.setWidget(single_plot_container) self._reserve_overlay_scrollbar_space(self.single_plot_scroll) right_layout = QGridLayout() right_layout.setHorizontalSpacing(24) right_layout.setVerticalSpacing(16) right_layout.addLayout(plot_header, 0, 0) right_layout.addWidget(self.single_summary, 1, 0) right_layout.addWidget(self.single_plot_scroll, 2, 0) right_layout.setRowStretch(2, 2) layout = QGridLayout() layout.setHorizontalSpacing(24) layout.setVerticalSpacing(20) layout.addWidget(left_panel, 0, 0, 1, 1) layout.addLayout(right_layout, 0, 1, 1, 1) # Full-width rows below layout.addWidget(converter, 1, 0, 1, 2) layout.addWidget(self.single_table, 2, 0, 1, 2) layout.setColumnStretch(0, 0) layout.setColumnStretch(1, 1) layout.setRowStretch(0, 3) layout.setRowStretch(2, 1) outer.addLayout(layout) return container def _tune_table_headers(self) -> None: def tune( table: Any, default_size: int = 110, min_size: int = 80, stretch_last: bool = True, ) -> None: hdr = table.horizontalHeader() hdr.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) hdr.setDefaultSectionSize(default_size) hdr.setMinimumSectionSize(min_size) hdr.setStretchLastSection(stretch_last) hdr.setTextElideMode(Qt.TextElideMode.ElideMiddle) tune(self.single_table, default_size=110, min_size=80, stretch_last=False) self.single_table.horizontalHeader().setSectionResizeMode( 0, QHeaderView.ResizeMode.ResizeToContents ) self.single_table.horizontalHeader().setSectionResizeMode( 1, QHeaderView.ResizeMode.ResizeToContents ) tune(self.single_summary, default_size=120, min_size=90) tune(self.multi_full_table, default_size=120, min_size=90) def _reserve_overlay_scrollbar_space(self, scroll_area: QScrollArea) -> None: """Avoid overlay scrollbars clipping the scroll area viewport. Some Qt styles render scrollbars as overlays (not consuming layout width). If the vertical scrollbar overlaps the viewport, reserve space via viewport margins so plot canvases and labels aren't clipped. """ if not hasattr(self, "_scroll_overlay_helpers"): self._scroll_overlay_helpers: list[QObject] = [] self._scroll_overlay_helpers.append( OverlayScrollbarMarginHelper(self, scroll_area) ) def _run_single(self) -> None: formula, density, energy_cfg = self.single_form.values() if not formula: self._error("Please enter a chemical formula") return logger.info( "single_compute_clicked", extra={ "formula": formula, "density": density, "points": energy_cfg.points, "logspace": energy_cfg.logspace, }, ) self._info("Computing…") self.single_form.compute_button.setEnabled(False) self.single_form.compute_button.setText("Computing...") self.single_save_png.setEnabled(False) self.single_export_csv.setEnabled(False) if self.threadpool is None: self.threadpool = QThreadPool.globalInstance() worker = CalculationWorker(compute_single, formula, density, energy_cfg) worker.signals.finished.connect(self._on_single_finished) worker.signals.error.connect(self._on_single_error) self._track_worker(worker) if self.threadpool is not None: self.threadpool.start(worker) def _on_single_finished(self, result: Any) -> None: self.single_form.compute_button.setEnabled(True) self.single_form.compute_button.setText("Compute") self.single_result = result self.single_save_png.setEnabled(True) self.single_export_csv.setEnabled(True) logger.info( "single_compute_complete", extra={"formula": result.formula, "points": len(result.energy_kev)}, ) self._info("Single calculation complete") self.toast.show_toast("Single calculation done", "success") self._refresh_single_views() def _on_single_error(self, message: str) -> None: self.single_form.compute_button.setEnabled(True) self.single_form.compute_button.setText("Compute") self.single_save_png.setEnabled(True) self.single_export_csv.setEnabled(True) logger.error("single_compute_failed", extra={"message": message}) self._error(message) def _refresh_single_views(self) -> None: if self.single_result is None: return prop = self.single_property.currentText() # type: ignore[unreachable] self.single_plot.set_scales( self.single_logx.isChecked(), self.single_logy.isChecked() ) ylabel = self._label_for_property(prop) # Update plot self.single_plot.plot_single(self.single_result, prop, ylabel) # Update table with multiple properties energies = self.single_result.energy_kev self.single_table.setRowCount(len(energies)) for i in range(len(energies)): cells = TableFormatter.format_single_row(self.single_result, i) for col, text in enumerate(cells): item = QTableWidgetItem(text) if col != 0 and col != 1: item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) self.single_table.setItem(i, col, item) self.single_table.resizeColumnsToContents() # Summary row summary = TableFormatter.format_summary(self.single_result) for col, text in enumerate(summary): self.single_summary.setItem(0, col, QTableWidgetItem(text)) self.single_summary.resizeColumnsToContents() # Plot f1/f2 only if >1 point if len(energies) > 1: self.single_f1f2.render_result(self.single_result) else: self.single_f1f2.clear() # ------------------------------------------------------------------ # Multi tab def _build_multi_tab(self) -> QWidget: container = QWidget() outer = QVBoxLayout(container) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(8) # Material entry row self.multi_formula = QLineEdit() self.multi_formula.setPlaceholderText("e.g. SiO2") self.multi_formula.setToolTip("Enter chemical formula for the material") self.multi_density = QDoubleSpinBox() self.multi_density.setRange(0.001, 100.0) self.multi_density.setDecimals(4) self.multi_density.setValue(2.2) self.multi_density.setSuffix(" g/cm³") self.multi_density.setToolTip("Mass density in g/cm³") add_btn = QPushButton("Add material") add_btn.clicked.connect(self._add_material) add_btn.setShortcut("Alt+A") add_btn.setToolTip("Add the formula/density to the list (Alt+A)") remove_btn = QPushButton("Remove selected") remove_btn.clicked.connect(self._remove_material) remove_btn.setShortcut("Alt+R") remove_btn.setToolTip("Remove selected rows (Alt+R)") self.multi_preset = QComboBox() self.multi_preset.addItem("Add preset material") for name in self.material_presets: self.multi_preset.addItem(name) self.multi_preset.currentTextChanged.connect(self._add_multi_preset) self.multi_preset.setToolTip("Quickly add a common material") add_btn.setProperty("class", "primary") remove_btn.setProperty("class", "secondary") material_box = QGroupBox("Materials") entry_row = QGridLayout() entry_row.setHorizontalSpacing(10) entry_row.addWidget(QLabel("Formula"), 0, 0) entry_row.addWidget(self.multi_formula, 0, 1) entry_row.addWidget(QLabel("Density"), 0, 2) entry_row.addWidget(self.multi_density, 0, 3) buttons_row = QHBoxLayout() buttons_row.setSpacing(10) buttons_row.addStretch(1) buttons_row.addWidget(add_btn) buttons_row.addWidget(remove_btn) entry_row.addLayout(buttons_row, 1, 0, 1, 6) entry_row.addWidget(QLabel("Preset"), 2, 0) entry_row.addWidget(self.multi_preset, 2, 1, 1, 5) material_box.setLayout(entry_row) # Energy controls self.multi_energy_start = QDoubleSpinBox() self.multi_energy_start.setRange(0.03, 30.0) self.multi_energy_start.setDecimals(3) self.multi_energy_start.setValue(8.0) self.multi_energy_start.setSuffix(" keV") self.multi_energy_end = QDoubleSpinBox() self.multi_energy_end.setRange(0.03, 30.0) self.multi_energy_end.setDecimals(3) self.multi_energy_end.setValue(12.0) self.multi_energy_end.setSuffix(" keV") self.multi_energy_points = QSpinBox() self.multi_energy_points.setRange(1, 5000) self.multi_energy_points.setValue(50) self.multi_logspace = QCheckBox("Log-spaced energies") self.multi_logspace.setToolTip("Use logarithmic spacing for the energy grid") energy_box = QGroupBox("Energy range") energy_layout = QHBoxLayout() energy_layout.addWidget(QLabel("Start")) energy_layout.addWidget(self.multi_energy_start) energy_layout.addWidget(QLabel("End")) energy_layout.addWidget(self.multi_energy_end) energy_layout.addWidget(QLabel("Points")) energy_layout.addWidget(self.multi_energy_points) energy_layout.addWidget(self.multi_logspace) energy_layout.addStretch(1) energy_box.setLayout(energy_layout) self.multi_table = MaterialTable() self.multi_property = QComboBox() self.multi_property.addItems(PROPERTIES) self.multi_property.currentTextChanged.connect(self._refresh_multi_views) self.multi_property.setToolTip( "Choose which property to compare across materials" ) self.multi_compute_btn = QPushButton("Compute comparison") self.multi_compute_btn.setProperty("class", "primary") self.multi_compute_btn.setShortcut("Ctrl+Shift+Return") self.multi_compute_btn.setToolTip( "Compute properties for listed materials (Ctrl+Shift+Enter)" ) self.multi_compute_btn.clicked.connect(self._run_multi) self.multi_save_png = QPushButton("Save plot PNG") self.multi_save_png.setProperty("class", "secondary") self.multi_save_png.setShortcut("Ctrl+Alt+S") self.multi_save_png.setToolTip("Export comparison plot (Ctrl+Alt+S)") self.multi_save_png.clicked.connect(self._save_multi_png) self.multi_export_csv = QPushButton("Export CSV") self.multi_export_csv.setProperty("class", "secondary") self.multi_export_csv.setShortcut("Ctrl+Alt+E") self.multi_export_csv.setToolTip("Export comparison data (Ctrl+Alt+E)") self.multi_export_csv.clicked.connect(self._export_multi_csv) self.multi_logx = QCheckBox("Log X") self.multi_logy = QCheckBox("Log Y") self.multi_logx.stateChanged.connect(self._refresh_multi_views) self.multi_logy.stateChanged.connect(self._refresh_multi_views) # Plot tabs self.multi_plot = PlotCanvas() self.multi_f1f2_plot = MultiF1F2Plot() self.multi_plot_tabs = QTabWidget() self.multi_plot_tabs.setMinimumHeight(260) self.multi_plot_tabs.addTab(self.multi_plot, "Property plot") self.multi_plot_tabs.addTab(self.multi_f1f2_plot, "f1 / f2") # Full-parameter table (long-form): same parameters as Single, with Material/Density self.multi_full_table = QTableWidget(0, 14) self.multi_full_table.setAlternatingRowColors(True) self.multi_full_table.setHorizontalHeaderLabels( [ "Material", "Density (g/cm³)", "Energy (keV)", "Wavelength (Å)", "δ", "β", "θc (deg)", "θc (mrad)", "Atten. length (cm)", "μ (1/cm)", "f1 (e)", "f2 (e)", "Re SLD (Å⁻²)", "Im SLD (Å⁻²)", ] ) self.multi_full_table.verticalHeader().setVisible(False) header_row = QHBoxLayout() header_row.setSpacing(12) header_row.addWidget(QLabel("Property")) header_row.addWidget(self.multi_property) header_row.addWidget(self.multi_logx) header_row.addWidget(self.multi_logy) header_row.addStretch(1) header_row.addWidget(self.multi_save_png) header_row.addWidget(self.multi_export_csv) multi_plot_container = QWidget() # Give the scroll area real overflow so the scrollbar can actually scroll. multi_plot_container.setMinimumHeight(720) multi_plot_layout = QVBoxLayout(multi_plot_container) multi_plot_layout.setContentsMargins(0, 0, 0, 0) multi_plot_layout.setSpacing(0) multi_plot_layout.addWidget(self.multi_plot_tabs) self.multi_plot_scroll = QScrollArea() self.multi_plot_scroll.setWidgetResizable(True) self.multi_plot_scroll.setHorizontalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) self.multi_plot_scroll.setVerticalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOn ) self.multi_plot_scroll.setWidget(multi_plot_container) self._reserve_overlay_scrollbar_space(self.multi_plot_scroll) left_panel = QWidget() left_panel.setMinimumWidth(420) left_panel.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding ) left_layout = QVBoxLayout(left_panel) left_layout.setSpacing(24) left_layout.addWidget(material_box) left_layout.addWidget(energy_box) left_layout.addWidget(self.multi_table) left_layout.addWidget(self.multi_compute_btn) left_layout.addStretch(1) right_layout = QGridLayout() right_layout.setHorizontalSpacing(24) right_layout.setVerticalSpacing(16) right_layout.addLayout(header_row, 0, 0) right_layout.addWidget(self.multi_plot_scroll, 1, 0) right_layout.setRowStretch(1, 2) layout = QGridLayout() layout.setHorizontalSpacing(24) layout.setVerticalSpacing(20) layout.addWidget(left_panel, 0, 0, 1, 1) layout.addLayout(right_layout, 0, 1, 1, 1) layout.addWidget(self.multi_full_table, 1, 0, 1, 2) layout.setColumnStretch(0, 0) layout.setColumnStretch(1, 1) layout.setRowStretch(0, 3) layout.setRowStretch(1, 1) outer.addLayout(layout) return container def _set_tab_order(self) -> None: # Single tab order self.setTabOrder(self.single_preset, self.energy_preset) self.setTabOrder(self.energy_preset, self.single_form.formula) self.setTabOrder(self.single_form.formula, self.single_form.density) self.setTabOrder(self.single_form.density, self.single_form.energy_start) self.setTabOrder(self.single_form.energy_start, self.single_form.energy_end) self.setTabOrder(self.single_form.energy_end, self.single_form.energy_points) self.setTabOrder(self.single_form.energy_points, self.single_form.logspace) self.setTabOrder(self.single_form.logspace, self.single_form.compute_button) self.setTabOrder(self.single_form.compute_button, self.single_property) self.setTabOrder(self.single_property, self.single_logx) self.setTabOrder(self.single_logx, self.single_logy) self.setTabOrder(self.single_logy, self.single_save_png) self.setTabOrder(self.single_save_png, self.single_export_csv) # Multi tab order (kept simple left-to-right, top-to-bottom) self.setTabOrder(self.multi_formula, self.multi_density) self.setTabOrder(self.multi_density, self.multi_preset) self.setTabOrder(self.multi_preset, self.multi_table) self.setTabOrder(self.multi_table, self.multi_energy_start) self.setTabOrder(self.multi_energy_start, self.multi_energy_end) self.setTabOrder(self.multi_energy_end, self.multi_energy_points) self.setTabOrder(self.multi_energy_points, self.multi_logspace) self.setTabOrder(self.multi_logspace, self.multi_compute_btn) self.setTabOrder(self.multi_compute_btn, self.multi_property) self.setTabOrder(self.multi_property, self.multi_logx) self.setTabOrder(self.multi_logx, self.multi_logy) self.setTabOrder(self.multi_logy, self.multi_save_png) self.setTabOrder(self.multi_save_png, self.multi_export_csv) def _add_material(self) -> None: formula = self.multi_formula.text().strip() density = float(self.multi_density.value()) try: self.multi_table.add_material(formula, density) self.multi_formula.clear() self.multi_formula.setFocus() logger.info( "multi_add_material", extra={"formula": formula, "density": density} ) except ValueError as exc: self.toast.show_toast(str(exc), "error") def _add_multi_preset(self, name: str) -> None: if name in self.material_presets: self.multi_formula.setText(name) self.multi_density.setValue(self.material_presets[name]) self._add_material() self.multi_preset.setCurrentIndex(0) def _remove_material(self) -> None: self.multi_table.remove_selected() logger.info( "multi_remove_material", extra={"remaining": len(self.multi_table.materials()[0])}, ) def _multi_energy_cfg(self) -> EnergyConfig: return EnergyConfig( start_kev=self.multi_energy_start.value(), end_kev=self.multi_energy_end.value(), points=self.multi_energy_points.value(), logspace=self.multi_logspace.isChecked(), ) def _run_multi(self) -> None: formulas, densities = self.multi_table.materials() if not formulas: self._error("Add at least one material") return energy_cfg = self._multi_energy_cfg() logger.info( "multi_compute_clicked", extra={ "count": len(formulas), "points": energy_cfg.points, "logspace": energy_cfg.logspace, }, ) self._info("Computing…") self._show_progress(True, 0) self.multi_compute_btn.setEnabled(False) self.multi_compute_btn.setText("Computing...") self.multi_save_png.setEnabled(False) self.multi_export_csv.setEnabled(False) if self.threadpool is None: self.threadpool = QThreadPool.globalInstance() worker = CalculationWorker( compute_multiple, formulas, densities, energy_cfg, ) worker.signals.progress.connect(self._progress_multi) worker.signals.finished.connect(self._on_multi_finished) worker.signals.error.connect(self._on_multi_error) self._track_worker(worker) if self.threadpool is not None: self.threadpool.start(worker) def _on_multi_finished(self, results: Any) -> None: self.multi_compute_btn.setEnabled(True) self.multi_compute_btn.setText("Compute comparison") self.multi_save_png.setEnabled(True) self.multi_export_csv.setEnabled(True) self._show_progress(False, 0) self.multi_results = results logger.info( "multi_compute_complete", extra={"count": len(results), "first": next(iter(results.keys()), "")}, ) self.multi_comparison = None self._info("Multi-material comparison complete") self.toast.show_toast("Comparison done", "success") self._refresh_multi_views() def _on_multi_error(self, message: str) -> None: self.multi_compute_btn.setEnabled(True) self.multi_compute_btn.setText("Compute comparison") self.multi_save_png.setEnabled(True) self.multi_export_csv.setEnabled(True) self._show_progress(False, 0) logger.error("multi_compute_failed", extra={"message": message}) self._error(message) def _progress_multi(self, value: int) -> None: self._show_progress(True, value) def _refresh_multi_views(self) -> None: if not self.multi_results: return prop = self.multi_property.currentText() # type: ignore[unreachable] self.multi_plot.set_scales( self.multi_logx.isChecked(), self.multi_logy.isChecked() ) ylabel = self._label_for_property(prop) self.multi_plot.plot_multi(self.multi_results, prop, ylabel) # f1/f2 plot self.multi_f1f2_plot.render_multi(self.multi_results) # Full-parameter table (long-form) total_rows = sum(len(res.energy_kev) for res in self.multi_results.values()) self.multi_full_table.setRowCount(total_rows) row_idx = 0 for formula, res in self.multi_results.items(): for i in range(len(res.energy_kev)): cells = TableFormatter.format_multi_row(formula, res, i) for col, text in enumerate(cells): item = QTableWidgetItem(text) if col >= 1: item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) self.multi_full_table.setItem(row_idx, col, item) row_idx += 1 self.multi_full_table.resizeColumnsToContents() # ------------------------------------------------------------------ # Status helpers def _info(self, message: str) -> None: self.status_bar.showMessage(message, 5000) self.toast.show_toast(message, "info") def _error(self, message: str) -> None: self.status_bar.showMessage(message, 10000) self.toast.show_toast(message, "error", duration_ms=3500) QMessageBox.critical(self, "Error", message) def _show_progress(self, active: bool, value: int = 0) -> None: if active: self.progress.setRange(0, 100) self.progress.setValue(max(0, min(100, value))) self.progress.setVisible(True) else: self.progress.setVisible(False) self.progress.setRange(0, 1) self.progress.setValue(0) def _toggle_log_path(self) -> None: path = get_log_file_path() if path: if self.log_path_toggle.isChecked(): self.log_path_label.setText(f"Log: {path}") self.log_path_label.setVisible(True) logger.info("log_path_shown", extra={"path": path}) else: self.log_path_label.clear() self.log_path_label.setVisible(False) else: self.status_bar.showMessage("File logging is disabled", 5000) logger.info("log_path_missing")
[docs] def resizeEvent(self, event: Any) -> None: super().resizeEvent(event) if hasattr(self, "toast"): self.toast._reposition()
# ------------------------------------------------------------------ # Export helpers @staticmethod def _sanitize_filename(text: str) -> str: """Replace characters unsafe for filenames with underscores.""" return re.sub(r"[^\w\-.]", "_", text) def _save_single_png(self) -> None: if self.single_result is None: self._error("No data to export yet") return prop = self.single_property.currentText() # type: ignore[unreachable] logger.info("single_save_png_clicked", extra={"property": prop}) suggested = ( f"single_{self._sanitize_filename(self.single_result.formula)}_{prop}.png" ) current_plot = self.single_plot_tabs.currentWidget() self._save_plot(current_plot, suggested) def _save_multi_png(self) -> None: if not self.multi_results: self._error("No data to export yet") return prop = self.multi_property.currentText() # type: ignore[unreachable] logger.info("multi_save_png_clicked", extra={"property": prop}) current_plot = self.multi_plot_tabs.currentWidget() self._save_plot(current_plot, f"multi_{prop}.png") def _save_plot(self, plot_widget: QWidget, suggested: str) -> None: default_dir = QStandardPaths.writableLocation( QStandardPaths.StandardLocation.PicturesLocation ) if not default_dir: default_dir = str(Path.home()) path, _ = QFileDialog.getSaveFileName( self, "Save plot", str(Path(default_dir) / suggested), "PNG Files (*.png)", ) if not path: logger.info("save_plot_cancelled", extra={"suggested": suggested}) return fig = getattr(plot_widget, "figure", None) if fig is None: self._error("Plot figure not available to save") logger.error( "plot_save_failed", extra={"path": path, "reason": "no figure"} ) return fig.savefig(path, dpi=300) logger.info("plot_saved", extra={"path": path, "suggested": suggested}) self._info(f"Saved plot to {path}") def _export_single_csv(self) -> str | None: if self.single_result is None: self._error("No data to export yet") return None prop = self.single_property.currentText() # type: ignore[unreachable] logger.info("single_export_csv_clicked", extra={"property": prop}) fname = ( f"single_{self._sanitize_filename(self.single_result.formula)}_{prop}.csv" ) default_dir = QStandardPaths.writableLocation( QStandardPaths.StandardLocation.DocumentsLocation ) if not default_dir: default_dir = str(Path.home()) folder = QFileDialog.getExistingDirectory( self, "Select folder to save CSV", default_dir ) if not folder: logger.info("export_single_cancelled", extra={"suggested": fname}) return None path = str(Path(folder) / fname) energies = self.single_result.energy_kev with open(path, "w", newline="", encoding="utf-8") as fh: writer = csv.writer(fh) writer.writerow( [ "energy_kev", "wavelength_angstrom", "delta", "beta", "critical_angle_deg", "critical_angle_mrad", "attenuation_length_cm", "mu_1_per_cm", "f1", "f2", "real_sld_per_ang2", "imag_sld_per_ang2", ] ) for i, e in enumerate(energies): crit_deg = self.single_result.critical_angle_degrees[i] crit_mrad = crit_deg * 3.141592653589793 / 180.0 * 1000.0 atten = self.single_result.attenuation_length_cm[i] mu = 1.0 / atten if atten != 0 else 0.0 writer.writerow( [ e, self.single_result.wavelength_angstrom[i], self.single_result.dispersion_delta[i], self.single_result.absorption_beta[i], crit_deg, crit_mrad, atten, mu, self.single_result.scattering_factor_f1[i], self.single_result.scattering_factor_f2[i], self.single_result.real_sld_per_ang2[i], self.single_result.imaginary_sld_per_ang2[i], ] ) self._info(f"Saved CSV to {path}") logger.info( "export_single_csv", extra={ "path": path, "formula": self.single_result.formula, "property": prop, "rows": len(energies), }, ) return path def _export_multi_csv(self) -> str | None: if not self.multi_results: self._error("No data to export yet") return None logger.info("multi_export_csv_clicked") # type: ignore[unreachable] fname = "multi_full.csv" default_dir = QStandardPaths.writableLocation( QStandardPaths.StandardLocation.DocumentsLocation ) if not default_dir: default_dir = str(Path.home()) folder = QFileDialog.getExistingDirectory( self, "Select folder to save CSV", default_dir ) if not folder: logger.info("export_multi_cancelled", extra={"suggested": fname}) return None path = str(Path(folder) / fname) headers = [ "material", "density_g_cm3", "energy_kev", "wavelength_angstrom", "delta", "beta", "critical_angle_deg", "critical_angle_mrad", "attenuation_length_cm", "mu_1_per_cm", "f1", "f2", "real_sld_per_ang2", "imag_sld_per_ang2", ] with open(path, "w", newline="", encoding="utf-8") as fh: writer = csv.writer(fh) writer.writerow(headers) for formula, res in self.multi_results.items(): density = getattr(res, "density_g_cm3", 0.0) for i, e in enumerate(res.energy_kev): crit_deg = res.critical_angle_degrees[i] crit_mrad = crit_deg * 3.141592653589793 / 180.0 * 1000.0 atten = res.attenuation_length_cm[i] mu = 1.0 / atten if atten != 0 else 0.0 writer.writerow( [ formula, density, e, res.wavelength_angstrom[i], res.dispersion_delta[i], res.absorption_beta[i], crit_deg, crit_mrad, atten, mu, res.scattering_factor_f1[i], res.scattering_factor_f2[i], res.real_sld_per_ang2[i], res.imaginary_sld_per_ang2[i], ] ) self._info(f"Saved CSV to {path}") logger.info( "export_multi_csv", extra={"path": path, "materials": len(self.multi_results)}, ) return path def _label_for_property(self, prop: str) -> str: labels = { "attenuation_length_cm": "Attenuation length (cm)", "dispersion_delta": "Dispersion δ", "absorption_beta": "Absorption β", "critical_angle_degrees": "Critical angle (deg)", "real_sld_per_ang2": "Real SLD (Å⁻²)", "imaginary_sld_per_ang2": "Imag SLD (Å⁻²)", } return labels.get(prop, prop.replace("_", " ")) def _track_worker(self, worker): # type: ignore[no-untyped-def] self._workers.append(worker) worker.signals.finished.connect(lambda _res, w=worker: self._cleanup_worker(w)) worker.signals.error.connect(lambda _msg, w=worker: self._cleanup_worker(w)) def _cleanup_worker(self, worker): # type: ignore[no-untyped-def] if worker in self._workers: self._workers.remove(worker) # ------------------------------------------------------------------ # Presets helpers def _apply_single_preset(self, name: str) -> None: if name in self.material_presets: self.single_form.formula.setText(name) self.single_form.density.setValue(self.material_presets[name]) logger.info("single_preset_applied", extra={"preset": name}) else: return def _apply_energy_preset(self, name: str) -> None: if name not in self.energy_presets: return start, end, pts, logspace = self.energy_presets[name] self.single_form.energy_start.setValue(start) self.single_form.energy_end.setValue(end) self.single_form.energy_points.setValue(pts) self.single_form.logspace.setChecked(logspace) logger.info( "single_energy_preset_applied", extra={ "preset": name, "start": start, "end": end, "points": pts, "logspace": logspace, }, ) def _convert_e2w(self) -> None: energy = self.conv_energy.value() try: wl = energy_to_wavelength(energy) self.conv_wavelength.setValue(wl) self._info("Converted energy to wavelength") logger.info("convert_e2w", extra={"energy": energy, "wavelength": wl}) except Exception as exc: logger.error( "convert_e2w_failed", extra={"energy": energy, "error": str(exc)} ) self._error(str(exc)) def _convert_w2e(self) -> None: wl = self.conv_wavelength.value() try: energy = wavelength_to_energy(wl) self.conv_energy.setValue(energy) self._info("Converted wavelength to energy") logger.info("convert_w2e", extra={"wavelength": wl, "energy": energy}) except Exception as exc: logger.error( "convert_w2e_failed", extra={"wavelength": wl, "error": str(exc)} ) self._error(str(exc))