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
|