The three sections were placeholders holding a shape. This writes the first one: what MeshTerm is (one screen for what the radio can tell you, the five menu sections as a list, and the standing promise that nothing transmits unasked), where it came from (June 2026, a few serial scripts, grown by the recording; the mesh's scarce airtime is why it listens more than it speaks; later the PicoCalc), and the licence — MIT, as the package metadata declares, with the credit MeshCore and OpenStreetMap are owed, the ODbL by name. The layout is unchanged: title, standfirst, three sections, rule, colophon. It just runs longer than a screenful now, which is what the sticky headings and the section jumps were for. Link targets also drop a leading www., so the OSM copyright URL reads as the host it is rather than a subdomain nobody types. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
467 lines
20 KiB
Python
467 lines
20 KiB
Python
"""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 ``www.``, no trailing slash.
|
|
|
|
An in-page anchor shows nothing at all — it names a place in the page the reader is
|
|
already on.
|
|
"""
|
|
if not href or href.startswith("#"):
|
|
return ""
|
|
for noise in _URL_NOISE:
|
|
if href.lower().startswith(noise):
|
|
href = href[len(noise):]
|
|
break
|
|
if href.lower().startswith("www."):
|
|
href = href[4:]
|
|
return href.rstrip("/")
|
|
|
|
|
|
__all__ = ["INDENT", "Block", "MarkdownDoc", "render_markdown"]
|