diff --git a/CLAUDE.md b/CLAUDE.md index ea1598f..d1601f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 · diff --git a/meshterm/tools/about.py b/meshterm/tools/about.py new file mode 100644 index 0000000..092a97e --- /dev/null +++ b/meshterm/tools/about.py @@ -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() diff --git a/meshterm/tools/base.py b/meshterm/tools/base.py index 2b5a360..e3157ce 100644 --- a/meshterm/tools/base.py +++ b/meshterm/tools/base.py @@ -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: diff --git a/meshterm/ui/about.py b/meshterm/ui/about.py new file mode 100644 index 0000000..1dc2bea --- /dev/null +++ b/meshterm/ui/about.py @@ -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", +] diff --git a/meshterm/ui/specimen.py b/meshterm/ui/specimen.py index be95762..5ab5ce7 100644 --- a/meshterm/ui/specimen.py +++ b/meshterm/ui/specimen.py @@ -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) diff --git a/meshterm/ui/theme.py b/meshterm/ui/theme.py index 78443d8..53756b9 100644 --- a/meshterm/ui/theme.py +++ b/meshterm/ui/theme.py @@ -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) diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index 461b098..c6e34bc 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -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) diff --git a/tests/test_about.py b/tests/test_about.py new file mode 100644 index 0000000..2bfc50d --- /dev/null +++ b/tests/test_about.py @@ -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) diff --git a/tests/test_gallery.py b/tests/test_gallery.py index 9b761f9..020dc90 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -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 diff --git a/tests/test_menus.py b/tests/test_menus.py index 3e2aeca..7760630 100644 --- a/tests/test_menus.py +++ b/tests/test_menus.py @@ -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.