Files
MeshTerm/meshterm/ui/markdown.py
T
Jean-Pierre MartineauandClaude Opus 5 9f5d337cae About MeshTerm: the page says something now
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>
2026-08-11 17:55:41 -04:00

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"]