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:
1 parent
5aeb034c06
commit
eaafc87403
8 files changed
+183
-18
No files matched your search
@@ -2,5 +2,5 @@
|
||||
[38;2;251;217;80m████[38;2;80;93;111m╗[0m [38;2;251;217;80m████[38;2;80;93;111m║[38;2;251;217;80m██[38;2;80;93;111m╔════╝[38;2;251;217;80m██[38;2;80;93;111m╔════╝[38;2;251;217;80m██[38;2;80;93;111m║[0m [38;2;251;217;80m██[38;2;80;93;111m║╚══[38;2;251;217;80m██[38;2;80;93;111m╔══╝[38;2;251;217;80m██[38;2;80;93;111m╔════╝[38;2;251;217;80m██[38;2;80;93;111m╔══[38;2;251;217;80m██[38;2;80;93;111m╗[38;2;251;217;80m████[38;2;80;93;111m╗[0m [38;2;251;217;80m████[38;2;80;93;111m║[0m
|
||||
[38;2;248;113;113m██[38;2;80;93;111m╔[38;2;248;113;113m████[38;2;80;93;111m╔[38;2;248;113;113m██[38;2;80;93;111m║[38;2;248;113;113m█████[38;2;80;93;111m╗[0m [38;2;248;113;113m███████[38;2;80;93;111m╗[38;2;248;113;113m███████[38;2;80;93;111m║[0m [38;2;248;113;113m██[38;2;80;93;111m║[0m [38;2;248;113;113m█████[38;2;80;93;111m╗[0m [38;2;248;113;113m██████[38;2;80;93;111m╔╝[38;2;248;113;113m██[38;2;80;93;111m╔[38;2;248;113;113m████[38;2;80;93;111m╔[38;2;248;113;113m██[38;2;80;93;111m║[0m
|
||||
[38;2;185;28;28m██[38;2;80;93;111m║╚[38;2;185;28;28m██[38;2;80;93;111m╔╝[38;2;185;28;28m██[38;2;80;93;111m║[38;2;185;28;28m██[38;2;80;93;111m╔══╝[0m [38;2;80;93;111m╚════[38;2;185;28;28m██[38;2;80;93;111m║[38;2;185;28;28m██[38;2;80;93;111m╔══[38;2;185;28;28m██[38;2;80;93;111m║[0m [38;2;185;28;28m██[38;2;80;93;111m║[0m [38;2;185;28;28m██[38;2;80;93;111m╔══╝[0m [38;2;185;28;28m██[38;2;80;93;111m╔══[38;2;185;28;28m██[38;2;80;93;111m╗[38;2;185;28;28m██[38;2;80;93;111m║╚[38;2;185;28;28m██[38;2;80;93;111m╔╝[38;2;185;28;28m██[38;2;80;93;111m║[0m
|
||||
[38;2;80;93;111m██[38;2;80;93;111m║[0m [38;2;80;93;111m╚═╝[0m [38;2;80;93;111m██[38;2;80;93;111m║[38;2;80;93;111m███████[38;2;80;93;111m╗[38;2;80;93;111m███████[38;2;80;93;111m║[38;2;80;93;111m██[38;2;80;93;111m║[0m [38;2;80;93;111m██[38;2;80;93;111m║[0m [38;2;80;93;111m██[38;2;80;93;111m║[0m [38;2;80;93;111m███████[38;2;80;93;111m╗[38;2;80;93;111m██[38;2;80;93;111m║[0m [38;2;80;93;111m██[38;2;80;93;111m║[38;2;80;93;111m██[38;2;80;93;111m║[0m [38;2;80;93;111m╚═╝[0m [38;2;80;93;111m██[38;2;80;93;111m║[0m
|
||||
[38;2;80;93;111m██║[0m [38;2;80;93;111m╚═╝[0m [38;2;80;93;111m██║███████╗███████║██║[0m [38;2;80;93;111m██║[0m [38;2;80;93;111m██║[0m [38;2;80;93;111m███████╗██║[0m [38;2;80;93;111m██║██║[0m [38;2;80;93;111m╚═╝[0m [38;2;80;93;111m██║[0m
|
||||
[38;2;80;93;111m╚═╝[0m [38;2;80;93;111m╚═╝╚══════╝╚══════╝╚═╝[0m [38;2;80;93;111m╚═╝[0m [38;2;80;93;111m╚═╝[0m [38;2;80;93;111m╚══════╝╚═╝[0m [38;2;80;93;111m╚═╝╚═╝[0m [38;2;80;93;111m╚═╝[0m
|
||||
File renamed without changes.
@@ -0,0 +1,6 @@
|
||||
[38;2;255;255;255m██[38;2;80;93;111m╗[0m [38;2;255;255;255m██[38;2;80;93;111m╗[38;2;255;255;255m█████[38;2;80;93;111m╗[38;2;255;255;255m████[38;2;80;93;111m╗[38;2;255;255;255m██[38;2;80;93;111m╗[38;2;255;255;255m██[38;2;80;93;111m╗[38;2;255;255;255m██████[38;2;80;93;111m╗[38;2;255;255;255m█████[38;2;80;93;111m╗[38;2;255;255;255m█████[38;2;80;93;111m╗[0m [38;2;255;255;255m██[38;2;80;93;111m╗[0m [38;2;255;255;255m██[38;2;80;93;111m╗[0m
|
||||
[38;2;251;217;80m███████[38;2;80;93;111m║[38;2;251;217;80m██[38;2;80;93;111m╔══╝[38;2;251;217;80m██[38;2;80;93;111m╔═╝[38;2;251;217;80m██[38;2;80;93;111m║[38;2;251;217;80m██[38;2;80;93;111m║╚═[38;2;251;217;80m██[38;2;80;93;111m╔═╝[38;2;251;217;80m██[38;2;80;93;111m╔══╝[38;2;251;217;80m██[38;2;80;93;111m╔═[38;2;251;217;80m██[38;2;80;93;111m╗[38;2;251;217;80m███████[38;2;80;93;111m║[0m
|
||||
[38;2;248;113;113m██[38;2;80;93;111m╔[38;2;248;113;113m█[38;2;80;93;111m╔[38;2;248;113;113m██[38;2;80;93;111m║[38;2;248;113;113m████[38;2;80;93;111m╗[0m [38;2;248;113;113m████[38;2;80;93;111m╗[38;2;248;113;113m█████[38;2;80;93;111m║[0m [38;2;248;113;113m██[38;2;80;93;111m║[0m [38;2;248;113;113m████[38;2;80;93;111m╗[0m [38;2;248;113;113m█████[38;2;80;93;111m╔╝[38;2;248;113;113m██[38;2;80;93;111m╔[38;2;248;113;113m█[38;2;80;93;111m╔[38;2;248;113;113m██[38;2;80;93;111m║[0m
|
||||
[38;2;185;28;28m██[38;2;80;93;111m║╚╝[38;2;185;28;28m██[38;2;80;93;111m║[38;2;185;28;28m██[38;2;80;93;111m╔═╝[0m [38;2;80;93;111m╚═[38;2;185;28;28m██[38;2;80;93;111m║[38;2;185;28;28m██[38;2;80;93;111m╔[38;2;185;28;28m██[38;2;80;93;111m║[0m [38;2;185;28;28m██[38;2;80;93;111m║[0m [38;2;185;28;28m██[38;2;80;93;111m╔═╝[0m [38;2;185;28;28m██[38;2;80;93;111m╔═[38;2;185;28;28m██[38;2;80;93;111m╗[38;2;185;28;28m██[38;2;80;93;111m║╚╝[38;2;185;28;28m██[38;2;80;93;111m║[0m
|
||||
[38;2;80;93;111m██║[0m [38;2;80;93;111m██║█████╗████║██║██║[0m [38;2;80;93;111m██║[0m [38;2;80;93;111m█████╗██║[0m [38;2;80;93;111m██║██║[0m [38;2;80;93;111m██║[0m
|
||||
[38;2;80;93;111m╚═╝[0m [38;2;80;93;111m╚═╝╚════╝╚═══╝╚═╝╚═╝[0m [38;2;80;93;111m╚═╝[0m [38;2;80;93;111m╚════╝╚═╝[0m [38;2;80;93;111m╚═╝╚═╝[0m [38;2;80;93;111m╚═╝[0m
|
||||
@@ -0,0 +1,6 @@
|
||||
██╗ ██╗█████╗████╗██╗██╗██████╗█████╗█████╗ ██╗ ██╗
|
||||
███████║██╔══╝██╔═╝██║██║╚═██╔═╝██╔══╝██╔═██╗███████║
|
||||
██╔█╔██║████╗ ████╗█████║ ██║ ████╗ █████╔╝██╔█╔██║
|
||||
██║╚╝██║██╔═╝ ╚═██║██╔██║ ██║ ██╔═╝ ██╔═██╗██║╚╝██║
|
||||
██║ ██║█████╗████║██║██║ ██║ █████╗██║ ██║██║ ██║
|
||||
╚═╝ ╚═╝╚════╝╚═══╝╚═╝╚═╝ ╚═╝ ╚════╝╚═╝ ╚═╝╚═╝ ╚═╝
|
||||
+50
-14
@@ -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 []
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user