Files
Jean-Pierre MartineauandClaude Opus 5.5 abfd32c491 Docstrings and comments of meshterm/ui are in ASD-STE100
The same rewrite as for the core package, for the screens, the dialogs,
the map, the charts, and the CLI renderers. The glossary words are used
for the screen: the user (not the reader), dialog (not popup), viewport,
list window, highlight, pointer, graph lane, marker, raster, result
screen, and key prefix (never hash for a truncated key).

The rewrite also corrects approximately 70 docstrings and comments that
no longer agreed with the code: rows that no longer exist (Back rows),
counts that changed, renamed constants, wrong cross-references, and
behaviour that changed. Each correction follows the code; no code
changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 20:37:26 -04:00

536 lines
24 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Markdown that MeshTerm draws in its own style. This is the renderer for the About pages.
A page of prose is the one type of screen that the app cannot build from rows, lanes, and
marks, because it is paragraphs. Thus the prose is in ``.md`` files in ``meshterm/assets``,
and this module changes them into Rich renderables. The module does not print the
conventions of markdown (boxed headings, ``#`` marks left in the text, and a slab with
syntax colours for each fence). Instead it maps each construct onto the visual language
that the other screens of the app already use. Thus a written page can be next to a
screen of contacts and not look as if it came from another program:
* ``#`` is the name of the page itself, in the brand hue. It is the wordmark, the author,
or whatever the page is about. ``##`` is a section, in the same accent that each body
heading uses. ``###`` is a sub-heading in a section. A heading can have the standard
muted aside if you write ``## Title · note``. Emphasis in a heading is its qualifier.
This is how ``# MeshTerm *v0.1.0*`` prints the version in muted style beside the name.
* The prose of a page hangs at :data:`INDENT` cells under its flush heading, as a block.
A line that wraps is under the paragraph that it belongs to. It never goes back to
column 0, where it would look like a new thought with no heading (the hanging-indent
rule of ``CLAUDE.md``).
* Two paragraphs are the page frame and not the body. Both are flush and muted. The
**standfirst** is directly under the ``#`` title (the one-line description of
MeshTerm, or the "wrote MeshTerm" of the author). The **colophon** is the last
paragraph of the document, below a ``---`` rule. The copyright is there.
* A list hangs on a bullet in a two-column grid. Thus a wrapped item is under its own
text, and a nested list is indented under its parent. Quotes and fenced code have a rail
down the left side. The rail is on each line, also on the lines that wrap.
* A fence with the tag ``qr`` draws nothing of itself. Its body is a URL, and the page
shows the scannable code of the app for that URL (:func:`~meshterm.ui.qr.qr_text`).
* Emphasis, code, links, and struck-out text use the ``md.*`` styles. These styles, the
same as each style name here, have a meaning on both platforms (refer to
:mod:`meshterm.ui.theme`). A link shows its target after the text, unless the text
already shows it. Nothing here emits an OSC hyperlink, because many users of the app
have a framebuffer console.
The parser is :mod:`markdown_it` (CommonMark, with tables and strikethrough). It is a real
parser and not a set of regular expressions, so an author can write ordinary markdown
and get the result that the author intended. All the code below walks its tree. Nothing
else in the app must know that markdown exists.
The output is a :class:`MarkdownDoc`. It is a plain Rich renderable and also a list of
top-level blocks. Thus a screen can render the page block by block and record the place
where each ``##`` section starts. This is the same landmark mechanism that a grouped
select list uses, so the headings pin to the top row while 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
#: The number of cells at which the prose of a page hangs under its flush heading. The code
#: applies it as :class:`~rich.padding.Padding` around the whole block and not as spaces
#: in the text, so a paragraph that wraps keeps its indent on each line.
INDENT = 2
#: The parser: CommonMark, with the two GFM extensions that a page needs. The typographer
#: stays off. It would change quotes and dashes to characters that the console font may
#: not have, and the app writes its own em dashes.
_MD = MarkdownIt("commonmark").enable(["table", "strikethrough"])
#: The bullets for each nesting depth. They repeat in turn. Both glyphs are in the PicoCalc
#: console font, and the mark language does not use either of them anywhere else. ``•`` is
#: not a status. ``▸`` is the shape of the list pointer, which is correct for a sub-item.
_BULLETS = ("•", "▸")
#: The left rail that a quote or a fenced block has, and the width in cells that it uses.
_RAIL = "│ "
_RAIL_W = cell_len(_RAIL)
#: The style of the text of a heading, for each level. ``h3`` and the deeper levels use
#: :data:`_SUBHEADING_STYLE`. They are indented with the prose that they head, because a
#: sub-heading belongs to its section and does not stand beside it.
_HEADING_STYLES = {"h1": "brand", "h2": "accent"}
_SUBHEADING_STYLE = "md.strong"
#: The schemes that the code removes from the target of a link when it shows the target
#: beside the link text. On a page that is 53 cells wide, the host and the path are
#: important, and the protocol is not.
_URL_NOISE = ("https://", "http://", "mailto:")
@dataclass(frozen=True)
class Block:
"""One top-level block of a rendered page.
Attributes:
renderable: The Rich content of the block: a paragraph, a list, a rule, or a blank
separator line.
heading: Whether this block is one of the section landmarks of the page (refer to
:attr:`MarkdownDoc.sections`).
label: The plain text of the heading, for a landmark. Empty for other blocks.
qr: Whether this block is the scannable code of a ``qr`` fence. The block is
marked so that the scripted CLI, which cannot use a code, can remove it
(refer to :meth:`MarkdownDoc.for_script`).
rule: Whether this block is a ``---`` horizontal rule. The block is marked for the
same reason. A rule is drawn at the full width of the console that prints it,
and the scripted console is 16384 cells wide.
"""
renderable: RenderableType
heading: bool = False
label: str = ""
qr: bool = False
rule: bool = False
class MarkdownDoc:
"""A rendered markdown page: one renderable, and the blocks that it has.
If you print it (``meshterm about`` of the scripted CLI), it draws the blocks in
order, so it behaves as any other Rich renderable. A full-screen page instead walks
:attr:`blocks`. It renders each block and records the line where each section heading
is. This makes a heading sticky, and it gives the ``^PgUp``/``^PgDn`` section jumps a
meaning on a page that is long enough to scroll.
"""
def __init__(self, blocks: Iterable[Block]) -> None:
"""Keep the ``blocks``, which are already rendered, as one page."""
self.blocks: tuple[Block, ...] = tuple(blocks)
@property
def sections(self) -> tuple[str, ...]:
"""The section headings of the page, in order. A jump steps from one to the next."""
return tuple(block.label for block in self.blocks if block.heading)
def for_script(self) -> MarkdownDoc:
"""The same page without the parts that only a screen can use, and with no gap.
Two blocks are for the eye. In a pipe they are worse than useless:
* A **QR** is a second rendering of a URL that the page already prints as a link.
It is drawn for a phone that points at a screen. If the output goes to a file,
the QR is a block of block characters that has no new information.
* A **rule** has the size of the console that prints it, and the scripted console
is :data:`~meshterm.ui.script.WIDTH` cells wide. Thus the ``---`` above a
colophon becomes one line of 16384 characters, which is tens of kilobytes of
``─`` in a page of prose. A rule is also a frame, and the scripted CLI does not
draw frames. The blank line on each side of the rule already gives the same
information.
Returns:
A new document. This document is not changed.
"""
kept: list[Block] = []
for block in self.blocks:
if block.qr or block.rule:
# The blank separator that was before the removed block goes with it. Thus
# the removal does not leave two blank lines where the block was.
if kept and isinstance(kept[-1].renderable, Text) and not kept[-1].renderable.plain:
kept.pop()
continue
kept.append(block)
return MarkdownDoc(kept)
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
"""Render each block in order. This makes the document a plain renderable."""
for block in self.blocks:
yield block.renderable
class _Railed:
"""A renderable with a rail down its left edge, on each line that it wraps to.
Rich has no such box. A panel draws all four sides, and a table cell draws its rail one
time against a neighbour of many lines. The rail is the purpose of a quote or a fenced
block, so it must stay when the text wraps. If it does not, a quote of three lines
looks like a quote of one line with two loose lines after it.
"""
def __init__(self, body: RenderableType, *, style: str) -> None:
"""Draw ``body`` with a rail in ``style`` beside it."""
self._body = body
self._style = style
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
"""Render the body at a width that the rail reduces, then add the rail to each line."""
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 in the style that MeshTerm uses for prose.
Args:
source: The markdown text (CommonMark, with tables and ``~~strikethrough~~``).
Returns:
The page. It is ready to print, or to put in a :class:`~meshterm.ui.about.AboutPage`.
"""
return MarkdownDoc(_document(SyntaxTreeNode(_MD.parse(source)).children))
# -- the shape of the document --------------------------------------------------------
def _document(nodes: Sequence[SyntaxTreeNode]) -> list[Block]:
"""Lay out the top-level blocks, with the page frame and the blank lines between them.
One blank line separates the blocks, but not directly after a heading. The first
paragraph of a section is directly under its heading, as in each other body section of
the app. This function recognizes the two frame paragraphs (the standfirst and the
colophon), and :func:`_block` does not. A paragraph is a frame paragraph because of
its place in the page, not because of anything in 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 ""
qr = node.type == "fence" and node.info.strip() == "qr"
rule = node.type == "hr"
for rendered in _block(node, indent=INDENT, depth=0):
blocks.append(Block(rendered, heading=heading, label=label, qr=qr, rule=rule))
heading = False # a heading is one block, so no second block gets the mark
return blocks
def _landmark_tag(nodes: Sequence[SyntaxTreeNode]) -> str:
"""The heading level of the sections of this page: ``h2``, or ``h1`` if there is no ``h2``.
A page normally has its title with ``#`` and its sections with ``##``. A page that has
only ``#`` headings gets the same landmarks, and not no landmarks.
"""
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 two flush muted lines of the page frame, or ``None`` for an ordinary block.
The standfirst is the paragraph directly under the page title. The colophon is the
last paragraph of the document, below a ``---`` rule. Both describe the page and say
nothing in it. Thus both are flush with the title and are less prominent, in the same
voice as an empty state.
"""
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 syntax-tree node of the block.
indent: The number of cells at which the prose hangs. At the page level it is
:data:`INDENT`. It is ``0`` in a container that already gives the offset (a
list item, a quote, or a cell).
depth: The nesting depth of the list. It selects the bullet.
Returns:
The renderables of the block (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 must not have raw HTML. But it is worse to remove content with no
# message than to show it, so print it as the muted literal text that 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 with a blank line between them, unless ``tight`` (in 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 in it, drawn as the scannable code of the app.
The code is generated when the page opens. It is not pasted into the ``.md`` as
half-block art. Art does not survive markdown. A row that starts with a light module
starts with a space, and each parser removes that space. Then the row moves one module
to the left. A code that nobody can scan is worse than no code. If the code is drawn
at run time, the link is written only one time, in the fence, and the code can never
be different from it.
Args:
data: The body of the fence, which is the URL to encode.
Returns:
The code from :func:`~meshterm.ui.qr.qr_text`. It is the same white-on-black
widget that the share screen draws. Thus a QR looks the same everywhere in
MeshTerm, and it reads the same with any palette of the terminal. This is the only
place where a code is inside a page and does not take the whole frame. It is an
illustration in the prose, drawn where the fence is.
"""
from .qr import qr_text
return qr_text(data)
def _pad(renderable: RenderableType, indent: int) -> RenderableType:
"""Hang ``renderable`` at ``indent`` cells. If it is flush, return it as it is."""
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 in a section."""
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 list or a numbered list, with one hanging-indent grid row for each item.
Each item is a grid of two columns: its marker, then its content. Thus a wrapped item
is under its own first line, and a nested list is indented under the text that it
belongs to. A loose list is a list that the author spaced out in the source. It 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, with one thin line under them.
The alignment of a column follows the ``---:`` and ``:---:`` markers of the markdown.
The parser gives them 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:
"""The alignment of a table cell, from the ``text-align`` attribute of the parser."""
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 the children of an inline node into one styled :class:`~rich.text.Text`.
Args:
node: The ``inline`` node (the single child of a paragraph, a heading, or a cell).
style: The base style of the plain text of the run.
heading: Whether this run is a heading. In a heading the marks have a different
meaning: emphasis becomes the muted qualifier, and a ``·`` starts 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 that its mark needs."""
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. In a heading, the ``·`` aside is split off and made 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 shows it.
Nothing here is clickable, because one of the platforms is a framebuffer console. A
link with the text "the repo" must show where the repo is. If it does not, it gives
no information. If the text of a link already has the address (a bare URL, or an
autolink), the address is shown one time.
"""
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.``, and no trailing slash.
An anchor in the page shows nothing, because it names a place in the page that the user
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"]