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>
120 lines
5.2 KiB
Python
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)"
|
|
)
|