Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/qtxterm/about_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""A small About box: which build is running, and a way to copy that."""

from __future__ import annotations

from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)

from qtxterm.branding import LOGO_PATH
from qtxterm.version_info import version_string

LOGO_SIZE = 64

COPIED_TEXT = "Copied"


class AboutDialog(QDialog):
"""Shows the running build's version, commit and source location."""

def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("About qtxterm")

self.details = version_string()

layout = QHBoxLayout(self)

self.logo = QLabel(self)
if LOGO_PATH.is_file():
self.logo.setPixmap(
QIcon(str(LOGO_PATH)).pixmap(QSize(LOGO_SIZE, LOGO_SIZE))
)
self.logo.setAlignment(Qt.AlignmentFlag.AlignTop)
layout.addWidget(self.logo)

right = QVBoxLayout()
layout.addLayout(right)

self.text = QLabel(self.details, self)
# Selectable so the version can be dragged out even without the button,
# and monospaced so the aligned labels in version_lines() stay aligned.
self.text.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
| Qt.TextInteractionFlag.TextSelectableByKeyboard
)
self.text.setStyleSheet("font-family: monospace;")
right.addWidget(self.text)

buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self)
self.copy_button = QPushButton("Copy", self)
self.copy_button.setDefault(True)
buttons.addButton(self.copy_button, QDialogButtonBox.ButtonRole.ActionRole)
self.copy_button.clicked.connect(self.copy_details)
buttons.rejected.connect(self.reject)
right.addWidget(buttons)

def copy_details(self) -> None:
"""Put the version block on the clipboard, ready to paste into an issue."""
clipboard = QGuiApplication.clipboard()
if clipboard is None:
return
clipboard.setText(self.details)
# Confirm in place. A dialog raising another dialog to say "done" is
# more interruption than a copy button deserves.
self.copy_button.setText(COPIED_TEXT)
10 changes: 10 additions & 0 deletions src/qtxterm/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QApplication, QDockWidget, QMainWindow, QStyle

from qtxterm.about_dialog import AboutDialog
from qtxterm.appearance import AppearanceStore
from qtxterm.branding import app_icon
from qtxterm.cron import CronStore
Expand Down Expand Up @@ -182,10 +183,19 @@ def _build_help_menu(self) -> None:
self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxQuestion)
)
self._usage_action.triggered.connect(self.show_usage)
self._help_menu.addSeparator()
self._about_action = self._help_menu.addAction("About qtxterm")
self._about_action.setIcon(
self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation)
)
self._about_action.triggered.connect(self.show_about)

def show_usage(self) -> None:
HelpDialog(self).exec()

def show_about(self) -> None:
AboutDialog(self).exec()

def _build_file_menu(self) -> None:
# QMenu.addMenu() parents the submenu in C++, but without a Python-side
# reference kept alive too, PySide6 can garbage-collect the wrapper (and
Expand Down
152 changes: 152 additions & 0 deletions src/qtxterm/version_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Where the running code came from, for the About dialog.

The packaged version alone cannot answer "which build am I looking at?" -
`pyproject.toml` carries a static version that only moves on a release, so
every commit between two releases reports the same string. An editable
install makes that worse: the code is read live from a checkout, so the
version is fixed while the code underneath it changes with every branch
switch.

Git knows the answer and is right there in a checkout, so ask it, and fall
back to the packaged metadata when it is not - an installed wheel has no
`.git`, and a user machine may have no `git` at all.
"""

from __future__ import annotations

import subprocess
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import cache
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path

PACKAGE_DIR = Path(__file__).resolve().parent

# src/qtxterm -> src -> repo root. Absent from an installed wheel, which is
# exactly the signal that this is not a development build.
REPO_ROOT = PACKAGE_DIR.parent.parent

# Git is normally instant, but it takes locks and can block on a busy repo.
# The About dialog is not worth hanging the UI for, so cap the wait and
# degrade to the packaged version instead.
GIT_TIMEOUT_SECONDS = 2.0

UNKNOWN_VERSION = "unknown"


@dataclass(frozen=True)
class BuildInfo:
"""Identity of the running build, however much of it could be determined."""

version: str
describe: str | None = None
commit_datetime: datetime | None = None
source: Path | None = None
installed_datetime: datetime | None = None

@property
def is_development(self) -> bool:
"""Whether this is running from a checkout rather than an installed copy."""
return self.describe is not None


def _run_git(*args: str) -> str | None:
"""Run a git command in the repo, or return None if git cannot answer.

Covers the three ways this fails in the wild: no git on PATH
(FileNotFoundError), no repository to read (non-zero exit), and a repo
that does not answer promptly (TimeoutExpired).
"""
if not (REPO_ROOT / ".git").exists():
return None
try:
result = subprocess.run(
["git", *args],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=GIT_TIMEOUT_SECONDS,
check=False,
)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
return result.stdout.strip() or None


def packaged_version() -> str:
"""The version recorded at install time, or a placeholder if unreadable."""
try:
return version("qtxterm")
except PackageNotFoundError:
return UNKNOWN_VERSION


def _commit_datetime() -> datetime | None:
raw = _run_git("log", "-1", "--format=%cI")
if raw is None:
return None
try:
return datetime.fromisoformat(raw)
except ValueError:
return None


def _installed_datetime() -> datetime | None:
"""When the package landed on disk.

The package directory's mtime is written when the wheel is unpacked, so
for an installed copy it dates the install. It is meaningless for an
editable install, where the directory is the checkout - hence only used
when git found nothing.
"""
try:
stamp = PACKAGE_DIR.stat().st_mtime
except OSError:
return None
# Read as UTC then converted to local, so the result is timezone-aware
# and prints an offset like the git commit date does.
return datetime.fromtimestamp(stamp, tz=UTC).astimezone()


@cache
def build_info() -> BuildInfo:
"""Identify the running build. Cached - the answer cannot change mid-run."""
describe = _run_git("describe", "--always", "--dirty", "--tags")
if describe is None:
return BuildInfo(
version=packaged_version(),
installed_datetime=_installed_datetime(),
)
return BuildInfo(
version=packaged_version(),
describe=describe,
commit_datetime=_commit_datetime(),
source=REPO_ROOT,
)


def _format(moment: datetime) -> str:
return moment.strftime("%Y-%m-%d %H:%M:%S %z").strip()


def version_lines(info: BuildInfo | None = None) -> list[str]:
"""The About dialog's text, one line per fact that could be determined."""
info = info if info is not None else build_info()
lines = [f"qtxterm {info.version}"]
if info.describe:
lines.append(info.describe)
if info.commit_datetime:
lines.append(f"commit {_format(info.commit_datetime)}")
if info.installed_datetime:
lines.append(f"installed {_format(info.installed_datetime)}")
if info.source:
lines.append(f"source {info.source}")
return lines


def version_string(info: BuildInfo | None = None) -> str:
"""The About dialog's text as one block, also what Copy puts on the clipboard."""
return "\n".join(version_lines(info))
39 changes: 39 additions & 0 deletions tests/test_about_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""The About dialog shows the build and copies it."""

from __future__ import annotations

from PySide6.QtGui import QGuiApplication

from qtxterm.about_dialog import COPIED_TEXT, AboutDialog
from qtxterm.version_info import version_string


def test_dialog_shows_the_version_block(qtbot) -> None:
dialog = AboutDialog()
qtbot.addWidget(dialog)

assert dialog.text.text() == version_string()
assert "qtxterm" in dialog.text.text()


def test_copy_puts_the_details_on_the_clipboard(qtbot) -> None:
dialog = AboutDialog()
qtbot.addWidget(dialog)

dialog.copy_details()

assert QGuiApplication.clipboard().text() == dialog.details
assert dialog.copy_button.text() == COPIED_TEXT


def test_dialog_survives_a_missing_logo(qtbot, monkeypatch, tmp_path) -> None:
"""Assets can be absent from a broken install; the build info still matters."""
import qtxterm.about_dialog as about_dialog

monkeypatch.setattr(about_dialog, "LOGO_PATH", tmp_path / "gone.svg")

dialog = AboutDialog()
qtbot.addWidget(dialog)

assert dialog.logo.pixmap().isNull()
assert dialog.text.text()
40 changes: 40 additions & 0 deletions tests/test_main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,46 @@ def exec(self) -> int:
window.close()


def test_help_menu_about_action_opens_the_dialog(
qtbot, monkeypatch, tmp_path: Path
) -> None:
"""exec() is monkeypatched - a real modal would block the test run."""
import qtxterm.main_window as main_window

opened = []

class FakeAboutDialog:
def __init__(self, parent=None) -> None:
opened.append(parent)

def exec(self) -> int:
return 0

monkeypatch.setattr(main_window, "AboutDialog", FakeAboutDialog)

window = MainWindow(settings=make_settings(tmp_path))
qtbot.addWidget(window)
window.show()

assert window._about_action.text() == "About qtxterm"
assert not window._about_action.icon().isNull()

window._about_action.trigger()

assert opened == [window]

window.close()


def test_help_menu_lists_usage_then_about(qtbot, tmp_path: Path) -> None:
window = MainWindow(settings=make_settings(tmp_path))
qtbot.addWidget(window)

entries = [a.text() for a in window._help_menu.actions() if not a.isSeparator()]

assert entries == ["Usage", "About qtxterm"]


def test_menu_bar_order(qtbot, tmp_path: Path) -> None:
window = MainWindow(settings=make_settings(tmp_path))
qtbot.addWidget(window)
Expand Down
Loading
Loading