LCOV - code coverage report
Current view: top level - src/pairinteraction_gui - main_window.py (source / functions) Coverage Total Hit
Test: coverage.info Lines: 81.2 % 149 121
Test Date: 2026-11-02 09:44:19 Functions: 92.9 % 14 13

            Line data    Source code
       1              : # SPDX-FileCopyrightText: 2025 PairInteraction Developers
       2              : # SPDX-License-Identifier: LGPL-3.0-or-later
       3            1 : from __future__ import annotations
       4              : 
       5            1 : import logging
       6            1 : from typing import TYPE_CHECKING, TypeVar
       7              : 
       8            1 : from PySide6.QtCore import QSize, Qt, QTimer
       9            1 : from PySide6.QtGui import QAction, QActionGroup, QIcon, QKeySequence, QShortcut
      10            1 : from PySide6.QtWidgets import (
      11              :     QDockWidget,
      12              :     QMainWindow,
      13              :     QMessageBox,
      14              :     QSizePolicy,
      15              :     QStatusBar,
      16              :     QToolBar,
      17              :     QWidget,
      18              : )
      19              : 
      20            1 : import pairinteraction
      21            1 : from pairinteraction import Database
      22            1 : from pairinteraction.cli import download_databases
      23            1 : from pairinteraction_gui.app import Application
      24            1 : from pairinteraction_gui.config.base_config import BaseConfig
      25            1 : from pairinteraction_gui.page import (
      26              :     LifetimesPage,
      27              :     OneAtomPage,
      28              :     TwoAtomsPage,
      29              : )
      30            1 : from pairinteraction_gui.page.base_page import SimulationPage
      31            1 : from pairinteraction_gui.qobjects import NamedStackedWidget
      32            1 : from pairinteraction_gui.settings import SettingsManager
      33            1 : from pairinteraction_gui.theme import theme_manager
      34            1 : from pairinteraction_gui.worker import MultiThreadWorker
      35              : 
      36              : if TYPE_CHECKING:
      37              :     from pathlib import Path
      38              : 
      39              :     from PySide6.QtCore import QObject
      40              :     from PySide6.QtGui import QCloseEvent
      41              : 
      42              :     from pairinteraction_gui.page import BasePage
      43              : 
      44              :     ChildType = TypeVar("ChildType", bound=QObject)
      45              : 
      46            1 : logger = logging.getLogger(__name__)
      47              : 
      48              : 
      49            1 : class MainWindow(QMainWindow):
      50              :     """Main window for the PairInteraction GUI application."""
      51              : 
      52            1 :     def __init__(self, *, cache_dir: Path | None = None, enable_theme_hot_reload: bool = False) -> None:
      53              :         """Initialize the main window."""
      54            1 :         super().__init__()
      55            1 :         self._theme_hot_reload_enabled = enable_theme_hot_reload
      56              : 
      57            1 :         self.setWindowTitle(f"PairInteraction v{pairinteraction.__version__}")
      58            1 :         self.resize(1200, 800)
      59              : 
      60            1 :         self.statusbar = self.setup_statusbar()
      61            1 :         self.dockwidget = self.setup_dockwidget()
      62              : 
      63            1 :         self.stacked_pages = self.setup_stacked_pages()
      64            1 :         self.toolbar = self.setup_toolbar()
      65              : 
      66            1 :         self.settings_manager = SettingsManager(cache_dir)
      67            1 :         self.restore_settings()
      68            1 :         self.setup_autosave()
      69              : 
      70            1 :         self.init_keyboard_shortcuts()
      71            1 :         self.connect_signals()
      72              : 
      73            1 :         self.apply_theme()
      74            1 :         if enable_theme_hot_reload:
      75            0 :             theme_manager.enable_hot_reload()
      76            0 :             theme_manager.signals.themes_reloaded.connect(self.apply_theme)
      77              : 
      78            1 :     def connect_signals(self) -> None:
      79              :         """Connect signals to slots."""
      80            1 :         self.signals = Application.instance().signals
      81            1 :         self.signals.ask_download_database.connect(self.ask_download_database)
      82              : 
      83            1 :     def apply_theme(self) -> None:
      84              :         """Apply the current main application theme."""
      85            1 :         app = Application.instance()
      86            1 :         app.setPalette(theme_manager.get_palette())
      87            1 :         self.setStyleSheet(theme_manager.get_theme())
      88              : 
      89            1 :     def findChild(  # type: ignore [override] # explicitly override type hints
      90              :         self, type_: type[ChildType], name: str, options: Qt.FindChildOption | None = None
      91              :     ) -> ChildType:
      92            1 :         if options is None:
      93            1 :             options = Qt.FindChildOption.FindChildrenRecursively
      94            1 :         return super().findChild(type_, name, options)  # type: ignore [return-value] # explicitly override type hints
      95              : 
      96            1 :     def setup_statusbar(self) -> QStatusBar:
      97              :         """Set up the status bar.
      98              : 
      99              :         The status bar message is set to "Ready" by default.
     100              :         It can be updated with a new message by either from the main window instance:
     101              :             `self.statusbar.showMessage("Ready", timeout=0)`
     102              :         or from outside the main window instance:
     103              :             `QApplication.sendEvent(self, QStatusTipEvent("Ready"))`
     104              :         """
     105            1 :         statusbar = QStatusBar(self)
     106            1 :         statusbar.setFixedHeight(25)
     107            1 :         self.setStatusBar(statusbar)
     108            1 :         statusbar.showMessage("Ready", timeout=0)
     109            1 :         return statusbar
     110              : 
     111            1 :     def setup_dockwidget(self) -> QDockWidget:
     112              :         """Create a configuration dock widget for the main window."""
     113            1 :         dockwidget = QDockWidget()
     114            1 :         dockwidget.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea)
     115            1 :         dockwidget.setTitleBarWidget(QWidget())  # This removes the title bar
     116              : 
     117            1 :         dockwidget.setMinimumWidth(375)
     118            1 :         dockwidget.setVisible(False)
     119            1 :         self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dockwidget)
     120            1 :         return dockwidget
     121              : 
     122            1 :     def setup_stacked_pages(self) -> NamedStackedWidget[BasePage]:
     123              :         """Set up the different pages for each toolbar option."""
     124            1 :         stacked_pages = NamedStackedWidget["BasePage"]()
     125            1 :         self.setCentralWidget(stacked_pages)
     126              : 
     127            1 :         stacked_pages.addNamedWidget(OneAtomPage(), "OneAtomPage")
     128            1 :         stacked_pages.addNamedWidget(TwoAtomsPage(), "TwoAtomsPage")
     129            1 :         stacked_pages.addNamedWidget(LifetimesPage(), "LifetimesPage")
     130              :         # stacked_pages.addNamedWidget(C6Page(), "C6Page")
     131              : 
     132              :         # stacked_pages.addNamedWidget(SettingsPage(), "SettingsPage")
     133              :         # stacked_pages.addNamedWidget(AboutPage(), "AboutPage")
     134            1 :         return stacked_pages
     135              : 
     136            1 :     def setup_toolbar(self) -> QToolBar:
     137              :         """Set up the toolbar with icon buttons."""
     138            1 :         toolbar = QToolBar("Sidebar")
     139            1 :         toolbar.setObjectName("SidebarToolBar")
     140            1 :         toolbar.setMovable(False)
     141            1 :         toolbar.setOrientation(Qt.Orientation.Vertical)
     142            1 :         toolbar.setIconSize(QSize(32, 32))
     143            1 :         toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
     144              : 
     145            1 :         toolbar_group = QActionGroup(self)
     146            1 :         toolbar_group.setExclusive(True)
     147              : 
     148            1 :         for name, page in self.stacked_pages.items():
     149              :             # add a spacer widget
     150            1 :             if name == "about":
     151            0 :                 spacer_widget = QWidget()
     152            0 :                 spacer_widget.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
     153            0 :                 toolbar.addWidget(spacer_widget)
     154              : 
     155            1 :             action = QAction(self)
     156            1 :             action.setObjectName(name)
     157            1 :             action.setText(page.title)
     158            1 :             action.setToolTip(page.tooltip)
     159            1 :             action.setCheckable(True)
     160            1 :             if page.icon_path:
     161            0 :                 action.setIcon(QIcon(str(page.icon_path)))
     162              : 
     163            1 :             toolbar.addAction(action)
     164            1 :             toolbar_group.addAction(action)
     165              : 
     166            1 :             action.triggered.connect(lambda checked, name=name: self.stacked_pages.setCurrentNamedWidget(name))
     167              : 
     168            1 :         default_page = "OneAtomPage"
     169            1 :         self.findChild(QAction, default_page).setChecked(True)
     170            1 :         self.stacked_pages.setCurrentNamedWidget(default_page)
     171              : 
     172            1 :         self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, toolbar)
     173              : 
     174            1 :         return toolbar
     175              : 
     176            1 :     def init_keyboard_shortcuts(self) -> None:
     177              :         """Initialize keyboard shortcuts."""
     178              :         # Add Ctrl+W shortcut to close the window
     179            1 :         close_shortcut = QShortcut(QKeySequence("Ctrl+W"), self)
     180            1 :         close_shortcut.activated.connect(lambda: logger.info("Ctrl+W detected. Shutting down gracefully..."))
     181            1 :         close_shortcut.activated.connect(self.close)
     182              : 
     183            1 :     def setup_autosave(self, interval_ms: int = 5_000) -> None:
     184              :         """Periodically persist settings so they survive a forced or unexpected shutdown."""
     185            1 :         self._autosave_timer = QTimer(self)
     186            1 :         self._autosave_timer.setInterval(interval_ms)
     187            1 :         self._autosave_timer.timeout.connect(self.save_settings)
     188            1 :         self._autosave_timer.start()
     189              : 
     190            1 :     def save_settings(self) -> bool:
     191              :         """Save all interactive widget state to disk.
     192              : 
     193              :         Returns True if anything was actually written, i.e. False if nothing changed since the last save.
     194              :         """
     195            1 :         for page_name, page in self.stacked_pages.items():
     196            1 :             if not isinstance(page, SimulationPage):
     197            0 :                 continue
     198            1 :             attr_dict = {name: attr for name, attr in vars(page).items() if isinstance(attr, BaseConfig)}
     199            1 :             for name, widget in attr_dict.items():
     200            1 :                 self.settings_manager.save_widget_state(widget, f"{page_name}/{name}")
     201            1 :         return self.settings_manager.sync()
     202              : 
     203            1 :     def restore_settings(self) -> None:
     204              :         """Restore all interactive widget state from disk."""
     205            1 :         for page_name, page in self.stacked_pages.items():
     206            1 :             if not isinstance(page, SimulationPage):
     207            0 :                 continue
     208            1 :             attr_dict = {name: attr for name, attr in vars(page).items() if isinstance(attr, BaseConfig)}
     209            1 :             for name, widget in attr_dict.items():
     210            1 :                 self.settings_manager.restore_widget_state(widget, f"{page_name}/{name}")
     211              : 
     212            1 :     def closeEvent(self, event: QCloseEvent) -> None:
     213              :         """Make sure to also call Application.quit() when closing the window."""
     214            1 :         logger.debug("Close event triggered.")
     215            1 :         self._autosave_timer.stop()
     216            1 :         self.save_settings()
     217            1 :         Application.quit()
     218            1 :         event.accept()
     219              : 
     220            1 :     def ask_download_database(self, species: str) -> bool:
     221            0 :         msg_box = QMessageBox()
     222            0 :         msg_box.setWindowTitle("Download missing database tables?")
     223            0 :         msg_box.setText(f"Database tables for {species} not found.")
     224            0 :         msg_box.setInformativeText("Would you like to download the missing database tables?")
     225            0 :         msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
     226              : 
     227            0 :         download = msg_box.exec() == QMessageBox.StandardButton.Yes
     228            0 :         if download:
     229            0 :             self.statusbar.showMessage("Downloading database table ...", timeout=0)
     230              : 
     231            0 :             worker = MultiThreadWorker(lambda: download_databases([species]))
     232            0 :             worker.enable_busy_indicator(self.stacked_pages.currentWidget())
     233              : 
     234            0 :             msg = "Successfully downloaded database table for " + species
     235            0 :             worker.signals.result.connect(lambda _result: self.statusbar.showMessage(msg, timeout=0))
     236            0 :             worker.signals.result.connect(lambda _result: setattr(Database, "_global_database", None))
     237            0 :             page = self.stacked_pages.currentWidget()
     238            0 :             if isinstance(page, SimulationPage):
     239            0 :                 ket_config = page.ket_config
     240            0 :                 for i in range(ket_config.n_atoms):
     241            0 :                     worker.signals.result.connect(
     242              :                         lambda _, atom=i: ket_config.signal_species_changed.emit(atom, ket_config.get_species(atom))
     243              :                     )
     244            0 :             worker.start()
     245              : 
     246            0 :         return download
        

Generated by: LCOV version 2.0-1