Files
MeshTerm/meshterm/tools/about.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

242 lines
8.2 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The About MeshTerm tools: the four written pages in the menu's last section.
*About MeshTerm*, *About the author*, *Join Discord*, *Support MeshTerm* — in that
order, which is the order a stranger asks the questions in: what is this, who made it,
where is everyone, how do I help. They share the *This app* section with Preferences,
which leads it: the section is the app's own scope, and the one row that *changes*
MeshTerm sits above the four that describe it.
Each is a page rather than a feature: it reads nothing, transmits nothing, and needs no
device, so it opens straight to its screen (see :mod:`meshterm.ui.about`) instead of
prompting for anything first.
Each also keeps a CLI face — ``meshterm about``, ``meshterm about-author``,
``meshterm discord``, ``meshterm support`` — printing the same page to the terminal, so
the answers are reachable from a shell without launching the full-screen session.
The pages themselves are written in markdown under ``meshterm/assets/pages`` (see
:mod:`meshterm.ui.about`); nothing here knows what any of them say.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..context import AppContext
from .base import Tool, ToolResult, register
if TYPE_CHECKING:
from ..ui.markdown import MarkdownDoc
from ..ui.report import Facts
class _AboutTool(Tool):
"""Shared behaviour for the four About pages: open the screen, or print the page.
The pages differ only in their title, icon, and content, so everything else — the
menu-only screen open, the printing CLI face, the run row — lives here once. A
subclass supplies :meth:`page`, and nothing more.
"""
category = "This app"
@staticmethod
def page() -> MarkdownDoc:
"""Build this page's content.
Overridden by each page. Subclasses import their builder *inside* the override
rather than at module scope: the registry imports every tool module at startup
(see :func:`~meshterm.tools.load_all_tools`), and a page's content is only ever
wanted once someone actually opens it.
"""
raise NotImplementedError
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
"""Open the page; returning ``None`` completes the invocation with no run row.
The page transmits nothing and stores nothing, so — like the Trophy case and the
mesh walk — it lives here rather than in :meth:`run`, and logs no run of its own.
Args:
ctx: Shared application context.
Returns:
Always ``None``.
"""
from ..ui.about import open_about_page
await open_about_page(ctx, self.title, self.page())
return None
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""State the page — only reachable from the CLI (the menu opens the screen).
Without its scannable codes: a QR is a second rendering of a link the page already
prints, drawn for a phone pointed at a screen, and redirected into a file it is a
block of block characters around nothing new.
Args:
ctx: Shared application context.
params: Unused beyond the injected ``_run_id``.
Returns:
A :class:`ToolResult` naming the page that was shown.
"""
return ToolResult(
summary={"page": self.name},
report=(_written_page(self.name, self.title, self.page().for_script()),),
)
@register
class AboutMeshTermTool(_AboutTool):
"""What MeshTerm is, where it came from, and the terms it ships under."""
name = "about"
title = "About MeshTerm"
icon = "📖"
help = "What this is, and the terms it ships under"
order = 10 # the question a stranger asks first
@staticmethod
def page() -> MarkdownDoc:
"""The *About MeshTerm* page."""
from ..ui.about import about_meshterm
return about_meshterm()
@register
class AboutAuthorTool(_AboutTool):
"""The person behind MeshTerm, on the mesh and off it."""
name = "about-author"
title = "About the author"
icon = "👤"
help = "The person behind MeshTerm, on and off the mesh"
order = 20 # who made the thing you just read about
@staticmethod
def page() -> MarkdownDoc:
"""The *About the author* page."""
from ..ui.about import about_author
return about_author()
@register
class JoinDiscordTool(_AboutTool):
"""The community server: one invite link, and a QR of it for a phone to read."""
name = "discord"
title = "Join Discord"
icon = "🔗"
help = "The invite link to the MeshTerm community server"
order = 25 # where everyone else is, once you know what this is and who made it
@staticmethod
def page() -> MarkdownDoc:
"""The *Join Discord* page."""
from ..ui.about import join_discord
return join_discord()
@register
class SupportProjectTool(_AboutTool):
"""What keeps MeshTerm going, and the ways — paid and unpaid — to help it along."""
name = "support"
title = "Support MeshTerm"
icon = "💰"
help = "What keeps MeshTerm going, and how to help"
order = 30 # the ask, and only once the pages before it have earned it
@staticmethod
def page() -> MarkdownDoc:
"""The *Support MeshTerm* page."""
from ..ui.about import support_project
return support_project()
def _written_page(name: str, title: str, doc: MarkdownDoc) -> Facts:
"""One prose page, stated once for both faces.
**Wrapped**, and that is the one deliberate exception to "no line wrapping". The rule
exists so a *record* is never split across two lines with its fields landing under the
wrong headings; a paragraph has no records and no headings, and left unwrapped these
four pages print as single six-hundred-cell lines no terminal can read. So the page is
drawn on a console :data:`~meshterm.ui.script.PAGE_WIDTH` cells wide — a fixed width,
not the terminal's, so the same paragraph reads the same piped, redirected, or shipped
as the document's ``text``.
``links`` is the one machine-actionable thing on a page of prose, and the one thing the
menu bothers to draw a QR of. ``text`` is the page's plain rendering rather than a fold
of the markdown into nested JSON: a structured markdown tree would be a second
rendering, not data, and nothing would consume it.
"""
from .. import __version__
from ..ui import fields
from ..ui.report import BARE, Column, Facts
text = _rendered_page(doc)
return Facts(
key="page",
fields=(
fields.word("page", "page"),
fields.word("title", "title"),
fields.word("version", "version"),
fields.word("text", "text"),
Column(key="links"),
),
values={
"page": name,
"title": title,
# Duplicated from the package on purpose: a page's own facts are part of its
# content, and this is the version its `{version}` placeholder resolved to.
"version": __version__,
"text": text,
"links": _links(text),
},
shape=BARE,
bare="text",
)
def _rendered_page(doc: MarkdownDoc) -> str:
"""The page as plain text, folded to the fixed page width."""
import io
from rich.console import Console
from ..ui import script
buffer = io.StringIO()
Console(
file=buffer,
width=script.PAGE_WIDTH,
color_system=None,
highlight=False,
emoji=False,
markup=False,
).print(doc)
return "\n".join(line.rstrip() for line in buffer.getvalue().splitlines()).strip("\n")
def _links(text: str) -> list[str]:
"""Every URL the page carries, in document order, de-duplicated.
Read off the rendered page rather than the markdown source, so a link written as a
reference and a link written inline both count once and both read as what the reader
sees. Trailing punctuation is not part of a URL and is trimmed.
"""
import re
seen: list[str] = []
for match in re.findall(r"https?://[^\s<>()\[\]]+", text):
url = match.rstrip(".,;:*_")
if url not in seen:
seen.append(url)
return seen