Source code for nlsq.gui_qt
"""
NLSQ Qt GUI - Native PySide6 Desktop Application
This module provides a native Qt-based desktop application for NLSQ curve fitting,
with GPU-accelerated plotting via pyqtgraph and native desktop integration.
Usage:
python -m nlsq.gui_qt
Or programmatically:
from nlsq.gui_qt import run_desktop
run_desktop()
"""
from __future__ import annotations
import faulthandler
import os
import sys
import traceback
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from PySide6.QtWidgets import QApplication
__all__ = ["run_desktop"]
# ---------------------------------------------------------------------------
# macOS SIGBUS Prevention — Defence in Depth
#
# SIGBUS on macOS is caused by Metal/OpenGL/XLA conflicts in the GPU driver.
# Layer 1 (nlsq/__init__.py): env vars — JAX_PLATFORM_NAME, MPLBACKEND, etc.
# Layer 2 (here): explicit matplotlib.use(), faulthandler, Qt RHI guard
# Layer 3 (plots/__init__.py): pyqtgraph useOpenGL=False
# Layer 4 (main_window.py): deferred QMessageBox via QTimer.singleShot
# ---------------------------------------------------------------------------
# Enable faulthandler early so any SIGBUS/SIGSEGV prints a traceback
# to stderr instead of dying silently.
faulthandler.enable()
if sys.platform == "darwin":
# Force matplotlib to non-interactive backend before anything imports it.
# The MPLBACKEND env var (set in nlsq/__init__.py) covers first import,
# but an explicit use() call is the belt-and-suspenders guarantee.
try:
import matplotlib
matplotlib.use("Agg")
except Exception:
pass
# Force Qt to use software OpenGL rendering instead of hardware
# Metal/OpenGL. On macOS 26+ with Apple Silicon, the Metal-backed QRhi
# can SIGBUS when creating surfaces before the native window handle is
# ready, or when mixing OpenGL and Metal contexts.
os.environ.setdefault("QT_OPENGL", "software")
os.environ.setdefault("QSG_RHI_BACKEND", "software")
os.environ.setdefault("QT_QUICK_BACKEND", "software")
os.environ.setdefault("LIBGL_ALWAYS_SOFTWARE", "1")
def _exception_hook(exc_type: type, exc_value: BaseException, exc_tb: object) -> None:
"""Global exception hook to handle uncaught exceptions.
Args:
exc_type: Exception type
exc_value: Exception value
exc_tb: Exception traceback
"""
# Format the traceback
tb_str = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
# Log to stderr
sys.stderr.write(f"Uncaught exception:\n{tb_str}\n")
# Try to show error dialog if Qt is available
try:
from PySide6.QtWidgets import QApplication, QMessageBox
app = QApplication.instance()
if app is not None:
# Try to save autosave before showing dialog
try:
from nlsq.gui_qt.autosave import AUTOSAVE_FILE
# Write crash marker
crash_info = {
"crash": True,
"error": str(exc_value),
"traceback": tb_str,
}
import json
if AUTOSAVE_FILE.exists():
data = json.loads(AUTOSAVE_FILE.read_text(encoding="utf-8"))
data["crash_info"] = crash_info
AUTOSAVE_FILE.write_text(
json.dumps(data, indent=2), encoding="utf-8"
)
except Exception:
pass
# Show error dialog (skip on macOS if window may not be
# fully realised — showing a dialog can itself SIGBUS)
if sys.platform != "darwin" or app.activeWindow() is not None:
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Critical)
msg.setWindowTitle("NLSQ Error")
msg.setText("An unexpected error occurred.")
msg.setInformativeText(
"The application encountered an error and needs to close.\n\n"
"Your session will be recovered on next launch."
)
msg.setDetailedText(tb_str)
msg.exec()
except Exception:
pass
[docs]
def run_desktop() -> int:
"""Launch the NLSQ Qt desktop application.
Returns:
int: Application exit code (0 for success)
"""
_debug = bool(os.environ.get("NLSQ_DEBUG"))
def _stage(msg: str) -> None:
if _debug:
sys.stderr.write(f"[nlsq-gui] {msg}\n")
sys.stderr.flush()
# Install global exception hook
sys.excepthook = _exception_hook
_stage("exception hook installed")
from PySide6.QtGui import Qt
from PySide6.QtWidgets import QApplication
_stage("PySide6 imported")
from nlsq.gui_qt.app_state import AppState
from nlsq.gui_qt.main_window import MainWindow
_stage("app modules imported")
# Create application
app = QApplication(sys.argv)
_stage("QApplication created")
app.setApplicationName("NLSQ Curve Fitting")
app.setOrganizationName("NLSQ")
# Apply Fusion style for cross-platform consistency
app.setStyle("Fusion")
_stage("Fusion style applied")
# Apply dark theme by default using Qt 6.5+ built-in color scheme.
# Protected: some macOS + PySide6 combinations crash here.
import contextlib
with contextlib.suppress(Exception):
app.styleHints().setColorScheme(Qt.ColorScheme.Dark)
_stage("color scheme set")
# Let the platform plugin fully initialise its native surfaces before
# constructing heavyweight widgets. On macOS this ensures Metal has a
# valid context, preventing SIGBUS when the first widget paints.
app.processEvents()
_stage("processEvents done")
# Create centralized state
app_state = AppState()
_stage("AppState created")
# Create and show main window
window = MainWindow(app_state)
_stage("MainWindow created")
window.show()
_stage("window shown — entering event loop")
# Event-loop debug probes: these fire INSIDE app.exec() so we can see
# exactly how far the event loop gets before the SIGBUS.
if _debug:
from PySide6.QtCore import QTimer as _QT
_QT.singleShot(0, lambda: _stage("EVENT LOOP: first tick (0 ms)"))
_QT.singleShot(100, lambda: _stage("EVENT LOOP: tick at 100 ms"))
_QT.singleShot(500, lambda: _stage("EVENT LOOP: stable (500 ms)"))
# Env dump: compare interactive terminal vs subprocess env when
# diagnosing crashes that reproduce only interactively.
if os.environ.get("NLSQ_DUMP_ENV"):
_stage("=== ENVIRONMENT DUMP ===")
for k in sorted(os.environ):
if any(
p in k.upper()
for p in (
"QT",
"DISPLAY",
"XDG",
"LANG",
"LC_",
"DYLD",
"MallocNanoZone",
"METAL",
"GPU",
"GL",
"JAX",
"XLA",
"OMP",
"NLSQ",
"PYTHON",
"VIRTUAL",
"PATH",
)
):
_stage(f" {k}={os.environ[k]}")
_stage("=== END DUMP ===")
return app.exec()