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> &nbsp;·&nbsp; "
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)
FPM_DOCS_URL = 'https://fpm.fortran-lang.org'
PROJECT_URL = 'https://github.com/jacobwilliams/VibeFPMGUI'
def tool_version(command: list[str], timeout: float = 5) -> str:
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).

def fpm_version(fpm: str) -> str:
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.

TYPED_CODE = "program orbit\n use iso_fortran_env\n implicit none\n integer, parameter :: &\n wp = real64\n real(wp) :: r(3), v(3)\n real(wp) :: dt = 0.01_wp\n integer :: i\n\n r = [1.0_wp, 0.0_wp, 0.0_wp]\n v = [0.0_wp, 1.0_wp, 0.0_wp]\n do i = 1, 1000\n call step(r, v, dt)\n end do\n print '(3f9.4)', r\n\ncontains\n\n pure subroutine step(r, v, h)\n real(wp), intent(inout) :: r(3)\n real(wp), intent(inout) :: v(3)\n real(wp), intent(in) :: h\n real(wp) :: a(3)\n ! gravity, mu = 1\n a = -r / norm2(r)**3\n v = v + h * a\n r = r + h * v\n end subroutine step\n\nend program orbit\n"
class TypingCode(PySide6.QtWidgets.QWidget):
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.

TypingCode( code: str = "program orbit\n use iso_fortran_env\n implicit none\n integer, parameter :: &\n wp = real64\n real(wp) :: r(3), v(3)\n real(wp) :: dt = 0.01_wp\n integer :: i\n\n r = [1.0_wp, 0.0_wp, 0.0_wp]\n v = [0.0_wp, 1.0_wp, 0.0_wp]\n do i = 1, 1000\n call step(r, v, dt)\n end do\n print '(3f9.4)', r\n\ncontains\n\n pure subroutine step(r, v, h)\n real(wp), intent(inout) :: r(3)\n real(wp), intent(inout) :: v(3)\n real(wp), intent(in) :: h\n real(wp) :: a(3)\n ! gravity, mu = 1\n a = -r / norm2(r)**3\n v = v + h * a\n r = r + h * v\n end subroutine step\n\nend program orbit\n", parent=None)
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.

FRAME_MS = 40
CHARS_PER_SECOND = 18
PAUSE = 2.5
OPACITY = 0.55
font_
metrics
line_height
lines
typed
offset
timer
def showEvent(self, event):
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

def hideEvent(self, event):
219    def hideEvent(self, event):
220        super().hideEvent(event)
221        self.timer.stop()

hideEvent(self, event: PySide6.QtGui.QHideEvent, /) -> None

def paintEvent(self, event):
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

staticMetaObject = PySide6.QtCore.QMetaObject("TypingCode" inherits "QWidget": )
class AboutDialog(PySide6.QtWidgets.QDialog):
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> &nbsp;·&nbsp; "
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

AboutDialog(fpm: str, compiler: str, compiler_source: str = '', parent=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> &nbsp;·&nbsp; "
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.

version_labels: dict[str, PySide6.QtWidgets.QLabel]
typing
def show_packages(self):
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()
staticMetaObject = PySide6.QtCore.QMetaObject("AboutDialog" inherits "QDialog": )