`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>
313 lines
12 KiB
Python
313 lines
12 KiB
Python
"""The markdown renderer: what each construct becomes in MeshTerm's visual language.
|
|
|
|
The About pages are written rather than composed (see :mod:`meshterm.ui.markdown`), so
|
|
what is worth pinning here is the *translation* — that a heading takes the same accent
|
|
every body section uses, that prose and lists hang in their own block instead of falling
|
|
back to column 0, that a rail survives wrapping, that a link says where it goes on a
|
|
terminal where nothing is clickable — and that the whole vocabulary survives the
|
|
PicoCalc's 512 glyphs and 16 palette slots.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from rich.cells import cell_len
|
|
from rich.padding import Padding
|
|
from rich.text import Text
|
|
|
|
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
|
from meshterm.ui.fontset import FONT_CODEPOINTS
|
|
from meshterm.ui.markdown import MarkdownDoc, render_markdown
|
|
from meshterm.ui.tui.render import render_lines
|
|
from tests.conftest import plain
|
|
|
|
#: One page exercising every construct the renderer knows, used wherever a test wants
|
|
#: the whole vocabulary at once rather than one mark of it.
|
|
KITCHEN_SINK = """# MeshTerm *v0.1.0*
|
|
|
|
a terminal companion for MeshCore LoRa mesh devices
|
|
|
|
## What it is · the short version
|
|
|
|
MeshTerm is a **full-screen** companion for *MeshCore* devices, with `traces`,
|
|
maps and a ~~telepathic~~ courier queue over the top.
|
|
|
|
### Under the hood
|
|
|
|
- contacts, sorted however you like
|
|
- discovered nodes, and the ones you added
|
|
- a map you can walk with the arrow keys
|
|
|
|
1. plug the radio in
|
|
2. run `meshterm`
|
|
|
|
> Nothing here transmits without being asked.
|
|
|
|
```
|
|
meshterm --mock --platform picocalc
|
|
```
|
|
|
|
| Platform | Cols | Colours |
|
|
|---|---:|---|
|
|
| regular | 72 | truecolor |
|
|
|
|
Find it at [the repo](https://github.com/example/meshterm).
|
|
|
|
---
|
|
|
|
Copyright © 2026 Somebody
|
|
"""
|
|
|
|
|
|
def _lines(source: str, width: int = 72) -> list[str]:
|
|
"""The page as a reader sees it: plain text, one entry per rendered line.
|
|
|
|
Trailing padding is dropped — an indented block pads out to the full width, which no
|
|
reader can see and no assertion here is about.
|
|
"""
|
|
rendered = plain(render_lines(render_markdown(source), width)).splitlines()
|
|
return [line.rstrip() for line in rendered]
|
|
|
|
|
|
def _texts(source: str) -> list[Text]:
|
|
"""Every block of the page that is a run of text, unwrapped from its indent."""
|
|
texts = []
|
|
for block in render_markdown(source).blocks:
|
|
renderable = block.renderable
|
|
while isinstance(renderable, Padding):
|
|
renderable = renderable.renderable
|
|
if isinstance(renderable, Text):
|
|
texts.append(renderable)
|
|
return texts
|
|
|
|
|
|
def _spans(source: str) -> list[tuple[str, str]]:
|
|
"""Every styled run in the page's block texts, as ``(text, style)`` pairs."""
|
|
return [
|
|
(text.plain[span.start : span.end], str(span.style))
|
|
for text in _texts(source)
|
|
for span in text.spans
|
|
]
|
|
|
|
|
|
# -- headings and the page frame ------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("source", "style"),
|
|
[("# Title", "brand"), ("## Section", "accent"), ("### Sub", "md.strong")],
|
|
)
|
|
def test_each_heading_level_takes_the_hue_its_rank_calls_for(source, style) -> None: # noqa: ANN001
|
|
"""Each heading level takes the hue its rank calls for.
|
|
|
|
``#`` is the page's own name, ``##`` a section in the app's body accent, and
|
|
``###`` a sub-heading inside one.
|
|
"""
|
|
assert _spans(source)[0][1] == style
|
|
|
|
|
|
def test_a_headings_dot_opens_the_standard_muted_aside() -> None:
|
|
"""``## Title · note`` is the ``accent title · muted note`` shape body headings use."""
|
|
doc = render_markdown("## What it is · the short version")
|
|
heading = doc.blocks[0].renderable
|
|
|
|
assert heading.plain == "What it is · the short version"
|
|
assert [str(span.style) for span in heading.spans][-1] == "muted"
|
|
|
|
|
|
def test_emphasis_inside_a_heading_is_its_qualifier() -> None:
|
|
"""That is how the wordmark carries a muted version beside it, in one line of source."""
|
|
doc = render_markdown("# MeshTerm *v0.1.0*")
|
|
|
|
assert doc.blocks[0].renderable.plain == "MeshTerm v0.1.0"
|
|
assert [str(span.style) for span in doc.blocks[0].renderable.spans] == ["brand", "muted"]
|
|
|
|
|
|
def test_the_page_frame_sits_flush_and_muted() -> None:
|
|
"""The page frame sits flush and muted.
|
|
|
|
The standfirst under the title and the colophon under the closing rule describe
|
|
the page rather than saying anything in it, so both recede and neither is
|
|
indented.
|
|
"""
|
|
source = "# Title\n\nthe standfirst\n\n## Section\n\nbody\n\n---\n\nthe colophon\n"
|
|
lines = _lines(source)
|
|
styles = dict(_spans(source))
|
|
|
|
assert lines[1] == "the standfirst" # flush, not hanging at INDENT
|
|
assert lines[-1] == "the colophon"
|
|
assert styles["the standfirst"] == "muted" and styles["the colophon"] == "muted"
|
|
|
|
|
|
def test_a_paragraph_is_only_a_colophon_at_the_foot_of_the_page() -> None:
|
|
"""A rule mid-page is just a rule; the paragraph under it is ordinary prose."""
|
|
lines = _lines("## Section\n\n---\n\nstill body\n\nand more\n")
|
|
|
|
assert " still body" in lines
|
|
|
|
|
|
# -- prose, lists, rails --------------------------------------------------------------
|
|
|
|
|
|
def test_prose_hangs_under_its_heading_on_every_wrapped_line() -> None:
|
|
"""A continuation line falling back to column 0 would read as a new, unheaded thought."""
|
|
lines = [line for line in _lines("## S\n\n" + "word " * 40, 30) if line.strip()]
|
|
|
|
assert len(lines) > 3 # it really did wrap
|
|
assert all(line.startswith(" ") for line in lines[1:]), lines
|
|
|
|
|
|
def test_a_wrapped_list_item_aligns_under_its_own_text() -> None:
|
|
"""The bullet is a gutter, so the item's second line starts where its first line did."""
|
|
lines = [line for line in _lines("- " + "word " * 20, 30) if line.strip()]
|
|
|
|
assert lines[0].startswith(" • word")
|
|
assert all(line.startswith(" word") for line in lines[1:]), lines
|
|
|
|
|
|
def test_a_nested_list_steps_in_under_the_item_it_belongs_to() -> None:
|
|
"""And takes the sub-item bullet, so depth reads without counting spaces."""
|
|
lines = _lines("- parent\n - child\n")
|
|
|
|
assert lines[0] == " • parent"
|
|
assert lines[1].startswith(" ▸ child")
|
|
|
|
|
|
def test_a_numbered_list_counts_from_its_own_start() -> None:
|
|
"""Markdown's ``3.`` means three, and a two-digit run keeps its numbers aligned."""
|
|
lines = [line for line in _lines("9. nine\n10. ten\n") if line.strip()]
|
|
|
|
assert lines[0].startswith(" 9. nine")
|
|
assert lines[1].startswith(" 10. ten")
|
|
|
|
|
|
def test_a_quote_keeps_its_rail_down_every_line_it_wraps_to() -> None:
|
|
"""A quote keeps its rail down every line it wraps to.
|
|
|
|
A rail that only marked the first line would make a three-line quote look like
|
|
one quoted line followed by two loose ones.
|
|
"""
|
|
lines = [line for line in _lines("> " + "word " * 20, 30) if line.strip()]
|
|
|
|
assert len(lines) > 2
|
|
assert all(line.startswith(" │ ") for line in lines), lines
|
|
|
|
|
|
def test_a_fenced_block_keeps_its_own_lines() -> None:
|
|
"""Code is quoted verbatim, one rendered line per source line."""
|
|
lines = [line for line in _lines("```\none\ntwo\n```\n") if line.strip()]
|
|
|
|
assert [line.rstrip() for line in lines] == [" │ one", " │ two"]
|
|
|
|
|
|
def test_a_qr_fence_draws_the_code_rather_than_the_url_inside_it() -> None:
|
|
"""The one fence that isn't quoted: its body is a link, and the page shows the code.
|
|
|
|
Drawn live from the fence, never pasted in as half-block art — art loses a row whose
|
|
first module is light, because that row starts with a space and markdown eats it.
|
|
"""
|
|
source = """```qr
|
|
https://example.com/x
|
|
```
|
|
"""
|
|
lines = [line for line in _lines(source) if line.strip()]
|
|
|
|
assert lines, "the fence rendered nothing"
|
|
assert not any("example.com" in line for line in lines), "the URL was printed as text"
|
|
assert all(set(line.strip()) <= set("█▀▄ ") for line in lines), lines
|
|
# Two of the three finder squares sit at either end of the code's first module row.
|
|
top = lines[0].strip()
|
|
assert top.startswith("█▀▀▀▀▀█") and top.endswith("█▀▀▀▀▀█"), top
|
|
|
|
|
|
def test_a_rule_runs_the_full_width_of_the_page() -> None:
|
|
"""``---`` is a page rule, not an indented one — it separates, so it spans."""
|
|
line = next(line for line in _lines("a\n\n---\n\nb\n", 40) if "─" in line)
|
|
|
|
assert line == "─" * 40
|
|
|
|
|
|
# -- inline marks ---------------------------------------------------------------------
|
|
|
|
|
|
def test_each_inline_mark_takes_its_own_style() -> None:
|
|
"""Strong, emphasis, code and a struck-out run are four voices, not one."""
|
|
styles = dict(_spans("**bold** *soft* `code` ~~gone~~\n"))
|
|
|
|
assert styles["bold"] == "md.strong"
|
|
assert styles["soft"] == "md.em"
|
|
assert styles["code"] == "md.code"
|
|
assert styles["gone"] == "md.strike"
|
|
|
|
|
|
def test_a_link_shows_where_it_goes() -> None:
|
|
"""A link shows where it goes.
|
|
|
|
Nothing is clickable on a framebuffer console, so a link that reads "the repo" has
|
|
to name the repo or it says nothing at all — and with the scheme stripped, it is
|
|
noise.
|
|
"""
|
|
text = _texts("see [the repo](https://www.github.com/example/meshterm/)\n")[0]
|
|
|
|
assert text.plain == "see the repo github.com/example/meshterm"
|
|
|
|
|
|
def test_a_link_that_already_says_its_target_says_it_once() -> None:
|
|
"""A bare URL or an autolink is its own label; repeating it would just eat cells."""
|
|
text = _texts("mail <hello@example.com> or <https://example.com>\n")[0]
|
|
|
|
assert text.plain == "mail hello@example.com or https://example.com"
|
|
|
|
|
|
def test_a_table_aligns_the_way_its_markdown_says() -> None:
|
|
"""``---:`` is a right-aligned column, and the header keeps the body's own hairline."""
|
|
lines = [line for line in _lines("| A | B |\n|---|--:|\n| 1 | 22222 |\n", 30) if line.strip()]
|
|
|
|
assert lines[0].split() == ["A", "B"]
|
|
assert "─" in lines[1]
|
|
assert lines[2].rstrip().endswith("22222")
|
|
|
|
|
|
# -- the document itself --------------------------------------------------------------
|
|
|
|
|
|
def test_sections_are_the_pages_h2_headings() -> None:
|
|
"""The landmarks a sticky heading and a section jump step by."""
|
|
doc = render_markdown("# Title\n\n## One\n\na\n\n### deeper\n\n## Two\n\nb\n")
|
|
|
|
assert doc.sections == ("One", "Two")
|
|
|
|
|
|
def test_a_page_written_entirely_in_top_level_headings_still_has_sections() -> None:
|
|
"""One authoring style shouldn't silently cost a page its landmarks."""
|
|
assert render_markdown("# One\n\na\n\n# Two\n\nb\n").sections == ("One", "Two")
|
|
|
|
|
|
def test_the_document_is_a_plain_renderable_too() -> None:
|
|
"""The scripted CLI prints the same page it opens (``meshterm about``)."""
|
|
assert isinstance(render_markdown(KITCHEN_SINK), MarkdownDoc)
|
|
assert _lines(KITCHEN_SINK)[0] == "MeshTerm v0.1.0"
|
|
|
|
|
|
# -- the console --------------------------------------------------------------------
|
|
|
|
|
|
def test_every_construct_survives_the_picocalc() -> None:
|
|
"""The whole vocabulary inside 53 cells, the 512-glyph font and the 16 slots.
|
|
|
|
The gallery makes this promise for the About pages themselves; this makes it for
|
|
every construct a page *could* grow, so a table or a code fence added to one of them
|
|
is a known quantity rather than tofu found on-device.
|
|
"""
|
|
set_platform(PICOCALC)
|
|
try:
|
|
rendered = render_lines(render_markdown(KITCHEN_SINK), PICOCALC.readable_cols)
|
|
finally:
|
|
set_platform(REGULAR)
|
|
|
|
for index, line in enumerate(rendered):
|
|
assert cell_len(plain(line)) <= PICOCALC.readable_cols, (index, line)
|
|
assert "[38;2;" not in line and "[38;5;" not in line, (index, line)
|
|
strays = {ch for ch in plain(line) if ord(ch) >= 0x20 and ord(ch) not in FONT_CODEPOINTS}
|
|
assert not strays, (index, sorted(strays), line)
|