Files
MeshTerm/tests/test_logo.py
T
Jean-Pierre MartineauandClaude Opus 5 d3f901a5a1 A bright run says so in the colour it already had
Moving brightness out of bold and into the `9N` bank settled what a span says
when it names a colour. It left unsettled what happens when a sequence changes
the intensity and names no colour at all -- which is exactly how the art
announces a bright run. `ESC[1m` had its `1` stripped, the brightness was
remembered for whichever later sequence happened to name a foreground, and the
colour already in force was never restated. The run carried on in the dim bank
wearing the `22` that had been forced in front of it.

The wide mark does this twenty times, and five more as `ESC[1;4Nm`, where the
background survived and the brightness did not. On screen it reads as pairs of
half-blocks that should fade dark into light both coming out dark, most visibly
in the run beside the copyright line.

So the standing foreground is tracked, and restated in whichever bank now
applies whenever the intensity changes without one being named. A reset takes
the standing colour with it, because it takes the terminal's too.

There were no tests over the art loader at all -- `test_theme16.py` covers the
theme's own bold-brightness rules, which is a different question -- so this adds
some. The per-sequence invariant is the one that matters: counting bright
emissions across a file proves nothing, because one `1m` brightens every later
sequence and the totals stay high while individual runs go missing (149 emitted
against 64 requested, in a file that was drawing the bug). Every `1m` that fires
with a colour standing has to leave a bright foreground behind, and now each one
is checked.

Fixes: #11

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 02:52:44 -04:00

120 lines
5.2 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The splash art's colour rewriting: brightness said as a colour, never as bold.
The art is DOS-era ANSI, where `1m` lifts the foreground into the bright bank and every
span after it inherits that. Neither half of that survives the trip here: rows are handed
to Rich one at a time, so nothing inherits, and on macOS Terminal bold asks for a heavier
face and leaves the colour alone. So `_state_intensity` restates brightness as the
aixterm bright bank (`9N`), which can only ever mean a colour.
These pin the part that is easy to get subtly wrong — a sequence that changes the
intensity while naming no colour at all, which is exactly how the art announces a bright
run. Both splash files are full of bare `1m`, and dropping one silently draws a whole run
in the dim bank: two spans meant to read dark then light both come out dark.
"""
from __future__ import annotations
import re
from pathlib import Path
from meshterm.ui.logo import _state_intensity
_SPLASH = Path(__file__).resolve().parent.parent / "meshterm" / "assets" / "splash"
_SGR = re.compile(r"\x1b\[([0-9;]*)m")
def _params(text: str) -> list[list[str]]:
"""Every SGR sequence in `text`, split into its parameters.
Matched as whole tokens rather than by substring: a pattern like ``[0-9;]*1`` also
matches the trailing digit of ``31``, which would read a perfectly ordinary red as a
request for bold.
"""
return [found.group(1).split(";") for found in _SGR.finditer(text)]
def _brights(text: str) -> int:
"""How many sequences in `text` name a bright-bank foreground."""
return sum(
any(p.isdigit() and 90 <= int(p) <= 97 for p in parts)
for parts in _params(text)
)
# -- a bare intensity change ----------------------------------------------------------
def test_bare_bright_lifts_the_colour_already_in_force() -> None:
"""`1m` on its own brightens the standing foreground, not just the next one named."""
out = _state_intensity("\x1b[0;37mdim\x1b[1mbright")
assert "\x1b[97m" in out, out.replace("\x1b", "ESC")
def test_bare_bright_carries_a_background_without_losing_the_foreground() -> None:
"""`1;41m` names a background and no foreground; the standing one still brightens."""
out = _state_intensity("\x1b[0;32mgreen\x1b[1;41mbright on red")
assert "\x1b[41;92m" in out, out.replace("\x1b", "ESC")
def test_returning_to_dim_restates_the_colour_in_the_dim_bank() -> None:
"""`22m` has the same problem in reverse: the run would stay in the bright bank."""
out = _state_intensity("\x1b[0;31mred\x1b[1mbright\x1b[22mdim again")
assert out.endswith("dim again")
assert "\x1b[22;31m" in out, out.replace("\x1b", "ESC")
def test_a_reset_takes_the_standing_colour_with_it() -> None:
"""After `0m` there is no colour in force, so a later `1m` has nothing to restate."""
out = _state_intensity("\x1b[0;36mcyan\x1b[0m\x1b[1mplain")
tail = out.split("plain")[0].split("\x1b[0m")[-1]
assert _brights(tail) == 0, out.replace("\x1b", "ESC")
def test_a_named_foreground_is_still_rewritten_in_place() -> None:
"""The original behaviour: `1;33m` becomes `93m`, with the bold dropped."""
out = _state_intensity("\x1b[0m\x1b[1;33myellow")
assert "\x1b[93m" in out, out.replace("\x1b", "ESC")
# -- the art itself -------------------------------------------------------------------
def test_no_splash_row_asks_a_terminal_for_bold() -> None:
"""Bold is a font weight on macOS Terminal, so none may reach the screen."""
for art in sorted(_SPLASH.glob("*.ans")):
raw = art.read_bytes().decode("cp437", errors="replace")
for number, line in enumerate(raw.split("\n"), start=1):
for parts in _params(_state_intensity(line)):
assert "1" not in parts, f"{art.name}:{number} still asks for bold"
def test_no_bright_run_in_the_art_is_silently_dropped() -> None:
"""A `1m` with a colour standing must leave a bright-bank foreground behind.
Counting brights across a file cannot catch this -- one `1m` brightens every later
sequence, so the totals stay comfortably high while individual runs go missing. The
invariant is per sequence: walk the source keeping track of whether a colour is in
force, and every sequence that turns brightness on while one is must produce a
bright-bank emission in the rewritten row.
"""
for art in sorted(_SPLASH.glob("*.ans")):
raw = art.read_bytes().decode("cp437", errors="replace")
for number, line in enumerate(raw.split("\n"), start=1):
standing, owed = None, 0
for parts in _params(line):
for part in parts:
if part in ("", "0"):
standing = None
elif re.fullmatch(r"3[0-7]", part):
standing = part
if "1" in parts and standing is not None:
owed += 1
if owed:
assert _brights(_state_intensity(line)) >= owed, (
f"{art.name}:{number} turns brightness on {owed} time(s) with a "
f"colour standing, but the rewrite emits "
f"{_brights(_state_intensity(line))} bright foreground(s)"
)