`E501` across 69 lines, wrapped rather than exempted. The prose ones — module docstrings in `menu`, `node_detail_screen`, `pathline`, `pathgraph`, `walk_screen`, `trace_runner` and `test_about` — are reflowed as paragraphs, so the block keeps its shape instead of gaining one short orphan line. Nothing they say changed. The code ones mostly gained a line break inside a call. Where a break would have read worse than a name, the sub-expression got one instead: `reach` in `topology`'s quality clamp, `plain_cells` in `pathline`, `numbering` in the trace list, `listed` in `flash.py`'s refusal, and a small `shape`/`is_strip` helper in two tests that were comparing or matching the same shape twice on one line. Four docstring summaries lost a word they were not using. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
233 lines
10 KiB
Python
233 lines
10 KiB
Python
"""The MeshTerm wordmark for the startup splash, loaded from the assets folder.
|
|
|
|
The art itself lives in ``meshterm/assets/splash`` as pre-coloured ``.ans`` files — the
|
|
classic ANSI-art extension — one per canvas width, each named for the width it was drawn
|
|
at: ``logo.71.ans``, ``logo.53.ans``. Each mark is drawn by hand in an art editor and needs
|
|
no source but itself: there is nothing here that generates a wordmark, and nothing to
|
|
re-run after editing one. Files are read fresh on every call, so the mark can be re-styled
|
|
by editing the art alone: no code change and no restart of the design loop — and a new size
|
|
is a file dropped in the folder, since the ladder is read from the names (:func:`_variants`)
|
|
rather than listed here.
|
|
|
|
Three things separate a real ``.ans`` from a text file with colour in it, and :func:`_rows`
|
|
handles them all so the art stays authorable in the tools that drew it:
|
|
|
|
* **Codepage 437.** The blocks and box-drawing (``█▓▒░`` and ``╔═╗``) are single bytes in
|
|
DOS's codepage, not UTF-8. We try UTF-8 first and fall back, so a mark saved as UTF-8 and
|
|
one exported from an art editor both read.
|
|
* **Auto-wrap.** An art editor omits the line break on a row that fills the canvas and
|
|
lets the terminal wrap it, so the file's newlines are *not* the picture's rows. The
|
|
canvas width comes from the SAUCE record on the end of the file — which has to be
|
|
trimmed off in the same breath, being metadata rather than art.
|
|
* **Blank cells that aren't spaces.** An editor writes an untouched cell as a NUL, and a
|
|
DOS console dutifully leaves it blank. Rich measures it as nothing at all, so every one
|
|
swallowed a column and slid the rest of its row leftwards.
|
|
|
|
A fourth trap belongs to the *art* rather than the loader: codepage 437's first 32 bytes
|
|
are pictures on a DOS console (``►◄‼``) and control characters everywhere else, so a mark
|
|
that draws with them measures short here and, at ``0x13``, sends XOFF to a real console.
|
|
There is nothing to decode there — the byte has to become a glyph in the file — so it is
|
|
held by a test instead (``test_theme16``), alongside the one that keeps the narrow mark
|
|
inside the console font.
|
|
|
|
There is more than one mark, at different widths, and the *screen* picks — not the
|
|
platform. A 53-column PicoCalc console and a desktop terminal dragged narrow have the same
|
|
problem, so :func:`load_logo` answers the only question that matters: which is the widest
|
|
mark that fits the columns I have?
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from rich.cells import cell_len
|
|
from rich.text import Text
|
|
|
|
#: Where the art lives — a sibling of the code, not mixed into it, and inside the package
|
|
#: so it ships with an installed wheel.
|
|
_ASSETS = Path(__file__).resolve().parent.parent / "assets" / "splash"
|
|
|
|
#: ``logo.<width>.ans`` — a mark's file is named for the canvas it was drawn on, so the
|
|
#: folder listing *is* the size ladder and :func:`_variants` needs to read nothing else.
|
|
#: (The old 8.3 spelling is gone with it: the editors that draw this stuff manage a second
|
|
#: dot, and a name that states its width is worth more than a name DOS could have opened.)
|
|
_NAMED_WIDTH = re.compile(r"logo\.(\d+)\.ans$")
|
|
|
|
#: A colour change and nothing else. Every escape these marks use is an SGR, so re-breaking
|
|
#: a row only ever has to carry a *colour* across the seam — never a cursor move.
|
|
_SGR = re.compile(r"\x1b\[[0-9;]*m")
|
|
|
|
#: A foreground on the dim bank, said without a word about intensity. Bold *is* brightness
|
|
#: on the PicoCalc console, so a bare one inherits whatever the span before it left set and
|
|
#: lands a bank too high — the theme's own styles state their intent for the same reason.
|
|
_DIM_FG = re.compile(r"3[0-7]")
|
|
|
|
#: Parameters that settle the intensity question themselves, so a span carrying one needs
|
|
#: no help: a reset, bold, faint, or an explicit return to normal.
|
|
_SAYS_INTENSITY = frozenset({"", "0", "1", "2", "22"})
|
|
|
|
#: SAUCE rides on the end of an art file behind DOS's end-of-file mark: 128 bytes naming
|
|
#: the author, the canvas and the font. None of it is meant to reach the screen.
|
|
_EOF_MARK = b"\x1a"
|
|
_SAUCE_LEN = 128
|
|
|
|
#: Cells a DOS console draws blank but a modern renderer would swallow or mis-measure: NUL
|
|
#: is how an art editor spells "nothing here", and codepage 437's 0xff is a hard space.
|
|
#: Both become a plain space, so the column they hold survives into the frame.
|
|
_BLANK_CELLS = {0x00: " ", 0xA0: " "}
|
|
|
|
|
|
def _variants() -> list[str]:
|
|
"""Every mark in the folder, widest first.
|
|
|
|
Adding a size is dropping the file in — nothing here lists them. The width in the name
|
|
only *orders* the ladder; which mark fits is still settled by measuring the art itself
|
|
(see :func:`load_logo`), so a mark whose name overstates it is caught rather than
|
|
trusted. A file that doesn't spell a width is not a mark and is passed over.
|
|
"""
|
|
named = []
|
|
for path in _ASSETS.glob("logo.*.ans"):
|
|
found = _NAMED_WIDTH.search(path.name)
|
|
if found:
|
|
named.append((int(found.group(1)), path.name))
|
|
return [name for _, name in sorted(named, reverse=True)]
|
|
|
|
|
|
def _split_sauce(raw: bytes) -> tuple[bytes, int | None]:
|
|
"""The art alone, and the canvas width SAUCE claims for it (``None`` when unsigned).
|
|
|
|
A file with no record is returned whole and unmeasured: its newlines are its rows, and
|
|
re-breaking one would be inventing a canvas the author never declared.
|
|
"""
|
|
cut = raw.rfind(_EOF_MARK)
|
|
if cut < 0:
|
|
return raw, None
|
|
trailer = raw[cut + 1 :]
|
|
if not trailer.startswith(b"SAUCE") or len(trailer) < _SAUCE_LEN:
|
|
return raw, None
|
|
width = int.from_bytes(trailer[96:98], "little") # TInfo1 — characters per row
|
|
return raw[:cut], width or None
|
|
|
|
|
|
def _decode(raw: bytes) -> str:
|
|
"""An art file's text, in whichever of the two codepages it was written."""
|
|
try:
|
|
return raw.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return raw.decode("cp437")
|
|
|
|
|
|
def _rewrap(line: str, width: int) -> list[str]:
|
|
"""Re-break one auto-wrapping line into rows of ``width`` printable cells.
|
|
|
|
The rows are drawn independently — each becomes its own ``Text.from_ansi`` — so a
|
|
colour set before the seam has to be restated after it. A real terminal needs no such
|
|
help: it never stopped reading the one stream, so the state simply persisted.
|
|
"""
|
|
rows: list[str] = []
|
|
buf: list[str] = []
|
|
state = ""
|
|
cells = 0
|
|
at = 0
|
|
while at < len(line):
|
|
found = _SGR.match(line, at)
|
|
if found:
|
|
seq = found.group()
|
|
buf.append(seq)
|
|
params = seq[2:-1]
|
|
if params in ("", "0"):
|
|
state = "" # a plain reset leaves nothing to restate
|
|
elif params.split(";")[0] in ("", "0"):
|
|
state = seq # reset-and-set: this sequence *is* the whole state
|
|
else:
|
|
state += seq
|
|
at = found.end()
|
|
continue
|
|
buf.append(line[at])
|
|
at += 1
|
|
cells += 1
|
|
if cells == width:
|
|
rows.append("".join(buf))
|
|
buf = [state] if state else []
|
|
cells = 0
|
|
if cells or not rows: # the short last row, or a blank line that is still a row
|
|
rows.append("".join(buf))
|
|
return rows
|
|
|
|
|
|
def _state_intensity(row: str) -> str:
|
|
"""Rewrite the row's colour changes so every one says whether it is bright.
|
|
|
|
An art editor spells brightness the DOS way — ``1m`` lifts the bank and each span after
|
|
it inherits that — which reads correctly only while the escapes stay one unbroken
|
|
stream. They don't here: a row is handed to Rich on its own, and the console draws bold
|
|
*as* the bright bank, so a span that never mentions intensity is read against whatever
|
|
happened to be set. Saying it outright changes no colour; it just stops one drifting.
|
|
"""
|
|
out: list[str] = []
|
|
bright = False
|
|
at = 0
|
|
while at < len(row):
|
|
found = _SGR.match(row, at)
|
|
if not found:
|
|
out.append(row[at])
|
|
at += 1
|
|
continue
|
|
params = found.group()[2:-1]
|
|
parts = params.split(";") if params else [""]
|
|
for part in parts: # what this sequence leaves the intensity set to
|
|
if part in ("", "0", "22"):
|
|
bright = False
|
|
elif part == "1":
|
|
bright = True
|
|
dim = any(_DIM_FG.fullmatch(p) for p in parts)
|
|
if dim and not any(p in _SAYS_INTENSITY for p in parts):
|
|
out.append("\x1b[" + ";".join(["1" if bright else "22"] + parts) + "m")
|
|
else:
|
|
out.append(found.group())
|
|
at = found.end()
|
|
return "".join(out)
|
|
|
|
|
|
def _rows(name: str) -> list[str]:
|
|
"""The named mark's rows, or ``[]`` when it can't be read."""
|
|
try:
|
|
raw = (_ASSETS / name).read_bytes()
|
|
except OSError:
|
|
return []
|
|
art, width = _split_sauce(raw)
|
|
text = _decode(art).translate(_BLANK_CELLS)
|
|
lines = [line.rstrip("\r") for line in text.split("\n")]
|
|
if lines and lines[-1] == "": # drop the trailing newline's empty row
|
|
lines.pop()
|
|
if width:
|
|
lines = [row for line in lines for row in _rewrap(line, width)]
|
|
return [_state_intensity(row) for row in lines]
|
|
|
|
|
|
def logo_width(rows: list[str]) -> int:
|
|
"""The display width of a mark's widest row, escape sequences discounted."""
|
|
return max((cell_len(Text.from_ansi(row).plain) for row in rows), default=0)
|
|
|
|
|
|
def load_logo(max_cols: int | None = None) -> list[str]:
|
|
"""Return the widest wordmark that fits ``max_cols``, as pre-coloured ANSI rows.
|
|
|
|
Args:
|
|
max_cols: The columns available to draw in. ``None`` asks for the full-size mark
|
|
without fitting — for a caller that will fit it later (see
|
|
:func:`~meshterm.ui.tui.frame.compose_startup`, which knows the real width
|
|
only at compose time).
|
|
|
|
Returns:
|
|
The mark's rows, or ``[]`` when none fits (or none could be read) — the splash
|
|
draws no banner at all rather than a torn one, and a missing file degrades the
|
|
same way rather than raising.
|
|
"""
|
|
for name in _variants():
|
|
rows = _rows(name)
|
|
if rows and (max_cols is None or logo_width(rows) <= max_cols):
|
|
return rows
|
|
return []
|