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:
1 parent
216a6549f5
commit
a27b5951b7
5 files changed
+64
-49
No files matched your search
@@ -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__}"
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user