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>
93 lines
3.5 KiB
Python
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)
|