LCOV - code coverage report
Current view: top level - src/pairinteraction_gui - main_window.py (source / functions) Hit Total Coverage
Test: coverage.info Lines: 113 141 80.1 %
Date: 2026-04-17 09:29:39 Functions: 12 13 92.3 %

          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
       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             : 
      69           1 :         self.init_keyboard_shortcuts()
      70           1 :         self.connect_signals()
      71             : 
      72           1 :         self.apply_theme()
      73           1 :         if enable_theme_hot_reload:
      74           0 :             theme_manager.enable_hot_reload()
      75           0 :             theme_manager.signals.themes_reloaded.connect(self.apply_theme)
      76             : 
      77           1 :     def connect_signals(self) -> None:
      78             :         """Connect signals to slots."""
      79           1 :         self.signals = Application.instance().signals
      80           1 :         self.signals.ask_download_database.connect(self.ask_download_database)
      81             : 
      82           1 :     def apply_theme(self) -> None:
      83             :         """Apply the current main application theme."""
      84           1 :         app = Application.instance()
      85           1 :         app.setPalette(theme_manager.get_palette())
      86           1 :         self.setStyleSheet(theme_manager.get_theme())
      87             : 
      88           1 :     def findChild(  # type: ignore [override] # explicitly override type hints
      89             :         self, type_: type[ChildType], name: str, options: Qt.FindChildOption | None = None
      90             :     ) -> ChildType:
      91           1 :         if options is None:
      92           1 :             options = Qt.FindChildOption.FindChildrenRecursively
      93           1 :         return super().findChild(type_, name, options)  # type: ignore [return-value] # explicitly override type hints
      94             : 
      95           1 :     def setup_statusbar(self) -> QStatusBar:
      96             :         """Set up the status bar.
      97             : 
      98             :         The status bar message is set to "Ready" by default.
      99             :         It can be updated with a new message by either from the main window instance:
     100             :             `self.statusbar.showMessage("Ready", timeout=0)`
     101             :         or from outside the main window instance:
     102             :             `QApplication.sendEvent(self, QStatusTipEvent("Ready"))`
     103             :         """
     104           1 :         statusbar = QStatusBar(self)
     105           1 :         statusbar.setFixedHeight(25)
     106           1 :         self.setStatusBar(statusbar)
     107           1 :         statusbar.showMessage("Ready", timeout=0)
     108           1 :         return statusbar
     109             : 
     110           1 :     def setup_dockwidget(self) -> QDockWidget:
     111             :         """Create a configuration dock widget for the main window."""
     112           1 :         dockwidget = QDockWidget()
     113           1 :         dockwidget.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea)
     114           1 :         dockwidget.setTitleBarWidget(QWidget())  # This removes the title bar
     115             : 
     116           1 :         dockwidget.setMinimumWidth(375)
     117           1 :         dockwidget.setVisible(False)
     118           1 :         self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dockwidget)
     119           1 :         return dockwidget
     120             : 
     121           1 :     def setup_stacked_pages(self) -> NamedStackedWidget[BasePage]:
     122             :         """Set up the different pages for each toolbar option."""
     123           1 :         stacked_pages = NamedStackedWidget["BasePage"]()
     124           1 :         self.setCentralWidget(stacked_pages)
     125             : 
     126           1 :         stacked_pages.addNamedWidget(OneAtomPage(), "OneAtomPage")
     127           1 :         stacked_pages.addNamedWidget(TwoAtomsPage(), "TwoAtomsPage")
     128           1 :         stacked_pages.addNamedWidget(LifetimesPage(), "LifetimesPage")
     129             :         # stacked_pages.addNamedWidget(C6Page(), "C6Page")
     130             : 
     131             :         # stacked_pages.addNamedWidget(SettingsPage(), "SettingsPage")
     132             :         # stacked_pages.addNamedWidget(AboutPage(), "AboutPage")
     133           1 :         return stacked_pages
     134             : 
     135           1 :     def setup_toolbar(self) -> QToolBar:
     136             :         """Set up the toolbar with icon buttons."""
     137           1 :         toolbar = QToolBar("Sidebar")
     138           1 :         toolbar.setObjectName("SidebarToolBar")
     139           1 :         toolbar.setMovable(False)
     140           1 :         toolbar.setOrientation(Qt.Orientation.Vertical)
     141           1 :         toolbar.setIconSize(QSize(32, 32))
     142           1 :         toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
     143             : 
     144           1 :         toolbar_group = QActionGroup(self)
     145           1 :         toolbar_group.setExclusive(True)
     146             : 
     147           1 :         for name, page in self.stacked_pages.items():
     148             :             # add a spacer widget
     149           1 :             if name == "about":
     150           0 :                 spacer_widget = QWidget()
     151           0 :                 spacer_widget.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
     152           0 :                 toolbar.addWidget(spacer_widget)
     153             : 
     154           1 :             action = QAction(self)
     155           1 :             action.setObjectName(name)
     156           1 :             action.setText(page.title)
     157           1 :             action.setToolTip(page.tooltip)
     158           1 :             action.setCheckable(True)
     159           1 :             if page.icon_path:
     160           0 :                 action.setIcon(QIcon(str(page.icon_path)))
     161             : 
     162           1 :             toolbar.addAction(action)
     163           1 :             toolbar_group.addAction(action)
     164             : 
     165           1 :             action.triggered.connect(lambda checked, name=name: self.stacked_pages.setCurrentNamedWidget(name))
     166             : 
     167           1 :         default_page = "OneAtomPage"
     168           1 :         self.findChild(QAction, default_page).setChecked(True)
     169           1 :         self.stacked_pages.setCurrentNamedWidget(default_page)
     170             : 
     171           1 :         self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, toolbar)
     172             : 
     173           1 :         return toolbar
     174             : 
     175           1 :     def init_keyboard_shortcuts(self) -> None:
     176             :         """Initialize keyboard shortcuts."""
     177             :         # Add Ctrl+W shortcut to close the window
     178           1 :         close_shortcut = QShortcut(QKeySequence("Ctrl+W"), self)
     179           1 :         close_shortcut.activated.connect(lambda: logger.info("Ctrl+W detected. Shutting down gracefully..."))
     180           1 :         close_shortcut.activated.connect(self.close)
     181             : 
     182           1 :     def save_settings(self) -> None:
     183             :         """Save all interactive widget state to disk."""
     184           1 :         for page_name, page in self.stacked_pages.items():
     185           1 :             if not isinstance(page, SimulationPage):
     186           0 :                 continue
     187           1 :             attr_dict = {name: attr for name, attr in vars(page).items() if isinstance(attr, BaseConfig)}
     188           1 :             for name, widget in attr_dict.items():
     189           1 :                 self.settings_manager.save_widget_state(widget, f"{page_name}/{name}")
     190             : 
     191           1 :     def restore_settings(self) -> None:
     192             :         """Restore all interactive widget state from disk."""
     193           1 :         for page_name, page in self.stacked_pages.items():
     194           1 :             if not isinstance(page, SimulationPage):
     195           0 :                 continue
     196           1 :             attr_dict = {name: attr for name, attr in vars(page).items() if isinstance(attr, BaseConfig)}
     197           1 :             for name, widget in attr_dict.items():
     198           1 :                 self.settings_manager.restore_widget_state(widget, f"{page_name}/{name}")
     199             : 
     200           1 :     def closeEvent(self, event: QCloseEvent) -> None:
     201             :         """Make sure to also call Application.quit() when closing the window."""
     202           1 :         logger.debug("Close event triggered.")
     203           1 :         self.save_settings()
     204           1 :         Application.quit()
     205           1 :         event.accept()
     206             : 
     207           1 :     def ask_download_database(self, species: str) -> bool:
     208           0 :         msg_box = QMessageBox()
     209           0 :         msg_box.setWindowTitle("Download missing database tables?")
     210           0 :         msg_box.setText(f"Database tables for {species} not found.")
     211           0 :         msg_box.setInformativeText("Would you like to download the missing database tables?")
     212           0 :         msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
     213             : 
     214           0 :         download = msg_box.exec() == QMessageBox.StandardButton.Yes
     215           0 :         if download:
     216           0 :             self.statusbar.showMessage("Downloading database table ...", timeout=0)
     217             : 
     218           0 :             worker = MultiThreadWorker(lambda: download_databases([species]))
     219           0 :             worker.enable_busy_indicator(self.stacked_pages.currentWidget())
     220             : 
     221           0 :             msg = "Successfully downloaded database table for " + species
     222           0 :             worker.signals.result.connect(lambda _result: self.statusbar.showMessage(msg, timeout=0))
     223           0 :             worker.signals.result.connect(lambda _result: setattr(Database, "_global_database", None))
     224           0 :             page = self.stacked_pages.currentWidget()
     225           0 :             if isinstance(page, SimulationPage):
     226           0 :                 ket_config = page.ket_config
     227           0 :                 for i in range(ket_config.n_atoms):
     228           0 :                     worker.signals.result.connect(
     229             :                         lambda _, atom=i: ket_config.signal_species_changed.emit(atom, ket_config.get_species(atom))
     230             :                     )
     231           0 :             worker.start()
     232             : 
     233           0 :         return download

Generated by: LCOV version 1.16