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 : import os
7 1 : import signal
8 1 : from typing import TYPE_CHECKING
9 :
10 1 : from PySide6.QtCore import QObject, QRectF, QSocketNotifier, Qt, QTimer, Signal
11 1 : from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen, QPixmap
12 1 : from PySide6.QtWidgets import QApplication, QSplashScreen
13 :
14 1 : from pairinteraction_gui.worker import MultiThreadWorker
15 :
16 : if TYPE_CHECKING:
17 : from types import FrameType
18 :
19 1 : logger = logging.getLogger(__name__)
20 :
21 :
22 1 : class MainSignals(QObject):
23 : """Signals for the application.
24 :
25 : We store an instance of this signal class in the Application instance, see app.py.
26 : So to access these signals (from anywhere in the application), you can use
27 : `Application.instance().signals`.
28 : """
29 :
30 1 : ask_download_database = Signal(str)
31 :
32 :
33 1 : class Application(QApplication):
34 : """Add some global signals to the QApplication."""
35 :
36 1 : signals = MainSignals()
37 :
38 1 : @staticmethod
39 1 : def instance() -> Application:
40 : """Return the current instance of the application."""
41 1 : return QApplication.instance() # type: ignore [return-value]
42 :
43 1 : def allow_ctrl_c(self) -> None:
44 : # Create a pipe to communicate between the signal handler and the Qt event loop
45 0 : pipe_r, pipe_w = os.pipe()
46 :
47 0 : def signal_handler(signal: int, frame: FrameType | None) -> None:
48 0 : os.write(pipe_w, b"x") # Write a single byte to the pipe
49 :
50 0 : signal.signal(signal.SIGINT, signal_handler)
51 :
52 0 : def handle_signal() -> None:
53 0 : os.read(pipe_r, 1) # Read the byte from the pipe to clear it
54 0 : logger.info("Ctrl+C detected in terminal. Shutting down gracefully...")
55 0 : self.quit()
56 :
57 0 : sn = QSocketNotifier(pipe_r, QSocketNotifier.Type.Read, parent=self)
58 0 : sn.activated.connect(handle_signal)
59 :
60 : # Create a timer to ensure the event loop processes events regularly
61 : # This makes Ctrl+C work even when the application is idle
62 0 : timer = QTimer(self)
63 0 : timer.timeout.connect(lambda: None) # Do nothing, just wake up the event loop
64 0 : timer.start(200)
65 :
66 1 : @staticmethod
67 1 : def quit() -> None:
68 : """Quit the application."""
69 1 : logger.debug("Calling Application.quit().")
70 1 : MultiThreadWorker.terminate_all()
71 1 : QApplication.quit()
72 1 : logger.debug("Application.quit() done.")
73 :
74 :
75 1 : class SplashScreen(QSplashScreen):
76 : """Startup splash screen with the PairInteraction logo."""
77 :
78 1 : def __init__(self) -> None:
79 0 : super().__init__()
80 0 : self.setWindowTitle("Launching the PairInteraction GUI")
81 0 : border = 16
82 0 : logo_size = 250
83 0 : full_size = logo_size + 2 * border
84 0 : pixmap = QPixmap(full_size, full_size)
85 0 : pixmap.fill(QColor("#28354e")) # "Dark" theme color
86 0 : painter = QPainter(pixmap)
87 0 : painter.translate(border, border)
88 0 : _draw_logo(painter, logo_size)
89 0 : painter.end()
90 0 : self.setPixmap(pixmap)
91 :
92 :
93 1 : def _draw_logo(painter: QPainter, size: int) -> None:
94 : """Draw the PairInteraction logo using QPainter, ported from data/icon.tex (TikZ source)."""
95 0 : s = size / 19.0
96 :
97 0 : def tx(x: float) -> float:
98 0 : return (x + 2.0) * s
99 :
100 0 : def ty(y: float) -> float:
101 0 : return (16.0 - y) * s
102 :
103 0 : painter.save()
104 0 : painter.setRenderHint(QPainter.RenderHint.Antialiasing)
105 0 : painter.setClipRect(QRectF(0.0, 0.0, float(size), float(size)))
106 :
107 : # White rounded background
108 0 : painter.setPen(Qt.PenStyle.NoPen)
109 0 : painter.setBrush(QColor("white"))
110 0 : painter.drawRoundedRect(QRectF(0.0, 0.0, float(size), float(size)), 4.0 * s, 4.0 * s)
111 :
112 : # Thick round-capped pen
113 0 : pen = QPen()
114 0 : pen.setWidthF(0.8 * s)
115 0 : pen.setCapStyle(Qt.PenCapStyle.RoundCap)
116 0 : pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
117 0 : painter.setBrush(Qt.BrushStyle.NoBrush)
118 :
119 : # Coordinate axes
120 0 : pen.setColor(QColor("black"))
121 0 : painter.setPen(pen)
122 0 : axes = QPainterPath()
123 0 : axes.moveTo(tx(0), ty(13))
124 0 : axes.lineTo(tx(0), ty(0))
125 0 : axes.lineTo(tx(15), ty(0))
126 0 : painter.drawPath(axes)
127 :
128 : # Red curve
129 0 : pen.setColor(QColor("red"))
130 0 : painter.setPen(pen)
131 0 : red = QPainterPath()
132 0 : red.moveTo(tx(1), ty(12))
133 0 : red.cubicTo(tx(3), ty(-10), tx(6), ty(2), tx(15), ty(1))
134 0 : painter.drawPath(red)
135 :
136 : # Blue curve
137 0 : pen.setColor(QColor("blue"))
138 0 : painter.setPen(pen)
139 0 : blue = QPainterPath()
140 0 : blue.moveTo(tx(2), ty(12))
141 0 : blue.cubicTo(tx(4), ty(-6), tx(6), ty(5), tx(15), ty(2))
142 0 : painter.drawPath(blue)
143 :
144 : # PI text
145 0 : font = QFont("serif")
146 0 : font.setPixelSize(round(10.0 * s))
147 0 : painter.setFont(font)
148 0 : pen.setColor(QColor("black"))
149 0 : painter.setPen(pen)
150 0 : cx, cy = tx(9), ty(9)
151 0 : painter.drawText(
152 : QRectF(cx - 7.0 * s, cy - 6.0 * s, 14.0 * s, 12.0 * s),
153 : Qt.AlignmentFlag.AlignCenter,
154 : "PI",
155 : )
156 :
157 0 : painter.restore()
|