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_gui.app import Application 23 1 : from pairinteraction_gui.page import ( 24 : LifetimesPage, 25 : OneAtomPage, 26 : TwoAtomsPage, 27 : ) 28 1 : from pairinteraction_gui.page.base_page import SimulationPage 29 1 : from pairinteraction_gui.qobjects import NamedStackedWidget 30 1 : from pairinteraction_gui.theme import main_theme 31 1 : from pairinteraction_gui.utils import download_databases_mp 32 1 : from pairinteraction_gui.worker import MultiProcessWorker, MultiThreadWorker 33 : 34 : if TYPE_CHECKING: 35 : from PySide6.QtCore import QObject 36 : from PySide6.QtGui import QCloseEvent 37 : 38 : from pairinteraction_gui.page import BasePage 39 : 40 : ChildType = TypeVar("ChildType", bound=QObject) 41 : 42 1 : logger = logging.getLogger(__name__) 43 : 44 : 45 1 : class MainWindow(QMainWindow): 46 : """Main window for the PairInteraction GUI application.""" 47 : 48 1 : def __init__(self) -> None: 49 : """Initialize the main window.""" 50 1 : super().__init__() 51 : 52 1 : self.setWindowTitle(f"PairInteraction v{pairinteraction.__version__}") 53 1 : self.resize(1200, 800) 54 1 : self.setStyleSheet(main_theme) 55 : 56 1 : self.statusbar = self.setup_statusbar() 57 1 : self.dockwidget = self.setup_dockwidget() 58 : 59 1 : self.stacked_pages = self.setup_stacked_pages() 60 1 : self.toolbar = self.setup_toolbar() 61 : 62 1 : self.init_keyboard_shortcuts() 63 1 : self.connect_signals() 64 : 65 1 : MultiProcessWorker.create_pool() 66 : 67 1 : def connect_signals(self) -> None: 68 : """Connect signals to slots.""" 69 1 : self.signals = Application.instance().signals 70 : 71 1 : self.signals.ask_download_database.connect(self.ask_download_database) 72 : 73 1 : def findChild( # type: ignore [override] # explicitly override type hints 74 : self, type_: type[ChildType], name: str, options: Qt.FindChildOption | None = None 75 : ) -> ChildType: 76 1 : if options is None: 77 1 : options = Qt.FindChildOption.FindChildrenRecursively 78 1 : return super().findChild(type_, name, options) # type: ignore [return-value] # explicitly override type hints 79 : 80 1 : def setup_statusbar(self) -> QStatusBar: 81 : """Set up the status bar. 82 : 83 : The status bar message is set to "Ready" by default. 84 : It can be updated with a new message by either from the main window instance: 85 : `self.statusbar.showMessage("Ready", timeout=0)` 86 : or from outside the main window instance: 87 : `QApplication.sendEvent(self, QStatusTipEvent("Ready"))` 88 : """ 89 1 : statusbar = QStatusBar(self) 90 1 : statusbar.setFixedHeight(25) 91 1 : self.setStatusBar(statusbar) 92 1 : statusbar.showMessage("Ready", timeout=0) 93 1 : return statusbar 94 : 95 1 : def setup_dockwidget(self) -> QDockWidget: 96 : """Create a configuration dock widget for the main window.""" 97 1 : dockwidget = QDockWidget() 98 1 : dockwidget.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea) 99 1 : dockwidget.setTitleBarWidget(QWidget()) # This removes the title bar 100 : 101 1 : dockwidget.setMinimumWidth(375) 102 1 : dockwidget.setVisible(False) 103 1 : self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dockwidget) 104 1 : return dockwidget 105 : 106 1 : def setup_stacked_pages(self) -> NamedStackedWidget[BasePage]: 107 : """Set up the different pages for each toolbar option.""" 108 1 : stacked_pages = NamedStackedWidget["BasePage"]() 109 1 : self.setCentralWidget(stacked_pages) 110 : 111 1 : stacked_pages.addNamedWidget(OneAtomPage(), "OneAtomPage") 112 1 : stacked_pages.addNamedWidget(TwoAtomsPage(), "TwoAtomsPage") 113 1 : stacked_pages.addNamedWidget(LifetimesPage(), "LifetimesPage") 114 : # stacked_pages.addNamedWidget(C6Page(), "C6Page") 115 : 116 : # stacked_pages.addNamedWidget(SettingsPage(), "SettingsPage") 117 : # stacked_pages.addNamedWidget(AboutPage(), "AboutPage") 118 1 : return stacked_pages 119 : 120 1 : def setup_toolbar(self) -> QToolBar: 121 : """Set up the toolbar with icon buttons.""" 122 1 : toolbar = QToolBar("Sidebar") 123 1 : toolbar.setMovable(False) 124 1 : toolbar.setOrientation(Qt.Orientation.Vertical) 125 1 : toolbar.setIconSize(QSize(32, 32)) 126 1 : toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) 127 : 128 1 : toolbar_group = QActionGroup(self) 129 1 : toolbar_group.setExclusive(True) 130 : 131 1 : for name, page in self.stacked_pages.items(): 132 : # add a spacer widget 133 1 : if name == "about": 134 0 : spacer_widget = QWidget() 135 0 : spacer_widget.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) 136 0 : toolbar.addWidget(spacer_widget) 137 : 138 1 : action = QAction(self) 139 1 : action.setObjectName(name) 140 1 : action.setText(page.title) 141 1 : action.setToolTip(page.tooltip) 142 1 : action.setCheckable(True) 143 1 : if page.icon_path: 144 0 : action.setIcon(QIcon(str(page.icon_path))) 145 : 146 1 : toolbar.addAction(action) 147 1 : toolbar_group.addAction(action) 148 : 149 1 : action.triggered.connect(lambda checked, name=name: self.stacked_pages.setCurrentNamedWidget(name)) 150 : 151 1 : default_page = "OneAtomPage" 152 1 : self.findChild(QAction, default_page).setChecked(True) 153 1 : self.stacked_pages.setCurrentNamedWidget(default_page) 154 : 155 1 : self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, toolbar) 156 : 157 1 : return toolbar 158 : 159 1 : def init_keyboard_shortcuts(self) -> None: 160 : """Initialize keyboard shortcuts.""" 161 : # Add Ctrl+W shortcut to close the window 162 1 : close_shortcut = QShortcut(QKeySequence("Ctrl+W"), self) 163 1 : close_shortcut.activated.connect(lambda: logger.info("Ctrl+W detected. Shutting down gracefully...")) 164 1 : close_shortcut.activated.connect(self.close) 165 : 166 1 : def closeEvent(self, event: QCloseEvent) -> None: 167 : """Make sure to also call Application.quit() when closing the window.""" 168 1 : logger.debug("Close event triggered.") 169 1 : Application.quit() 170 1 : event.accept() 171 : 172 1 : def ask_download_database(self, species: str) -> bool: 173 0 : msg_box = QMessageBox() 174 0 : msg_box.setWindowTitle("Download missing database tables?") 175 0 : msg_box.setText(f"Database tables for {species} not found.") 176 0 : msg_box.setInformativeText("Would you like to download the missing database tables?") 177 0 : msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) 178 : 179 0 : download = msg_box.exec() == QMessageBox.StandardButton.Yes 180 0 : if download: 181 0 : self.statusbar.showMessage("Downloading database table ...", timeout=0) 182 : 183 0 : worker = MultiThreadWorker(lambda: download_databases_mp([species])) 184 0 : worker.enable_busy_indicator(self.stacked_pages.currentWidget()) 185 : 186 0 : msg = "Successfully downloaded database table for " + species 187 0 : worker.signals.result.connect(lambda _result: self.statusbar.showMessage(msg, timeout=0)) 188 0 : worker.signals.result.connect(lambda _result: setattr(Database, "_global_database", None)) 189 0 : worker.signals.result.connect(lambda _result: MultiProcessWorker.terminate_all(create_new_pool=True)) 190 0 : page = self.stacked_pages.currentWidget() 191 0 : if isinstance(page, SimulationPage): 192 0 : ket_config = page.ket_config 193 0 : for i in range(ket_config.n_atoms): 194 0 : worker.signals.result.connect( 195 : lambda _, atom=i: ket_config.signal_species_changed.emit(atom, ket_config.get_species(atom)) 196 : ) 197 0 : worker.start() 198 : 199 0 : return download