`ruff format .` across the repo: 153 files, no behaviour change. This is the second gate CONTRIBUTING.md promises, and until now it reported 154 files needing reformat — so a first contributor ran the command the guide told them to run and could not tell their own diff from the background. Landed on its own, with nothing else in it, because a whole-repo reformat is only safe to skip past in blame if it is provably nothing but layout. It was checked that way rather than assumed: every changed file was formatted in memory and compared by full `ast.dump()`, and 152 of 153 come out structurally identical (the one exception was a docstring opening on a quote character, fixed in the commit before this one). The string and comment token streams were diffed separately, so a quote flip or an edited comment could not hide inside a re-layout — five quote-style normalisations and sixteen implicit-concatenation joins, all value-identical, and one commented-out line gaining the space after its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite is 1888 green, gallery gate and `test_theme16` included. What actually changed, in order of how much of the diff it is: wrapped calls and signatures rejoin onto one line where they fit inside the hundred columns; the magic trailing comma expands what it is there to expand; complex slices gain their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are laid out to be read were fenced in the previous commit and are untouched here. The aligned trailing-comment column is gone from about ninety lanes that were not fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced where it sits. Every comment stays on the entry it describes, so nothing there reads wrong; it just no longer lines up. Add this commit to `.git-blame-ignore-revs`, which follows immediately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
487 lines
21 KiB
Python
487 lines
21 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.
|
|
* A fence tagged ``qr`` draws nothing of itself: its body is a URL, and the page shows
|
|
the app's scannable code for it (:func:`~meshterm.ui.qr.qr_text`).
|
|
* 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 collections.abc import Iterable, Sequence
|
|
from dataclasses import dataclass
|
|
|
|
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: SyntaxTreeNode | None, *, last: bool
|
|
) -> RenderableType | None:
|
|
"""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 == "fence" and node.info.strip() == "qr":
|
|
return [_pad(_qr(node.content.strip()), 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 _qr(data: str) -> RenderableType:
|
|
"""A ``qr`` fence: the URL inside it, drawn as the app's scannable code.
|
|
|
|
The code is generated as the page opens rather than pasted into the ``.md`` as
|
|
half-block art. Art cannot survive markdown — a row that happens to open on a light
|
|
module opens on a *space*, and every parser in the world eats it, shearing that row
|
|
one module to the left — and a code nobody can scan is worse than no code. Drawing it
|
|
live also means the link is written once, in the fence, and the code can never drift
|
|
from it.
|
|
|
|
Args:
|
|
data: The fence's body — the URL to encode.
|
|
|
|
Returns:
|
|
The code, from :func:`~meshterm.ui.qr.qr_text` — the same black-on-white widget
|
|
the share popups draw, so a QR looks the same everywhere in MeshTerm and reads
|
|
the same whatever palette the terminal is running.
|
|
"""
|
|
from .qr import qr_text
|
|
|
|
return qr_text(data)
|
|
|
|
|
|
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"]
|