About MeshTerm: a sixth section, for the questions the five doings can't hold
The menu asks "What would you like to do?" and its five sections each name a doing. "What is this thing, and who made it" is not one, and had nowhere to go — so it gets the sixth section, the one that names a subject, sorted last where a reader who has run out of things to do finds it. Three pages, in the order a stranger asks: About MeshTerm what this is, where it came from, its terms About the author the person behind it, on the mesh and off it Support this project what keeps it going, and the ways to help Every page is scaffolding: the headings are the settled part, and each section carries one lowercase muted "placeholder — …" line where its prose will go. That is the app's own empty-state voice, so an unwritten page reads as deliberately unwritten rather than as a screen that failed to load, and filling one in means replacing its placeholder — the screen, the menu rows and the CLI faces all follow unchanged. What is not a placeholder is the version, the author and the copyright span: they come from the package itself, so the About page can never describe a build it isn't running inside. The pages act on nothing — no radio, no database, no device — so each opens straight to its screen with no prompt first, and each keeps a CLI face (meshterm about / about-author / support) printing the same page to a shell. Two new icons, both funnelled for the console: 📖 read/about becomes ¶, the page-of-prose mark, and 💰 support/donate becomes $. ScrollScreen earns a derived footer hint on the way through. A read-only body has only two keys to name, the pager and Esc, so passing "↑↓ PgUp/ PgDn scroll · Esc close" at every call site was both repetition and a lie on a body that fits — the standing rule is that a footer never advertises a key that would do nothing. Left unset, it now names the pager only while the content actually overflows, and takes its Esc verb from the surface: a floating view closes, a full screen goes back. Every existing caller passes its hint explicitly and is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
936ab10770
commit
3ccffd13c9
10 files changed
+515
-8
No files matched your search
@@ -144,7 +144,8 @@ Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now a
|
||||
⚙ parameter · `#` count · ▶ run · ⚡ explore/probe · ★ best/winner · ⭐ watch ·
|
||||
📤 send now · 📨 courier/queue · 💬 chat · 🔔 notify · 🔕 mute (notifications off) ·
|
||||
📱 QR · 🔗 link · ↻ re-read · ↕ reorder · ⇄ reverse (flip a path's direction) ·
|
||||
🏆 trophy case/record · ⌨ command line · 🚪 quit. Packet-class icons (feed/viewer
|
||||
🏆 trophy case/record · ⌨ command line · 📖 read/about · 💰 support/donate ·
|
||||
🚪 quit. Packet-class icons (feed/viewer
|
||||
lane): 📢 advert · 📊 telemetry · 📦 packet · 💬 message · ✅ ack. Raw payload
|
||||
classes (`PAYLOAD_ICONS`): 📻 channel text · 💽 channel data · 📩 direct message
|
||||
(overheard) · 📥 request · 📮 response · 🎭 anon request · 🧭 path · 🎯 trace ·
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""The About MeshTerm tools: the three pages under the menu's last section.
|
||||
|
||||
*About MeshTerm*, *About the author*, *Support this project* — in that order, which is
|
||||
the order a stranger asks the questions in: what is this, who made it, how do I help.
|
||||
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 support`` — printing the same page to the terminal, so the answers are
|
||||
reachable from a shell without launching the full-screen session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from rich.console import RenderableType
|
||||
|
||||
from ..context import AppContext
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
|
||||
class _AboutTool(Tool):
|
||||
"""Shared behaviour for the three 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 = "About MeshTerm"
|
||||
|
||||
@staticmethod
|
||||
def page() -> RenderableType:
|
||||
"""Build this page's Rich 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) -> Optional[dict[str, Any]]:
|
||||
"""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:
|
||||
"""Print the page — only reachable from the CLI (the menu opens the screen).
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
params: Unused beyond the injected ``_run_id``.
|
||||
|
||||
Returns:
|
||||
A :class:`ToolResult` naming the page that was shown.
|
||||
"""
|
||||
ctx.ui.show(self.page())
|
||||
return ToolResult(summary={"page": self.name})
|
||||
|
||||
|
||||
@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, where it came from, and the terms it ships under"
|
||||
order = 10 # the question a stranger asks first
|
||||
|
||||
@staticmethod
|
||||
def page() -> RenderableType:
|
||||
"""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 the mesh and off it"
|
||||
order = 20 # who made the thing you just read about
|
||||
|
||||
@staticmethod
|
||||
def page() -> RenderableType:
|
||||
"""The *About the author* page."""
|
||||
from ..ui.about import about_author
|
||||
|
||||
return about_author()
|
||||
|
||||
|
||||
@register
|
||||
class SupportProjectTool(_AboutTool):
|
||||
"""What keeps MeshTerm going, and the ways — paid and unpaid — to help it along."""
|
||||
|
||||
name = "support"
|
||||
title = "Support this project"
|
||||
icon = "💰"
|
||||
help = "What keeps MeshTerm going, and the ways to help it along"
|
||||
order = 30 # the ask, and only once the first two have earned it
|
||||
|
||||
@staticmethod
|
||||
def page() -> RenderableType:
|
||||
"""The *Support this project* page."""
|
||||
from ..ui.about import support_project
|
||||
|
||||
return support_project()
|
||||
+15
-3
@@ -155,9 +155,21 @@ _REGISTRY: dict[str, Tool] = {}
|
||||
#: pick a recipient from), then Watch (what the mesh is doing, what it did, what to be
|
||||
#: told about), Explore (where the nodes are, how they reach each other, and the boards
|
||||
#: that score the walking), then the two scopes a setting can belong to — This node, the
|
||||
#: radio in your hand, and Other nodes, someone else's over the mesh. Categories not
|
||||
#: listed here sort last, alphabetically, so a new category still appears.
|
||||
_CATEGORY_ORDER = ["Message", "Watch", "Explore", "This node", "Other nodes"]
|
||||
#: radio in your hand, and Other nodes, someone else's over the mesh.
|
||||
#:
|
||||
#: About MeshTerm closes the list and is the one section that names a *subject* instead:
|
||||
#: "what is this thing, and who made it" is a question none of the five doings can hold,
|
||||
#: and its pages act on nothing — so it sits last, where a reader who has run out of
|
||||
#: things to do finds it. Categories not listed here sort last, alphabetically, so a new
|
||||
#: category still appears.
|
||||
_CATEGORY_ORDER = [
|
||||
"Message",
|
||||
"Watch",
|
||||
"Explore",
|
||||
"This node",
|
||||
"Other nodes",
|
||||
"About MeshTerm",
|
||||
]
|
||||
|
||||
|
||||
def _category_rank(category: str) -> int:
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""The About MeshTerm pages: the app, the person who wrote it, and how to help it along.
|
||||
|
||||
Three read-only pages hung under the main menu's *About MeshTerm* section — the one
|
||||
section that names a subject rather than a doing, because "what is this thing, and who
|
||||
made it" is a question the other five can't answer. Each opens as a full screen, scrolls
|
||||
if the terminal is short, and is left with Esc; nothing here touches the radio or the
|
||||
database, so a page reads the same with no device attached at all.
|
||||
|
||||
Every page is **scaffolding** today. The headings are the real shape — that is the part
|
||||
worth settling first — and each carries one lowercase muted ``placeholder — …`` line
|
||||
where its prose will go, in the same empty-state voice the rest of the app uses, so an
|
||||
unwritten page reads as deliberately unwritten rather than as a screen that failed to
|
||||
load. Filling one in means replacing its placeholder with prose; the screen, the menu
|
||||
rows, and the CLI faces need no change to follow.
|
||||
|
||||
What is *not* a placeholder: the version, the author, and the copyright span all come
|
||||
from :mod:`meshterm` itself (:func:`~meshterm.copyright_notice`), so the About page can
|
||||
never drift from the package it describes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.padding import Padding
|
||||
from rich.text import Text
|
||||
|
||||
from .. import __author__, __version__, copyright_notice
|
||||
from .tui import ScrollScreen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
|
||||
|
||||
#: Cells every page's prose hangs at, under its flush section heading. Applied as
|
||||
#: :class:`~rich.padding.Padding` rather than a literal two spaces so a paragraph that
|
||||
#: wraps keeps its block — a continuation line falling back to column 0 would read as a
|
||||
#: new, unheaded thought (the app's hanging-indent rule, see ``CLAUDE.md``).
|
||||
_INDENT = 2
|
||||
|
||||
|
||||
def _heading(title: str, note: str = "") -> Text:
|
||||
"""A page section heading: accent title, optional muted ``·`` note.
|
||||
|
||||
Args:
|
||||
title: The section's name, sentence case.
|
||||
note: An optional aside chained after it in muted.
|
||||
|
||||
Returns:
|
||||
The heading line.
|
||||
"""
|
||||
text = Text(title, style="accent")
|
||||
if note:
|
||||
text.append(f" · {note}", style="muted")
|
||||
return text
|
||||
|
||||
|
||||
def _para(body: str, *, style: str = "") -> RenderableType:
|
||||
"""One paragraph of page prose, indented under its heading and wrapped as a block."""
|
||||
return Padding(Text(body, style=style), (0, 0, 0, _INDENT))
|
||||
|
||||
|
||||
def _placeholder(what: str) -> RenderableType:
|
||||
"""The stand-in for prose not written yet.
|
||||
|
||||
Written in the app's empty-state voice — lowercase, muted, an em-dash explanation,
|
||||
never parenthesized — so an unfinished section is legibly unfinished instead of
|
||||
looking like a rendering failure.
|
||||
|
||||
Args:
|
||||
what: What will eventually be written here.
|
||||
|
||||
Returns:
|
||||
The muted placeholder line, indented like the prose it stands in for.
|
||||
"""
|
||||
return _para(f"placeholder — {what}", style="muted")
|
||||
|
||||
|
||||
def about_meshterm() -> RenderableType:
|
||||
"""The *About MeshTerm* page: what the app is, where it came from, its terms.
|
||||
|
||||
The lead is live package data — the wordmark, the running version, and the canonical
|
||||
copyright line — so this page always describes the build it is running inside.
|
||||
"""
|
||||
lead = Text()
|
||||
lead.append("MeshTerm", style="brand")
|
||||
lead.append(f" v{__version__}", style="muted")
|
||||
return Group(
|
||||
lead,
|
||||
Text("a terminal companion for MeshCore LoRa mesh devices", style="muted"),
|
||||
Text(),
|
||||
_heading("What it is"),
|
||||
_placeholder("what MeshTerm does, and who it is for"),
|
||||
Text(),
|
||||
_heading("Where it came from"),
|
||||
_placeholder("why it was built, and what it grew out of"),
|
||||
Text(),
|
||||
_heading("Licence"),
|
||||
_placeholder("the terms this ships under"),
|
||||
Text(),
|
||||
Text(copyright_notice(), style="muted"),
|
||||
)
|
||||
|
||||
|
||||
def about_author() -> RenderableType:
|
||||
"""The *About the author* page: the person behind MeshTerm, on the mesh and off it."""
|
||||
return Group(
|
||||
Text(__author__),
|
||||
Text("wrote MeshTerm", style="muted"),
|
||||
Text(),
|
||||
_heading("Who"),
|
||||
_placeholder("a few lines about the person behind MeshTerm"),
|
||||
Text(),
|
||||
_heading("On the mesh"),
|
||||
_placeholder("call sign, home node, the mesh this was written on"),
|
||||
Text(),
|
||||
_heading("Elsewhere"),
|
||||
_placeholder("where to find the author off the mesh"),
|
||||
)
|
||||
|
||||
|
||||
def support_project() -> RenderableType:
|
||||
"""The *Support this project* page: what keeps it going, and how to chip in.
|
||||
|
||||
Deliberately two-sided — money is one way to help and not the only one, so the page
|
||||
keeps a section for the other kind rather than folding it into a donate link.
|
||||
"""
|
||||
return Group(
|
||||
_heading("Why it needs support"),
|
||||
_placeholder("what keeps the project going, and what it costs"),
|
||||
Text(),
|
||||
_heading("Chip in"),
|
||||
_placeholder("where to sponsor, donate, or buy a coffee"),
|
||||
Text(),
|
||||
_heading("Other ways to help"),
|
||||
_placeholder("report a bug, test on hardware, tell another operator"),
|
||||
)
|
||||
|
||||
|
||||
class AboutPage(ScrollScreen):
|
||||
"""One About page: a read-only body, drawn full-screen, left with Esc.
|
||||
|
||||
A thin name over the shared read-only screen — the pages carry no controls of their
|
||||
own, so everything they need (the pager, the derived footer hint that only names the
|
||||
pager when there *is* something to page, the PicoCalc F-key lane that dims on the same
|
||||
gate) already lives in :class:`~meshterm.ui.tui.screen.ScrollScreen`. Only the framing
|
||||
differs: these are full screens rather than floating views, so Esc reads *back*.
|
||||
"""
|
||||
|
||||
def __init__(self, title: str, body: RenderableType) -> None:
|
||||
"""Show ``body`` as the page under ``title``.
|
||||
|
||||
Args:
|
||||
title: The page's heading — sentence case, no icon (icons live in the menu
|
||||
rows that open these pages, never in a title).
|
||||
body: The page's content, from one of the builders above.
|
||||
"""
|
||||
super().__init__(body, title=title, floating=False)
|
||||
|
||||
|
||||
async def open_about_page(ctx: "AppContext", title: str, body: RenderableType) -> None:
|
||||
"""Open one About page full-screen and hold it until the reader backs out.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context (must be running the interactive TUI).
|
||||
title: The page's heading.
|
||||
body: The page's content.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called outside the interactive menu (no full-screen session).
|
||||
"""
|
||||
from .surface import TuiUi
|
||||
|
||||
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
|
||||
raise RuntimeError("the About pages are only available in the menu")
|
||||
await ctx.ui.session.run_screen(AboutPage(title, body))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AboutPage",
|
||||
"about_author",
|
||||
"about_meshterm",
|
||||
"open_about_page",
|
||||
"support_project",
|
||||
]
|
||||
@@ -103,7 +103,7 @@ def specimen_lines() -> list[RenderableType]:
|
||||
concepts = Text("concepts ")
|
||||
for icon in ("📡", "🕒", "🔄", "💾", "📂", "🔑", "🗑", "✎", "⚡", "⭐", "📤", "📨"):
|
||||
concepts.append(glyph(icon) + " ")
|
||||
for icon in ("🔔", "🔕", "📱", "🔗", "🏆", "⌨"):
|
||||
for icon in ("🔔", "🔕", "📱", "🔗", "🏆", "⌨", "📖", "💰"):
|
||||
concepts.append(glyph(icon) + " ")
|
||||
lines.append(concepts)
|
||||
|
||||
|
||||
@@ -555,6 +555,8 @@ _GLYPH_MAP: dict[str, str] = {
|
||||
"🔗": "&", # link — the joining glyph
|
||||
"🏆": "★", # trophy case — the best/winner star
|
||||
"⌨": "❯", # command line — the prompt cursor
|
||||
"📖": "¶", # read/about — a page of prose (the text mark, as ¶ is for a message)
|
||||
"💰": "$", # support/donate — the plainest possible money mark
|
||||
"🚪": "", # quit — no icon; the word carries it
|
||||
"🌍": "@", # map/world (globe family)
|
||||
"🕸": "∟", # mesh walk (route family)
|
||||
|
||||
@@ -512,7 +512,7 @@ class ScrollScreen(Screen):
|
||||
renderable: RenderableType,
|
||||
*,
|
||||
title: str = "",
|
||||
footer_hint: str = "↑↓ PgUp/PgDn scroll · Esc close",
|
||||
footer_hint: Optional[str] = None,
|
||||
floating: bool = True,
|
||||
) -> None:
|
||||
"""Wrap a renderable in a dismissable, scrollable screen.
|
||||
@@ -520,15 +520,32 @@ class ScrollScreen(Screen):
|
||||
Args:
|
||||
renderable: The Rich content to display.
|
||||
title: Heading for the screen/dialog.
|
||||
footer_hint: Footer key hint.
|
||||
footer_hint: Footer key hint, or ``None`` to derive it (see
|
||||
:attr:`footer_hint`).
|
||||
floating: Whether to draw as a centered dialog over the parent.
|
||||
"""
|
||||
super().__init__()
|
||||
self._renderable = renderable
|
||||
self.title = title
|
||||
self.footer_hint = footer_hint
|
||||
self._footer_hint = footer_hint
|
||||
self.floating = floating
|
||||
|
||||
@property
|
||||
def footer_hint(self) -> str: # type: ignore[override]
|
||||
"""The footer keys — whatever the caller passed, or the honest default.
|
||||
|
||||
A read-only body has only two keys to name, the pager and Esc, so the hint can
|
||||
be derived rather than spelled out at each call site — and derived, it keeps the
|
||||
standing rule that a footer never advertises a key that would do nothing: the
|
||||
scroll atom appears only while the body is actually taller than its viewport
|
||||
(:attr:`Screen.content_overflows`). The Esc verb follows the surface the standards
|
||||
assign it: a floating read-only view *closes*, a full screen goes *back*.
|
||||
"""
|
||||
if self._footer_hint is not None:
|
||||
return self._footer_hint
|
||||
verb = "Esc close" if self.floating else "Esc back"
|
||||
return f"↑↓ PgUp/PgDn scroll · {verb}" if self.content_overflows else verb
|
||||
|
||||
def render_body(self, width: int) -> list[str]:
|
||||
"""Render the wrapped content to ANSI lines and remember the total count."""
|
||||
lines = render_lines(self._renderable, width)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""About MeshTerm tests: the three pages, their placeholders, and their menu section.
|
||||
|
||||
The pages carry no state and no controls, so what is worth pinning is what they *say*
|
||||
and how they are framed: the live package facts they must never drift from, the
|
||||
empty-state voice their unwritten sections speak in, the derived footer that only names
|
||||
the pager when there is something to page, and the section they hang under in the main
|
||||
menu. Width discipline on both platforms is the gallery's job (``test_gallery``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from meshterm import __author__, __version__, copyright_notice
|
||||
from meshterm.ui.about import (
|
||||
AboutPage,
|
||||
about_author,
|
||||
about_meshterm,
|
||||
support_project,
|
||||
)
|
||||
|
||||
from tests.conftest import plain
|
||||
|
||||
|
||||
def _page(builder, *, viewport: int = 40, width: int = 72) -> str:
|
||||
"""Render one page as a reader sees it, with the frame's viewport already recorded."""
|
||||
screen = AboutPage("About MeshTerm", builder())
|
||||
screen.note_viewport(viewport)
|
||||
lines = screen.render_body(width)
|
||||
screen.note_metrics(len(lines), viewport)
|
||||
return plain(lines)
|
||||
|
||||
|
||||
def test_about_meshterm_leads_with_live_package_facts() -> None:
|
||||
"""The version and copyright come from the package, so the page can't describe a
|
||||
build it isn't running inside."""
|
||||
text = _page(about_meshterm)
|
||||
|
||||
assert f"MeshTerm v{__version__}" in text
|
||||
assert copyright_notice() in text
|
||||
|
||||
|
||||
def test_about_author_names_the_author_from_the_package() -> None:
|
||||
"""The author line is package metadata too — one spelling, one place to change it."""
|
||||
assert __author__ in _page(about_author)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("builder", "headings"),
|
||||
[
|
||||
(about_meshterm, ("What it is", "Where it came from", "Licence")),
|
||||
(about_author, ("Who", "On the mesh", "Elsewhere")),
|
||||
(
|
||||
support_project,
|
||||
("Why it needs support", "Chip in", "Other ways to help"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_each_page_shows_its_sections(builder, headings) -> None: # noqa: ANN001
|
||||
"""The headings are the settled part of a scaffolded page — they render, in order."""
|
||||
text = _page(builder)
|
||||
|
||||
at = [text.index(heading) for heading in headings]
|
||||
assert at == sorted(at), f"{headings} are out of order in the rendered page"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("builder", [about_meshterm, about_author, support_project])
|
||||
def test_unwritten_sections_speak_the_empty_state_voice(builder) -> None: # noqa: ANN001
|
||||
"""Placeholders read as deliberately unwritten: lowercase, em-dashed, unparenthesized.
|
||||
|
||||
The app's empty-state rule (see the UX standards in ``CLAUDE.md``) is what keeps a
|
||||
scaffolded page from looking like a screen that failed to load.
|
||||
"""
|
||||
text = _page(builder)
|
||||
placeholders = [line for line in text.splitlines() if "placeholder" in line]
|
||||
|
||||
assert placeholders, "a scaffolded page must say so"
|
||||
for line in placeholders:
|
||||
body = line.strip()
|
||||
assert body.startswith("placeholder — "), body
|
||||
assert "(" not in body and ")" not in body, body
|
||||
|
||||
|
||||
def test_prose_wraps_into_its_own_indented_block() -> None:
|
||||
"""A wrapped placeholder hangs under its heading rather than falling to column 0."""
|
||||
screen = AboutPage("Support this project", support_project())
|
||||
screen.note_viewport(40)
|
||||
# Narrow enough to force every placeholder to wrap at least once.
|
||||
lines = plain(screen.render_body(28)).splitlines()
|
||||
|
||||
wrapped = [line for line in lines if line.strip() and not line.startswith(" ")]
|
||||
assert all(
|
||||
"placeholder" not in line for line in wrapped
|
||||
), "prose escaped its indent block"
|
||||
|
||||
|
||||
def test_footer_names_the_pager_only_when_there_is_something_to_page() -> None:
|
||||
"""The derived hint obeys the rule that a footer never advertises a dead key."""
|
||||
screen = AboutPage("About MeshTerm", about_meshterm())
|
||||
|
||||
screen.note_metrics(12, 40) # the whole page fits — nothing to scroll
|
||||
assert screen.footer_hint == "Esc back"
|
||||
|
||||
screen.note_metrics(60, 20) # taller than the viewport — the pager is live
|
||||
assert screen.footer_hint == "↑↓ PgUp/PgDn scroll · Esc back"
|
||||
|
||||
|
||||
def test_page_is_a_full_screen_not_a_floating_view() -> None:
|
||||
"""These are pages, so Esc reads *back*; a floating read-only view would say *close*."""
|
||||
assert AboutPage("About MeshTerm", about_meshterm()).floating is False
|
||||
|
||||
|
||||
def test_menu_rows_carry_the_icon_and_the_titles_do_not() -> None:
|
||||
"""Each page has a menu icon; none of it leaks into the title the screen draws."""
|
||||
from meshterm.tools import get_tool
|
||||
|
||||
for name in ("about", "about-author", "support"):
|
||||
tool = get_tool(name)
|
||||
assert tool is not None
|
||||
assert tool.icon, f"{name} has no menu icon"
|
||||
assert tool.icon not in tool.title
|
||||
assert tool.title.isascii(), f"{name}'s title carries a non-ascii glyph"
|
||||
|
||||
|
||||
def test_every_page_icon_has_a_picocalc_glyph() -> None:
|
||||
"""No emoji reaches the console: each icon folds to a font character (CLAUDE.md)."""
|
||||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||||
from meshterm.tools import get_tool
|
||||
from meshterm.ui.fontset import FONT_CODEPOINTS
|
||||
from meshterm.ui.theme import glyph
|
||||
|
||||
set_platform(PICOCALC)
|
||||
try:
|
||||
for name in ("about", "about-author", "support"):
|
||||
tool = get_tool(name)
|
||||
assert tool is not None
|
||||
compact = glyph(tool.icon)
|
||||
assert compact and compact != tool.icon, f"{name}'s icon is unmapped"
|
||||
assert all(ord(ch) in FONT_CODEPOINTS for ch in compact), compact
|
||||
finally:
|
||||
set_platform(REGULAR)
|
||||
@@ -54,6 +54,7 @@ from meshterm.services.message_paths import Arrival
|
||||
from meshterm.services.monitor_service import ACTIVITY_BUCKETS
|
||||
from meshterm.services.records import CATEGORY_BY_ID
|
||||
from meshterm.services.topology import MeshTopology, build_topology
|
||||
from meshterm.ui.about import AboutPage, about_author, about_meshterm, support_project
|
||||
from meshterm.ui.chat import ChatScreen
|
||||
from meshterm.ui.contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING
|
||||
from meshterm.ui.contacts_screen import ContactsScreen
|
||||
@@ -396,6 +397,18 @@ def _tx_sweep(cols: int, rows: int) -> Screen:
|
||||
return screen
|
||||
|
||||
|
||||
def _about_meshterm(cols: int, rows: int) -> Screen:
|
||||
return AboutPage("About MeshTerm", about_meshterm())
|
||||
|
||||
|
||||
def _about_author(cols: int, rows: int) -> Screen:
|
||||
return AboutPage("About the author", about_author())
|
||||
|
||||
|
||||
def _support_project(cols: int, rows: int) -> Screen:
|
||||
return AboutPage("Support this project", support_project())
|
||||
|
||||
|
||||
_ENTRIES: list[_Entry] = [
|
||||
_Entry("dashboard", _dashboard),
|
||||
_Entry("contacts", _contacts, has_exit_group=True),
|
||||
@@ -413,6 +426,9 @@ _ENTRIES: list[_Entry] = [
|
||||
_Entry("packet_viewer", _packet_viewer),
|
||||
_Entry("trace", _trace),
|
||||
_Entry("tx_sweep", _tx_sweep),
|
||||
_Entry("about_meshterm", _about_meshterm),
|
||||
_Entry("about_author", _about_author),
|
||||
_Entry("support_project", _support_project),
|
||||
]
|
||||
|
||||
#: (platform, cols, rows) combos every entry above renders under. PicoCalc gets both its
|
||||
|
||||
@@ -151,6 +151,10 @@ def test_main_menu_sections_answer_the_menus_own_question() -> None:
|
||||
recorded history with the live views it is the past tense of, the two walks with the
|
||||
topology they walk over, and a setting lands under the scope it changes — this radio,
|
||||
or someone else's over the mesh.
|
||||
|
||||
About MeshTerm is the deliberate exception, and the reason it sorts last: its pages
|
||||
do nothing at all, so it names a subject and stays out of the workflow the five
|
||||
doings above it describe.
|
||||
"""
|
||||
from meshterm.tools import load_all_tools
|
||||
from meshterm.tools.base import _CATEGORY_ORDER, all_tools
|
||||
@@ -168,6 +172,7 @@ def test_main_menu_sections_answer_the_menus_own_question() -> None:
|
||||
"Explore": ["map", "walk", "trace", "trace-path", "records"],
|
||||
"This node": ["info", "config", "device-actions", "advert"],
|
||||
"Other nodes": ["repeater-admin", "tx-optimize"],
|
||||
"About MeshTerm": ["about", "about-author", "support"],
|
||||
}
|
||||
# No section is so big it stops being a grouping (the old Mesh bucket held seven of
|
||||
# nineteen rows), and none is a bucket of one.
|
||||
|
||||
Reference in new issue
Block a user