Files
MeshTerm/meshterm/ui/tui/spinner.py
T
Jean-Pierre MartineauandClaude Opus 5 63cd4e5f96 Performance: SQLite WAL, lighter startup, per-platform tick rates
PicoCalc plan phase P2, driven by measurements taken on the Lyra as a
paired A/B (git archive HEAD vs the working tree, run side by side
against the same venv).

The headline is the database. Every packet the monitor hears is one
durable commit, and on the SD card each was costing 33.78 ms of fsync.
db._tune() opens in WAL with temp_store=MEMORY: 0.59 ms per commit, a
54x cut. synchronous=NORMAL is requested only once WAL is confirmed
engaged - the pragma returns the mode it settled on rather than raising
when a filesystem refuses it - because under WAL it risks the last
transactions to a power cut, and under the rollback journal it risks the
file itself. This device runs on a battery pack.

Startup: bleak/dbus-fast were already lazy; the real finds were two
cheap *values* dragging heavy subtrees. core/connection.py imports one
constant from core/channels.py, which imported PyCryptodome at module
level (224 ms of CPU-feature probing per run) - now deferred into the
two functions that decrypt. And tools/map.py's register_cli, which runs
for every tool at boot, reached into ui/map_screen.py for a --fraction
default, dragging the basemap's urllib.request -> http.client -> ssl
behind it; DEFAULT_VIEW_FRACTION moves to core/geo.py (pure arithmetic,
already on the boot path) and is re-exported so existing importers are
untouched. 26 modules and 316 ms of import self-time gone.

Per-frame: the corner glow only ever recolours truecolor foregrounds, so
on a 16-slot console it scanned every line of every frame to hand back
the identical list - skipped outright where effects are off, along with
the battery blink and the braille spinner cycle. _has_wide_glyph answers
from the platform when no emoji can be drawn instead of scanning the
composed frame (6.5-9.1 ms per repaint on its own). The header built its
segments twice to measure the compact separator, on exactly the narrow
terminals that take that branch every frame; it now builds once and
measures arithmetically. Together: 101.2 -> 73.2 ms per frame at 53x26,
151.8 -> 110.4 at 53x40, and 72x24 unregressed at 91.7.

That finally gave numbers to set the clocks against. refresh_interval
becomes per-platform tick_s (2.0 s on picocalc: a 1 Hz idle tick alone
was 7-11% of a core doing nothing, and only the header's per-minute
pulse drifts, since screens push their own repaints via invalidate).
The 0.12 s spinner was untenable - 84% of a core at 53x26, and at 53x40
it did not fit in its own interval at all. Five duplicate
_SPINNER_INTERVAL = 0.12 constants across the UI collapse into one
platform-aware spinner_interval() beside the Spinner it paces.

The header refactor was verified equivalent over 540 renders, plain text
and style spans both. The span check earned its keep: the first cut
passed style= to the Text constructor, which makes it the object's base
style and blankets everything appended after, so the version string
would have rendered brand-under-muted. test_header.py only asserted on
.plain and never saw it; it now pins the styling too.

tests/test_performance.py pins each contract as behaviour rather than
timing, including a subprocess guard that fails if Crypto, the network
stack, or the map screen ever creep back onto the startup import graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 03:40:42 -04:00

93 lines
3.5 KiB
Python

"""A reusable text spinner: one animated glyph for a "working" indicator.
A small, screen-agnostic animation helper. It holds a cycle of frame glyphs and a current
position; a caller advances it with :meth:`tick` (typically from an animation timer) and
reads the current frame as a bare glyph or as a styled Rich :class:`~rich.text.Text`. The
busy splash uses it, but it is deliberately independent of any screen so any "working"
indicator across the toolkit can reuse the same animation.
"""
from __future__ import annotations
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.
Attributes:
style: The Rich style :meth:`text` applies to the current glyph.
"""
#: The default Braille-dot frames — a smooth, single-cell rotation.
BRAILLE = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
#: A plain ASCII cycle for terminals/fonts without Braille glyphs.
LINE = "|/-\\"
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. 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
self.style = style
self._index = 0
@property
def frame(self) -> str:
"""The current frame's glyph."""
return self._frames[self._index]
@property
def frames(self) -> str:
"""The full cycle of frame glyphs, in order."""
return self._frames
def tick(self) -> None:
"""Advance to the next frame, wrapping around at the end of the cycle."""
self._index = (self._index + 1) % len(self._frames)
def reset(self) -> None:
"""Return the spinner to its first frame."""
self._index = 0
def text(self, style: Optional[str] = None) -> Text:
"""Return the current glyph as a styled Rich :class:`~rich.text.Text`.
Args:
style: A style to use instead of the spinner's own :attr:`style`.
Returns:
The current glyph, styled.
"""
return Text(self.frame, style=self.style if style is None else style)