Copyright: one canonical notice, hung under the logo

Consolidate the copyright into a single meshterm.copyright_notice() beside
__version__ (was defined twice, disagreeing on wording, holder, and year
span). Every startup splash draws from it, so it can never drift.

Place it immediately under the wordmark, right-justified to the logo edge, so
the two read as one signed block. Simplify the display logic: it now rides
every splash that shows the logo (picker, spinner, PIN, notices) rather than
being dropped after the first selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-14 02:07:53 -04:00
1 parent 216a6549f5
commit a27b5951b7
5 files changed
+64 -49

No files matched your search

+19
View File
@@ -1,3 +1,22 @@
"""MeshTerm: a modern command-line toolkit for tuning and exploring a MeshCore mesh."""
from datetime import datetime
__version__ = "0.1.0"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
COPYRIGHT_START_YEAR = 2026
def copyright_notice() -> str:
"""The one canonical copyright line, dated from :data:`COPYRIGHT_START_YEAR` to now.
A single publication year collapses to just that year ("Copyright © 2026 …"); once
time has moved on it becomes a span ("Copyright © 2026-2027 …"). Every splash and
screen that shows a copyright draws it from here so the wording, holder, and span can
never drift apart.
"""
year = datetime.now().year
span = str(COPYRIGHT_START_YEAR) if year <= COPYRIGHT_START_YEAR else f"{COPYRIGHT_START_YEAR}-{year}"
return f"Copyright © {span} {__author__}"
+8 -23
View File
@@ -19,12 +19,12 @@ device; anything else sends the user back to the list to choose another.
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Awaitable, Callable, Optional
from rich.cells import cell_len
from rich.text import Text
from .. import copyright_notice
from ..core.connection import DeviceAuthenticationError, DeviceCommandError
from ..core.device_store import DeviceStore, RememberedDevice
from ..core.discovery import DiscoveredDevice
@@ -81,11 +81,6 @@ def _type_cell(device: DiscoveredDevice) -> Text:
return cell
def _copyright() -> str:
"""The splash's muted copyright line, dated to the current year."""
return f"© {datetime.now().year} Johnputer"
def _pad(text: str, width: int) -> str:
"""Right-pad ``text`` with spaces to ``width`` display cells (wide-char aware)."""
return text + " " * max(0, width - cell_len(text))
@@ -159,7 +154,6 @@ async def _smoke_test(
name: str,
where: str,
verify: Verify,
footnote: Optional[str],
) -> Optional[dict]:
"""Smoke-test ``chosen`` behind the splash spinner, collecting a Bluetooth PIN if needed.
@@ -175,8 +169,6 @@ async def _smoke_test(
name: Its display name, woven into the spinner line and the PIN prompt.
where: A human phrase for its transport ("over Bluetooth" / "on COM5").
verify: The smoke-test callback (see :data:`Verify`); called with the PIN to try.
footnote: The splash footnote to keep drawn under the box (``None`` after the copyright
is retired).
Returns:
The device's self-info dict once it answers, or ``None`` — after showing the relevant
@@ -193,7 +185,7 @@ async def _smoke_test(
verify(chosen, pin),
title="Checking companion",
banner=load_logo(),
footnote=footnote,
footnote=copyright_notice(),
)
except DeviceAuthenticationError:
# The device answered the scan but won't connect until it's bonded (or the last PIN
@@ -203,7 +195,7 @@ 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=footnote,
footnote=copyright_notice(),
)
if entered is None:
return None # the user gave up → back to the device list
@@ -217,7 +209,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=footnote
notice, title="Can't connect yet", banner=load_logo(), footnote=copyright_notice()
)
return None
@@ -235,7 +227,7 @@ async def _smoke_test(
),
title="Not a MeshCore device",
banner=load_logo(),
footnote=footnote,
footnote=copyright_notice(),
)
return None
@@ -277,7 +269,7 @@ async def prompt_device(
),
title="Select a companion device",
banner=load_logo(),
footnote=_copyright(),
footnote=copyright_notice(),
)
return None
@@ -288,32 +280,25 @@ async def prompt_device(
# Preselect the remembered "last known good" device when it is currently attached.
default = next((d for d in devices if remembered and remembered.matches(d)), None)
# The copyright is a first-impression splash flourish: show it until the user commits to a
# device, then drop it for good — even if the smoke test fails and they return to re-pick.
footnote: Optional[str] = _copyright()
while True:
chosen = await ui.select_startup(
"Select a companion device",
_build_items(devices, remembered, registry),
default=default,
banner=load_logo(),
footnote=footnote,
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.
if chosen is None or chosen is _QUIT:
return None
# The user has committed to a device; retire the copyright from every screen after
# this point (the smoke-test spinner, any failure notice, and the re-opened picker).
footnote = None
name = _display_name(chosen, registry)
# "over Bluetooth" reads better than an address; a serial device names its port.
where = "over Bluetooth" if chosen.is_ble else f"on {chosen.port}"
# Smoke-test the choice in place (prompting for a PIN and retrying if it needs one).
# ``None`` means the smoke test failed and already showed the user why — pick again.
info = await _smoke_test(ui, chosen, name, where, verify, footnote)
info = await _smoke_test(ui, chosen, name, where, verify)
if info is None:
continue
+2 -6
View File
@@ -521,18 +521,14 @@ 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 datetime import datetime
from .. import copyright_notice
_copyright_start = 2026
_year = datetime.now().year
_span = str(_copyright_start) if _year <= _copyright_start else f"{_copyright_start}-{_year}"
footnote = f"Copyright (c) {_span} Jean-Pierre Martineau"
devices = await ctx.ui.busy_startup(
"Scanning for companion devices…",
discover_all(ble=True),
title="Select a companion device",
banner=load_logo(),
footnote=footnote,
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).
+15 -6
View File
@@ -220,6 +220,12 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
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_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
# wider than the terminal and never narrower than the title/hint it must show.
probe = max(10, min(cols - 6, 100))
@@ -235,11 +241,11 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
# once the device list has populated (the box only ever grows downward); the brief small
# states sit slightly high, the conventional optical placement for dialogs.
top = max(0, rows * 2 // 5 - banner_h - gap)
footnote_h = 2 if screen.footnote else 0 # a blank spacer line plus the note itself
footnote_h = 1 if screen.footnote else 0 # the note sits directly under the logo
# Rows left for the box below the fixed banner block: the panel border is 2 rows.
below = rows - top - banner_h - gap
below = rows - top - banner_h - footnote_h - gap
body_lines = screen.render_body(inner_w)
budget = below - 2 - footnote_h
budget = below - 2
vpad = _breathing_room(len(body_lines), budget)
viewport = max(1, min(len(body_lines), budget - 2 * vpad))
visible, more_above, more_below = _visible_slice(screen, body_lines, viewport)
@@ -261,13 +267,16 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
# Glow before centering, while the box still starts at column 0 of its own lines.
panel_lines = _center(apply_corner_glow(render_lines(panel, inner_w + 4)), cols)
# A small muted line (e.g. a copyright notice) sits a blank row below the box.
# A small muted line (e.g. a copyright notice) sits immediately under the logo, its right
# edge hung off the logo's right edge so the two read as one signed block.
footnote_lines: list[str] = []
if screen.footnote:
note = Text.from_markup(f"[muted]{screen.footnote}[/muted]")
footnote_lines = [""] + _center(render_lines(note, cell_len(screen.footnote)), cols)
rendered = render_lines(note, cell_len(screen.footnote))
pad = max(0, logo_right - cell_len(screen.footnote))
footnote_lines = [" " * pad + line for line in rendered]
block = banner + ([""] * gap) + panel_lines + footnote_lines
block = banner + footnote_lines + ([""] * gap) + panel_lines
lines = [""] * top + block
if len(lines) > rows:
lines = lines[:rows]
+20 -14
View File
@@ -17,6 +17,7 @@ from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from meshterm import copyright_notice
from meshterm.ui.tui import frame, glow
from meshterm.ui.tui.glow import apply_corner_glow
from meshterm.ui.tui.progress import ProgressScreen
@@ -470,15 +471,20 @@ def test_compose_startup_is_chromeless_and_shows_banner() -> None:
assert all(len(line.rstrip()) < 80 for line in plain.split("\n"))
def test_compose_startup_shows_footnote_below_box() -> None:
"""A footnote (e.g. a copyright) is drawn, muted and centered, below the box."""
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."""
screen = SelectScreen("pick", [Choice("a", 1)])
screen.chrome = False
screen.footnote = "© 2026 Johnputer"
screen.banner = ["A" * 40, "B" * 40] # a wide wordmark to hang the note off
screen.footnote = "note-xyz"
lines = Text.from_ansi(frame.compose_startup(screen, 80, 20)).plain.split("\n")
note = next(ln for ln in lines if "Homestead" in ln)
assert note.strip() == "© 2026 Johnputer" # its own line
assert note.startswith(" ") # centered, not flush-left
logo_rows = [i for i, ln in enumerate(lines) if set(ln.strip()) in ({"A"}, {"B"})]
note_row = next(i for i, ln in enumerate(lines) if "note-xyz" in ln)
assert lines[note_row].strip() == "note-xyz" # its own line, nothing else on it
assert note_row == logo_rows[-1] + 1 # immediately under the logo, no gap
# Right edges align: the note ends at the same column the logo ends.
assert len(lines[note_row].rstrip()) == len(lines[logo_rows[-1]].rstrip())
def test_compose_startup_box_is_horizontally_centered() -> None:
@@ -770,8 +776,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 below the box.
assert "Homestead" in captured["footnote"]
# A copyright footnote rides along for the splash to render beside the logo.
assert captured["footnote"] == copyright_notice()
# 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
@@ -872,8 +878,8 @@ def test_device_picker_smoke_tests_and_reprompts(tmp_path) -> None:
assert store.is_known(devices[0])
def test_device_picker_drops_copyright_after_first_selection(tmp_path) -> None:
"""The copyright shows on the opening splash, then never again — even on a re-pick."""
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."""
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import DiscoveredDevice
from meshterm.ui.device_picker import prompt_device
@@ -901,10 +907,10 @@ def test_device_picker_drops_copyright_after_first_selection(tmp_path) -> None:
return results.pop(0)
asyncio.run(prompt_device(_Ui(), devices, store, verify))
# Only the very first splash carries the copyright; the busy spinner, the failure
# notice, and the re-opened picker all drop it.
assert footnotes[0][0] == "select" and "Homestead" in footnotes[0][1]
assert all(footnote is None for _kind, footnote in footnotes[1:])
# 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.
assert len(footnotes) > 1
assert all(footnote == copyright_notice() for _kind, footnote in footnotes)
def test_device_picker_prompts_and_retries_ble_pin(tmp_path) -> None: