vibefpmgui.about
The About window: icon, version, links, and the versions of the tools in use; under the icon, Fortran being typed (a quiet animation, only while it's open).
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).""" 3 4from __future__ import annotations 5 6import html 7import platform 8import re 9import shutil 10import subprocess 11import sys 12import time 13 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) 32 33from . import __version__ 34from .console import NO_WINDOW 35from .icons import draw_app_icon, recolored_logo 36 37FPM_DOCS_URL = "https://fpm.fortran-lang.org" 38PROJECT_URL = "https://github.com/jacobwilliams/VibeFPMGUI" 39 40 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 "" 49 50 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 56 57 58class AnimatedLogo(QWidget): 59 """The app icon, its colors slowly going around the color wheel while 60 it's shown.""" 61 62 PERIOD = 20.0 # seconds for once around 63 FRAME_MS = 50 64 65 def __init__(self, size: int = 128, parent=None): 66 from qtpy.QtSvg import QSvgRenderer 67 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() 77 78 def _recolor(self): 79 self.renderer.load(recolored_logo(self.turn)) 80 self.update() 81 82 def _tick(self): 83 self.turn = ((time.monotonic() - self._start) / self.PERIOD) % 1.0 84 self._recolor() 85 86 def showEvent(self, event): 87 super().showEvent(event) 88 self.timer.start() 89 90 def hideEvent(self, event): 91 super().hideEvent(event) 92 self.timer.stop() 93 94 def paintEvent(self, event): 95 p = QPainter(self) 96 draw_app_icon(p, self.width(), self.renderer) 97 p.end() 98 99 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 109 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 116 117contains 118 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 129 130end program orbit 131""" 132 133 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.""" 138 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) 143 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) 159 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 165 166 from .highlighter import FortranSourceLexer 167 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 181 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) 185 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() 194 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): 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 207 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 212 213 def showEvent(self, event): 214 super().showEvent(event) 215 self._last = time.monotonic() 216 self.timer.start() 217 218 def hideEvent(self, event): 219 super().hideEvent(event) 220 self.timer.stop() 221 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): 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): 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: 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) 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() 268 269 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") 274 275 self.logo = AnimatedLogo(128) 276 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) 285 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) 315 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>") 321 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) 332 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) 339 340 top = QHBoxLayout() 341 top.setSpacing(16) 342 top.addLayout(side) 343 top.addLayout(text, 1) 344 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) 364 365 layout = QVBoxLayout(self) 366 layout.setContentsMargins(20, 20, 20, 14) 367 layout.addLayout(top) 368 layout.addLayout(buttons) 369 self.setMinimumWidth(560) 370 371 def show_packages(self): 372 from .packages import PackagesDialog 373 374 dialog = PackagesDialog(self) 375 try: 376 dialog.exec() 377 finally: 378 dialog.deleteLater() 379 380 @staticmethod 381 def _copy(text: str): 382 from qtpy.QtWidgets import QApplication 383 QApplication.clipboard().setText(text)
42def tool_version(command: list[str], timeout: float = 5) -> str: 43 """First useful line of ``<tool> --version`` ("" if it can't be run).""" 44 try: 45 out = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False, **NO_WINDOW) 46 except (OSError, subprocess.SubprocessError): 47 return "" 48 text = (out.stdout or out.stderr or "").strip() 49 return text.splitlines()[0].strip() if text else ""
First useful line of <tool> --version ("" if it can't be run).
52def fpm_version(fpm: str) -> str: 53 """e.g. "0.13.0, alpha" from fpm's "Version: 0.13.0, alpha" line.""" 54 line = tool_version([fpm, "--version"]) 55 match = re.match(r"^\s*Version:\s*(.+)$", line) 56 return match.group(1).strip() if match else line
e.g. "0.13.0, alpha" from fpm's "Version: 0.13.0, alpha" line.
59class AnimatedLogo(QWidget): 60 """The app icon, its colors slowly going around the color wheel while 61 it's shown.""" 62 63 PERIOD = 20.0 # seconds for once around 64 FRAME_MS = 50 65 66 def __init__(self, size: int = 128, parent=None): 67 from qtpy.QtSvg import QSvgRenderer 68 69 super().__init__(parent) 70 self.setFixedSize(size, size) 71 self.renderer = QSvgRenderer(self) 72 self.turn = 0.0 73 self._start = time.monotonic() 74 self.timer = QTimer(self) 75 self.timer.setInterval(self.FRAME_MS) 76 self.timer.timeout.connect(self._tick) 77 self._recolor() 78 79 def _recolor(self): 80 self.renderer.load(recolored_logo(self.turn)) 81 self.update() 82 83 def _tick(self): 84 self.turn = ((time.monotonic() - self._start) / self.PERIOD) % 1.0 85 self._recolor() 86 87 def showEvent(self, event): 88 super().showEvent(event) 89 self.timer.start() 90 91 def hideEvent(self, event): 92 super().hideEvent(event) 93 self.timer.stop() 94 95 def paintEvent(self, event): 96 p = QPainter(self) 97 draw_app_icon(p, self.width(), self.renderer) 98 p.end()
The app icon, its colors slowly going around the color wheel while it's shown.
66 def __init__(self, size: int = 128, parent=None): 67 from qtpy.QtSvg import QSvgRenderer 68 69 super().__init__(parent) 70 self.setFixedSize(size, size) 71 self.renderer = QSvgRenderer(self) 72 self.turn = 0.0 73 self._start = time.monotonic() 74 self.timer = QTimer(self) 75 self.timer.setInterval(self.FRAME_MS) 76 self.timer.timeout.connect(self._tick) 77 self._recolor()
__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
135class TypingCode(QWidget): 136 """Fortran being typed, a character at a time, scrolling up as it goes: 137 small, faint, in the window's colors (keywords in its accent), fading 138 out at the top and bottom. Runs only while shown.""" 139 140 FRAME_MS = 40 141 CHARS_PER_SECOND = 18 142 PAUSE = 2.5 # seconds at the end before typing it again 143 OPACITY = 0.55 # (subtle: under the version information) 144 145 def __init__(self, code: str = TYPED_CODE, parent=None): 146 super().__init__(parent) 147 self.font_ = QFontDatabase.systemFont(QFontDatabase.FixedFont) 148 self.font_.setPointSizeF(max(6.5, self.font_.pointSizeF() * 0.62)) 149 self.metrics = QFontMetricsF(self.font_) 150 self.line_height = self.metrics.lineSpacing() 151 self.lines = self._colored(code) 152 self.typed = 0.0 # characters typed (of the code, again and again: see _text_at) 153 self.offset = 0.0 # how far it's scrolled (pixels) 154 self._last = time.monotonic() 155 self.timer = QTimer(self) 156 self.timer.setInterval(self.FRAME_MS) 157 self.timer.timeout.connect(self._tick) 158 self.setMinimumHeight(int(self.line_height * 4)) 159 self.setAttribute(Qt.WA_TransparentForMouseEvents) 160 161 @staticmethod 162 def _colored(code: str) -> list[list[tuple[str, str]]]: 163 """The code's lines, each as (text, kind) pieces: "keyword", "comment", 164 "string", "number" or "" (see paintEvent for their colors).""" 165 from pygments.token import Comment, Keyword, Name, Number, String 166 167 from .highlighter import FortranSourceLexer 168 169 lines: list[list[tuple[str, str]]] = [[]] 170 for ttype, value in FortranSourceLexer().get_tokens(code): 171 kind = ("comment" if ttype in Comment else "string" if ttype in String else 172 "number" if ttype in Number else 173 "keyword" if ttype in Keyword or ttype in Name.Builtin else "") 174 for n, part in enumerate(value.split("\n")): 175 if n: 176 lines.append([]) 177 if part: 178 lines[-1].append((part, kind)) 179 while lines and not lines[-1]: 180 lines.pop() 181 return lines 182 183 def _chars(self) -> int: 184 """Characters in one go through the code (a line's end counts as one).""" 185 return sum(len(text) for line in self.lines for text, _kind in line) + len(self.lines) 186 187 def _tick(self): 188 now = time.monotonic() 189 elapsed, self._last = min(now - self._last, 0.2), now # (not a jump after being hidden) 190 self.typed += elapsed * self.CHARS_PER_SECOND 191 rows = self._rows() 192 target = max(0.0, rows * self.line_height - (self.height() - self.line_height * 1.5)) 193 self.offset += (target - self.offset) * min(1.0, elapsed * 6) # (eased: a smooth scroll) 194 self.update() 195 196 def _position(self) -> tuple[int, int, int]: 197 """(times through, line, characters typed of that line) at ``typed``.""" 198 total = self._chars() 199 cycle = total + int(self.PAUSE * self.CHARS_PER_SECOND) 200 rounds, left = divmod(int(self.typed), cycle) 201 left = min(left, total - 1) 202 for number, line in enumerate(self.lines): 203 length = sum(len(text) for text, _kind in line) + 1 204 if left < length: 205 return rounds, number, left 206 left -= length 207 return rounds, len(self.lines) - 1, 0 208 209 def _rows(self) -> int: 210 """Rows typed so far (each time through follows the last, after a blank row).""" 211 rounds, line, _chars = self._position() 212 return rounds * (len(self.lines) + 1) + line + 1 213 214 def showEvent(self, event): 215 super().showEvent(event) 216 self._last = time.monotonic() 217 self.timer.start() 218 219 def hideEvent(self, event): 220 super().hideEvent(event) 221 self.timer.stop() 222 223 def paintEvent(self, event): 224 pal = self.palette() 225 text = pal.color(QPalette.WindowText) 226 colors = {"": text, "keyword": pal.color(QPalette.Highlight), "comment": QColor(text), 227 "string": QColor("#2f9e44") if pal.color(QPalette.Window).lightness() > 128 else QColor("#69db7c"), 228 "number": QColor("#e8590c") if pal.color(QPalette.Window).lightness() > 128 else QColor("#ffa94d")} 229 colors["comment"].setAlpha(130) 230 p = QPainter(self) 231 p.setRenderHint(QPainter.TextAntialiasing) 232 p.setOpacity(self.OPACITY) 233 p.setFont(self.font_) 234 _rounds, _line, typed = self._position() 235 rows = self._rows() 236 lh, ascent = self.line_height, self.metrics.ascent() 237 first = max(0, int(self.offset // lh) - 1) 238 for row in range(first, rows): 239 y = row * lh - self.offset 240 if y > self.height(): 241 break 242 number = row % (len(self.lines) + 1) # (the last of each time through: the blank row between) 243 if number == len(self.lines): 244 continue 245 limit = typed if row == rows - 1 else None 246 x = 0.0 247 for piece, kind in self.lines[number]: 248 if limit is not None: 249 if limit <= 0: 250 break 251 piece, limit = piece[:limit], limit - len(piece) 252 p.setPen(colors[kind]) 253 p.drawText(QRectF(x, y, self.width() * 2, lh), Qt.AlignLeft | Qt.AlignVCenter, piece) 254 x += self.metrics.horizontalAdvance(piece) 255 if row == rows - 1 and int(time.monotonic() * 2) % 2 == 0: # (the cursor, blinking) 256 p.fillRect(QRectF(x + 1, y + (lh - ascent) / 2, 1.5, ascent), text) 257 # Fade out at the top and bottom (into the window's color). 258 p.setOpacity(1.0) 259 background = pal.color(QPalette.Window) 260 clear = QColor(background) 261 clear.setAlpha(0) 262 for top in (True, False): 263 band = QRectF(0, 0 if top else self.height() - lh * 2, self.width(), lh * 2) 264 gradient = QLinearGradient(band.topLeft(), band.bottomLeft()) 265 gradient.setColorAt(0.0, background if top else clear) 266 gradient.setColorAt(1.0, clear if top else background) 267 p.fillRect(band, gradient) 268 p.end()
Fortran being typed, a character at a time, scrolling up as it goes: small, faint, in the window's colors (keywords in its accent), fading out at the top and bottom. Runs only while shown.
145 def __init__(self, code: str = TYPED_CODE, parent=None): 146 super().__init__(parent) 147 self.font_ = QFontDatabase.systemFont(QFontDatabase.FixedFont) 148 self.font_.setPointSizeF(max(6.5, self.font_.pointSizeF() * 0.62)) 149 self.metrics = QFontMetricsF(self.font_) 150 self.line_height = self.metrics.lineSpacing() 151 self.lines = self._colored(code) 152 self.typed = 0.0 # characters typed (of the code, again and again: see _text_at) 153 self.offset = 0.0 # how far it's scrolled (pixels) 154 self._last = time.monotonic() 155 self.timer = QTimer(self) 156 self.timer.setInterval(self.FRAME_MS) 157 self.timer.timeout.connect(self._tick) 158 self.setMinimumHeight(int(self.line_height * 4)) 159 self.setAttribute(Qt.WA_TransparentForMouseEvents)
__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
214 def showEvent(self, event): 215 super().showEvent(event) 216 self._last = time.monotonic() 217 self.timer.start()
showEvent(self, event: PySide6.QtGui.QShowEvent, /) -> None
223 def paintEvent(self, event): 224 pal = self.palette() 225 text = pal.color(QPalette.WindowText) 226 colors = {"": text, "keyword": pal.color(QPalette.Highlight), "comment": QColor(text), 227 "string": QColor("#2f9e44") if pal.color(QPalette.Window).lightness() > 128 else QColor("#69db7c"), 228 "number": QColor("#e8590c") if pal.color(QPalette.Window).lightness() > 128 else QColor("#ffa94d")} 229 colors["comment"].setAlpha(130) 230 p = QPainter(self) 231 p.setRenderHint(QPainter.TextAntialiasing) 232 p.setOpacity(self.OPACITY) 233 p.setFont(self.font_) 234 _rounds, _line, typed = self._position() 235 rows = self._rows() 236 lh, ascent = self.line_height, self.metrics.ascent() 237 first = max(0, int(self.offset // lh) - 1) 238 for row in range(first, rows): 239 y = row * lh - self.offset 240 if y > self.height(): 241 break 242 number = row % (len(self.lines) + 1) # (the last of each time through: the blank row between) 243 if number == len(self.lines): 244 continue 245 limit = typed if row == rows - 1 else None 246 x = 0.0 247 for piece, kind in self.lines[number]: 248 if limit is not None: 249 if limit <= 0: 250 break 251 piece, limit = piece[:limit], limit - len(piece) 252 p.setPen(colors[kind]) 253 p.drawText(QRectF(x, y, self.width() * 2, lh), Qt.AlignLeft | Qt.AlignVCenter, piece) 254 x += self.metrics.horizontalAdvance(piece) 255 if row == rows - 1 and int(time.monotonic() * 2) % 2 == 0: # (the cursor, blinking) 256 p.fillRect(QRectF(x + 1, y + (lh - ascent) / 2, 1.5, ascent), text) 257 # Fade out at the top and bottom (into the window's color). 258 p.setOpacity(1.0) 259 background = pal.color(QPalette.Window) 260 clear = QColor(background) 261 clear.setAlpha(0) 262 for top in (True, False): 263 band = QRectF(0, 0 if top else self.height() - lh * 2, self.width(), lh * 2) 264 gradient = QLinearGradient(band.topLeft(), band.bottomLeft()) 265 gradient.setColorAt(0.0, background if top else clear) 266 gradient.setColorAt(1.0, clear if top else background) 267 p.fillRect(band, gradient) 268 p.end()
paintEvent(self, event: PySide6.QtGui.QPaintEvent, /) -> None
271class AboutDialog(QDialog): 272 def __init__(self, fpm: str, compiler: str, compiler_source: str = "", parent=None): 273 super().__init__(parent) 274 self.setWindowTitle("About VibeFPMGUI") 275 276 self.logo = AnimatedLogo(128) 277 278 title = QLabel(f"<h2 style='margin:0'>VibeFPMGUI</h2>" 279 f"<p style='margin-top:2px; color:gray'>Version {html.escape(__version__)}</p>") 280 blurb = QLabel("A graphical front end for the " 281 f"<a href='{FPM_DOCS_URL}'>Fortran Package Manager (fpm)</a>: build, run and " 282 "test fpm projects, explore their dependencies, edit sources and review " 283 "code with a local AI model.") 284 blurb.setWordWrap(True) 285 blurb.setOpenExternalLinks(True) 286 287 fpm_path = shutil.which(fpm) or fpm 288 fpm_ver = fpm_version(fpm) 289 compiler_ver = tool_version([compiler, "--version"]) 290 compiler_path = shutil.which(compiler) or compiler 291 compiler_tip = compiler_path + (f"\n(chosen by: {compiler_source})" if compiler_source else "") 292 # (name, value, tooltip: where the program is, shown on hover rather than taking up the window) 293 rows = [ 294 ("fpm", fpm_ver or "not found", fpm_path), 295 ("Compiler", compiler_ver or f"{compiler} not found", compiler_tip), 296 ("Python", platform.python_version(), sys.executable), 297 ("Qt", qVersion(), ""), 298 ("System", f"{platform.system()} {platform.release()} ({platform.machine()})", ""), 299 ] 300 versions = QGridLayout() 301 versions.setHorizontalSpacing(12) 302 versions.setVerticalSpacing(2) 303 self.version_labels: dict[str, QLabel] = {} 304 for row, (name, value, tip) in enumerate(rows): 305 key = QLabel(f"<b>{html.escape(name)}</b>") 306 missing = value.endswith("not found") 307 shown = QLabel(f"<i>{html.escape(value)}</i>" if missing else html.escape(value)) 308 shown.setWordWrap(True) 309 shown.setTextInteractionFlags(Qt.TextSelectableByMouse) 310 for label in (key, shown): 311 label.setToolTip(tip) 312 versions.addWidget(key, row, 0, Qt.AlignTop) 313 versions.addWidget(shown, row, 1) 314 self.version_labels[name] = shown 315 versions.setColumnStretch(1, 1) 316 317 links = QLabel(f"<a href='{FPM_DOCS_URL}'>fpm documentation</a> · " 318 f"<a href='{PROJECT_URL}'>VibeFPMGUI on GitHub</a>") 319 links.setOpenExternalLinks(True) 320 legal = QLabel("<span style='color:gray'>© 2026 Jacob Williams · MIT License<br>" 321 "fpm logo: © 2020 fpm contributors · MIT License</span>") 322 323 text = QVBoxLayout() 324 text.setSpacing(8) 325 text.addWidget(title) 326 text.addWidget(blurb) 327 text.addSpacing(4) 328 text.addLayout(versions) 329 text.addSpacing(4) 330 text.addWidget(links) 331 text.addWidget(legal) 332 text.addStretch(1) 333 334 self.typing = TypingCode() 335 self.typing.setFixedWidth(self.logo.width()) 336 side = QVBoxLayout() 337 side.setSpacing(12) 338 side.addWidget(self.logo, 0, Qt.AlignTop) 339 side.addWidget(self.typing, 1) # (under the icon, down to the window's bottom) 340 341 top = QHBoxLayout() 342 top.setSpacing(16) 343 top.addLayout(side) 344 top.addLayout(text, 1) 345 346 copy = QPushButton("Copy Versions") 347 copy.setToolTip("Copy the version information (handy for bug reports)") 348 # (Copied with where the programs are: handy in a bug report.) 349 plain = "\n".join(f"{name}: {value}" + (f" ({' '.join(tip.split())})" if tip else "") 350 for name, value, tip in rows) 351 plain = f"VibeFPMGUI {__version__}\n{plain}" 352 copy.clicked.connect(lambda: (self._copy(plain), copy.setText("Copied"))) 353 packages = QPushButton("Packages…") 354 packages.setToolTip("The packages installed in the environment the app runs in " 355 "(pixi list or conda list)") 356 packages.clicked.connect(self.show_packages) 357 close = QPushButton("Close") 358 close.setDefault(True) 359 close.clicked.connect(self.accept) 360 buttons = QHBoxLayout() 361 buttons.addWidget(copy) 362 buttons.addWidget(packages) 363 buttons.addStretch(1) 364 buttons.addWidget(close) 365 366 layout = QVBoxLayout(self) 367 layout.setContentsMargins(20, 20, 20, 14) 368 layout.addLayout(top) 369 layout.addLayout(buttons) 370 self.setMinimumWidth(560) 371 372 def show_packages(self): 373 from .packages import PackagesDialog 374 375 dialog = PackagesDialog(self) 376 try: 377 dialog.exec() 378 finally: 379 dialog.deleteLater() 380 381 @staticmethod 382 def _copy(text: str): 383 from qtpy.QtWidgets import QApplication 384 QApplication.clipboard().setText(text)
QDialog(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, sizeGripEnabled: bool | None = None, modal: bool | None = None) -> None
272 def __init__(self, fpm: str, compiler: str, compiler_source: str = "", parent=None): 273 super().__init__(parent) 274 self.setWindowTitle("About VibeFPMGUI") 275 276 self.logo = AnimatedLogo(128) 277 278 title = QLabel(f"<h2 style='margin:0'>VibeFPMGUI</h2>" 279 f"<p style='margin-top:2px; color:gray'>Version {html.escape(__version__)}</p>") 280 blurb = QLabel("A graphical front end for the " 281 f"<a href='{FPM_DOCS_URL}'>Fortran Package Manager (fpm)</a>: build, run and " 282 "test fpm projects, explore their dependencies, edit sources and review " 283 "code with a local AI model.") 284 blurb.setWordWrap(True) 285 blurb.setOpenExternalLinks(True) 286 287 fpm_path = shutil.which(fpm) or fpm 288 fpm_ver = fpm_version(fpm) 289 compiler_ver = tool_version([compiler, "--version"]) 290 compiler_path = shutil.which(compiler) or compiler 291 compiler_tip = compiler_path + (f"\n(chosen by: {compiler_source})" if compiler_source else "") 292 # (name, value, tooltip: where the program is, shown on hover rather than taking up the window) 293 rows = [ 294 ("fpm", fpm_ver or "not found", fpm_path), 295 ("Compiler", compiler_ver or f"{compiler} not found", compiler_tip), 296 ("Python", platform.python_version(), sys.executable), 297 ("Qt", qVersion(), ""), 298 ("System", f"{platform.system()} {platform.release()} ({platform.machine()})", ""), 299 ] 300 versions = QGridLayout() 301 versions.setHorizontalSpacing(12) 302 versions.setVerticalSpacing(2) 303 self.version_labels: dict[str, QLabel] = {} 304 for row, (name, value, tip) in enumerate(rows): 305 key = QLabel(f"<b>{html.escape(name)}</b>") 306 missing = value.endswith("not found") 307 shown = QLabel(f"<i>{html.escape(value)}</i>" if missing else html.escape(value)) 308 shown.setWordWrap(True) 309 shown.setTextInteractionFlags(Qt.TextSelectableByMouse) 310 for label in (key, shown): 311 label.setToolTip(tip) 312 versions.addWidget(key, row, 0, Qt.AlignTop) 313 versions.addWidget(shown, row, 1) 314 self.version_labels[name] = shown 315 versions.setColumnStretch(1, 1) 316 317 links = QLabel(f"<a href='{FPM_DOCS_URL}'>fpm documentation</a> · " 318 f"<a href='{PROJECT_URL}'>VibeFPMGUI on GitHub</a>") 319 links.setOpenExternalLinks(True) 320 legal = QLabel("<span style='color:gray'>© 2026 Jacob Williams · MIT License<br>" 321 "fpm logo: © 2020 fpm contributors · MIT License</span>") 322 323 text = QVBoxLayout() 324 text.setSpacing(8) 325 text.addWidget(title) 326 text.addWidget(blurb) 327 text.addSpacing(4) 328 text.addLayout(versions) 329 text.addSpacing(4) 330 text.addWidget(links) 331 text.addWidget(legal) 332 text.addStretch(1) 333 334 self.typing = TypingCode() 335 self.typing.setFixedWidth(self.logo.width()) 336 side = QVBoxLayout() 337 side.setSpacing(12) 338 side.addWidget(self.logo, 0, Qt.AlignTop) 339 side.addWidget(self.typing, 1) # (under the icon, down to the window's bottom) 340 341 top = QHBoxLayout() 342 top.setSpacing(16) 343 top.addLayout(side) 344 top.addLayout(text, 1) 345 346 copy = QPushButton("Copy Versions") 347 copy.setToolTip("Copy the version information (handy for bug reports)") 348 # (Copied with where the programs are: handy in a bug report.) 349 plain = "\n".join(f"{name}: {value}" + (f" ({' '.join(tip.split())})" if tip else "") 350 for name, value, tip in rows) 351 plain = f"VibeFPMGUI {__version__}\n{plain}" 352 copy.clicked.connect(lambda: (self._copy(plain), copy.setText("Copied"))) 353 packages = QPushButton("Packages…") 354 packages.setToolTip("The packages installed in the environment the app runs in " 355 "(pixi list or conda list)") 356 packages.clicked.connect(self.show_packages) 357 close = QPushButton("Close") 358 close.setDefault(True) 359 close.clicked.connect(self.accept) 360 buttons = QHBoxLayout() 361 buttons.addWidget(copy) 362 buttons.addWidget(packages) 363 buttons.addStretch(1) 364 buttons.addWidget(close) 365 366 layout = QVBoxLayout(self) 367 layout.setContentsMargins(20, 20, 20, 14) 368 layout.addLayout(top) 369 layout.addLayout(buttons) 370 self.setMinimumWidth(560)
__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, sizeGripEnabled: bool | None = None, modal: bool | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.