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
|