diff --git a/meshterm/__init__.py b/meshterm/__init__.py index 7988ff6..08f1831 100644 --- a/meshterm/__init__.py +++ b/meshterm/__init__.py @@ -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__}" diff --git a/meshterm/ui/device_picker.py b/meshterm/ui/device_picker.py index 085d801..048ec92 100644 --- a/meshterm/ui/device_picker.py +++ b/meshterm/ui/device_picker.py @@ -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 diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index b64810f..ce89bc2 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -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). diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 8c54051..5ce54b0 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -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] diff --git a/tests/test_tui.py b/tests/test_tui.py index 7878b49..2af2680 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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: