About pages: prose the app writes, not rows it composes
The three About pages were scaffolding built out of Text() calls, which
made writing them a Python edit and capped what they could ever say. They
are markdown now — meshterm/assets/pages/*.md, drawn by ui/markdown.py
into the app's own language rather than markdown's: # the page's name in
brand, ## a section in the body accent (with the muted · aside), ###
indented with its prose, paragraphs and lists hanging as blocks, quotes
and fences behind a rail that survives wrapping, tables through Rich, and
links that say where they go — nothing is clickable on a framebuffer
console. Two paragraphs are page frame rather than body and sit flush and
muted: the standfirst under the title, the colophon under the closing
rule, which is where the copyright lands.
The pages stay scaffolded on purpose — the placeholders are unchanged —
but filling one in is now editing its .md, and the live package facts
arrive as {version}/{author}/{copyright} so a page still can't drift from
the build it describes.
Being a document earns the screen two things a block never had: its ##
headings are landmarks, so a heading pins while its own prose scrolls
under it and ^PgUp/^PgDn step by them, and the console lane claims
Sect ↑/Sect ↓ on the free F1/F2 pair once a page has more than one
section — dimmed while the whole page fits, since the lane never
advertises a dead key.
Parsing is markdown-it-py (already under Rich, now declared): a real
CommonMark parser, so an author writes ordinary markdown. The md.* styles
are defined in both themes; the new test renders the whole vocabulary at
53 cells and asserts it stays inside the console font and the 16 slots.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3ccffd13c9
commit
0314ed7702
11 files changed
+1100
-117
No files matched your search
@@ -24,6 +24,8 @@ them instead of hand-rolling:
|
||||
|
||||
- `ui/menus.py` — `back_rows`, `exit_rows`, `menu_rows`, `lane_row`, `section_heading`,
|
||||
`confirm_discard`, `fit_cells`.
|
||||
- `ui/markdown.py` — `render_markdown` (THE prose renderer: a page of writing, drawn in
|
||||
the language below).
|
||||
- `ui/widgets.py` — `highlighted_hash` (THE key widget — shows a key, lights its hash),
|
||||
`format_ago` (prose ages),
|
||||
`_format_age` (column ages), `channel_glyph`, `_NODE_GLYPHS`, heard-age heat colouring.
|
||||
@@ -80,6 +82,21 @@ Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now a
|
||||
(`Platform.footer_fkeys`), never both: the query must be visible on every platform, and
|
||||
twice on none.
|
||||
|
||||
### Written pages
|
||||
|
||||
A screen that is *prose* (the three About pages) is **markdown**, not composed rows: the
|
||||
text lives in `meshterm/assets/pages/*.md` and `ui/markdown.py` draws it in the language
|
||||
above — `#` the page's own name in brand, `##` a section in the body accent (`## Title ·
|
||||
note` gives it the muted aside), `###` a sub-heading indented with its prose, paragraphs
|
||||
and lists hanging as blocks, quotes and fences behind a rail that survives wrapping,
|
||||
links showing where they go (nothing is clickable on a framebuffer console). Two
|
||||
paragraphs are page frame rather than body and sit flush and muted: the **standfirst**
|
||||
under the `#` title and the **colophon**, the last paragraph under a closing `---`. Live
|
||||
package facts arrive as `{version}` / `{author}` / `{copyright}` placeholders, filled as
|
||||
the page opens. The `##` headings are the page's landmarks — they pin and the section
|
||||
jumps step by them — so a written page earns `Sect ↑`/`Sect ↓` on the F-key lane exactly
|
||||
as a grouped list does. Filling a page in is editing its `.md`; no Python follows.
|
||||
|
||||
### Titles
|
||||
|
||||
- Sentence case, always ("Trace — YUL-Poly", "Nodes", "Path width", "Admin login").
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# MeshTerm *v{version}*
|
||||
|
||||
**MeshTerm** for **MeshCore**.
|
||||
|
||||
## What it is
|
||||
|
||||
**MeshTerm** is a full-featured **MeshCore** client for the terminal. It talks to companion devices running the stock firmware over serial, Bluetooth, or TCP.
|
||||
|
||||
Everything MeshTerm can do thru its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks!
|
||||
|
||||
Everything the radio overhears is recorded to a local database from the moment it's turned on — adverts, telemetry, signal quality, positions, etc. Features like maps, dashboard, and route graphs read back from it, so the longer it runs, the more it know about the mesh!
|
||||
|
||||
## Where it came from
|
||||
|
||||
The project had humble beginnings as a set of scripts in early 2026. As time passed, quality-of-life features were added, and at some point, the logical next step was to build a complete MeshCore client with all the features I felt were missing in other offerings.
|
||||
|
||||
I wanted something I could run from my computer. I wanted it to run on Windows, MacOS, and Linux, and I wanted it to work with any MeshCore companion device, via Bluetooth, USB, or TCP. I wanted a mouseless keyboard-driven MeshCore experience. I wanted something retro-futuristic. I wanted to push the envelope of what can be done on a text terminal, while keeping system requirements low so it could run on an older computer or a lower-end System on a Chip *(SoC)*.
|
||||
|
||||
## Platform-specific developments
|
||||
|
||||
In addition to generic Terminal setup, I wanted it to run on two devices I had lying around on my nerdcave.
|
||||
|
||||
### Clockwork uConsole
|
||||
MeshTerm is compatible with the **HackerGadgets AIO board v1**. The **AIO** board has just a raw LoRa radio, meaning it can't run MeshCore firmware. MeshTerm includes a script to run the mesh node in software, and expose it using a TCP port which MeshTerm can connect to.
|
||||
|
||||
MeshTerm feels right at home on the **uConsole**. Running MeshTerm on it makes it feel like a piece of gear straight out of a William Gibson novel.
|
||||
|
||||
### Clockwork PicoCalc
|
||||
Getting MeshTerm to run on the **PicoCalc** is a bit more involved, but totally worth it:
|
||||
- Swap out the **Raspberry Pi Pico** for a **Luckfox Lyra** to run **Calculinux**.
|
||||
- Add a compatible 3.3v USB wifi dongle (e.g.: **TP-Link TL-WN725N N150 Nano**) to the **Lyra**'s internal USB port.
|
||||
- Add a LoRa device. Here's a few options:
|
||||
- Connect a companion via USB-C;
|
||||
- add **Seeed Studio XIAO nRF52840 + Wio-SX1262** internally, soldered to GPIO connectors' legs.
|
||||
- In order to use the XIAO nRF52840 via GPIO, we do need to use a modified firmware. A **PR** *https://github.com/meshcore-dev/MeshCore/pull/3191* has been submitted to MeshCore, but it might take a while for it to go through.
|
||||
|
||||
The UX is a bit different on the the PicoCalc's 16-color 53-column screen layout, but it's been tailored to make good use of the available space and the PicoCalc's F-keys. The result is, in my humble opinion, the best MeshCore experience you can get on a portable device. With two 18650 batteries, it'll run all day.
|
||||
|
||||
## What the future holds
|
||||
|
||||
MeshTerm will evolve over time, it is a work in progress.
|
||||
|
||||
I've build MeshTerm for myself — I find that's when I produce my best work — but I sincerely hope it will be appreciated by others.
|
||||
|
||||
If you'd like a specific feature, pitch your idea on the Discord!
|
||||
|
||||
## Licence
|
||||
|
||||
MeshTerm ships under the **Apache License, Version 2.0** — free to use,
|
||||
change, and redistribute, commercially or otherwise, as long as the
|
||||
LICENSE and NOTICE files travel with it. The full terms:
|
||||
**apache.org/licenses/LICENSE-2.0** *https://www.apache.org/licenses/LICENSE-2.0*.
|
||||
|
||||
The licence covers the code, not the name: **MeshTerm and its logo are
|
||||
trademarks the author keeps**, so a fork goes out under a name of its own.
|
||||
The donate link in this app supports this project and its original
|
||||
author — a fork that keeps soliciting through it, without pointing it at
|
||||
itself, isn't affiliated with MeshTerm.
|
||||
|
||||
Built on **MeshCore** *https://meshcore.io/* — its firmware and companion
|
||||
library — with Rich, prompt_toolkit, Typer, bleak, pyserial, pycryptodome
|
||||
and Segno, each under its own terms.
|
||||
|
||||
Map data **© OpenStreetMap contributors**, served as vector tiles by
|
||||
**OpenFreeMap** *https://openfreemap.org/*, used under the
|
||||
**ODbL** *https://www.openstreetmap.org/copyright*.
|
||||
|
||||
---
|
||||
|
||||
{copyright}
|
||||
@@ -0,0 +1,20 @@
|
||||
## Who am I IRL
|
||||
I'm Jean-Pierre Martineau. I live in Montréal, Québec, Canada.
|
||||
|
||||
Data expert by day and tinkerer by night, I've spend my career designing solutions and writing code for various industries, from video-games to public transit. I've always had an unquenchable curiosity that drove me to pursue personal projects during my free time. MeshCore has been the focal point of my nights and weekends as of lately.
|
||||
|
||||
## Who am I on the mesh
|
||||
|
||||
I operate **YUL-Cartierville** and **YUL-Upper-Salaberry** repeaters in Montréal. My MeshCore handle is **Johnputer**. I am fluent in French and English. If you see me online, feel free to say hi!
|
||||
|
||||
## On the subject of AI assisted development
|
||||
|
||||
As a computer scientist, I find AI to be a powerful amplifier, not a substitute—of one's capabilities. For me, it's been a vector of productivity, allowing me to grasp something which would have been out of reach. I can spend my time ideating and let the machine take care of the nitty-gritty. Don't be fooled, sculpting MeshTerm into its current form still took a *lot* of work. Every nook and cranny of the app has been designed, tested, and iterated by me. Hundreds of hours of my own time, augmented by what I learned throughout my sofware developer career.
|
||||
|
||||
MeshTerm would *not* exist without the use of AI tools.
|
||||
|
||||
One detail: the about pages were hand-written by me, and the ANSI art splash screen logo was done by hand — no AI — using **Moebius**.
|
||||
|
||||
## How to reach me
|
||||
|
||||
You can reach me at *johnputer@meshterm.net*.
|
||||
@@ -0,0 +1,50 @@
|
||||
## Why it needs support
|
||||
|
||||
**MeshTerm** is free.
|
||||
|
||||
The project is a labour of love. I've poured hundreds of hours of my free time into it. Evenings, late nights, weekends, even some of my vacation time!
|
||||
|
||||
As I mentionned in the about pages, this project relies on AI tools to accelerate development. Without them, MeshTerm would absolutely not exist. Subscriptions to these AI tools are a recurring expense!
|
||||
|
||||
Routers, companion devices, handhelds, antennas, and batteries aren't super expensive individually, but it all adds up quickly!
|
||||
|
||||
If you want pitch in and contribute financially, you're more that welcome to do so. Any help will be greatly appreciated and put to good use.
|
||||
|
||||
## Chip in
|
||||
|
||||
### Buy me a coffee
|
||||
|
||||
- *https://buymeacoffee.com/johnputer*
|
||||
|
||||
```qr
|
||||
https://buymeacoffee.com/johnputer
|
||||
```
|
||||
|
||||
### Donate via Paypal
|
||||
|
||||
- *https://tinyurl.com/SupportMeshTerm*
|
||||
|
||||
```qr
|
||||
https://tinyurl.com/SupportMeshTerm
|
||||
```
|
||||
|
||||
### Become a patron
|
||||
|
||||
- *https://www.patreon.com/c/MeshTerm*
|
||||
|
||||
```qr
|
||||
https://www.patreon.com/c/MeshTerm
|
||||
```
|
||||
|
||||
## Other ways to help
|
||||
|
||||
- Spread the word and let others know how awesome **MeshTerm** is!
|
||||
- Join the Discord community!
|
||||
- Give this project a Star **⭐** on **GitHub**!
|
||||
- Open an issue on **GitHub** when you encounter a bug or problem!
|
||||
|
||||
*https://github.com/jpmartineau/MeshTerm*
|
||||
|
||||
```qr
|
||||
https://github.com/jpmartineau/MeshTerm
|
||||
```
|
||||
+12
-8
@@ -9,17 +9,21 @@ 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.
|
||||
|
||||
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 Any, Optional
|
||||
|
||||
from rich.console import RenderableType
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from ..context import AppContext
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..ui.markdown import MarkdownDoc
|
||||
|
||||
|
||||
class _AboutTool(Tool):
|
||||
"""Shared behaviour for the three About pages: open the screen, or print the page.
|
||||
@@ -32,8 +36,8 @@ class _AboutTool(Tool):
|
||||
category = "About MeshTerm"
|
||||
|
||||
@staticmethod
|
||||
def page() -> RenderableType:
|
||||
"""Build this page's Rich content.
|
||||
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
|
||||
@@ -84,7 +88,7 @@ class AboutMeshTermTool(_AboutTool):
|
||||
order = 10 # the question a stranger asks first
|
||||
|
||||
@staticmethod
|
||||
def page() -> RenderableType:
|
||||
def page() -> "MarkdownDoc":
|
||||
"""The *About MeshTerm* page."""
|
||||
from ..ui.about import about_meshterm
|
||||
|
||||
@@ -102,7 +106,7 @@ class AboutAuthorTool(_AboutTool):
|
||||
order = 20 # who made the thing you just read about
|
||||
|
||||
@staticmethod
|
||||
def page() -> RenderableType:
|
||||
def page() -> "MarkdownDoc":
|
||||
"""The *About the author* page."""
|
||||
from ..ui.about import about_author
|
||||
|
||||
@@ -120,7 +124,7 @@ class SupportProjectTool(_AboutTool):
|
||||
order = 30 # the ask, and only once the first two have earned it
|
||||
|
||||
@staticmethod
|
||||
def page() -> RenderableType:
|
||||
def page() -> "MarkdownDoc":
|
||||
"""The *Support this project* page."""
|
||||
from ..ui.about import support_project
|
||||
|
||||
|
||||
+92
-107
@@ -6,166 +6,151 @@ made it" is a question the other five can't answer. Each opens as a full screen,
|
||||
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.
|
||||
|
||||
Unlike every other screen in the app, these are **written** rather than composed: their
|
||||
content is markdown, living beside the wordmark in ``meshterm/assets/pages``, drawn
|
||||
through :mod:`meshterm.ui.markdown` in the app's own visual language (see that module
|
||||
for what each construct becomes). Filling a page in means editing its ``.md`` file —
|
||||
no screen, menu row, or CLI face has to follow — and everything markdown offers is
|
||||
available while doing it: sections, sub-headings, lists, quotes, links, tables.
|
||||
|
||||
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.
|
||||
load.
|
||||
|
||||
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.
|
||||
What is *not* written into those files: the version, the author, and the copyright span.
|
||||
Each page names them with a ``{version}`` / ``{author}`` / ``{copyright}`` placeholder
|
||||
that is filled from :mod:`meshterm` itself (:func:`~meshterm.copyright_notice`) as the
|
||||
page is opened, so the About page can never drift from the package it describes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
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 .markdown import MarkdownDoc, render_markdown
|
||||
from .tui import ScrollScreen
|
||||
from .tui.render import render_lines
|
||||
|
||||
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
|
||||
#: Where the written pages live — beside the wordmark, in the package's assets, so they
|
||||
#: ship with the wheel and can be edited without touching Python.
|
||||
_PAGES = Path(__file__).resolve().parent.parent / "assets" / "pages"
|
||||
|
||||
|
||||
def _heading(title: str, note: str = "") -> Text:
|
||||
"""A page section heading: accent title, optional muted ``·`` note.
|
||||
def _page(name: str) -> MarkdownDoc:
|
||||
"""Load ``assets/pages/<name>.md``, fill in the live package facts, and render it.
|
||||
|
||||
Args:
|
||||
title: The section's name, sentence case.
|
||||
note: An optional aside chained after it in muted.
|
||||
name: The page file's stem.
|
||||
|
||||
Returns:
|
||||
The heading line.
|
||||
The rendered page.
|
||||
"""
|
||||
text = Text(title, style="accent")
|
||||
if note:
|
||||
text.append(f" · {note}", style="muted")
|
||||
return text
|
||||
source = (_PAGES / f"{name}.md").read_text(encoding="utf-8")
|
||||
for token, value in (
|
||||
("{version}", __version__),
|
||||
("{author}", __author__),
|
||||
("{copyright}", copyright_notice()),
|
||||
):
|
||||
source = source.replace(token, value)
|
||||
return render_markdown(source)
|
||||
|
||||
|
||||
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 about_meshterm() -> MarkdownDoc:
|
||||
"""The *About MeshTerm* page: what the app is, where it came from, its terms."""
|
||||
return _page("about")
|
||||
|
||||
|
||||
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:
|
||||
def about_author() -> MarkdownDoc:
|
||||
"""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"),
|
||||
)
|
||||
return _page("author")
|
||||
|
||||
|
||||
def support_project() -> RenderableType:
|
||||
def support_project() -> MarkdownDoc:
|
||||
"""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"),
|
||||
)
|
||||
return _page("support")
|
||||
|
||||
|
||||
class AboutPage(ScrollScreen):
|
||||
"""One About page: a read-only body, drawn full-screen, left with Esc.
|
||||
"""One written 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*.
|
||||
Nearly all of it is the shared read-only screen — 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 — with two things added, both of which come from the body
|
||||
being a *document* rather than a block:
|
||||
|
||||
* It renders block by block and records where each ``##`` section starts, so a
|
||||
heading pins to the top row while its prose scrolls under it and ``^PgUp``/``^PgDn``
|
||||
step section by section (the same landmark machinery a grouped select list uses).
|
||||
* Its lane claims the left-hand ``Sect ↑`` / ``Sect ↓`` pair once the page has more
|
||||
than one section, because on the console the lane is the only place a chord can
|
||||
advertise itself.
|
||||
|
||||
Only the framing differs from a result window: 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``.
|
||||
def __init__(self, title: str, doc: MarkdownDoc) -> None:
|
||||
"""Show ``doc`` 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.
|
||||
doc: The rendered page, from one of the builders above.
|
||||
"""
|
||||
super().__init__(body, title=title, floating=False)
|
||||
super().__init__(doc, title=title, floating=False)
|
||||
self._doc = doc
|
||||
|
||||
@property
|
||||
def fkey_lane(self):
|
||||
"""The shared pager lane, plus the section step on a page that has sections.
|
||||
|
||||
A left-hand pair rises toward F1, so ``Sect ↑`` sits outside ``Sect ↓`` — the
|
||||
order a grouped select list uses on the same two keys, dispatching the same two
|
||||
actions. Both are lit only while the page actually scrolls: with the whole page
|
||||
on screen there is no section to step *to*.
|
||||
"""
|
||||
from .tui.fkeys import FPair, default_lane
|
||||
|
||||
lane = list(default_lane(nav=self.content_overflows))
|
||||
if len(self._doc.sections) >= 2:
|
||||
lane[0] = FPair("Sect ↑", "ctrl_pageup", enabled=self.content_overflows)
|
||||
lane[1] = FPair("Sect ↓", "ctrl_pagedown", enabled=self.content_overflows)
|
||||
return lane
|
||||
|
||||
def render_body(self, width: int) -> list[str]:
|
||||
"""Render the page a block at a time, noting where its section headings land."""
|
||||
lines: list[str] = []
|
||||
self._sticky_headers = []
|
||||
for block in self._doc.blocks:
|
||||
# A blank separator renders to no lines at all on its own (it is one empty
|
||||
# Text, and an empty render has nothing to split); inside the document's
|
||||
# Group it would be the empty line it is meant to be, so it stays one here.
|
||||
rendered = render_lines(block.renderable, width) or [""]
|
||||
if block.heading:
|
||||
self._sticky_headers.append((len(lines), rendered))
|
||||
lines.extend(rendered)
|
||||
self._scroll_total = max(1, len(lines))
|
||||
return lines
|
||||
|
||||
|
||||
async def open_about_page(ctx: "AppContext", title: str, body: RenderableType) -> None:
|
||||
async def open_about_page(ctx: "AppContext", title: str, doc: MarkdownDoc) -> 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.
|
||||
doc: The page's content.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called outside the interactive menu (no full-screen session).
|
||||
@@ -174,7 +159,7 @@ async def open_about_page(ctx: "AppContext", title: str, body: RenderableType) -
|
||||
|
||||
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))
|
||||
await ctx.ui.session.run_screen(AboutPage(title, doc))
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Markdown drawn in MeshTerm's own hand — the renderer behind the About pages.
|
||||
|
||||
A page of prose is the one kind of screen the app can't build out of rows, lanes and
|
||||
marks: it is paragraphs. So the prose lives in ``.md`` files under ``meshterm/assets``
|
||||
and this module turns them into Rich renderables — *not* by printing markdown's own
|
||||
conventions (boxed headings, ``#`` marks left in the text, a syntax-highlighted slab for
|
||||
every fence), but by mapping each construct onto the visual language the rest of the app
|
||||
already speaks, so a written page sits beside a screenful of contacts without looking
|
||||
imported from somewhere else:
|
||||
|
||||
* ``#`` is the page's own name in the brand hue — the wordmark, the author, whatever the
|
||||
page *is*; ``##`` is a section, drawn in the same accent every body heading uses, and
|
||||
``###`` a sub-heading inside one. A heading may carry the standard muted aside by
|
||||
writing ``## Title · note``, and any emphasis inside a heading is its qualifier (that
|
||||
is how ``# MeshTerm *v0.1.0*`` prints the version muted beside the name).
|
||||
* Prose hangs at :data:`INDENT` cells under its flush heading, as a block — a wrapped
|
||||
line lands under the paragraph it belongs to, never back at column 0 where it would
|
||||
read as a new, unheaded thought (``CLAUDE.md``'s hanging-indent rule).
|
||||
* Two paragraphs are page *frame* rather than body, and both sit flush and muted: the
|
||||
**standfirst** directly under the ``#`` title (MeshTerm's one-line description, the
|
||||
author's "wrote MeshTerm") and the **colophon** — the document's last paragraph, below
|
||||
a ``---`` rule — where the copyright goes.
|
||||
* Lists hang on a bullet in a two-column grid, so a wrapped item aligns under its own
|
||||
text and a nested list steps in under its parent. Quotes and fenced code run behind a
|
||||
rail down the left, drawn on *every* line including the wrapped ones.
|
||||
* Emphasis, code, links and struck-out runs take the ``md.*`` styles, which — like every
|
||||
style name here — mean something on both platforms (see :mod:`meshterm.ui.theme`). A
|
||||
link shows its target after the text unless the text already says it; nothing here
|
||||
emits an OSC hyperlink, because half the app's readers are a framebuffer console.
|
||||
|
||||
Parsing is :mod:`markdown_it` (CommonMark, plus tables and strikethrough) — a real
|
||||
parser rather than a pile of regexes, so an author can write ordinary markdown and get
|
||||
what they meant. Everything below walks its tree; nothing else in the app needs to know
|
||||
markdown exists.
|
||||
|
||||
The output is a :class:`MarkdownDoc`: a plain Rich renderable *and* a list of top-level
|
||||
blocks, which is what lets a screen render the page block by block and record where its
|
||||
``##`` sections start — the same landmark machinery a grouped select list uses, so the
|
||||
headings pin to the top row as their prose scrolls under them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Optional, Sequence
|
||||
|
||||
from markdown_it import MarkdownIt
|
||||
from markdown_it.tree import SyntaxTreeNode
|
||||
from rich import box
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
|
||||
from rich.padding import Padding
|
||||
from rich.rule import Rule
|
||||
from rich.segment import Segment
|
||||
from rich.styled import Styled
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
#: Cells a page's prose hangs at under its flush heading. Applied as
|
||||
#: :class:`~rich.padding.Padding` around the whole block rather than as literal spaces,
|
||||
#: so a paragraph that wraps keeps its indent on every line.
|
||||
INDENT = 2
|
||||
|
||||
#: The parser: CommonMark, with the two GFM extensions a page actually wants. The
|
||||
#: typographer stays off — it would turn quotes and dashes into characters the console
|
||||
#: font may not hold, and the app writes its own em dashes anyway.
|
||||
_MD = MarkdownIt("commonmark").enable(["table", "strikethrough"])
|
||||
|
||||
#: Bullets by nesting depth (cycled). Both glyphs are in the PicoCalc console font, and
|
||||
#: neither is claimed elsewhere in the mark language: ``•`` is nobody's status and ``▸``
|
||||
#: is the list-cursor shape, which is exactly what a sub-item is.
|
||||
_BULLETS = ("•", "▸")
|
||||
|
||||
#: The left rail a quote or a fenced block runs behind, and the width it costs.
|
||||
_RAIL = "│ "
|
||||
_RAIL_W = cell_len(_RAIL)
|
||||
|
||||
#: The style a heading's own text takes, by level. ``h3`` and deeper fall through to
|
||||
#: :data:`_SUBHEADING_STYLE` and are indented with the prose they head, because a
|
||||
#: sub-heading belongs to its section rather than standing beside it.
|
||||
_HEADING_STYLES = {"h1": "brand", "h2": "accent"}
|
||||
_SUBHEADING_STYLE = "md.strong"
|
||||
|
||||
#: Schemes stripped from a link's target when it is shown beside the link text: what
|
||||
#: matters on a 53-cell page is the host and the path, not the protocol.
|
||||
_URL_NOISE = ("https://", "http://", "mailto:")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Block:
|
||||
"""One top-level block of a rendered page.
|
||||
|
||||
Attributes:
|
||||
renderable: The block's Rich content — a paragraph, a list, a rule, a blank
|
||||
separator line.
|
||||
heading: Whether this block is one of the page's section landmarks (see
|
||||
:attr:`MarkdownDoc.sections`).
|
||||
label: The heading's plain text, for a landmark; empty otherwise.
|
||||
"""
|
||||
|
||||
renderable: RenderableType
|
||||
heading: bool = False
|
||||
label: str = ""
|
||||
|
||||
|
||||
class MarkdownDoc:
|
||||
"""A rendered markdown page: one renderable, and the blocks it is made of.
|
||||
|
||||
Printing it (the scripted CLI's ``meshterm about``) draws the blocks in order, so it
|
||||
behaves as any other Rich renderable. A full-screen page instead walks
|
||||
:attr:`blocks`, rendering each and noting the line its section headings land on —
|
||||
which is what makes a heading sticky and the ``^PgUp``/``^PgDn`` section jumps mean
|
||||
something on a page long enough to scroll.
|
||||
"""
|
||||
|
||||
def __init__(self, blocks: Iterable[Block]) -> None:
|
||||
"""Hold the already-rendered ``blocks`` as one page."""
|
||||
self.blocks: tuple[Block, ...] = tuple(blocks)
|
||||
|
||||
@property
|
||||
def sections(self) -> tuple[str, ...]:
|
||||
"""The page's section headings, in order — the landmarks a jump steps by."""
|
||||
return tuple(block.label for block in self.blocks if block.heading)
|
||||
|
||||
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
||||
"""Render every block in order (what makes the document a plain renderable)."""
|
||||
for block in self.blocks:
|
||||
yield block.renderable
|
||||
|
||||
|
||||
class _Railed:
|
||||
"""A renderable with a rail drawn down its left edge, on every line it wraps to.
|
||||
|
||||
Rich has no such box (a panel draws all four sides, a table cell draws its rail once
|
||||
against a multi-line neighbour), and the rail is the whole point of a quote or a
|
||||
fenced block: it has to survive the wrapping, or a quote three lines long looks like
|
||||
a quote one line long followed by two loose ones.
|
||||
"""
|
||||
|
||||
def __init__(self, body: RenderableType, *, style: str) -> None:
|
||||
"""Draw ``body`` behind a rail in ``style``."""
|
||||
self._body = body
|
||||
self._style = style
|
||||
|
||||
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
||||
"""Render the body a rail's width narrower, then prefix each line with the rail."""
|
||||
rail = Segment(_RAIL, console.get_style(self._style, default="none"))
|
||||
inner = options.update_width(max(1, options.max_width - _RAIL_W))
|
||||
for line in console.render_lines(self._body, inner, pad=True):
|
||||
yield rail
|
||||
yield from line
|
||||
yield Segment.line()
|
||||
|
||||
|
||||
def render_markdown(source: str) -> MarkdownDoc:
|
||||
"""Render a markdown document the way MeshTerm draws prose.
|
||||
|
||||
Args:
|
||||
source: The markdown text (CommonMark, plus tables and ``~~strikethrough~~``).
|
||||
|
||||
Returns:
|
||||
The page, ready to print or to hang in a :class:`~meshterm.ui.about.AboutPage`.
|
||||
"""
|
||||
return MarkdownDoc(_document(SyntaxTreeNode(_MD.parse(source)).children))
|
||||
|
||||
|
||||
# -- the document's own shape ---------------------------------------------------------
|
||||
|
||||
|
||||
def _document(nodes: Sequence[SyntaxTreeNode]) -> list[Block]:
|
||||
"""Lay the top-level blocks out, with the page frame and the blank lines between.
|
||||
|
||||
Blocks are separated by one blank line, except directly after a heading — a section's
|
||||
first paragraph sits *under* its heading, the way every other body section in the app
|
||||
is built. The two frame paragraphs (standfirst, colophon) are recognised here rather
|
||||
than in :func:`_block`, because what makes them frame is where they sit in the page,
|
||||
not anything about the paragraph itself.
|
||||
"""
|
||||
landmark = _landmark_tag(nodes)
|
||||
blocks: list[Block] = []
|
||||
for index, node in enumerate(nodes):
|
||||
previous = nodes[index - 1] if index else None
|
||||
if blocks and (previous is None or previous.type != "heading"):
|
||||
blocks.append(Block(Text()))
|
||||
frame = _frame_paragraph(node, previous, last=index == len(nodes) - 1)
|
||||
if frame is not None:
|
||||
blocks.append(Block(frame))
|
||||
continue
|
||||
heading = node.type == "heading" and node.tag == landmark
|
||||
label = _inline(node.children[0], heading=True).plain if heading else ""
|
||||
for rendered in _block(node, indent=INDENT, depth=0):
|
||||
blocks.append(Block(rendered, heading=heading, label=label))
|
||||
heading = False # a heading is one block; never mark a stray second
|
||||
return blocks
|
||||
|
||||
|
||||
def _landmark_tag(nodes: Sequence[SyntaxTreeNode]) -> str:
|
||||
"""Which heading level this page's sections are: ``h2``, or ``h1`` if it has none.
|
||||
|
||||
A page normally titles itself with ``#`` and sections itself with ``##``; one written
|
||||
entirely in ``#`` headings gets the same landmarks rather than none.
|
||||
"""
|
||||
tags = {node.tag for node in nodes if node.type == "heading"}
|
||||
return "h2" if "h2" in tags else "h1"
|
||||
|
||||
|
||||
def _frame_paragraph(
|
||||
node: SyntaxTreeNode, previous: Optional[SyntaxTreeNode], *, last: bool
|
||||
) -> Optional[RenderableType]:
|
||||
"""The page frame's two flush muted lines, or ``None`` for an ordinary block.
|
||||
|
||||
The standfirst is the paragraph directly under the page title; the colophon is the
|
||||
document's last paragraph, below a ``---`` rule. Both describe the page rather than
|
||||
saying anything in it, so both sit flush with the title and recede — the same voice
|
||||
an empty state speaks in.
|
||||
"""
|
||||
if node.type != "paragraph" or previous is None:
|
||||
return None
|
||||
standfirst = previous.type == "heading" and previous.tag == "h1"
|
||||
colophon = last and previous.type == "hr"
|
||||
if not (standfirst or colophon):
|
||||
return None
|
||||
return _inline(node.children[0], style="muted")
|
||||
|
||||
|
||||
# -- blocks ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _block(node: SyntaxTreeNode, *, indent: int, depth: int) -> list[RenderableType]:
|
||||
"""Render one block node.
|
||||
|
||||
Args:
|
||||
node: The block's syntax-tree node.
|
||||
indent: Cells to hang prose at — :data:`INDENT` at the page level, ``0`` inside a
|
||||
container that already provides the offset (a list item, a quote, a cell).
|
||||
depth: List-nesting depth, which picks the bullet.
|
||||
|
||||
Returns:
|
||||
The block's renderables (usually one).
|
||||
"""
|
||||
kind = node.type
|
||||
if kind == "heading":
|
||||
return [_heading(node, indent=indent)]
|
||||
if kind == "paragraph":
|
||||
return [_pad(_inline(node.children[0]), indent)]
|
||||
if kind in ("bullet_list", "ordered_list"):
|
||||
return [_pad(_list(node, depth=depth), indent)]
|
||||
if kind == "blockquote":
|
||||
quoted = Group(*_blocks(node.children, indent=0, depth=depth))
|
||||
return [_pad(_Railed(Styled(quoted, "md.quote"), style="md.bullet"), indent)]
|
||||
if kind in ("fence", "code_block"):
|
||||
code = Text(node.content.rstrip("\n"), style="md.code")
|
||||
return [_pad(_Railed(code, style="md.rail"), indent)]
|
||||
if kind == "hr":
|
||||
return [Rule(style="md.rail")]
|
||||
if kind == "table":
|
||||
return [_pad(_table(node), indent)]
|
||||
if kind in ("html_block", "html_inline"):
|
||||
# A page has no business carrying raw HTML, but dropping content silently is
|
||||
# worse than showing it: print it as the muted literal it is.
|
||||
return [_pad(Text(node.content.rstrip("\n"), style="muted"), indent)]
|
||||
if node.children:
|
||||
return _blocks(node.children, indent=indent, depth=depth)
|
||||
return []
|
||||
|
||||
|
||||
def _blocks(
|
||||
nodes: Sequence[SyntaxTreeNode], *, indent: int, depth: int, tight: bool = False
|
||||
) -> list[RenderableType]:
|
||||
"""Render sibling blocks, blank-separated unless ``tight`` (inside a list item)."""
|
||||
rendered: list[RenderableType] = []
|
||||
for index, node in enumerate(nodes):
|
||||
if rendered and not tight and nodes[index - 1].type != "heading":
|
||||
rendered.append(Text())
|
||||
rendered.extend(_block(node, indent=indent, depth=depth))
|
||||
return rendered
|
||||
|
||||
|
||||
def _pad(renderable: RenderableType, indent: int) -> RenderableType:
|
||||
"""Hang ``renderable`` at ``indent`` cells (unchanged when it is flush)."""
|
||||
if not indent:
|
||||
return renderable
|
||||
return Padding(renderable, (0, 0, 0, indent))
|
||||
|
||||
|
||||
def _heading(node: SyntaxTreeNode, *, indent: int) -> RenderableType:
|
||||
"""A heading: the page title, a section, or a sub-heading inside one."""
|
||||
style = _HEADING_STYLES.get(node.tag, _SUBHEADING_STYLE)
|
||||
text = _inline(node.children[0], style=style, heading=True)
|
||||
return text if node.tag in _HEADING_STYLES else _pad(text, indent)
|
||||
|
||||
|
||||
def _list(node: SyntaxTreeNode, *, depth: int) -> RenderableType:
|
||||
"""A bullet or numbered list: one hanging-indent grid row per item.
|
||||
|
||||
Each item is a two-column grid — its marker, then its content — so a wrapped item
|
||||
aligns under its own first line and a nested list steps in under the text it belongs
|
||||
to. A *loose* list (one the author spaced out in the source) keeps that spacing.
|
||||
"""
|
||||
ordered = node.type == "ordered_list"
|
||||
start = int(node.attrs.get("start", 1)) if ordered else 1
|
||||
items = node.children
|
||||
markers = [
|
||||
f"{start + index}." if ordered else _BULLETS[depth % len(_BULLETS)]
|
||||
for index in range(len(items))
|
||||
]
|
||||
width = max(cell_len(marker) for marker in markers) + 1 if markers else 0
|
||||
loose = any(
|
||||
child.type == "paragraph" and not child.hidden
|
||||
for item in items
|
||||
for child in item.children
|
||||
)
|
||||
|
||||
rows: list[RenderableType] = []
|
||||
for marker, item in zip(markers, items, strict=True):
|
||||
if rows and loose:
|
||||
rows.append(Text())
|
||||
grid = Table.grid(padding=0)
|
||||
grid.add_column(width=width, no_wrap=True)
|
||||
grid.add_column(overflow="fold")
|
||||
body = _blocks(item.children, indent=0, depth=depth + 1, tight=not loose)
|
||||
grid.add_row(
|
||||
Text(marker.ljust(width), style="md.bullet"),
|
||||
Group(*body) if len(body) != 1 else body[0],
|
||||
)
|
||||
rows.append(grid)
|
||||
return Group(*rows)
|
||||
|
||||
|
||||
def _table(node: SyntaxTreeNode) -> Table:
|
||||
"""A markdown table as a Rich table: accent headers, one hairline under them.
|
||||
|
||||
Column alignment follows the markdown's own ``---:``/``:---:`` markers, which the
|
||||
parser hands over as a CSS ``text-align`` on each cell.
|
||||
"""
|
||||
table = Table(
|
||||
box=box.SIMPLE_HEAD,
|
||||
show_edge=False,
|
||||
pad_edge=False,
|
||||
header_style="accent",
|
||||
border_style="md.rail",
|
||||
)
|
||||
head = next((child for child in node.children if child.type == "thead"), None)
|
||||
body = next((child for child in node.children if child.type == "tbody"), None)
|
||||
header_cells = head.children[0].children if head and head.children else []
|
||||
for cell in header_cells:
|
||||
table.add_column(_inline(cell.children[0]) if cell.children else Text(),
|
||||
justify=_justify(cell))
|
||||
for row in body.children if body else []:
|
||||
table.add_row(*(
|
||||
_inline(cell.children[0]) if cell.children else Text()
|
||||
for cell in row.children
|
||||
))
|
||||
return table
|
||||
|
||||
|
||||
def _justify(cell: SyntaxTreeNode) -> str:
|
||||
"""A table cell's alignment, from the parser's ``text-align`` attribute."""
|
||||
align = str(cell.attrs.get("style", ""))
|
||||
if "right" in align:
|
||||
return "right"
|
||||
if "center" in align:
|
||||
return "center"
|
||||
return "left"
|
||||
|
||||
|
||||
# -- inline runs ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _inline(node: SyntaxTreeNode, *, style: str = "", heading: bool = False) -> Text:
|
||||
"""Render an inline node's children into one styled :class:`~rich.text.Text`.
|
||||
|
||||
Args:
|
||||
node: The ``inline`` node (a paragraph's, a heading's, a cell's single child).
|
||||
style: The base style the run's plain text takes.
|
||||
heading: Whether this run is a heading, which changes what its marks mean —
|
||||
emphasis becomes the muted qualifier, and a ``·`` opens the muted aside.
|
||||
|
||||
Returns:
|
||||
The rendered run.
|
||||
"""
|
||||
text = Text(style=style or "")
|
||||
_append(text, node.children, style=style, heading=heading)
|
||||
return text
|
||||
|
||||
|
||||
def _append(
|
||||
text: Text, nodes: Sequence[SyntaxTreeNode], *, style: str, heading: bool
|
||||
) -> None:
|
||||
"""Append each inline node to ``text``, in the style its mark calls for."""
|
||||
for node in nodes:
|
||||
kind = node.type
|
||||
if kind == "text":
|
||||
_append_text(text, node.content, style=style, heading=heading)
|
||||
elif kind == "strong":
|
||||
_append(text, node.children, style="md.strong", heading=heading)
|
||||
elif kind == "em":
|
||||
_append(text, node.children, style="muted" if heading else "md.em",
|
||||
heading=heading)
|
||||
elif kind == "s":
|
||||
_append(text, node.children, style="md.strike", heading=heading)
|
||||
elif kind == "code_inline":
|
||||
text.append(node.content, style="md.code")
|
||||
elif kind == "link":
|
||||
_append_link(text, node, style=style, heading=heading)
|
||||
elif kind == "image":
|
||||
_append(text, node.children, style="md.em", heading=heading)
|
||||
elif kind == "softbreak":
|
||||
text.append(" ", style=style or None)
|
||||
elif kind == "hardbreak":
|
||||
text.append("\n", style=style or None)
|
||||
elif kind == "html_inline":
|
||||
text.append(node.content, style=style or None)
|
||||
elif node.children:
|
||||
_append(text, node.children, style=style, heading=heading)
|
||||
elif node.content:
|
||||
text.append(node.content, style=style or None)
|
||||
|
||||
|
||||
def _append_text(text: Text, content: str, *, style: str, heading: bool) -> None:
|
||||
"""Append plain text — splitting a heading's ``·`` aside off into muted."""
|
||||
if heading and " · " in content:
|
||||
title, _, note = content.partition(" · ")
|
||||
text.append(title, style=style or None)
|
||||
text.append(f" · {note}", style="muted")
|
||||
return
|
||||
if content:
|
||||
text.append(content, style=style or None)
|
||||
|
||||
|
||||
def _append_link(
|
||||
text: Text, node: SyntaxTreeNode, *, style: str, heading: bool
|
||||
) -> None:
|
||||
"""Append a link: its text, then its target unless the text already says it.
|
||||
|
||||
Nothing here is clickable — one of the two platforms is a framebuffer console — so a
|
||||
link that reads "the repo" has to show *where* the repo is or it says nothing at all.
|
||||
A link whose text already carries the address (a bare URL, an autolink) shows it once.
|
||||
"""
|
||||
label = Text()
|
||||
_append(label, node.children, style="md.link", heading=heading)
|
||||
text.append_text(label)
|
||||
target = _display_url(str(node.attrs.get("href", "")))
|
||||
if target and target.lower() not in label.plain.lower():
|
||||
text.append(" ", style=style or None)
|
||||
text.append(target, style="muted")
|
||||
|
||||
|
||||
def _display_url(href: str) -> str:
|
||||
"""A link target as a page shows it: no scheme, no trailing slash, no in-page anchor."""
|
||||
if not href or href.startswith("#"):
|
||||
return ""
|
||||
for noise in _URL_NOISE:
|
||||
if href.lower().startswith(noise):
|
||||
href = href[len(noise):]
|
||||
break
|
||||
return href.rstrip("/")
|
||||
|
||||
|
||||
__all__ = ["INDENT", "Block", "MarkdownDoc", "render_markdown"]
|
||||
@@ -136,6 +136,20 @@ MESH_THEME = Theme(
|
||||
# only PicoCalc's footer_fkeys ever asks for them.
|
||||
"fkey.chip": "bold #ffffff on #475569",
|
||||
"fkey.chip.shift": "bold #ffffff on #16a34a",
|
||||
# The prose voices — what a markdown page's inline marks are drawn in (see
|
||||
# ui.markdown). Headings borrow the styles the rest of the app already heads
|
||||
# sections with (``brand``, ``accent``), so only the *body* marks need names of
|
||||
# their own: emphasis brighter than the page, an aside dimmer than it, code and
|
||||
# links in the two hues nothing else in a body claims, a struck-out run receding
|
||||
# to the placeholder grey, and the bullet/rail chrome a list or a quote hangs on.
|
||||
"md.strong": "bold #e2e8f0",
|
||||
"md.em": "italic #cbd5e1",
|
||||
"md.code": "#7dd3fc",
|
||||
"md.link": "underline #a5b4fc",
|
||||
"md.quote": "italic #cbd5e1",
|
||||
"md.strike": "strike #64748b",
|
||||
"md.bullet": "#818cf8",
|
||||
"md.rail": "#475569",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -298,6 +312,23 @@ MESH_THEME_16 = Theme(
|
||||
# Shift bank, white text on both.
|
||||
"fkey.chip": "bold color(15) on color(7)",
|
||||
"fkey.chip.shift": "bold color(15) on color(2)",
|
||||
# Prose on the console. The page's body text is the default light grey (slot 7),
|
||||
# so emphasis is the one place the VT's bold-is-brightness rule *is* the design:
|
||||
# ``md.strong`` says bold on purpose and lands on white, exactly the step up the
|
||||
# truecolor theme spells out. Its opposite, ``md.em``, keeps slot 7 by saying so.
|
||||
# Code takes the dim cyan (never bold — slot 6's bright partner is the brand),
|
||||
# links the bright blue, and the chrome (bullets, rails, a struck run) the two
|
||||
# greys, so a page reads as text with landmarks rather than as a colour chart.
|
||||
# No underline anywhere: the VT renders it by swapping in the console's own
|
||||
# underline colour, which would take the link's hue away from it.
|
||||
"md.strong": "bold color(7)",
|
||||
"md.em": "not bold color(7)",
|
||||
"md.code": "not bold color(6)",
|
||||
"md.link": "color(12)",
|
||||
"md.quote": "not bold color(7)",
|
||||
"md.strike": "color(8)",
|
||||
"md.bullet": "color(12)",
|
||||
"md.rail": "color(8)",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ dependencies = [
|
||||
"bleak>=0.22", # Bluetooth LE scanning + companion connection
|
||||
"typer>=0.12", # scripted CLI; subcommands mirror the menu
|
||||
"rich>=13.7", # modern console rendering (progress, tables, panels)
|
||||
"markdown-it-py>=3.0", # CommonMark parser behind the written About pages
|
||||
"prompt_toolkit>=3.0", # full-screen interactive TUI (terminal, input, resize)
|
||||
"segno>=1.6", # pure-Python QR codes (channel share links)
|
||||
"tomli>=2.0; python_version < '3.11'", # TOML config reader on 3.10
|
||||
|
||||
+68
-2
@@ -3,8 +3,10 @@
|
||||
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``).
|
||||
the pager when there is something to page, the landmarks their written sections leave
|
||||
for the sticky heading and the section jump, and the section they hang under in the main
|
||||
menu. How markdown itself is drawn is ``test_markdown``'s subject; width discipline on
|
||||
both platforms is the gallery's (``test_gallery``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -110,6 +112,70 @@ def test_page_is_a_full_screen_not_a_floating_view() -> None:
|
||||
assert AboutPage("About MeshTerm", about_meshterm()).floating is False
|
||||
|
||||
|
||||
def test_pages_are_written_markdown_that_ships_with_the_package() -> None:
|
||||
"""Filling a page in is editing its ``.md`` file — no Python has to follow."""
|
||||
from meshterm.ui.about import _PAGES
|
||||
|
||||
for name in ("about", "author", "support"):
|
||||
source = (_PAGES / f"{name}.md").read_text(encoding="utf-8")
|
||||
assert source.lstrip().startswith("#"), f"{name}.md doesn't open with a heading"
|
||||
|
||||
|
||||
def test_no_package_placeholder_survives_into_the_rendered_page() -> None:
|
||||
"""The live facts are filled in as the page opens, so none of the braces reach a reader."""
|
||||
for builder in (about_meshterm, about_author, support_project):
|
||||
text = _page(builder)
|
||||
assert "{" not in text and "}" not in text, text
|
||||
|
||||
|
||||
@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_section_heading_is_a_landmark_the_page_can_pin(builder, headings) -> None: # noqa: ANN001
|
||||
"""A page is a document, so its ``##`` headings pin and its ^PgUp/^PgDn steps by them.
|
||||
|
||||
The screen records where each one landed while rendering (the same machinery a
|
||||
grouped select list uses), which is what makes a heading stay on the top row while
|
||||
its own prose scrolls under it.
|
||||
"""
|
||||
screen = AboutPage("About MeshTerm", builder())
|
||||
screen.note_viewport(40)
|
||||
lines = plain(screen.render_body(72)).splitlines()
|
||||
|
||||
assert [lines[at].strip() for at, _ in screen._sticky_headers] == list(headings)
|
||||
# Scrolled one line past its heading, the page pins that heading and nothing else.
|
||||
at = screen._sticky_headers[1][0]
|
||||
assert plain(screen.sticky_block(at + 1)) == headings[1]
|
||||
|
||||
|
||||
def test_the_console_lane_claims_the_section_step_only_while_the_page_scrolls() -> None:
|
||||
"""On the PicoCalc the lane is the only place a chord can advertise itself — but it
|
||||
never advertises a key that would do nothing (``CLAUDE.md``), and a page you can see
|
||||
whole has no section to step to."""
|
||||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||||
|
||||
set_platform(PICOCALC)
|
||||
try:
|
||||
screen = AboutPage("About MeshTerm", about_meshterm())
|
||||
screen.note_metrics(12, 40) # the whole page fits
|
||||
assert [pair.label for pair in screen.fkey_lane[:2]] == ["Sect ↑", "Sect ↓"]
|
||||
assert not any(pair.enabled for pair in screen.fkey_lane[:2])
|
||||
|
||||
screen.note_metrics(60, 20) # taller than the viewport
|
||||
assert all(pair.enabled for pair in screen.fkey_lane[:2])
|
||||
assert [pair.action for pair in screen.fkey_lane[:2]] == [
|
||||
"ctrl_pageup",
|
||||
"ctrl_pagedown",
|
||||
]
|
||||
finally:
|
||||
set_platform(REGULAR)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"""The markdown renderer: what each construct becomes in MeshTerm's visual language.
|
||||
|
||||
The About pages are written rather than composed (see :mod:`meshterm.ui.markdown`), so
|
||||
what is worth pinning here is the *translation* — that a heading takes the same accent
|
||||
every body section uses, that prose and lists hang in their own block instead of falling
|
||||
back to column 0, that a rail survives wrapping, that a link says where it goes on a
|
||||
terminal where nothing is clickable — and that the whole vocabulary survives the
|
||||
PicoCalc's 512 glyphs and 16 palette slots.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from rich.cells import cell_len
|
||||
from rich.padding import Padding
|
||||
from rich.text import Text
|
||||
|
||||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||||
from meshterm.ui.fontset import FONT_CODEPOINTS
|
||||
from meshterm.ui.markdown import MarkdownDoc, render_markdown
|
||||
from meshterm.ui.tui.render import render_lines
|
||||
|
||||
from tests.conftest import plain
|
||||
|
||||
#: One page exercising every construct the renderer knows, used wherever a test wants
|
||||
#: the whole vocabulary at once rather than one mark of it.
|
||||
KITCHEN_SINK = """# MeshTerm *v0.1.0*
|
||||
|
||||
a terminal companion for MeshCore LoRa mesh devices
|
||||
|
||||
## What it is · the short version
|
||||
|
||||
MeshTerm is a **full-screen** companion for *MeshCore* devices, with `traces`,
|
||||
maps and a ~~telepathic~~ courier queue over the top.
|
||||
|
||||
### Under the hood
|
||||
|
||||
- contacts, sorted however you like
|
||||
- discovered nodes, and the ones you added
|
||||
- a map you can walk with the arrow keys
|
||||
|
||||
1. plug the radio in
|
||||
2. run `meshterm`
|
||||
|
||||
> Nothing here transmits without being asked.
|
||||
|
||||
```
|
||||
meshterm --mock --platform picocalc
|
||||
```
|
||||
|
||||
| Platform | Cols | Colours |
|
||||
|---|---:|---|
|
||||
| regular | 72 | truecolor |
|
||||
|
||||
Find it at [the repo](https://github.com/example/meshterm).
|
||||
|
||||
---
|
||||
|
||||
Copyright © 2026 Somebody
|
||||
"""
|
||||
|
||||
|
||||
def _lines(source: str, width: int = 72) -> list[str]:
|
||||
"""The page as a reader sees it: plain text, one entry per rendered line.
|
||||
|
||||
Trailing padding is dropped — an indented block pads out to the full width, which no
|
||||
reader can see and no assertion here is about.
|
||||
"""
|
||||
rendered = plain(render_lines(render_markdown(source), width)).splitlines()
|
||||
return [line.rstrip() for line in rendered]
|
||||
|
||||
|
||||
def _texts(source: str) -> list[Text]:
|
||||
"""Every block of the page that is a run of text, unwrapped from its indent."""
|
||||
texts = []
|
||||
for block in render_markdown(source).blocks:
|
||||
renderable = block.renderable
|
||||
while isinstance(renderable, Padding):
|
||||
renderable = renderable.renderable
|
||||
if isinstance(renderable, Text):
|
||||
texts.append(renderable)
|
||||
return texts
|
||||
|
||||
|
||||
def _spans(source: str) -> list[tuple[str, str]]:
|
||||
"""Every styled run in the page's block texts, as ``(text, style)`` pairs."""
|
||||
return [
|
||||
(text.plain[span.start:span.end], str(span.style))
|
||||
for text in _texts(source)
|
||||
for span in text.spans
|
||||
]
|
||||
|
||||
|
||||
# -- headings and the page frame ------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "style"),
|
||||
[("# Title", "brand"), ("## Section", "accent"), ("### Sub", "md.strong")],
|
||||
)
|
||||
def test_each_heading_level_takes_the_hue_its_rank_calls_for(source, style) -> None: # noqa: ANN001
|
||||
"""``#`` is the page's own name, ``##`` a section in the app's body accent, ``###``
|
||||
a sub-heading inside one."""
|
||||
assert _spans(source)[0][1] == style
|
||||
|
||||
|
||||
def test_a_headings_dot_opens_the_standard_muted_aside() -> None:
|
||||
"""``## Title · note`` is the ``accent title · muted note`` shape body headings use."""
|
||||
doc = render_markdown("## What it is · the short version")
|
||||
heading = doc.blocks[0].renderable
|
||||
|
||||
assert heading.plain == "What it is · the short version"
|
||||
assert [str(span.style) for span in heading.spans][-1] == "muted"
|
||||
|
||||
|
||||
def test_emphasis_inside_a_heading_is_its_qualifier() -> None:
|
||||
"""That is how the wordmark carries a muted version beside it, in one line of source."""
|
||||
doc = render_markdown("# MeshTerm *v0.1.0*")
|
||||
|
||||
assert doc.blocks[0].renderable.plain == "MeshTerm v0.1.0"
|
||||
assert [str(span.style) for span in doc.blocks[0].renderable.spans] == ["brand", "muted"]
|
||||
|
||||
|
||||
def test_the_page_frame_sits_flush_and_muted() -> None:
|
||||
"""The standfirst under the title and the colophon under the closing rule describe
|
||||
the page rather than saying anything in it, so both recede and neither is indented."""
|
||||
source = "# Title\n\nthe standfirst\n\n## Section\n\nbody\n\n---\n\nthe colophon\n"
|
||||
lines = _lines(source)
|
||||
styles = dict(_spans(source))
|
||||
|
||||
assert lines[1] == "the standfirst" # flush, not hanging at INDENT
|
||||
assert lines[-1] == "the colophon"
|
||||
assert styles["the standfirst"] == "muted" and styles["the colophon"] == "muted"
|
||||
|
||||
|
||||
def test_a_paragraph_is_only_a_colophon_at_the_foot_of_the_page() -> None:
|
||||
"""A rule mid-page is just a rule; the paragraph under it is ordinary prose."""
|
||||
lines = _lines("## Section\n\n---\n\nstill body\n\nand more\n")
|
||||
|
||||
assert " still body" in lines
|
||||
|
||||
|
||||
# -- prose, lists, rails --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prose_hangs_under_its_heading_on_every_wrapped_line() -> None:
|
||||
"""A continuation line falling back to column 0 would read as a new, unheaded thought."""
|
||||
lines = [line for line in _lines("## S\n\n" + "word " * 40, 30) if line.strip()]
|
||||
|
||||
assert len(lines) > 3 # it really did wrap
|
||||
assert all(line.startswith(" ") for line in lines[1:]), lines
|
||||
|
||||
|
||||
def test_a_wrapped_list_item_aligns_under_its_own_text() -> None:
|
||||
"""The bullet is a gutter, so the item's second line starts where its first line did."""
|
||||
lines = [line for line in _lines("- " + "word " * 20, 30) if line.strip()]
|
||||
|
||||
assert lines[0].startswith(" • word")
|
||||
assert all(line.startswith(" word") for line in lines[1:]), lines
|
||||
|
||||
|
||||
def test_a_nested_list_steps_in_under_the_item_it_belongs_to() -> None:
|
||||
"""And takes the sub-item bullet, so depth reads without counting spaces."""
|
||||
lines = _lines("- parent\n - child\n")
|
||||
|
||||
assert lines[0] == " • parent"
|
||||
assert lines[1].startswith(" ▸ child")
|
||||
|
||||
|
||||
def test_a_numbered_list_counts_from_its_own_start() -> None:
|
||||
"""Markdown's ``3.`` means three, and a two-digit run keeps its numbers aligned."""
|
||||
lines = [line for line in _lines("9. nine\n10. ten\n") if line.strip()]
|
||||
|
||||
assert lines[0].startswith(" 9. nine")
|
||||
assert lines[1].startswith(" 10. ten")
|
||||
|
||||
|
||||
def test_a_quote_keeps_its_rail_down_every_line_it_wraps_to() -> None:
|
||||
"""A rail that only marked the first line would make a three-line quote look like
|
||||
one quoted line followed by two loose ones."""
|
||||
lines = [line for line in _lines("> " + "word " * 20, 30) if line.strip()]
|
||||
|
||||
assert len(lines) > 2
|
||||
assert all(line.startswith(" │ ") for line in lines), lines
|
||||
|
||||
|
||||
def test_a_fenced_block_keeps_its_own_lines() -> None:
|
||||
"""Code is quoted verbatim, one rendered line per source line."""
|
||||
lines = [line for line in _lines("```\none\ntwo\n```\n") if line.strip()]
|
||||
|
||||
assert [line.rstrip() for line in lines] == [" │ one", " │ two"]
|
||||
|
||||
|
||||
def test_a_rule_runs_the_full_width_of_the_page() -> None:
|
||||
"""``---`` is a page rule, not an indented one — it separates, so it spans."""
|
||||
line = next(line for line in _lines("a\n\n---\n\nb\n", 40) if "─" in line)
|
||||
|
||||
assert line == "─" * 40
|
||||
|
||||
|
||||
# -- inline marks ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_each_inline_mark_takes_its_own_style() -> None:
|
||||
"""Strong, emphasis, code and a struck-out run are four voices, not one."""
|
||||
styles = dict(_spans("**bold** *soft* `code` ~~gone~~\n"))
|
||||
|
||||
assert styles["bold"] == "md.strong"
|
||||
assert styles["soft"] == "md.em"
|
||||
assert styles["code"] == "md.code"
|
||||
assert styles["gone"] == "md.strike"
|
||||
|
||||
|
||||
def test_a_link_shows_where_it_goes() -> None:
|
||||
"""Nothing is clickable on a framebuffer console, so a link that reads "the repo"
|
||||
has to name the repo or it says nothing at all — scheme stripped, it is noise."""
|
||||
text = _texts("see [the repo](https://github.com/example/meshterm)\n")[0]
|
||||
|
||||
assert text.plain == "see the repo github.com/example/meshterm"
|
||||
|
||||
|
||||
def test_a_link_that_already_says_its_target_says_it_once() -> None:
|
||||
"""A bare URL or an autolink is its own label; repeating it would just eat cells."""
|
||||
text = _texts("mail <hello@example.com> or <https://example.com>\n")[0]
|
||||
|
||||
assert text.plain == "mail hello@example.com or https://example.com"
|
||||
|
||||
|
||||
def test_a_table_aligns_the_way_its_markdown_says() -> None:
|
||||
"""``---:`` is a right-aligned column, and the header keeps the body's own hairline."""
|
||||
lines = [line for line in _lines("| A | B |\n|---|--:|\n| 1 | 22222 |\n", 30) if line.strip()]
|
||||
|
||||
assert lines[0].split() == ["A", "B"]
|
||||
assert "─" in lines[1]
|
||||
assert lines[2].rstrip().endswith("22222")
|
||||
|
||||
|
||||
# -- the document itself --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sections_are_the_pages_h2_headings() -> None:
|
||||
"""The landmarks a sticky heading and a section jump step by."""
|
||||
doc = render_markdown("# Title\n\n## One\n\na\n\n### deeper\n\n## Two\n\nb\n")
|
||||
|
||||
assert doc.sections == ("One", "Two")
|
||||
|
||||
|
||||
def test_a_page_written_entirely_in_top_level_headings_still_has_sections() -> None:
|
||||
"""One authoring style shouldn't silently cost a page its landmarks."""
|
||||
assert render_markdown("# One\n\na\n\n# Two\n\nb\n").sections == ("One", "Two")
|
||||
|
||||
|
||||
def test_the_document_is_a_plain_renderable_too() -> None:
|
||||
"""The scripted CLI prints the same page it opens (``meshterm about``)."""
|
||||
assert isinstance(render_markdown(KITCHEN_SINK), MarkdownDoc)
|
||||
assert _lines(KITCHEN_SINK)[0] == "MeshTerm v0.1.0"
|
||||
|
||||
|
||||
# -- the console --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_construct_survives_the_picocalc() -> None:
|
||||
"""The whole vocabulary inside 53 cells, the 512-glyph font and the 16 slots.
|
||||
|
||||
The gallery makes this promise for the About pages themselves; this makes it for
|
||||
every construct a page *could* grow, so a table or a code fence added to one of them
|
||||
is a known quantity rather than tofu found on-device.
|
||||
"""
|
||||
set_platform(PICOCALC)
|
||||
try:
|
||||
rendered = render_lines(render_markdown(KITCHEN_SINK), PICOCALC.readable_cols)
|
||||
finally:
|
||||
set_platform(REGULAR)
|
||||
|
||||
for index, line in enumerate(rendered):
|
||||
assert cell_len(plain(line)) <= PICOCALC.readable_cols, (index, line)
|
||||
assert "[38;2;" not in line and "[38;5;" not in line, (index, line)
|
||||
strays = {ch for ch in plain(line) if ord(ch) >= 0x20 and ord(ch) not in FONT_CODEPOINTS}
|
||||
assert not strays, (index, sorted(strays), line)
|
||||
Reference in new issue
Block a user