diff --git a/meshterm/core/channels.py b/meshterm/core/channels.py index a023a9a..4748f31 100644 --- a/meshterm/core/channels.py +++ b/meshterm/core/channels.py @@ -20,8 +20,15 @@ from hashlib import sha256 from typing import Iterable, Optional from urllib.parse import parse_qs, quote, urlsplit -from Crypto.Cipher import AES -from Crypto.Hash import HMAC, SHA256 as _SHA256 +# PyCryptodome is imported inside the two functions that need it, never here. Importing +# it probes the host CPU's crypto feature set (``Crypto.Util._cpu_features``), which costs +# a fifth of a second on the PicoCalc's Cortex-A7 — paid by *every* startup, because this +# module reaches the boot path through a single constant: core.connection imports +# CHANNEL_SLOT_PROBE_CAP, and core.channel_store imports the derivation helpers. Neither +# decrypts anything at import. Deferring the import moves that cost onto the first +# overheard channel frame, where the work is actually wanted; every later call is a +# sys.modules dict hit, and both functions bind the names once per call rather than per +# candidate channel, so the reception path pays effectively nothing. #: Matches the ``Name: message`` convention channel senders use to identify themselves #: (the protocol carries no sender field). The name is 1–20 non-colon characters and must @@ -349,6 +356,8 @@ def identify_channel( The channel's ``(name, effective key)``, or ``None`` when no known channel's MAC matches — a channel we don't hold the key for, or a bare fingerprint collision. """ + from Crypto.Hash import HMAC, SHA256 as _SHA256 # deferred: see the module header + try: mac = bytes.fromhex(cipher_mac) msg = bytes.fromhex(crypted) @@ -392,6 +401,8 @@ def decrypt_channel_text( The decrypted text and its channel, or ``None`` if no known channel's MAC matches or the ciphertext is malformed. """ + from Crypto.Cipher import AES # deferred: see the module header + try: msg = bytes.fromhex(crypted) except ValueError: diff --git a/meshterm/core/geo.py b/meshterm/core/geo.py index 398867e..8162800 100644 --- a/meshterm/core/geo.py +++ b/meshterm/core/geo.py @@ -24,6 +24,18 @@ EARTH_RADIUS_KM = 6371.0088 #: is ``TILE_PX * 2**zoom`` pixels square at a given zoom. TILE_PX = 256 +#: Default fraction of nodes a map view frames: the densest half, so a few distant outliers +#: don't zoom the whole mesh out to a continent. See :meth:`Viewport.fit`. +#: +#: It lives here, beside the viewport maths it parameterises, rather than in the map screen +#: that made it — the ``map`` subcommand needs it as a ``--fraction`` default at *CLI +#: registration* time, which happens for every tool on every startup. Reaching into +#: :mod:`meshterm.ui.map_screen` for it dragged the whole map stack (and, through the +#: basemap's tile fetcher, ``urllib.request`` → ``http.client`` → ``ssl``) into every run, +#: including runs that never open a map. This module is pure arithmetic and already on the +#: boot path, so the constant is free here. +DEFAULT_VIEW_FRACTION = 0.5 + @dataclass(frozen=True, slots=True) class BBox: diff --git a/meshterm/persistence/db.py b/meshterm/persistence/db.py index d958465..cf7a506 100644 --- a/meshterm/persistence/db.py +++ b/meshterm/persistence/db.py @@ -168,6 +168,52 @@ CREATE INDEX IF NOT EXISTS idx_messages_peer ON messages(peer); """ +def _tune(conn: sqlite3.Connection) -> None: + """Apply the durability/throughput pragmas, on the safe side of every trade-off. + + MeshTerm writes constantly and in tiny pieces — every packet the monitor hears becomes + an ``observations`` row — and its most constrained host (the PicoCalc) keeps its + database on an SD card, where SQLite's default of a full ``fsync`` per transaction + costs milliseconds *each*. Three pragmas move that cost without moving the risk: + + * ``journal_mode=WAL`` — writers append to a log instead of rewriting the rollback + journal, which turns each insert's several synchronous seeks into one append, and + lets a reader (a screen redrawing) run against a writer (the monitor recording) + instead of blocking on it. This one is *persistent*: it lives in the database file, + so it survives into every later connection once set. + * ``synchronous=NORMAL`` — the actual latency win, but **only requested once WAL is + confirmed engaged**. Under WAL, NORMAL risks losing the last few transactions to a + power cut and nothing worse; under the rollback journal it can leave the file + *corrupt*. A PicoCalc running on a battery pack loses power for real, so on any + filesystem that refused WAL this stays at the default ``FULL`` — slow and intact + beats fast and unreadable. + * ``temp_store=MEMORY`` — sorts and temporary b-trees stay in RAM rather than landing + on the card. This workload's temporaries are small (screen-sized query results), so + the memory is bounded and the card sees strictly less traffic. + + WAL can legitimately be unavailable — a read-only mount, or a filesystem without the + shared-memory primitive it needs — and SQLite reports that by *returning* the mode it + actually left the database in rather than raising, so the result is inspected instead + of trusted. Either way this is best-effort tuning: a database that will not take the + pragmas is still a perfectly working database, so nothing here is allowed to fail the + open. + + Args: + conn: The freshly opened connection, before any schema work. + """ + # Returns the resulting mode as a row — "wal" only if the switch actually took. + try: + mode = conn.execute("PRAGMA journal_mode = WAL;").fetchone() + except sqlite3.Error: # pragma: no cover - filesystem-dependent + mode = None + if mode is not None and str(mode[0]).lower() == "wal": + conn.execute("PRAGMA synchronous = NORMAL;") + try: + conn.execute("PRAGMA temp_store = MEMORY;") + except sqlite3.Error: # pragma: no cover - defensive; no known failure mode + pass + + def connect(db_path: Path) -> sqlite3.Connection: """Open (creating if needed) the SQLite database and ensure the schema exists. @@ -175,11 +221,13 @@ def connect(db_path: Path) -> sqlite3.Connection: db_path: Filesystem location of the database. Parent directories are created. Returns: - An open connection with ``Row`` factory and foreign keys enabled. + An open connection with ``Row`` factory, foreign keys enabled, and the + durability/throughput pragmas applied (see :func:`_tune`). """ db_path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row + _tune(conn) conn.execute("PRAGMA foreign_keys = ON;") conn.executescript(_SCHEMA) _migrate(conn) diff --git a/meshterm/platforms.py b/meshterm/platforms.py index fc2cd6a..7bbe3f6 100644 --- a/meshterm/platforms.py +++ b/meshterm/platforms.py @@ -72,9 +72,20 @@ class Platform: header battery blink, the braille spinner. ``False`` drops to static chrome and a ``LINE`` spinner fallback — cheaper, and the glow already no-ops on a non-truecolor theme, so this just makes that explicit. Wired in P2/P3. - tick_s: The :class:`~prompt_toolkit.application.Application` refresh interval. - Both platforms start at today's 1.0s; PicoCalc's is revisited in P2 once - on-device CPU numbers exist to tune against. + tick_s: The :class:`~prompt_toolkit.application.Application` refresh interval — the + *idle* repaint cadence only, since every screen pushes its own repaint through + ``TuiSession.invalidate`` when its data actually moves. Composing one frame was + measured at 74 ms on the PicoCalc at 53×26 (110 ms at 53×40), so a 1 Hz idle + tick alone costs 7–11% of a core doing nothing; it ticks at half that rate + there. What drifts is cosmetic — the header's per-minute pulse and its packet + counter — and none of it is legible at one-second resolution anyway. + spinner_tick_s: Seconds between frames of a "working" spinner. The single source for + every animated wait in the app. The desktop's 0.12 s is not a rate this hardware + can hold: a PicoCalc frame would claim 62% of a core at 53×26, and at 53×40 its + 110 ms of compose would leave under 10 ms of each interval for the work actually + being waited on — and before this phase's savings it did not fit at all, at + 152 ms against a 120 ms budget. It slows to a cadence the hardware can hold + comfortably while still reading as alive. battery: Which source feeds the header's battery gauge — ``"companion"`` (read from the connected MeshCore device) or ``"host"`` (the PicoCalc's own sysfs ``power_supply`` driver, confirmed present in P0). Wired in P5. @@ -98,6 +109,7 @@ class Platform: name_colour: str effects: bool tick_s: float + spinner_tick_s: float battery: str modifier_watch: bool @@ -117,6 +129,7 @@ REGULAR = Platform( name_colour="key", effects=True, tick_s=1.0, + spinner_tick_s=0.12, battery="companion", modifier_watch=False, ) @@ -140,7 +153,8 @@ PICOCALC = Platform( truecolor=False, name_colour="type", effects=False, - tick_s=1.0, + tick_s=2.0, + spinner_tick_s=0.5, battery="host", modifier_watch=True, ) diff --git a/meshterm/tools/map.py b/meshterm/tools/map.py index ddc4e3f..b679c34 100644 --- a/meshterm/tools/map.py +++ b/meshterm/tools/map.py @@ -22,7 +22,7 @@ from rich.table import Table from rich.text import Text from ..context import AppContext -from ..core.geo import usable_fix +from ..core.geo import DEFAULT_VIEW_FRACTION, usable_fix from ..core.models import NODE_TYPE_REPEATER from .base import Tool, ToolResult, register @@ -64,7 +64,7 @@ class MapTool(Tool): interactive = isinstance(ctx.ui, TuiUi) and not params.get("static") if interactive: - from ..ui.map_screen import DEFAULT_VIEW_FRACTION, open_map + from ..ui.map_screen import open_map await open_map( ctx, markers, fraction=params.get("fraction", DEFAULT_VIEW_FRACTION) @@ -103,7 +103,7 @@ class MapTool(Tool): from ..core.geo import Viewport from ..ui.map_render import render_map - from ..ui.map_screen import DEFAULT_VIEW_FRACTION, basemap_source + from ..ui.map_screen import basemap_source cell_w = int(params.get("width") or min(ctx.console.size.width - 2, 160)) cell_h = max(12, cell_w * 4 // 13) # keep roughly the terminal's aspect @@ -142,7 +142,6 @@ class MapTool(Tool): app: The Typer application. """ from ..cli import run_tool_command - from ..ui.map_screen import DEFAULT_VIEW_FRACTION @app.command(name=self.name, help=self.help) def _map( diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index e26886a..08d7961 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -34,7 +34,7 @@ from .tui.prompt import ( ) from .tui.render import render_hanging, render_lines, right_aligned_tail from .tui.screen import CANCEL, Screen -from .tui.spinner import Spinner +from .tui.spinner import Spinner, spinner_interval if TYPE_CHECKING: from ..context import AppContext @@ -56,9 +56,6 @@ _MENTION = re.compile(r"@\[([^\]]{1,20})\]") _DELIVERED = ("✓", "ok") _FAILED = ("✗", "err") -#: Seconds between spinner frames on a message that is still awaiting its ack. -_SPINNER_INTERVAL = 0.12 - #: The sender-prefix parser, shared app-wide from the protocol layer (the transcript, #: the conversation picker, the dashboard feed, and the message-paths matcher must all #: split ``Name: body`` identically). Kept under its old private name for the callers @@ -684,7 +681,8 @@ class ChatScreen(Screen): """Await ``coro`` while animating the delivery spinner on the in-flight message. A background timer advances the shared spinner and repaints every - :data:`_SPINNER_INTERVAL` seconds, so a pending message's trailing glyph spins until + :func:`~meshterm.ui.tui.spinner.spinner_interval` seconds (the platform's spin + cadence), so a pending message's trailing glyph spins until the ack resolves. The timer is always cancelled (and awaited, so it can't outlive the send as a stray pending task) before returning. The spinner is cosmetic, so any hiccup in the animation is swallowed rather than allowed to break the send. @@ -693,7 +691,7 @@ class ChatScreen(Screen): async def animate() -> None: while True: - await asyncio.sleep(_SPINNER_INTERVAL) + await asyncio.sleep(spinner_interval()) self._spinner.tick() self._session.invalidate() diff --git a/meshterm/ui/map_screen.py b/meshterm/ui/map_screen.py index 5a30448..51df031 100644 --- a/meshterm/ui/map_screen.py +++ b/meshterm/ui/map_screen.py @@ -26,7 +26,7 @@ import asyncio import math from typing import TYPE_CHECKING, Callable, Optional -from ..core.geo import EARTH_RADIUS_KM, Viewport, clamp_lat +from ..core.geo import DEFAULT_VIEW_FRACTION, EARTH_RADIUS_KM, Viewport, clamp_lat from ..core.mvt import Layer from ..services.basemap import BasemapSource from .map_render import MapMarker, render_map @@ -49,10 +49,6 @@ _PAN_DIRS: dict[str, tuple[int, int]] = { #: How far past the tile source's max zoom the display may go (lower tiles are magnified). _OVERZOOM = 2 -#: Default fraction of nodes the map frames on open — the densest half, so a few distant -#: outliers don't zoom the whole mesh out to a continent. See :meth:`geo.Viewport.fit`. -DEFAULT_VIEW_FRACTION = 0.5 - #: The zoom an Enter-to-frame homes in at when the matches set no extent of their own — a #: single node (or several at one spot) has nothing to frame, so Enter zooms to this #: street-level closeness rather than the fit's neutral default. Capped at the tile diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index 7e201e2..ec5379b 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -42,6 +42,7 @@ from .tui import ( TuiSession, ) from .tui.emoji_width import calibrate as calibrate_emoji_width +from .tui.spinner import spinner_interval #: Grace period (seconds) allowed for the whole exit sequence — the full-screen unwind, @@ -61,9 +62,6 @@ _LIVENESS_POLL_S = 2.0 #: can't fire a false "disconnected" prompt (seconds). _LIVENESS_CONFIRM_S = 0.4 -#: Seconds between frames of the reconnect dialog's spinner while it waits for the device. -_RECONNECT_SPINNER_S = 0.12 - def _arm_exit_watchdog(seconds: float = _EXIT_WATCHDOG_S) -> None: """Guarantee the process terminates even if the exit path wedges. @@ -126,6 +124,11 @@ _SPARK_MIN_CELLS = 24 #: many packets-per-minute, so a single stray reads as a small nub, not a full column. _HEADER_ACTIVITY_FLOOR = 3.0 +#: The header's default segment separator, and the tighter one it falls back to when the +#: roomy form would squeeze the pulse below :data:`_SPARK_MIN_CELLS` (see :func:`_header`). +_SEP_ROOMY = " · " +_SEP_COMPACT = " · " + def _header(ctx: AppContext, cache: dict, width: int) -> Text: """Build the persistent one-line header: who's connected, unread mail, mesh pulse. @@ -156,15 +159,30 @@ def _header(ctx: AppContext, cache: dict, width: int) -> Text: # leading separator) before the pulse claims the rest; a wider separator estimate for # the fit decision is harmless slack. battery = _battery_segment(ctx) - reserve = (cell_len(" · ") + battery.cell_len) if battery.cell_len else 0 - sep = " · " - header = _header_segments(ctx, cache, sep) - if width - header.cell_len - reserve < _SPARK_MIN_CELLS: - sep = " · " - header = _header_segments(ctx, cache, sep) - reserve = (cell_len(sep) + battery.cell_len) if battery.cell_len else 0 + # The separator choice changes only the *joins*, never the segments themselves, so the + # segments are built once and each candidate width is arithmetic: one separator per + # segment (each is preceded by one, and a trailing one leads into the pulse). Building + # them twice to measure the second option was pure waste on exactly the narrow terminals + # that take the compact branch every repaint — the PicoCalc's 53 columns among them. + pieces = _header_segments(ctx, cache) + content = sum(piece.cell_len for piece in pieces) + + def _measure(sep: str) -> tuple[int, int]: + """The joined header width and the right-edge reserve, for one separator.""" + joined = content + len(pieces) * cell_len(sep) + return joined, (cell_len(sep) + battery.cell_len) if battery.cell_len else 0 + + sep = _SEP_ROOMY + header_w, reserve = _measure(sep) + if width - header_w - reserve < _SPARK_MIN_CELLS: + sep = _SEP_COMPACT + header_w, reserve = _measure(sep) + header = Text() + for piece in pieces: + header.append_text(piece) + header.append(sep) # Two dot columns per cell: every cell left of the reserved tail shows two minutes. - room = width - header.cell_len - reserve + room = width - header_w - reserve if room > 0: # Buckets seeded from a previous session's stored history draw grey; only # traffic this session actually heard pulses green. @@ -189,7 +207,7 @@ def _header(ctx: AppContext, cache: dict, width: int) -> Text: # gauge land against the edge. With no room for a pulse (a very narrow terminal), # pad instead so the gauge still sits in the corner rather than trailing the text. if room <= 0: - header.append(" " * max(0, width - header.cell_len - reserve)) + header.append(" " * max(0, width - header_w - reserve)) header.append(sep) header.append_text(battery) return header @@ -220,49 +238,61 @@ def _battery_segment(ctx: AppContext) -> Text: reading = ctx.battery.reading() if reading is None: return Text() - frame = int(time.monotonic() / _BATTERY_ANIM_S) + # Frame 0 is the gauge's resting state: the true fill, unblinking. A platform without + # effects holds it there, so the charging sweep and the low-battery blink never run — + # both exist to catch the eye, and neither is worth a forced repaint (nor, on a 16-slot + # console, a colour swap) on hardware where the cells are dear. + frame = int(time.monotonic() / _BATTERY_ANIM_S) if get_platform().effects else 0 return battery_cell(reading.percent, charging=reading.charging, frame=frame) -def _header_segments(ctx: AppContext, cache: dict, sep: str) -> Text: - """The header's fixed segments — everything left of the pulse — at one separator width. +def _header_segments(ctx: AppContext, cache: dict) -> list[Text]: + """The header's fixed segments — everything left of the pulse — unjoined. - Built twice per repaint at worst (roomy first, compact if the row is tight; see - :func:`_header`). The trailing separator is included, so the caller can append the - sparkline directly after it. + Separator-free by design: which separator fits depends on how wide these come out, and + the choice changes nothing about the segments themselves, so :func:`_header` measures + both options arithmetically off this one build and joins once. Args: ctx: The shared application context. cache: The device-label cache (see :func:`_device_label`). - sep: The separator between segments (``" · "`` or the compact ``" · "``). Returns: - The fixed-left portion of the header row. + The segments in display order — the app mark, the device, then whichever badges + have something to report. :func:`_header` writes one separator after each. """ - header = Text() - header.append("MeshTerm", style="brand") - header.append(f" v{__version__}", style="muted") - header.append(sep) + # Each segment starts as an unstyled Text and takes its styles per append. A base style + # passed to the constructor would instead blanket everything appended after it, layering + # the mark's brand under the version's muted. + mark = Text() + mark.append("MeshTerm", style="brand") + mark.append(f" v{__version__}", style="muted") + segments = [mark] + + device = Text() if ctx.mock: - header.append("simulator", style="warn") + device.append("simulator", style="warn") else: name, where = _device_label(ctx, cache) - header.append(name or "no device", style=None if name else "muted") + device.append(name or "no device", style=None if name else "muted") if where: - header.append(f" ({where})", style="muted") + device.append(f" ({where})", style="muted") + segments.append(device) + unread = ctx.chat.unread_total() if unread: - header.append(sep) - header.append("●", style="err") - header.append(f" {unread}", style="warn") + badge = Text() + badge.append("●", style="err") + badge.append(f" {unread}", style="warn") + segments.append(badge) alerts = ctx.watchtower.unacked_count() if alerts: # The Watchtower's badge: a triangle so it never reads as unread mail. - header.append(sep) - header.append("▲", style="err") - header.append(f" {alerts}", style="warn") - header.append(sep) - return header + badge = Text() + badge.append("▲", style="err") + badge.append(f" {alerts}", style="warn") + segments.append(badge) + return segments def _device_label(ctx: AppContext, cache: dict) -> tuple[str, str]: @@ -878,7 +908,7 @@ async def _handle_disconnect(ctx: AppContext, session: TuiSession) -> bool: async def _animate_dialog(session: TuiSession, dialog: ReconnectDialog) -> None: """Advance the reconnect dialog's spinner and repaint on a steady cadence, until cancelled.""" while True: - await asyncio.sleep(_RECONNECT_SPINNER_S) + await asyncio.sleep(spinner_interval()) dialog.tick() session.invalidate() diff --git a/meshterm/ui/repeater_admin.py b/meshterm/ui/repeater_admin.py index af9ecaf..f859408 100644 --- a/meshterm/ui/repeater_admin.py +++ b/meshterm/ui/repeater_admin.py @@ -46,16 +46,13 @@ from .menus import ( ) from .trace_screen import TracingDialog from .tui import Choice, Separator -from .tui.spinner import Spinner +from .tui.spinner import Spinner, spinner_interval from .widgets import _age_seconds, _format_age if TYPE_CHECKING: from ..context import AppContext from ..core.connection import Device -#: Seconds between spinner frames while a remote command is in flight. -_SPINNER_INTERVAL = 0.12 - #: Seconds to wait for one remote reply. Repeaters answer over the mesh — multi-hop #: routes take seconds — so this is deliberately more patient than a local command. _REPLY_TIMEOUT_S = 10.0 @@ -470,7 +467,7 @@ async def _run_under_dialog(ctx: "AppContext", title: str, work) -> bool: async def animate() -> None: while True: - await asyncio.sleep(_SPINNER_INTERVAL) + await asyncio.sleep(spinner_interval()) spinner.tick() session.invalidate() @@ -608,7 +605,7 @@ async def _command_line(ctx: "AppContext", device: "Device", node: Contact) -> N async def animate() -> None: while True: - await asyncio.sleep(_SPINNER_INTERVAL) + await asyncio.sleep(spinner_interval()) if screen.busy: screen.tick() session.invalidate() diff --git a/meshterm/ui/trace_screen.py b/meshterm/ui/trace_screen.py index c9701fb..5e25810 100644 --- a/meshterm/ui/trace_screen.py +++ b/meshterm/ui/trace_screen.py @@ -100,16 +100,13 @@ from .menus import back_rows, section_heading from .theme import snr_style from .tui.render import render_lines, render_to_ansi from .tui.screen import ListWindow, Screen -from .tui.spinner import Spinner +from .tui.spinner import Spinner, spinner_interval from .pathline import PathHop, PathLine, path_line from .widgets import NodeResolver, _link_text, _route_path, highlighted_hash if TYPE_CHECKING: from ..context import AppContext -#: Seconds between spinner frames while a trace or probe is in flight. -_SPINNER_INTERVAL = 0.12 - #: How wide the per-hop SNR quality bars draw, in characters. Each character packs #: two fill steps (see :func:`snr_bar`), so the bar reads at 16-step resolution in #: half the columns a one-step-per-cell block bar would need. @@ -525,7 +522,7 @@ class TraceScreen(Screen): async def _animate(self) -> None: """Advance the in-flight spinner and repaint on a steady cadence, until cancelled.""" while True: - await asyncio.sleep(_SPINNER_INTERVAL) + await asyncio.sleep(spinner_interval()) self._spinner.tick() self._session.invalidate() @@ -1534,7 +1531,7 @@ async def _open_session( async def animate() -> None: while True: - await asyncio.sleep(_SPINNER_INTERVAL) + await asyncio.sleep(spinner_interval()) spinner.tick() session.invalidate() @@ -1807,7 +1804,7 @@ async def _open_session( async def animate() -> None: while True: - await asyncio.sleep(_SPINNER_INTERVAL) + await asyncio.sleep(spinner_interval()) spinner.tick() session.invalidate() diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index b7d6b42..913c8be 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -14,6 +14,7 @@ from rich.console import Group, RenderableType from rich.panel import Panel from rich.text import Text +from ...platforms import get_platform from ..theme import hint_style, title_style from .glow import apply_corner_glow from .render import render_lines @@ -189,8 +190,14 @@ def compose_base( viewport = max(1, rows - header_h - 1 - 2) # minus footer(1) and panel border(2) panel = _panel(base, cols - 4, viewport, active=True) footer = Text.from_markup(f"[muted]{footer_hint}[/muted]") - # The glow pass lights the outer frame *and* any tool panels nested in the body. - lines = header_lines + apply_corner_glow(render_lines(Group(panel, footer), cols)) + # The glow pass lights the outer frame *and* any tool panels nested in the body. It only + # ever recolours truecolor foregrounds (see glow._advance_fg), so on a platform without + # effects — which is also a platform without truecolor — it would scan every line of + # every frame and hand back the identical list. Skipping it outright is the same picture + # for none of the work. Read live rather than bound at import: set_platform() runs in the + # CLI callback, long after this module is imported (see meshterm.platforms). + body = render_lines(Group(panel, footer), cols) + lines = header_lines + (apply_corner_glow(body) if get_platform().effects else body) # Guarantee we never exceed the terminal height (pt would otherwise clip unpredictably). if len(lines) > rows: lines = lines[:rows] diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 7bb4abc..dd2363e 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -234,7 +234,15 @@ def _has_wide_glyph(text: str) -> bool: marks (``▲●■``) and chart braille are width-1 everywhere, so they never trip this. The ``>= 0x1100`` guard skips the ASCII/Latin bulk of a frame before the width lookup, which matters because this runs over the whole composed frame on every repaint. + + On a platform that draws no emoji the answer is ``False`` by construction — its console + font is a fixed 512-glyph set with no wide glyph in it, so nothing a frame can contain + would return ``True``. Answering from the platform instead of the text skips a + per-character scan of the entire frame on every repaint, and with it the scrub machinery + that a ``True`` would arm, leaving the cheap differential paint permanently in play. """ + if not get_platform().emoji: + return False return any(ord(ch) >= 0x1100 and get_cwidth(ch) == 2 for ch in text) @@ -1112,7 +1120,9 @@ class TuiSession: key_bindings=self._key_bindings(), full_screen=True, mouse_support=False, - refresh_interval=1.0, # keep the live monitor counter in the header ticking + # Keeps the live monitor counter in the header ticking. Per-platform, so a host + # where an idle repaint is expensive can breathe more slowly between frames. + refresh_interval=get_platform().tick_s, input=self._input, output=self._resolve_output(), ) diff --git a/meshterm/ui/tui/spinner.py b/meshterm/ui/tui/spinner.py index 396b06e..6a00b13 100644 --- a/meshterm/ui/tui/spinner.py +++ b/meshterm/ui/tui/spinner.py @@ -13,6 +13,23 @@ from typing import Optional from rich.text import Text +from ...platforms import get_platform + + +def spinner_interval() -> float: + """Seconds a "working" animation should wait between frames, on this platform. + + The single source of the app's spin cadence — every animated wait sleeps on this rather + than on its own constant, so the rate is one platform decision instead of five copies + drifting apart. Called at each tick rather than read into a module constant: a constant + would freeze whatever platform was active at *import* time, which is always the default + (``set_platform`` runs later, in the CLI callback — see :mod:`meshterm.platforms`). + + Returns: + The active platform's :attr:`~meshterm.platforms.Platform.spinner_tick_s`. + """ + return get_platform().spinner_tick_s + class Spinner: """An animated spinner that cycles through a set of frame glyphs. @@ -26,13 +43,19 @@ class Spinner: #: A plain ASCII cycle for terminals/fonts without Braille glyphs. LINE = "|/-\\" - def __init__(self, frames: str = BRAILLE, *, style: str = "accent") -> None: + def __init__(self, frames: Optional[str] = None, *, style: str = "accent") -> None: """Create a spinner resting on its first frame. Args: - frames: The glyphs to cycle through, one per animation frame. + frames: The glyphs to cycle through, one per animation frame. Defaults to the + active platform's cycle — :data:`BRAILLE` where effects are on, the + four-frame :data:`LINE` where they aren't. Resolved here, at construction, + so a spinner built after ``set_platform`` picks the right cycle and never + re-decides while it spins. style: The Rich style :meth:`text` applies to the current glyph. """ + if frames is None: + frames = self.BRAILLE if get_platform().effects else self.LINE if not frames: raise ValueError("a spinner needs at least one frame") self._frames = frames diff --git a/meshterm/ui/tx_screen.py b/meshterm/ui/tx_screen.py index 2354089..37ac3c2 100644 --- a/meshterm/ui/tx_screen.py +++ b/meshterm/ui/tx_screen.py @@ -46,15 +46,12 @@ from .theme import name_style, snr_style from .trace_screen import TracingDialog, _collapse_trace_width, snr_bar from .tui.render import render_lines, render_to_ansi from .tui.screen import Screen -from .tui.spinner import Spinner +from .tui.spinner import Spinner, spinner_interval from .widgets import NodeResolver if TYPE_CHECKING: from ..context import AppContext -#: Seconds between spinner frames while the sweep is measuring. -_SPINNER_INTERVAL = 0.12 - #: Human phrasing for each optimizer phase reported through ``on_phase``. _PHASE_LABELS = { "coarse": "coarse sweep", @@ -963,7 +960,7 @@ async def _animate( ) -> None: """Advance the in-flight dialog's spinner and status on a steady cadence.""" while True: - await asyncio.sleep(_SPINNER_INTERVAL) + await asyncio.sleep(spinner_interval()) spinner.tick() flight.status = screen.phase_label() session.invalidate() diff --git a/tests/test_chat.py b/tests/test_chat.py index c7ef038..f82868d 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -582,7 +582,7 @@ async def test_direct_send_spins_until_the_ack_resolves(monkeypatch) -> None: """A pending direct message spins while the send is in flight, then settles on ✓.""" from meshterm.ui.tui.spinner import Spinner - monkeypatch.setattr("meshterm.ui.chat._SPINNER_INTERVAL", 0.005) + monkeypatch.setattr("meshterm.ui.chat.spinner_interval", lambda: 0.005) session = _StubSession() release = asyncio.Event() diff --git a/tests/test_disconnect.py b/tests/test_disconnect.py index c5e4710..f17f08f 100644 --- a/tests/test_disconnect.py +++ b/tests/test_disconnect.py @@ -704,7 +704,7 @@ async def test_handle_disconnect_auto_reconnects_when_port_returns( monkeypatch.setattr(connection, "serial_port_present", fake_present) monkeypatch.setattr(AppContext, "reconnect", fake_reconnect) monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0) - monkeypatch.setattr(menu, "_RECONNECT_SPINNER_S", 0.0) + monkeypatch.setattr(menu, "spinner_interval", lambda: 0.0) session = _FakeSession() try: @@ -729,7 +729,7 @@ async def test_handle_disconnect_quits_when_user_presses_quit( # The port never comes back, so the only way out is the Quit button. monkeypatch.setattr(connection, "serial_port_present", lambda port: False) monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0) - monkeypatch.setattr(menu, "_RECONNECT_SPINNER_S", 0.0) + monkeypatch.setattr(menu, "spinner_interval", lambda: 0.0) session = _FakeSession() diff --git a/tests/test_header.py b/tests/test_header.py index 5e54625..3ec8244 100644 --- a/tests/test_header.py +++ b/tests/test_header.py @@ -32,6 +32,25 @@ def test_header_shows_the_watchtower_badge() -> None: assert "▲" not in _header(_ctx(alerts=0), {}, 80).plain +def test_header_segments_style_each_run_on_its_own() -> None: + """A segment's styles sit side by side, never layered one under the next. + + The segments are built as their own ``Text`` objects and joined (see + :func:`~meshterm.ui.menu._header_segments`), which makes it easy to reach for a + constructor ``style=`` — but that becomes the object's *base* style and blankets + everything appended after it, so the version string would render brand-under-muted and + each badge's count would inherit its glyph's error red. + """ + header = _header(_ctx(unread=3, alerts=2), {}, 120) + covering = { + header.plain[span.start : span.end]: span.style + for span in header.spans + if span.style in ("brand", "err") + } + assert covering.get("MeshTerm") == "brand" # not "MeshTerm v1.2.3" + assert set(covering) == {"MeshTerm", "●", "▲"} # neither badge's count is swept in + + def test_header_sparkline_fills_the_row_exactly() -> None: """Every cell the fixed segments leave is sparkline — no short rows, no overflow.""" for width in (60, 100): diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..1a920dc --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,220 @@ +"""The P2 performance contracts: what must stay cheap, and what must stay off the boot path. + +These are behaviour tests, not benchmarks — a wall-clock assertion would be flaky on CI and +would say nothing about *why* a regression happened. Each test instead pins the structural +property the optimisation rests on: the database opens in the mode that makes small writes +cheap, the frame skips the passes that cannot change its pixels, and the startup import graph +stays clear of the three subtrees that were dragging a fifth of a second each onto every run. + +The numbers behind these choices were measured on the PicoCalc (Luckfox Lyra, ~1 GHz +Cortex-A7, SD card) and are recorded in the plan's Measurements appendix. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from rich.text import Text + +from meshterm.persistence import db +from meshterm.platforms import PICOCALC, REGULAR, set_platform +from meshterm.ui.tui.frame import compose_base +from meshterm.ui.tui.screen import Screen +from meshterm.ui.tui.session import _has_wide_glyph +from meshterm.ui.tui.spinner import Spinner + + +# --- the database's write path ------------------------------------------------------ + + +def test_connect_opens_in_wal_mode(tmp_path: Path) -> None: + """WAL is what turns each tiny insert into an append instead of a journal rewrite.""" + conn = db.connect(tmp_path / "wal.db") + assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal" + + +def test_relaxed_sync_is_only_taken_together_with_wal(tmp_path: Path) -> None: + """``synchronous=NORMAL`` is safe under WAL and corrupting without it — never split. + + Under WAL, NORMAL risks losing the last transactions to a power cut; under the rollback + journal it risks the file itself. The PicoCalc runs on a battery pack, so the two + settings travel together or not at all. + """ + conn = db.connect(tmp_path / "sync.db") + journal = conn.execute("PRAGMA journal_mode").fetchone()[0].lower() + synchronous = conn.execute("PRAGMA synchronous").fetchone()[0] + if journal == "wal": + assert synchronous == 1 # NORMAL + else: # pragma: no cover - only on a filesystem that refuses WAL + assert synchronous == 2 # FULL, the safe default left untouched + + +def test_temp_store_is_memory(tmp_path: Path) -> None: + """Sorts and temporary b-trees stay in RAM rather than landing on the SD card.""" + conn = db.connect(tmp_path / "temp.db") + assert conn.execute("PRAGMA temp_store").fetchone()[0] == 2 # MEMORY + + +def test_tuning_survives_a_reopen(tmp_path: Path) -> None: + """WAL is persistent, so a second session inherits it — and with it the relaxed sync.""" + path = tmp_path / "reopen.db" + db.connect(path).close() + conn = db.connect(path) + assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal" + assert conn.execute("PRAGMA synchronous").fetchone()[0] == 1 + + +# --- what must not be imported at startup ------------------------------------------- + +#: Subtrees that cost a fifth of a second *each* to import on the Lyra and are needed by no +#: startup path: PyCryptodome probes the CPU's crypto features (reached only through +#: ``core.channels``, whose constants the boot path wanted), and the map screen drags the +#: basemap's tile fetcher — ``urllib.request`` → ``http.client`` → ``ssl`` — behind it. +_MUST_STAY_LAZY = ("Crypto", "urllib.request", "http.client", "meshterm.ui.map_screen") + + +def test_startup_does_not_import_the_heavy_optional_subtrees() -> None: + """``import meshterm.cli`` stays clear of crypto, the network stack, and the map. + + Run in a subprocess because the assertion is about a *fresh* interpreter's import graph: + by the time this suite is running, half the codebase is already in ``sys.modules``. + """ + probe = ( + "import sys, meshterm.cli;" + "print(','.join(sorted(m for m in sys.modules" + f" if any(m == h or m.startswith(h + '.') for h in {_MUST_STAY_LAZY!r}))))" + ) + out = subprocess.run( + [sys.executable, "-c", probe], capture_output=True, text=True, check=True + ).stdout.strip() + assert out == "", f"these should not load at startup: {out}" + + +def test_the_deferred_crypto_still_decrypts() -> None: + """Deferring the import must not have cost the function its names.""" + from meshterm.core.channels import decrypt_channel_text, identify_channel + + # No known channel matches, but reaching the "no match" answer means HMAC/AES resolved. + assert identify_channel("ab", "cdef", "00" * 16, [("#public", b"\x01" * 16)]) is None + assert decrypt_channel_text("ab", "cdef", "00" * 16, [("#public", b"\x01" * 16)]) is None + + +def test_map_default_fraction_is_the_same_constant_from_either_module() -> None: + """The map screen re-exports the geo constant, so existing importers still see one value.""" + from meshterm.core.geo import DEFAULT_VIEW_FRACTION as from_geo + from meshterm.ui.map_screen import DEFAULT_VIEW_FRACTION as from_screen + + assert from_geo == from_screen + + +# --- per-frame work the platform can skip ------------------------------------------- + + +class _Body(Screen): + """A minimal screen, just enough for ``compose_base`` to frame something.""" + + title = "Perf" + + def render_body(self, width: int) -> list[str]: # noqa: D102 - inherited docstring + return ["body"] + + +def _glow_calls(cols: int = 60, rows: int = 20) -> int: + """Compose one frame, counting how many times the corner-glow pass ran.""" + import meshterm.ui.tui.frame as frame_mod + + original = frame_mod.apply_corner_glow + calls = 0 + + def counting(lines: list[str]) -> list[str]: + nonlocal calls + calls += 1 + return original(lines) + + frame_mod.apply_corner_glow = counting + try: + compose_base(Text("hdr"), _Body(), "Esc back", cols, rows) + finally: + frame_mod.apply_corner_glow = original + return calls + + +def test_glow_runs_on_regular() -> None: + """The desktop frame keeps its lit corners.""" + assert _glow_calls() == 1 + + +def test_glow_is_skipped_where_effects_are_off() -> None: + """The pass only recolours truecolor foregrounds, so on PicoCalc it is pure scan cost.""" + set_platform(PICOCALC) + assert _glow_calls() == 0 + + +def test_wide_glyph_scan_short_circuits_without_emoji() -> None: + """A no-emoji console font has no wide glyph, so the per-frame scan can be skipped.""" + waving = "hi \U0001f44b" + assert _has_wide_glyph(waving) is True + set_platform(PICOCALC) + assert _has_wide_glyph(waving) is False + + +def test_spinner_cycle_follows_the_platform() -> None: + """Braille where effects are on, the plain LINE cycle where they aren't.""" + assert Spinner().frames == Spinner.BRAILLE + set_platform(PICOCALC) + assert Spinner().frames == Spinner.LINE + + +def test_an_explicit_spinner_cycle_still_wins() -> None: + """The platform supplies a *default*; a caller that names its frames keeps them.""" + set_platform(PICOCALC) + assert Spinner("ab").frames == "ab" + + +def test_battery_gauge_holds_still_where_effects_are_off() -> None: + """No charging sweep and no low-battery blink: the gauge draws its resting frame.""" + from meshterm.services.battery_service import BatteryReading + from meshterm.ui.menu import _battery_segment + + class _Ctx: + class battery: # noqa: N801 - a stand-in for the service, not a real class name + @staticmethod + def reading() -> BatteryReading: + return BatteryReading(millivolts=3300, percent=5, charging=True) + + ctx = _Ctx() + set_platform(PICOCALC) + # Frame 0 of a charging sweep is the empty cell; any later frame differs. Two calls at + # different wall-clock instants must agree, which they only can if the clock isn't read. + first = _battery_segment(ctx).plain # type: ignore[arg-type] + second = _battery_segment(ctx).plain # type: ignore[arg-type] + assert first == second + set_platform(REGULAR) + assert _battery_segment(ctx).plain.endswith("5%") # type: ignore[arg-type] + + +def test_idle_and_spin_cadences_are_slower_where_a_frame_is_dear() -> None: + """PicoCalc composes a frame in ~74 ms, so both animation clocks have to give. + + At the desktop's rates a 1 Hz idle tick alone would spend 7-11% of the core doing + nothing, and a 0.12 s spinner would ask for more frames per second than the hardware + can physically compose. Neither is a free-floating preference: both must stay strictly + slower than regular's. + """ + assert REGULAR.tick_s == 1.0 + assert REGULAR.spinner_tick_s == 0.12 + assert PICOCALC.tick_s > REGULAR.tick_s + # A frame costs ~110 ms at the taller 53x40 geometry; a spin cadence under that would + # queue repaints faster than they can finish. + assert PICOCALC.spinner_tick_s > 0.12 + + +def test_spinner_interval_reads_the_active_platform() -> None: + """One source for the spin cadence, re-read per tick so set_platform is never missed.""" + from meshterm.ui.tui.spinner import spinner_interval + + assert spinner_interval() == REGULAR.spinner_tick_s + set_platform(PICOCALC) + assert spinner_interval() == PICOCALC.spinner_tick_s