Files
MeshTerm/meshterm/ui/renderers.py
T
Jean-Pierre MartineauandClaude Opus 5 72f72b84cd Diagnostics is prose, so it is a written page
The block is a title, a standfirst, sections and a list under each. That
is a page of writing, not a screenful of rows, and the app already knows
how to draw one: the About pages' markdown renderer. So the tool states
its report as before and a third projection turns it into markdown, which
is exactly what the report seam was built for -- no tool touched, no
report changed, one new function beside the other two faces.

Being a written page buys the two things a long block most needs and that
a hand-built screen had neither of. Its ## headings are landmarks, so each
pins to the top row while its own section scrolls under it and ^PgUp/^PgDn
step section by section; and the PicoCalc lane picks up Sect up / Sect
down for the same reason a grouped list does. AboutPage already does all
of it, so the page is that page with one verb added.

The frame comes back with it. The bare frame was there to keep a border
off every line a mouse would drag through, which mattered while copying
off the screen was the only way out; saving is the way out now, so the
page can be a normal screen and the save can be advertised where this app
advertises verbs -- the footer hint on a desktop, the lane's one free F3
slot on the console -- instead of in the page's own body, which belongs to
the report. The answer moves to a popup with it: a path is worth reading
carefully, a write can fail, and the page underneath is unchanged and
still there when the popup goes.

Saving markdown is the same decision as drawing it. One source is what the
page renders and what the file holds, and an issue tracker renders it as
written, with no fence and no apology. Every value is a code span, which
is correctness and not taste: a Windows path is full of backslashes that
markdown reads as escapes, and an error a stranger's firmware wrote can
hold an asterisk. A value already holding a backtick gets a longer fence.

Rows are a list and not a table, measured rather than assumed -- a
markdown table's cells are cropped with an ellipsis at 53 columns, and a
bug report whose one long value is cut is missing the half that mattered.
A list item wraps and hangs under its own text at any width.

Two smaller things fell out. A block may carry a caption, the heading a
format that has headings draws above it -- plain and JSON have no use for
one and ignore it. And the host block splits in two, because a "it looks
wrong" report is about the terminal and its reader should not have to pass
the processor architecture on the way to it.

The command line is untouched: aligned records, because that face is a
stream to grep and headings are furniture on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 17:10:31 -04:00

412 lines
18 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The two faces of one answer: a renderer turns a report into bytes.
A tool states its answer as a :mod:`~meshterm.ui.report`; this module is where that
becomes output, and the CLI boundary picks which one runs (see
:func:`~meshterm.cli.main_callback`). Nothing above here knows what a gutter or a comma is,
and nothing below here knows what a contact is. That separation is the whole point: a
third format later — YAML, CSV, whatever somebody needs — is a new class in this file and
not an edit to twenty tools.
**Plain** (:class:`PlainRenderer`) is for a person at a prompt: aligned columns, ages, a
route drawn with arrows, and a ``sysctl -a`` block for a set of facts about one thing. It
is written in the vocabulary of :mod:`meshterm.ui.script`, which is where the reasons for
each of its rules live.
**JSON** (:class:`JsonRenderer`) is for a program, often on another machine and often
later. It prints **the answer itself** — an array for a listing, an object for a set of
facts — compact, one line, UTF-8, no ASCII escaping, keys in the order the report declares
them. There is no wrapper: ``meshterm contacts --json | jq '.[].node.name'`` reads what it
looks like it reads, and the report a caller needs is still ``$?``, exactly as it is
without the flag. So a failure prints **nothing at all** on stdout — the ``meshterm: what
went wrong`` line stays on stderr where every utility puts it, and stdout holding a valid
document is then a reliable signal that the command worked.
Two rules cover a report of more than one block, and between them they produce the
documents ``trace`` and ``tx-optimize`` are specified with. A **single-block** report *is*
its block's document. A **multi-block** report is one object: each :class:`Listing` under
its own key, and each :class:`Facts` merged in at the top level — a facts block is the set
of facts about the one thing the command answered, so it is the document rather than a
compartment inside it.
"""
from __future__ import annotations
import json
import re
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from enum import Enum
from typing import TYPE_CHECKING, Any
from rich.console import Console
from rich.text import Text
from . import script
from .report import BARE, SILENT, Block, Facts, Lane, Listing, Report
if TYPE_CHECKING: # pragma: no cover - typing only
from ..context import AppContext
class OutputFormat(str, Enum):
"""The formats a scripted run can print its answer in.
An enum rather than the ``--json`` boolean it replaces, because the boolean was
already being asked questions it could not answer — ``tx-optimize`` read it as "may I
prompt?" — and because a third member here costs one renderer and no tool changes at
all. ``--json`` stays the documented spelling and is sugar for this.
"""
PLAIN = "plain"
JSON = "json"
def markdown_blocks(report: Report | None) -> str:
"""Project a report as markdown sections — the third face, and the only *document* one.
The plain face is a stream of records and the machine face is a document for a
program; this one is a page for a person to read, on a screen through
:func:`~meshterm.ui.markdown.render_markdown` and in an issue tracker through nothing
at all. It is where :attr:`~meshterm.ui.report.Block.caption` finally means something:
markdown has headings, and a heading is what a reader navigates by.
Every value is written as **inline code**, and that is correctness rather than taste. A
Windows path is full of backslashes, which markdown reads as escapes, and an error
message a stranger's firmware wrote can hold an asterisk or a bracket. Inside a code
span none of it is interpreted, so the value that arrives is the value that left — the
same reason :func:`~meshterm.ui.script.name` quotes what it cannot vouch for. A value
already holding a backtick gets a longer fence rather than a broken one.
Rows are a **list**, not a table, and that was measured rather than assumed: a markdown
table's cells are cropped with an ellipsis when the frame is narrow, and a diagnostics
page whose one long value is cut is missing the half that mattered. A list item wraps
and hangs under its own text at any width.
Args:
report: The blocks to project, or ``None`` for nothing at all.
Returns:
Markdown source: one ``##`` section per captioned block, blank-line separated.
"""
sections: list[str] = []
for block in report or ():
rows = _markdown_rows(block)
head = f"## {block.caption}\n\n" if block.caption else ""
# A section with nothing in it still says so. An absence is a fact here — "no
# preferences changed" is something a maintainer can act on, where a section
# silently missing only raises the question of whether it was ever built.
sections.append(head + ("\n".join(rows) if rows else "*none*"))
return "\n\n".join(sections)
def _markdown_rows(block: Block) -> list[str]:
"""One markdown list item per row of a block."""
if isinstance(block, Facts):
return [f"- **{key}** — {_code(value)}" for key, value in facts_pairs(block)]
lanes = block.lanes()
if not lanes:
return []
items: list[str] = []
for row in block.rows:
# The first lane names the record and the rest describe it, which is what a
# two-lane listing (a table and its count, a preference and its value) wants and
# what a wider one degrades to gracefully.
cells = [lane.render(row.get(column.key)) for column, lane in lanes]
described = " · ".join(_code(cell) for cell in cells[1:])
items.append(f"- **{cells[0]}** — {described}" if described else f"- **{cells[0]}**")
return items
def _code(value: str) -> str:
"""One value as a markdown code span, fenced long enough to survive its own backticks."""
if not value:
return ""
longest = max((len(run) for run in re.findall(r"`+", value)), default=0)
fence = "`" * (longest + 1)
# A span that starts or ends with a backtick needs a space the parser then eats.
pad = " " if value.startswith("`") or value.endswith("`") else ""
return f"{fence}{pad}{value}{pad}{fence}"
def facts_pairs(block: Facts) -> list[tuple[str, str]]:
"""Project a :class:`Facts` block into its ``(key, value)`` text rows.
The plain face's half of a facts block, lifted out of :class:`PlainRenderer` because
one surface in the menu wants the same rows laid out differently: the Diagnostics page
hangs a long value under its key rather than cropping it, since a cropped path in a
bug report is worse than a wrapped one. Sharing the projection is what keeps the two
from drifting — a field added to a report appears on both without anyone remembering.
Laying the rows out is the caller's business; turning a typed value into text is not,
and that stays here, in the declared :class:`~meshterm.ui.report.Lane`.
Args:
block: The facts block to project.
Returns:
One ``(header, cell)`` pair per lane, in the order the block declares them.
"""
rows: list[tuple[str, str]] = []
for column in block.fields:
value = block.values.get(column.key)
if block.omit_absent and (value is None or value == ""):
continue
for lane in column.lanes:
rows.append((lane.header, lane.render(value)))
return rows
class Renderer:
"""Turns a report into bytes. One subclass per format."""
def render(self, report: Report | None) -> None:
"""Print a completed report, or nothing at all when there is none."""
raise NotImplementedError
@contextmanager
def stream(self, listing: Listing) -> Iterator[Any]:
"""Open a live listing whose rows arrive one at a time.
The narrow second seam, and it exists because a stream has no end to return from:
``monitor`` and ``chat listen`` run until a window closes or somebody interrupts
them, so their answer cannot be a value handed back at the end. Two commands use
it. Nothing else should — a command that can build a whole report should, because
a report is the thing every format already knows how to read.
Args:
listing: The shape of the records to come — its columns, its lane order, and
(for the plain face) the width each lane is pinned to. Its ``rows`` are
ignored; rows arrive through the yielded callable.
Yields:
A callable taking one typed row mapping.
"""
raise NotImplementedError
class PlainRenderer(Renderer):
"""The face a person reads: aligned text on the scripted console.
Attributes:
console: The plain console (see :func:`meshterm.ui.script.console`).
"""
def __init__(self, console: Console) -> None:
"""Bind the renderer to the console its output goes to."""
self.console = console
def render(self, report: Report | None) -> None:
"""Draw each block in order, one blank line between them.
The separator is counted off what was *drawn* rather than off the block index: a
block can render to nothing (a listing with no rows, an acknowledgement whose plain
answer is its exit status), and a blank line above the first visible block would be
a line of output the command did not have.
"""
drawn_any = False
for block in report or ():
drawn = self._block(block)
if drawn is None:
continue
if drawn_any:
self.console.print(script.blank())
self.console.print(drawn)
drawn_any = True
def _block(self, block: Block) -> Any:
"""The renderable for one block, or ``None`` where it prints nothing."""
if isinstance(block, Listing):
return self._listing(block) if block.rows else None
if isinstance(block, Facts):
return self._facts(block)
raise TypeError(f"unrenderable block: {block!r}") # pragma: no cover - closed set
def _listing(self, block: Listing) -> Any:
"""A listing as its header line and its padded records."""
lanes = block.lanes()
table = script.columns(
*(lane.header for _, lane in lanes),
right=[lane.header for _, lane in lanes if lane.align == "right"],
)
# `config show` is an array to a parser and a `sysctl -a` block to a reader, so it
# keeps the columns and drops the heading row. Same table either way — the padding,
# the no-wrap and the crop are what make a record splittable, and none of them is
# the header's doing.
table.show_header = block.headed
for row in block.rows:
table.add_row(*(lane.render(row.get(column.key)) for column, lane in lanes))
return table
def _facts(self, block: Facts) -> Any:
"""A facts block as a key/value listing, one bare value, or nothing."""
if block.shape == SILENT:
return None
if block.shape == BARE:
column = next(c for c in block.fields if c.key == block.bare)
value = block.values.get(column.key)
if value is None:
# The bare form prints the value, and there is no value: a node that never
# answered, a slot with nothing in it. A lone `-` there would be a line of
# output where the exit status is the whole report.
return None
# As a Text, not a markup string: this is a private key, a share URL or a
# remote node's own reply, and Rich would read a bracket in one as a style tag.
return Text(column.lanes[0].render(value))
return script.pairs(facts_pairs(block))
@contextmanager
def stream(self, listing: Listing) -> Iterator[Any]:
"""Print the header line, then one pinned-lane record per row as it arrives."""
lanes = listing.lanes()
pinned = script.stream(
*((lane.header, _lane_width(lane)) for _, lane in lanes),
right=[lane.header for _, lane in lanes if lane.align == "right"],
)
self.console.print(pinned.header, highlight=False)
def row(record: Mapping[str, Any]) -> None:
cells = [lane.render(record.get(column.key)) for column, lane in lanes]
self.console.print(pinned.record(*cells), highlight=False)
yield row
class JsonRenderer(Renderer):
"""The face a program reads: one compact document, or one per streamed record.
Attributes:
console: The plain console, used only for its stream — the document is written
through :func:`json.dumps` rather than by Rich, so nothing can pad, wrap,
highlight or otherwise adorn it.
"""
def __init__(self, console: Console) -> None:
"""Bind the renderer to the console its output goes to."""
self.console = console
def render(self, report: Report | None) -> None:
"""Print the report as one document, or nothing when there is no report at all."""
if report is None:
return
blocks = [block for block in report if not block.plain_only]
if not blocks:
return
self._write(self._document(blocks))
def _document(self, blocks: Sequence[Block]) -> Any:
"""Fold the blocks into the one value this command answers with."""
if len(blocks) == 1:
return self._value(blocks[0])
document: dict[str, Any] = {}
for block in blocks:
if isinstance(block, Facts):
document.update(self._facts(block))
else:
document[block.key] = self._value(block)
return document
def _value(self, block: Block) -> Any:
"""One block's document: an array for a listing, an object for a set of facts."""
if isinstance(block, Listing):
return [self._row(block, row) for row in block.rows]
if isinstance(block, Facts):
return self._facts(block)
raise TypeError(f"unrenderable block: {block!r}") # pragma: no cover - closed set
@staticmethod
def _row(block: Listing, row: Mapping[str, Any]) -> dict[str, Any]:
"""One record, with every declared key present (``null`` where it is absent)."""
return {
column.key: column.json(row.get(column.key))
for column in block.columns
if column.json is not None
}
@staticmethod
def _facts(block: Facts) -> dict[str, Any]:
"""One facts block as an object, keeping every declared key.
``omit_absent`` is a *plain* concession — ``info`` leaves out a reading the
firmware does not answer for, so a dash never claims the radio reported nothing.
A consumer cannot act on that distinction and pays a membership test on every
field for it, so the document keeps the key and writes ``null``.
"""
return {
column.key: column.json(block.values.get(column.key))
for column in block.fields
if column.json is not None
}
def _write(self, document: Any) -> None:
"""Write one compact document and terminate it with a newline."""
line = json.dumps(
document,
# A node name in Cyrillic is a node name, not a run of `\uXXXX`. The scripted
# console already reconfigures stdout to UTF-8 for exactly this class of
# character.
ensure_ascii=False,
# Compact and one line, so a stream and a one-shot answer are read the same
# way and `jq .` can re-indent for a person at no cost. Nothing can un-stream
# a document that was pretty-printed across four hundred lines.
separators=(",", ":"),
)
self.console.file.write(line + "\n")
self.console.file.flush()
@contextmanager
def stream(self, listing: Listing) -> Iterator[Any]:
"""Print one document per record, flushed, as each arrives.
Every line is the same shape, which is what makes the stream readable without a
discriminator: a consumer reads line by line and never has to ask which kind of
record it is holding. The closing per-node summary the plain face draws is marked
``plain_only`` for that reason — it is derivable from the records above it, and a
second shape mid-stream would cost every reader a branch it otherwise never needs.
"""
def row(record: Mapping[str, Any]) -> None:
self._write(self._row(listing, record))
yield row
def for_format(output: OutputFormat, console: Console) -> Renderer:
"""The renderer for one output format.
Args:
output: What the caller asked for.
console: The console to print on.
Returns:
The renderer.
"""
return JsonRenderer(console) if output is OutputFormat.JSON else PlainRenderer(console)
@contextmanager
def stream(ctx: AppContext, listing: Listing) -> Iterator[Any]:
"""Open a live listing on whichever renderer this run is using.
Args:
ctx: The application context, for its output format and console.
listing: The stream's shape (see :meth:`Renderer.stream`).
Yields:
A callable taking one typed row mapping.
"""
with for_format(ctx.output, ctx.console).stream(listing) as row:
yield row
def _lane_width(lane: Lane) -> int:
"""How wide a streamed lane is pinned.
A live capture cannot size its columns the way :func:`~meshterm.ui.script.columns`
does, because it has not seen the records yet. So each lane declares the width it is
pinned to, and a value wider than that overruns and pushes the rest of its row right
rather than being cut — which is why the one unbounded field goes last (see
:class:`meshterm.ui.script.Lanes`).
"""
return max(len(lane.header), lane.width)