Narrow wordmark for narrow screens; move the art out of the code

JP drew a 53-cell MeshTerm at the original's height. It gets the same
colouring: the vertical ramp — white, yellow, light red, red, light
black — with every box-drawing character, shadow row and letter bevel
alike, in the light black.

The art moves to meshterm/assets/. It was sitting in meshterm/ui/ among
the modules, which is the wrong place for a file nothing imports; it
still ships inside the package, since the splash reads it at runtime and
an installed venv has no repo to read from. scripts/colour-logo.py paints
a .txt into its .ansi, so the palette is stated once in code instead of
retyped into escape sequences by hand, and re-styling stays a text edit.

Which mark is drawn is a question about the *screen*, not the platform: a
53-column console and a desktop terminal dragged narrow have the same
problem. load_logo(cols) answers it — widest that fits — and
compose_startup asks, since it is the only place that knows the width.
Below the smallest mark it returns nothing at all; no banner beats a torn
one. A test now draws the real wordmark at each platform's width, which
is the gate the splash never had (it is the one screen the gallery
doesn't cover, which is how a 71-cell mark shipped to a 53-cell console).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-07 21:53:28 -04:00
1 parent 5aeb034c06
commit eaafc87403
8 files changed
+183 -18

No files matched your search

@@ -2,5 +2,5 @@
████╗ ████║██╔════╝██╔════╝██║ ██║╚══██╔══╝██╔════╝██╔══██╗████╗ ████║
██╔████╔██║█████╗ ███████╗███████║ ██║ █████╗ ██████╔╝██╔████╔██║
██║╚██╔╝██║██╔══╝ ╚════██║██╔══██║ ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║
██║ ╚═╝ ██║███████╗███████║██║ ██║ ██║ ███████╗██║ ██║██║ ╚═╝ ██║
██║ ╚═╝ ██║███████╗███████║██║ ██║ ██║ ███████╗██║ ██║██║ ╚═╝ ██║
╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝
File renamed without changes.
+6
View File
@@ -0,0 +1,6 @@
██╗ ██╗█████╗████╗██╗██╗██████╗█████╗█████╗ ██╗ ██╗
███████║██╔══╝██╔═╝██║██║╚═██╔═╝██╔══╝██╔═██╗███████║
██╔█╔██║████╗ ████╗█████║ ██║ ████╗ █████╔╝██╔█╔██║
██║╚╝██║██╔═╝ ╚═██║██╔██║ ██║ ██╔═╝ ██╔═██╗██║╚╝██║
██║ ██║█████╗████║██║██║ ██║ █████╗██║ ██║██║ ██║
╚═╝ ╚═╝╚════╝╚═══╝╚═╝╚═╝ ╚═╝ ╚════╝╚═╝ ╚═╝╚═╝ ╚═╝
+6
View File
@@ -0,0 +1,6 @@
██╗ ██╗█████╗████╗██╗██╗██████╗█████╗█████╗ ██╗ ██╗
███████║██╔══╝██╔═╝██║██║╚═██╔═╝██╔══╝██╔═██╗███████║
██╔█╔██║████╗ ████╗█████║ ██║ ████╗ █████╔╝██╔█╔██║
██║╚╝██║██╔═╝ ╚═██║██╔██║ ██║ ██╔═╝ ██╔═██╗██║╚╝██║
██║ ██║█████╗████║██║██║ ██║ █████╗██║ ██║██║ ██║
╚═╝ ╚═╝╚════╝╚═══╝╚═╝╚═╝ ╚═╝ ╚════╝╚═╝ ╚═╝╚═╝ ╚═╝
+50 -14
View File
@@ -1,30 +1,66 @@
"""The MeshTerm wordmark for the startup splash, loaded from ``logo.ansi``.
"""The MeshTerm wordmark for the startup splash, loaded from the assets folder.
The art lives in a sibling ``logo.ansi`` file (pre-coloured ANSI, one row per line) and is
read fresh on every call, so the wordmark can be re-styled by editing that file alone — no
code change or restart of the design loop required.
The art itself lives in ``meshterm/assets`` — hand-drawn ``.txt`` sources beside the
pre-coloured ``.ansi`` the splash actually draws (``scripts/colour-logo.py`` paints one
into the other). Files are read fresh on every call, so the wordmark can be re-styled by
editing the art alone: no code change and no restart of the design loop.
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
from pathlib import Path
from typing import Optional
#: The ANSI art file, kept beside this module so it ships inside the package.
_LOGO_PATH = Path(__file__).with_name("logo.ansi")
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"
#: Every wordmark, widest first. :func:`load_logo` walks this and takes the first that
#: fits, so adding a size is dropping a file in and naming it here.
_VARIANTS: tuple[str, ...] = ("logo.ansi", "logo_53.ansi")
def load_logo() -> list[str]:
"""Return the wordmark's rows (pre-coloured ANSI strings), or ``[]`` if unavailable.
Read from :data:`_LOGO_PATH` on each call so an edit to the art is picked up without a
restart. A missing or unreadable file degrades to no banner rather than raising, so the
splash still works.
"""
def _rows(name: str) -> list[str]:
"""The named mark's rows, or ``[]`` when it can't be read."""
try:
text = _LOGO_PATH.read_text(encoding="utf-8")
text = (_ASSETS / name).read_text(encoding="utf-8")
except OSError:
return []
lines = text.split("\n")
if lines and lines[-1] == "": # drop the trailing newline's empty row
lines.pop()
return 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: Optional[int] = 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 []
+8 -3
View File
@@ -16,6 +16,7 @@ from rich.panel import Panel
from rich.text import Text
from ...platforms import Platform, get_platform, on_platform
from ..logo import load_logo, logo_width
from ..theme import fold_text, hint_style, title_style
from .glow import apply_corner_glow
from .render import render_lines
@@ -350,14 +351,18 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
Returns:
An ANSI string of exactly ``rows`` lines, each within ``cols`` columns.
"""
banner = _banner_lines(screen.banner or [], cols)
# A screen names its wordmark; the width to draw it at is only known here, so this is
# where the size is chosen — a narrow terminal gets a narrow mark rather than a torn one.
raw_banner = screen.banner or []
if raw_banner and logo_width(raw_banner) > cols:
raw_banner = load_logo(cols)
banner = _banner_lines(raw_banner, cols)
banner_h = len(banner)
gap = 1 if banner else 0 # the blank line under the banner
# The logo's own left margin and width, so the footnote below can hang off its right edge
# (the wordmark is centered as one block, so every row shares this margin).
raw_banner = screen.banner or []
logo_w = max((cell_len(Text.from_ansi(row).plain) for row in raw_banner), default=0)
logo_w = logo_width(raw_banner)
logo_right = max(0, (cols - logo_w) // 2) + logo_w
# Size the box to its widest real row (probe at a generous width, then measure), never
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Colour a plain-text wordmark into the pre-coloured ANSI the splash draws.
The art itself is hand-drawn and lives in ``meshterm/assets`` as a ``.txt``; this paints
it and writes the matching ``.ansi`` beside it, so the colour scheme is stated once here
rather than re-typed into every escape sequence by hand.
python scripts/colour-logo.py # repaint every asset/*.txt
python scripts/colour-logo.py logo_53 # just that one
The scheme is a vertical ramp over the letter rows — white, yellow, light red, red, light
black, hottest at the top — with every box-drawing character (the drop shadow and the
letters' own bevels) in the light black regardless of its row. Each colour is chosen so the
PicoCalc console's 16-slot quantizer lands it on exactly one palette slot (15, 11, 9, 1, 8)
while it stays a warm fade in truecolour on the desktop; see ``theme._VT_SLOTS``.
"""
from __future__ import annotations
import sys
from pathlib import Path
ASSETS = Path(__file__).resolve().parent.parent / "meshterm" / "assets"
#: Letter-row colours, hottest first. A mark with more rows than this reuses the last one.
ROW_RGB = ("255;255;255", "251;217;80", "248;113;113", "185;28;28", "80;93;111")
#: Every non-block character — the drop shadow and the bevels — takes this.
SHADOW_RGB = "80;93;111"
#: The letter body. Everything else in the art is shadow.
BLOCK = "█"
def colour(art: str) -> str:
"""Paint one plain-text wordmark, returning the pre-coloured ANSI."""
lines = art.split("\n")
if lines and lines[-1] == "":
lines.pop()
out_lines = []
for row, line in enumerate(lines):
hue = ROW_RGB[min(row, len(ROW_RGB) - 1)]
out, current = [], None
for ch in line:
if ch == " ":
if current is not None:
out.append("\x1b[0m")
current = None
out.append(" ")
continue
want = hue if ch == BLOCK else SHADOW_RGB
if want != current:
out.append(f"\x1b[38;2;{want}m")
current = want
out.append(ch)
if current is not None:
out.append("\x1b[0m")
out_lines.append("".join(out))
return "\n".join(out_lines) + "\n"
def main() -> None:
"""Repaint the named marks, or every ``.txt`` in the assets folder."""
names = sys.argv[1:] or sorted(p.stem for p in ASSETS.glob("*.txt"))
for name in names:
source = ASSETS / f"{name}.txt"
target = ASSETS / f"{name}.ansi"
target.write_text(colour(source.read_text(encoding="utf-8")), encoding="utf-8")
print(f"{source.name} -> {target.name}")
if __name__ == "__main__":
main()
+39
View File
@@ -741,6 +741,45 @@ def test_compose_startup_is_chromeless_and_shows_banner() -> None:
assert all(len(line.rstrip()) < 80 for line in plain.split("\n"))
def test_startup_splash_fits_every_platform_width() -> None:
"""The real wordmark, drawn at each platform's own width, never overruns it.
The full-size mark is 71 cells; a PicoCalc console is 53. The splash is the one screen
the gallery doesn't cover, and it shipped torn there until the narrow mark landed —
so this is the gate.
"""
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.logo import load_logo
try:
for platform in (REGULAR, PICOCALC):
set_platform(platform)
cols = platform.readable_cols
screen = SelectScreen("Choose a device", [Choice("alpha", 1)])
screen.chrome = False
screen.banner = load_logo() # what a real caller sets: the full-size mark
screen.footnote = copyright_notice()
out = frame.compose_startup(screen, cols, platform.readable_rows)
for i, line in enumerate(Text.from_ansi(out).plain.split("\n")):
assert cell_len(line) <= cols, (
f"{platform.name} splash line {i} is {cell_len(line)} cells, over {cols}"
)
finally:
set_platform(REGULAR)
def test_logo_takes_the_widest_mark_that_fits_the_columns() -> None:
"""The screen picks the size, not the platform — a narrow desktop gets the small mark."""
from meshterm.ui.logo import load_logo, logo_width
wide = logo_width(load_logo())
narrow = logo_width(load_logo(53))
assert 0 < narrow <= 53 < wide
assert logo_width(load_logo(wide)) == wide # room for the big one → the big one
assert logo_width(load_logo(wide - 1)) == narrow # a column short → step down
assert load_logo(narrow - 1) == [] # nothing fits: no banner beats a torn one
def test_compose_startup_shows_footnote_under_logo() -> None:
"""A footnote (e.g. a copyright) is drawn muted, immediately under the logo and
right-aligned to the logo's right edge, so the two read as one signed block."""