Coverage for vibefpmgui/about.py: 95%
252 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The About window: icon, version, links, and the versions of the tools in use;
2under the icon, Fortran being typed (a quiet animation, only while it's open)."""
4from __future__ import annotations
6import html
7import platform
8import re
9import shutil
10import subprocess
11import sys
12import time
14from qtpy.QtCore import QRectF, Qt, QTimer, qVersion
15from qtpy.QtGui import (
16 QColor,
17 QFontDatabase,
18 QFontMetricsF,
19 QLinearGradient,
20 QPainter,
21 QPalette,
22)
23from qtpy.QtWidgets import (
24 QDialog,
25 QGridLayout,
26 QHBoxLayout,
27 QLabel,
28 QPushButton,
29 QVBoxLayout,
30 QWidget,
31)
33from . import __version__
34from .console import NO_WINDOW
35from .icons import draw_app_icon, recolored_logo
37FPM_DOCS_URL = "https://fpm.fortran-lang.org"
38PROJECT_URL = "https://github.com/jacobwilliams/VibeFPMGUI"
41def tool_version(command: list[str], timeout: float = 5) -> str:
42 """First useful line of ``<tool> --version`` ("" if it can't be run)."""
43 try:
44 out = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False, **NO_WINDOW)
45 except (OSError, subprocess.SubprocessError):
46 return ""
47 text = (out.stdout or out.stderr or "").strip()
48 return text.splitlines()[0].strip() if text else ""
51def fpm_version(fpm: str) -> str:
52 """e.g. "0.13.0, alpha" from fpm's "Version: 0.13.0, alpha" line."""
53 line = tool_version([fpm, "--version"])
54 match = re.match(r"^\s*Version:\s*(.+)$", line)
55 return match.group(1).strip() if match else line
58class AnimatedLogo(QWidget):
59 """The app icon, its colors slowly going around the color wheel while
60 it's shown."""
62 PERIOD = 20.0 # seconds for once around
63 FRAME_MS = 50
65 def __init__(self, size: int = 128, parent=None):
66 from qtpy.QtSvg import QSvgRenderer
68 super().__init__(parent)
69 self.setFixedSize(size, size)
70 self.renderer = QSvgRenderer(self)
71 self.turn = 0.0
72 self._start = time.monotonic()
73 self.timer = QTimer(self)
74 self.timer.setInterval(self.FRAME_MS)
75 self.timer.timeout.connect(self._tick)
76 self._recolor()
78 def _recolor(self):
79 self.renderer.load(recolored_logo(self.turn))
80 self.update()
82 def _tick(self):
83 self.turn = ((time.monotonic() - self._start) / self.PERIOD) % 1.0
84 self._recolor()
86 def showEvent(self, event):
87 super().showEvent(event)
88 self.timer.start()
90 def hideEvent(self, event):
91 super().hideEvent(event)
92 self.timer.stop()
94 def paintEvent(self, event):
95 p = QPainter(self)
96 draw_app_icon(p, self.width(), self.renderer)
97 p.end()
100# Typed under the icon, over and over (short lines: the column is the icon's width).
101TYPED_CODE = """program orbit
102 use iso_fortran_env
103 implicit none
104 integer, parameter :: &
105 wp = real64
106 real(wp) :: r(3), v(3)
107 real(wp) :: dt = 0.01_wp
108 integer :: i
110 r = [1.0_wp, 0.0_wp, 0.0_wp]
111 v = [0.0_wp, 1.0_wp, 0.0_wp]
112 do i = 1, 1000
113 call step(r, v, dt)
114 end do
115 print '(3f9.4)', r
117contains
119 pure subroutine step(r, v, h)
120 real(wp), intent(inout) :: r(3)
121 real(wp), intent(inout) :: v(3)
122 real(wp), intent(in) :: h
123 real(wp) :: a(3)
124 ! gravity, mu = 1
125 a = -r / norm2(r)**3
126 v = v + h * a
127 r = r + h * v
128 end subroutine step
130end program orbit
131"""
134class TypingCode(QWidget):
135 """Fortran being typed, a character at a time, scrolling up as it goes:
136 small, faint, in the window's colors (keywords in its accent), fading
137 out at the top and bottom. Runs only while shown."""
139 FRAME_MS = 40
140 CHARS_PER_SECOND = 18
141 PAUSE = 2.5 # seconds at the end before typing it again
142 OPACITY = 0.55 # (subtle: under the version information)
144 def __init__(self, code: str = TYPED_CODE, parent=None):
145 super().__init__(parent)
146 self.font_ = QFontDatabase.systemFont(QFontDatabase.FixedFont)
147 self.font_.setPointSizeF(max(6.5, self.font_.pointSizeF() * 0.62))
148 self.metrics = QFontMetricsF(self.font_)
149 self.line_height = self.metrics.lineSpacing()
150 self.lines = self._colored(code)
151 self.typed = 0.0 # characters typed (of the code, again and again: see _text_at)
152 self.offset = 0.0 # how far it's scrolled (pixels)
153 self._last = time.monotonic()
154 self.timer = QTimer(self)
155 self.timer.setInterval(self.FRAME_MS)
156 self.timer.timeout.connect(self._tick)
157 self.setMinimumHeight(int(self.line_height * 4))
158 self.setAttribute(Qt.WA_TransparentForMouseEvents)
160 @staticmethod
161 def _colored(code: str) -> list[list[tuple[str, str]]]:
162 """The code's lines, each as (text, kind) pieces: "keyword", "comment",
163 "string", "number" or "" (see paintEvent for their colors)."""
164 from pygments.token import Comment, Keyword, Name, Number, String
166 from .highlighter import FortranSourceLexer
168 lines: list[list[tuple[str, str]]] = [[]]
169 for ttype, value in FortranSourceLexer().get_tokens(code):
170 kind = ("comment" if ttype in Comment else "string" if ttype in String else
171 "number" if ttype in Number else
172 "keyword" if ttype in Keyword or ttype in Name.Builtin else "")
173 for n, part in enumerate(value.split("\n")):
174 if n:
175 lines.append([])
176 if part:
177 lines[-1].append((part, kind))
178 while lines and not lines[-1]:
179 lines.pop()
180 return lines
182 def _chars(self) -> int:
183 """Characters in one go through the code (a line's end counts as one)."""
184 return sum(len(text) for line in self.lines for text, _kind in line) + len(self.lines)
186 def _tick(self):
187 now = time.monotonic()
188 elapsed, self._last = min(now - self._last, 0.2), now # (not a jump after being hidden)
189 self.typed += elapsed * self.CHARS_PER_SECOND
190 rows = self._rows()
191 target = max(0.0, rows * self.line_height - (self.height() - self.line_height * 1.5))
192 self.offset += (target - self.offset) * min(1.0, elapsed * 6) # (eased: a smooth scroll)
193 self.update()
195 def _position(self) -> tuple[int, int, int]:
196 """(times through, line, characters typed of that line) at ``typed``."""
197 total = self._chars()
198 cycle = total + int(self.PAUSE * self.CHARS_PER_SECOND)
199 rounds, left = divmod(int(self.typed), cycle)
200 left = min(left, total - 1)
201 for number, line in enumerate(self.lines): 201 ↛ 206line 201 didn't jump to line 206 because the loop on line 201 didn't complete
202 length = sum(len(text) for text, _kind in line) + 1
203 if left < length:
204 return rounds, number, left
205 left -= length
206 return rounds, len(self.lines) - 1, 0
208 def _rows(self) -> int:
209 """Rows typed so far (each time through follows the last, after a blank row)."""
210 rounds, line, _chars = self._position()
211 return rounds * (len(self.lines) + 1) + line + 1
213 def showEvent(self, event):
214 super().showEvent(event)
215 self._last = time.monotonic()
216 self.timer.start()
218 def hideEvent(self, event):
219 super().hideEvent(event)
220 self.timer.stop()
222 def paintEvent(self, event):
223 pal = self.palette()
224 text = pal.color(QPalette.WindowText)
225 colors = {"": text, "keyword": pal.color(QPalette.Highlight), "comment": QColor(text),
226 "string": QColor("#2f9e44") if pal.color(QPalette.Window).lightness() > 128 else QColor("#69db7c"),
227 "number": QColor("#e8590c") if pal.color(QPalette.Window).lightness() > 128 else QColor("#ffa94d")}
228 colors["comment"].setAlpha(130)
229 p = QPainter(self)
230 p.setRenderHint(QPainter.TextAntialiasing)
231 p.setOpacity(self.OPACITY)
232 p.setFont(self.font_)
233 _rounds, _line, typed = self._position()
234 rows = self._rows()
235 lh, ascent = self.line_height, self.metrics.ascent()
236 first = max(0, int(self.offset // lh) - 1)
237 for row in range(first, rows): 237 ↛ 257line 237 didn't jump to line 257 because the loop on line 237 didn't complete
238 y = row * lh - self.offset
239 if y > self.height():
240 break
241 number = row % (len(self.lines) + 1) # (the last of each time through: the blank row between)
242 if number == len(self.lines): 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
243 continue
244 limit = typed if row == rows - 1 else None
245 x = 0.0
246 for piece, kind in self.lines[number]:
247 if limit is not None: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true
248 if limit <= 0:
249 break
250 piece, limit = piece[:limit], limit - len(piece)
251 p.setPen(colors[kind])
252 p.drawText(QRectF(x, y, self.width() * 2, lh), Qt.AlignLeft | Qt.AlignVCenter, piece)
253 x += self.metrics.horizontalAdvance(piece)
254 if row == rows - 1 and int(time.monotonic() * 2) % 2 == 0: # (the cursor, blinking) 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true
255 p.fillRect(QRectF(x + 1, y + (lh - ascent) / 2, 1.5, ascent), text)
256 # Fade out at the top and bottom (into the window's color).
257 p.setOpacity(1.0)
258 background = pal.color(QPalette.Window)
259 clear = QColor(background)
260 clear.setAlpha(0)
261 for top in (True, False):
262 band = QRectF(0, 0 if top else self.height() - lh * 2, self.width(), lh * 2)
263 gradient = QLinearGradient(band.topLeft(), band.bottomLeft())
264 gradient.setColorAt(0.0, background if top else clear)
265 gradient.setColorAt(1.0, clear if top else background)
266 p.fillRect(band, gradient)
267 p.end()
270class AboutDialog(QDialog):
271 def __init__(self, fpm: str, compiler: str, compiler_source: str = "", parent=None):
272 super().__init__(parent)
273 self.setWindowTitle("About VibeFPMGUI")
275 self.logo = AnimatedLogo(128)
277 title = QLabel(f"<h2 style='margin:0'>VibeFPMGUI</h2>"
278 f"<p style='margin-top:2px; color:gray'>Version {html.escape(__version__)}</p>")
279 blurb = QLabel("A graphical front end for the "
280 f"<a href='{FPM_DOCS_URL}'>Fortran Package Manager (fpm)</a>: build, run and "
281 "test fpm projects, explore their dependencies, edit sources and review "
282 "code with a local AI model.")
283 blurb.setWordWrap(True)
284 blurb.setOpenExternalLinks(True)
286 fpm_path = shutil.which(fpm) or fpm
287 fpm_ver = fpm_version(fpm)
288 compiler_ver = tool_version([compiler, "--version"])
289 compiler_path = shutil.which(compiler) or compiler
290 compiler_tip = compiler_path + (f"\n(chosen by: {compiler_source})" if compiler_source else "")
291 # (name, value, tooltip: where the program is, shown on hover rather than taking up the window)
292 rows = [
293 ("fpm", fpm_ver or "not found", fpm_path),
294 ("Compiler", compiler_ver or f"{compiler} not found", compiler_tip),
295 ("Python", platform.python_version(), sys.executable),
296 ("Qt", qVersion(), ""),
297 ("System", f"{platform.system()} {platform.release()} ({platform.machine()})", ""),
298 ]
299 versions = QGridLayout()
300 versions.setHorizontalSpacing(12)
301 versions.setVerticalSpacing(2)
302 self.version_labels: dict[str, QLabel] = {}
303 for row, (name, value, tip) in enumerate(rows):
304 key = QLabel(f"<b>{html.escape(name)}</b>")
305 missing = value.endswith("not found")
306 shown = QLabel(f"<i>{html.escape(value)}</i>" if missing else html.escape(value))
307 shown.setWordWrap(True)
308 shown.setTextInteractionFlags(Qt.TextSelectableByMouse)
309 for label in (key, shown):
310 label.setToolTip(tip)
311 versions.addWidget(key, row, 0, Qt.AlignTop)
312 versions.addWidget(shown, row, 1)
313 self.version_labels[name] = shown
314 versions.setColumnStretch(1, 1)
316 links = QLabel(f"<a href='{FPM_DOCS_URL}'>fpm documentation</a> · "
317 f"<a href='{PROJECT_URL}'>VibeFPMGUI on GitHub</a>")
318 links.setOpenExternalLinks(True)
319 legal = QLabel("<span style='color:gray'>© 2026 Jacob Williams · MIT License<br>"
320 "fpm logo: © 2020 fpm contributors · MIT License</span>")
322 text = QVBoxLayout()
323 text.setSpacing(8)
324 text.addWidget(title)
325 text.addWidget(blurb)
326 text.addSpacing(4)
327 text.addLayout(versions)
328 text.addSpacing(4)
329 text.addWidget(links)
330 text.addWidget(legal)
331 text.addStretch(1)
333 self.typing = TypingCode()
334 self.typing.setFixedWidth(self.logo.width())
335 side = QVBoxLayout()
336 side.setSpacing(12)
337 side.addWidget(self.logo, 0, Qt.AlignTop)
338 side.addWidget(self.typing, 1) # (under the icon, down to the window's bottom)
340 top = QHBoxLayout()
341 top.setSpacing(16)
342 top.addLayout(side)
343 top.addLayout(text, 1)
345 copy = QPushButton("Copy Versions")
346 copy.setToolTip("Copy the version information (handy for bug reports)")
347 # (Copied with where the programs are: handy in a bug report.)
348 plain = "\n".join(f"{name}: {value}" + (f" ({' '.join(tip.split())})" if tip else "")
349 for name, value, tip in rows)
350 plain = f"VibeFPMGUI {__version__}\n{plain}"
351 copy.clicked.connect(lambda: (self._copy(plain), copy.setText("Copied")))
352 packages = QPushButton("Packages…")
353 packages.setToolTip("The packages installed in the environment the app runs in "
354 "(pixi list or conda list)")
355 packages.clicked.connect(self.show_packages)
356 close = QPushButton("Close")
357 close.setDefault(True)
358 close.clicked.connect(self.accept)
359 buttons = QHBoxLayout()
360 buttons.addWidget(copy)
361 buttons.addWidget(packages)
362 buttons.addStretch(1)
363 buttons.addWidget(close)
365 layout = QVBoxLayout(self)
366 layout.setContentsMargins(20, 20, 20, 14)
367 layout.addLayout(top)
368 layout.addLayout(buttons)
369 self.setMinimumWidth(560)
371 def show_packages(self):
372 from .packages import PackagesDialog
374 dialog = PackagesDialog(self)
375 try:
376 dialog.exec()
377 finally:
378 dialog.deleteLater()
380 @staticmethod
381 def _copy(text: str):
382 from qtpy.QtWidgets import QApplication
383 QApplication.clipboard().setText(text)