From eaafc874037c6e5026e7194b62ef6f69e4bb63ec Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Fri, 7 Aug 2026 21:53:28 -0400 Subject: [PATCH] Narrow wordmark for narrow screens; move the art out of the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- meshterm/{ui => assets}/logo.ansi | 2 +- meshterm/{ui => assets}/logo.txt | 0 meshterm/assets/logo_53.ansi | 6 +++ meshterm/assets/logo_53.txt | 6 +++ meshterm/ui/logo.py | 64 +++++++++++++++++++++------ meshterm/ui/tui/frame.py | 11 +++-- scripts/colour-logo.py | 73 +++++++++++++++++++++++++++++++ tests/test_tui.py | 39 +++++++++++++++++ 8 files changed, 183 insertions(+), 18 deletions(-) rename meshterm/{ui => assets}/logo.ansi (81%) rename meshterm/{ui => assets}/logo.txt (100%) create mode 100644 meshterm/assets/logo_53.ansi create mode 100644 meshterm/assets/logo_53.txt create mode 100644 scripts/colour-logo.py diff --git a/meshterm/ui/logo.ansi b/meshterm/assets/logo.ansi similarity index 81% rename from meshterm/ui/logo.ansi rename to meshterm/assets/logo.ansi index c146453..9986004 100644 --- a/meshterm/ui/logo.ansi +++ b/meshterm/assets/logo.ansi @@ -2,5 +2,5 @@ ████╗ ████║██╔════╝██╔════╝██║ ██║╚══██╔══╝██╔════╝██╔══██╗████╗ ████║ ██╔████╔██║█████╗ ███████╗███████║ ██║ █████╗ ██████╔╝██╔████╔██║ ██║╚██╔╝██║██╔══╝ ╚════██║██╔══██║ ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║ -██║ ╚═╝ ██║███████╗███████║██║ ██║ ██║ ███████╗██║ ██║██║ ╚═╝ ██║ +██║ ╚═╝ ██║███████╗███████║██║ ██║ ██║ ███████╗██║ ██║██║ ╚═╝ ██║ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ diff --git a/meshterm/ui/logo.txt b/meshterm/assets/logo.txt similarity index 100% rename from meshterm/ui/logo.txt rename to meshterm/assets/logo.txt diff --git a/meshterm/assets/logo_53.ansi b/meshterm/assets/logo_53.ansi new file mode 100644 index 0000000..0f0912b --- /dev/null +++ b/meshterm/assets/logo_53.ansi @@ -0,0 +1,6 @@ +██╗ ██╗█████╗████╗██╗██╗██████╗█████╗█████╗ ██╗ ██╗ +███████║██╔══╝██╔═╝██║██║╚═██╔═╝██╔══╝██╔═██╗███████║ +██╔█╔██║████╗ ████╗█████║ ██║ ████╗ █████╔╝██╔█╔██║ +██║╚╝██║██╔═╝ ╚═██║██╔██║ ██║ ██╔═╝ ██╔═██╗██║╚╝██║ +██║ ██║█████╗████║██║██║ ██║ █████╗██║ ██║██║ ██║ +╚═╝ ╚═╝╚════╝╚═══╝╚═╝╚═╝ ╚═╝ ╚════╝╚═╝ ╚═╝╚═╝ ╚═╝ diff --git a/meshterm/assets/logo_53.txt b/meshterm/assets/logo_53.txt new file mode 100644 index 0000000..fcfc56d --- /dev/null +++ b/meshterm/assets/logo_53.txt @@ -0,0 +1,6 @@ +██╗ ██╗█████╗████╗██╗██╗██████╗█████╗█████╗ ██╗ ██╗ +███████║██╔══╝██╔═╝██║██║╚═██╔═╝██╔══╝██╔═██╗███████║ +██╔█╔██║████╗ ████╗█████║ ██║ ████╗ █████╔╝██╔█╔██║ +██║╚╝██║██╔═╝ ╚═██║██╔██║ ██║ ██╔═╝ ██╔═██╗██║╚╝██║ +██║ ██║█████╗████║██║██║ ██║ █████╗██║ ██║██║ ██║ +╚═╝ ╚═╝╚════╝╚═══╝╚═╝╚═╝ ╚═╝ ╚════╝╚═╝ ╚═╝╚═╝ ╚═╝ \ No newline at end of file diff --git a/meshterm/ui/logo.py b/meshterm/ui/logo.py index ac69988..02a9f46 100644 --- a/meshterm/ui/logo.py +++ b/meshterm/ui/logo.py @@ -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 [] diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index aa19074..0e80012 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -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 diff --git a/scripts/colour-logo.py b/scripts/colour-logo.py new file mode 100644 index 0000000..ac06dc3 --- /dev/null +++ b/scripts/colour-logo.py @@ -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() diff --git a/tests/test_tui.py b/tests/test_tui.py index e0bd40b..c3b8299 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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."""