#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Provides the main entry point and application management classes for the
simulation.
:module: main
:author: Le Bars, Yoann
"""
# Defines the public names of this module.
__all__ = ['main', 'AppManager']
import sys
import signal
from multiprocess import freeze_support
from PySide6.QtWidgets import QApplication, QMainWindow
from PySide6.QtCore import (
QLibraryInfo, QTranslator, QLocale, qCritical)
from PySide6.QtGui import QIcon
# QML is no longer used for the main window
# from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType
# from PySide6.QtQuick import QQuickView # Not used anymore
from src.args import CmdLineArgs, CmdLineValidationError, ExitCode
from src import constants
from src.logger import Logger
from src.qml_bridge import QmlBridge
from src.app_profiler import AppProfiler
from src import rc_linguist, rc_about, rc_icons # pylint: disable=unused-import
# Note: rc_main will be generated when main.qrc is compiled by pyside6-project
# For now, we'll load QML from file path instead of resources
# MainWindow class removed - replaced by native QMainWindow in AppManager
[docs]
class AppManager:
"""
Orchestrates the application's lifecycle: setup, execution, and cleanup.
This class encapsulates the initialisation of translations, argument parsing,
logging, and the main window, thereby simplifying the global `main` function.
:ivar list[str] _argv: The raw command-line arguments.
:ivar dict _kwargs: Keyword arguments for programmatic setup.
:ivar QApplication | None app: The core Qt application instance.
:ivar QLocale | None locale: The system locale used for translations.
:ivar CmdLineArgs | None cmd_args: Parsed and validated command-line arguments.
:ivar Logger | None logger: The application's logging manager.
:ivar QmlBridge | None bridge: The QML bridge for exposing C++ functionality.
:ivar QMainWindow | None main_window: The main application window.
:ivar Display | None _display_ref: Reference to Display to keep it alive until QApplication is destroyed.
"""
# We use Optional types because these attributes are initialised in setup(), not __init__().
app: QApplication | None = None
locale: QLocale | None = None
cmd_args: CmdLineArgs | None = None
logger: Logger | None = None
bridge: QmlBridge | None = None
main_window: QMainWindow | None = None # Native QMainWindow (not from QML)
_display_ref = None # Reference to Display to keep it alive until QApplication is destroyed
def __init__(self, argv: list[str], **kwargs):
"""
Initialises the application manager.
:param list[str] argv: Command-line arguments from `sys.argv`.
:param kwargs: Keyword arguments for programmatic setup (e.g., for testing).
"""
self._argv = argv
self._kwargs = kwargs
[docs]
def setup(self) -> None:
"""Initialises all application components."""
# The QApplication must be created before any other Qt objects.
AppProfiler.start_qt_init()
self.app = QApplication(self._argv)
AppProfiler.stop_qt_init()
self._setup_translations()
self._parse_and_validate_args(self._argv[1:], **self._kwargs)
assert self.cmd_args is not None, "Argument parsing failed"
self.logger = Logger(self.cmd_args)
self._setup_app_metadata()
# Create the QML bridge (used for Display and actions)
AppProfiler.start_window_creation()
self.bridge = QmlBridge(self.cmd_args, self.app)
AppProfiler.stop_window_creation()
# Create a main window widget to host the Display
self.main_window = QMainWindow()
self.main_window.setWindowTitle(
QApplication.translate("MainWindow", "Pure Python"))
self.main_window.resize(800, 600)
# Create native menu bar with QML bridge integration
# Use "MainWindow" context to match the translations
menu_bar = self.main_window.menuBar()
# File menu
file_menu = menu_bar.addMenu(
QApplication.translate("MainWindow", "&File"))
quit_action = file_menu.addAction(
QApplication.translate("MainWindow", "&Quit"))
quit_action.setShortcut("Ctrl+Q")
quit_action.triggered.connect(self.bridge.quit)
# Help menu
help_menu = menu_bar.addMenu(
QApplication.translate("MainWindow", "&Help"))
about_action = help_menu.addAction(
QApplication.translate("MainWindow", "About Pure Python"))
about_action.triggered.connect(self.bridge.showAbout)
about_qt_action = help_menu.addAction(
QApplication.translate("MainWindow", "About Qt"))
about_qt_action.triggered.connect(self.bridge.showAboutQt)
# Add the Display container as the central widget
# With simplified cleanup (letting QApplication handle destruction),
# createWindowContainer should work without segfault
container = self.bridge.displayContainer
if container is not None:
self.main_window.setCentralWidget(container)
# Connect simulation finished to close the window
self.bridge.simulationFinished.connect(self.main_window.close)
# --- Graceful Shutdown Setup ---
# Ensure that Ctrl+C (SIGINT) triggers a clean shutdown, allowing the
# `finally` block in `run()` to execute for resource cleanup.
signal.signal(signal.SIGINT, lambda *args: self.app.quit())
def _setup_translations(self) -> None:
"""Loads and installs Qt and application-specific translations."""
# Translation path for Qt base translations (e.g., "OK", "Cancel" buttons).
qt_path: str = QLibraryInfo.path(QLibraryInfo.TranslationsPath)
self.locale = QLocale.system()
# Load and install Qt base translations.
qt_translator = QTranslator(self.app)
if self.locale and qt_translator.load(self.locale, 'qtbase', '_', qt_path):
self.app.installTranslator(qt_translator)
# Load and install application-specific translations.
app_translator = QTranslator(self.app)
app_path = ':/translations/locales'
if self.locale and app_translator.load(self.locale, 'pure-python', '_', app_path):
self.app.installTranslator(app_translator)
def _parse_and_validate_args(self, argv: list[str], **kwargs) -> None:
"""
Parses and validates command-line arguments.
:param list[str] argv: The list of command-line arguments.
:param kwargs: Keyword arguments for testing.
:raises CmdLineValidationError: If arguments are invalid.
"""
if kwargs:
# For testing purposes
self.cmd_args = CmdLineArgs(**kwargs) # type: ignore
else:
# Parse arguments from the actual command line.
self.cmd_args = CmdLineArgs.parse(self.app, argv)
# Validate the parsed arguments.
self.cmd_args.validate()
def _setup_app_metadata(self) -> None:
"""Sets application-wide metadata like name, version, and icon."""
assert self.app is not None, "QApplication not initialised"
app_name = self.app.translate('main', 'Pure Python')
self.app.setApplicationName(app_name)
self.app.setApplicationDisplayName(app_name)
self.app.setApplicationVersion(constants.__version__)
self.app.setOrganizationName('eiG')
self.app.setOrganizationDomain('eig.fr')
self.app.setWindowIcon(QIcon(':/icons/resources/avatar-farfadet.jpg'))
[docs]
def run(self) -> ExitCode:
"""
Orchestrates the entire application lifecycle.
This method calls the setup routines, shows the main window, starts the Qt
event loop, and ensures that cleanup is performed upon exit.
:returns: The application's exit code.
"""
try:
self.setup()
assert self.main_window and self.app and self.logger and self.bridge, "Application setup failed"
# Show the main window (Display is embedded via container)
self.main_window.show()
self.bridge.startSimulation()
# Start timing the event loop
AppProfiler.start_event_loop()
exit_code = self.app.exec()
# Stop timing the event loop (exec() has returned)
AppProfiler.stop_event_loop()
except CmdLineValidationError as e:
qCritical(f"{e}")
return e.code
except Exception as e: # pylint: disable=broad-except
qCritical(f"An unknown error occurred: {e}")
return ExitCode.UNKNOWN_EXCEPTION
finally:
# Stop event loop timing if it's still running (should already be stopped, but be safe)
AppProfiler.stop_event_loop()
AppProfiler.start_cleanup()
# According to Qt forum suggestions:
# - Let QApplication deal with widget destruction automatically
# - Only stop the physics thread properly before QApplication destruction
# - No manual widget deletion needed after exec() returns
# Step 1: Stop the physics thread and cleanup display (prints profiling report)
# This is the only cleanup we need to do manually, as the thread must be
# stopped before QApplication is destroyed
if self.bridge:
self.bridge.cleanup()
# Step 2: Cleanup logger (closes log files)
if self.logger:
self.logger.cleanup()
# Step 3: Let QApplication handle all widget destruction automatically
# QApplication knows all widgets (via QApplication::allWidgets()) and will
# destroy them properly. No manual deletion needed.
AppProfiler.stop_cleanup()
AppProfiler.print_report()
return ExitCode(exit_code)
[docs]
def main(**kwargs: int | float | bool) -> ExitCode:
"""
Main entry point for the application.
NOTE: The Display (Qt3DWindow) is integrated into QMainWindow using
createWindowContainer(). With simplified cleanup (letting QApplication handle
destruction automatically), this works without segfault. See SEGFAULT_ISSUE.md
for details.
:param kwargs: Keyword arguments for testing, corresponding to command-line options
(e.g., `n_iter=100`, `n_bodies=10`). If provided, command-line parsing is
skipped.
:returns: The application's exit code.
:rtype: ExitCode
"""
manager = AppManager(sys.argv, **kwargs)
return manager.run()
if __name__ == "__main__":
# This is required for PyInstaller/Nuitka to correctly handle multiprocessing
# when the application is frozen into a single executable.
freeze_support()
# Set the start method to 'spawn' to ensure compatibility with multi-threaded
# frameworks like Qt. This must be done within the `if __name__ == '__main__':`
# block and before any multiprocessing objects are created.
from multiprocess import set_start_method # pylint: disable=ungrouped-imports
set_start_method('spawn')
# The return value of main() is used as the exit code.
# argparse's --help and --version actions call sys.exit() themselves.
# The path hack for direct script execution has been removed in favor of
# standard module execution (`python -m src.main`) to adhere to PEP 8
# and resolve Pylint's `wrong-import-position` warnings.
# If you need to run this file directly, you must ensure the project root
# is in your PYTHONPATH.
sys.exit(main())