diff --git a/.gitattributes b/.gitattributes index 5b549a2..89d67ac 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,9 @@ # Patch files are fed to `git apply` against LF sources; a CR sneaking into a context line # is how a patch that applied yesterday stops applying on somebody else's checkout. *.patch text eol=lf + +# ANSI art is bytes, not text: codepage 437 rather than UTF-8, a SAUCE record on the end +# whose FileSize counts them, and CRLFs that are part of the canvas. Git guessed "binary" +# only because the art carried NULs for its blank cells; those are spaces now, so say it +# outright before core.autocrlf starts rewriting a wordmark's line endings. +*.ans -text diff --git a/meshterm/assets/logo.ans b/meshterm/assets/logo.ans index 75d9c9e..74a03cb 100644 Binary files a/meshterm/assets/logo.ans and b/meshterm/assets/logo.ans differ diff --git a/meshterm/ui/device_picker.py b/meshterm/ui/device_picker.py index bfb56e3..ca65b84 100644 --- a/meshterm/ui/device_picker.py +++ b/meshterm/ui/device_picker.py @@ -24,7 +24,6 @@ from typing import TYPE_CHECKING, Awaitable, Callable, Mapping, Optional from rich.cells import cell_len from rich.text import Text -from .. import copyright_notice from ..core.config import DeviceProfile from ..core.connection import DeviceAuthenticationError, DeviceCommandError from ..core.device_store import DeviceStore, RememberedDevice @@ -204,7 +203,6 @@ async def _smoke_test( verify(chosen, pin), title="Checking companion", banner=load_logo(), - footnote=copyright_notice(), ) except DeviceAuthenticationError: # The device answered the scan but won't connect until it's bonded (or the last PIN @@ -214,7 +212,6 @@ async def _smoke_test( error=pin_error, help_text="The 6-digit code shown on the device or in the MeshCore app", banner=load_logo(), - footnote=copyright_notice(), ) if entered is None: return None # the user gave up → back to the device list @@ -228,7 +225,7 @@ async def _smoke_test( notice.append(str(exc), style="warn") notice.append("\nChoose another device.") await ui.notify_startup( - notice, title="Can't connect yet", banner=load_logo(), footnote=copyright_notice() + notice, title="Can't connect yet", banner=load_logo() ) return None @@ -252,7 +249,6 @@ async def _smoke_test( ), title="Not a MeshCore device", banner=load_logo(), - footnote=copyright_notice(), ) return None @@ -317,7 +313,6 @@ async def prompt_device( items, default=default, banner=load_logo(), - footnote=copyright_notice(), ) # Esc (``None``) and the Quit row both mean "leave the picker" — surface that to the # caller as ``None`` so it can exit the program instead of continuing device-less. @@ -500,7 +495,6 @@ async def _add_network_device( validate=_validate, help_text=f"host or host:port — the port defaults to {DEFAULT_TCP_PORT}", banner=load_logo(), - footnote=copyright_notice(), ) if entered is None: return None # cancelled → back to the device list @@ -550,7 +544,6 @@ async def _remove_network_device( title="Remove network device", confirm_label="Remove", banner=load_logo(), - footnote=copyright_notice(), backdrop_items=backdrop_items, backdrop_default=device, ) diff --git a/meshterm/ui/logo.py b/meshterm/ui/logo.py index 7d348b3..e36364f 100644 --- a/meshterm/ui/logo.py +++ b/meshterm/ui/logo.py @@ -8,8 +8,8 @@ an ANSI-art editor arrives already finished and has no source but itself. Files 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. -Two things separate a real ``.ans`` from a text file with colour in it, and :func:`_rows` -handles both so the art stays authorable in the tools that drew it: +Three things separate a real ``.ans`` from a text file with colour in it, and :func:`_rows` +handles them all so the art stays authorable in the tools that drew it: * **Codepage 437.** The blocks and box-drawing (``█▓▒░`` and ``╔═╗``) are single bytes in DOS's codepage, not UTF-8. We try UTF-8 first and fall back, so a mark we generated @@ -18,6 +18,9 @@ handles both so the art stays authorable in the tools that drew it: lets the terminal wrap it, so the file's newlines are *not* the picture's rows. The canvas width comes from the SAUCE record on the end of the file — which has to be trimmed off in the same breath, being metadata rather than art. +* **Blank cells that aren't spaces.** An editor writes an untouched cell as a NUL, and a + DOS console dutifully leaves it blank. Rich measures it as nothing at all, so every one + swallowed a column and slid the rest of its row leftwards. 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 @@ -51,6 +54,11 @@ _SGR = re.compile(r"\x1b\[[0-9;]*m") _EOF_MARK = b"\x1a" _SAUCE_LEN = 128 +#: Cells a DOS console draws blank but a modern renderer would swallow or mis-measure: NUL +#: is how an art editor spells "nothing here", and codepage 437's 0xff is a hard space. +#: Both become a plain space, so the column they hold survives into the frame. +_BLANK_CELLS = {0x00: " ", 0xA0: " "} + def _split_sauce(raw: bytes) -> tuple[bytes, Optional[int]]: """The art alone, and the canvas width SAUCE claims for it (``None`` when unsigned). @@ -121,7 +129,8 @@ def _rows(name: str) -> list[str]: except OSError: return [] art, width = _split_sauce(raw) - lines = [line.rstrip("\r") for line in _decode(art).split("\n")] + text = _decode(art).translate(_BLANK_CELLS) + lines = [line.rstrip("\r") for line in text.split("\n")] if lines and lines[-1] == "": # drop the trailing newline's empty row lines.pop() if width: diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index 5132d2a..2c9f8d5 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -578,14 +578,11 @@ async def _startup(ctx: AppContext) -> bool: baudrate = ctx.profile.baudrate if ctx.profile else 115200 # Enumerate serial ports (instant) and scan for BLE companions (a few seconds), behind # the splash spinner so the wait reads as intentional rather than a hang. - from .. import copyright_notice - devices = await ctx.ui.busy_startup( "Scanning for companion devices…", discover_all(ble=True), title="Select a companion device", banner=load_logo(), - footnote=copyright_notice(), ) # The smoke test opens the radio; on success we keep that live connection and reuse # it for the session rather than reopening (boards often reset on each open). diff --git a/meshterm/ui/settings_offer.py b/meshterm/ui/settings_offer.py index d487e5c..5704edc 100644 --- a/meshterm/ui/settings_offer.py +++ b/meshterm/ui/settings_offer.py @@ -122,7 +122,6 @@ async def offer_remembered_settings(ctx: AppContext) -> None: async def _prompt(ctx: AppContext, drifted: list[SettingDrift]) -> object: """Show the startup offer and return the chosen action, or ``None`` if dismissed.""" - from .. import copyright_notice from .logo import load_logo count = len(drifted) @@ -160,7 +159,6 @@ async def _prompt(ctx: AppContext, drifted: list[SettingDrift]) -> object: items, default=_RESTORE, banner=load_logo(), - footnote=copyright_notice(), ) diff --git a/tests/test_tui.py b/tests/test_tui.py index a4a5808..8da95dd 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1140,8 +1140,8 @@ def test_device_picker_builds_aligned_columns(tmp_path) -> None: asyncio.run(prompt_device(_Ui(), devices, store, _never)) # The banner (wordmark) is passed through so the splash can draw it. assert captured["banner"] and any("█" in row for row in captured["banner"]) - # A copyright footnote rides along for the splash to render beside the logo. - assert captured["footnote"] == copyright_notice() + # No footnote: the wordmark carries its own copyright, so the splash adds none. + assert captured["footnote"] is None # Each device row's port sits at the same column, proving the name column is padded. rows = [ it.label.plain if hasattr(it.label, "plain") else it.label @@ -1562,8 +1562,8 @@ def test_device_picker_smoke_tests_and_reprompts(tmp_path) -> None: assert store.is_known(devices[0]) -def test_device_picker_shows_copyright_on_every_splash(tmp_path) -> None: - """The copyright rides along the logo on every splash — picker, spinner, and notices.""" +def test_device_picker_leaves_the_copyright_to_the_wordmark(tmp_path) -> None: + """No splash states the copyright itself — the wordmark's own art already carries it.""" from meshterm.core.device_store import DeviceStore from meshterm.core.discovery import DiscoveredDevice from meshterm.ui.device_picker import prompt_device @@ -1591,10 +1591,11 @@ def test_device_picker_shows_copyright_on_every_splash(tmp_path) -> None: return results.pop(0) asyncio.run(prompt_device(_Ui(), devices, store, verify)) - # Every splash draws the logo, so every splash carries the copyright beside it — the - # opening picker, the smoke-test spinner, the failure notice, and the re-opened picker. + # Every splash draws the logo, and the logo is where the copyright lives now — so none + # of them adds a line of its own: not the opening picker, the smoke-test spinner, the + # failure notice, nor the re-opened picker. assert len(footnotes) > 1 - assert all(footnote == copyright_notice() for _kind, footnote in footnotes) + assert all(footnote is None for _kind, footnote in footnotes) def test_device_picker_prompts_and_retries_ble_pin(tmp_path) -> None: