Busy overlay: skeleton card with a Knight-Rider braille scanner

Replace the between-screens ring spinner with a titled skeleton card: a
heading, the one-cell working chip beside a "reading from <device>…"
caption, and a two-row braille LED bar in place of the old ring.

The bar is a Knight-Rider scanner — a red head sweeps left↔right and
bounces off both ends over a dark-gray track, dragging a persistence-of-
vision comet trail (per-tick decay) with a white-hot core. It rides the
same tick that spins the chip and fades in from black with the card, so
the wait reads as work rather than a frozen bar.

Thread an optional title/caption through busy_overlay so each gap names
the screen being fetched and the companion it's reading from. Give the
CLI progress dialog the same liveness: a spinning chip on every row and a
slim braille meter, with no dead track on indeterminate tasks. Drop the
now-unused RingSpinner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-13 03:43:15 -04:00
1 parent 71b7d44263
commit b508f2fe27
9 files changed
+433 -368

No files matched your search

+22 -7
View File
@@ -517,8 +517,8 @@ async def _startup(ctx: AppContext) -> bool:
ctx.adopt_device(probed["device"])
# Between the device splash and the first menu paint, resuming background listening opens
# the radio — a slow, silent step that would otherwise leave the screen blank for a beat.
# Float the ring spinner across that gap on any real link (see _busy_over_link).
async with _busy_over_link(ctx):
# Float the skeleton card across that gap on any real link (see _busy_over_link).
async with _busy_over_link(ctx, title="Starting up"):
await _resume_monitor(ctx)
return True
@@ -564,8 +564,10 @@ async def _resume_monitor(ctx: AppContext) -> None:
@asynccontextmanager
async def _busy_over_link(ctx: AppContext) -> AsyncIterator[None]:
"""Float the ring-spinner overlay for the wrapped block, on any real device link.
async def _busy_over_link(
ctx: AppContext, *, title: str = ""
) -> AsyncIterator[None]:
"""Float the skeleton-card overlay for the wrapped block, on any real device link.
Bluetooth is the worst offender, but serial navigation has a perceptible lag too, so the
overlay is installed for either transport. Only the mock simulator (which has no link and
@@ -574,14 +576,27 @@ async def _busy_over_link(ctx: AppContext) -> AsyncIterator[None]:
Args:
ctx: The shared application context (read for the active transport and UI surface).
title: An optional heading for the card, naming the screen being fetched.
"""
if ctx.active_transport is not None:
async with ctx.ui.busy_overlay():
async with ctx.ui.busy_overlay(_reading_caption(ctx), title=title):
yield
else:
yield
def _reading_caption(ctx: AppContext) -> str:
"""The skeleton card's caption: "reading from <companion>…", or a bare "reading…".
Names the chosen companion when one is known this session, so the wait reads as a
concrete fetch from a specific device rather than an abstract stall.
"""
device = ctx.selected_device
if device is not None and device.label:
return f"reading from {device.label}…"
return "reading…"
async def _run_selection(ctx: AppContext, name: str) -> None:
"""Gather parameters for, execute, and present a single tool from the menu.
@@ -607,9 +622,9 @@ async def _run_selection(ctx: AppContext, name: str) -> None:
try:
# Opening a tool can sit on a blank frame while the companion answers (the menu has
# been popped, its first prompt not yet pushed) — very noticeable over Bluetooth, but
# perceptible on serial too. Float the ring spinner through that gap so the wait reads
# perceptible on serial too. Float the skeleton card through that gap so the wait reads
# as work, not a hang. The overlay only paints between screens, so prompts show through.
async with _busy_over_link(ctx):
async with _busy_over_link(ctx, title=title):
params = await tool.prompt_params(ctx)
if params is None: # user cancelled a prompt
ctx.ui.discard()
+10 -8
View File
@@ -94,10 +94,10 @@ class Ui:
"""Return a progress context manager exposing ``add_task``/``advance``/``update``."""
raise NotImplementedError
def busy_overlay(self, message: str = ""): # noqa: ANN201 - async CM, varies by backend
"""Return an async context manager that floats a "working" spinner while its block runs.
def busy_overlay(self, message: str = "", *, title: str = ""): # noqa: ANN201 - async CM, varies by backend
"""Return an async context manager that floats a "working" skeleton while its block runs.
In the interactive menu this shows the top-most ring spinner (see
In the interactive menu this shows the top-most skeleton card (see
:meth:`~meshterm.ui.tui.session.TuiSession.busy_overlay`), used to cover the lag of a
Bluetooth operation that would otherwise leave the screen blank. In scripted CLI mode
it does nothing (there is no full-screen surface to float over)."""
@@ -269,8 +269,10 @@ class PlainUi(Ui):
return make_progress(self.console)
@asynccontextmanager
async def busy_overlay(self, message: str = "") -> AsyncIterator[None]:
"""Do nothing: the scripted CLI has no full-screen surface to float a spinner over."""
async def busy_overlay(
self, message: str = "", *, title: str = ""
) -> AsyncIterator[None]:
"""Do nothing: the scripted CLI has no full-screen surface to float a skeleton over."""
yield
def _no_prompt(self) -> RuntimeError:
@@ -484,9 +486,9 @@ class TuiUi(Ui):
"""Return a progress dialog context manager for the session."""
return self.session.progress(title)
def busy_overlay(self, message: str = ""): # noqa: ANN201
"""Float the session's top-most ring spinner while the wrapped block runs."""
return self.session.busy_overlay(message)
def busy_overlay(self, message: str = "", *, title: str = ""): # noqa: ANN201
"""Float the session's top-most skeleton card while the wrapped block runs."""
return self.session.busy_overlay(message, title=title)
# --- input ---------------------------------------------------------------
-2
View File
@@ -23,7 +23,6 @@ from .prompt import (
TextScreen,
TypedConfirmDialog,
)
from .ring import RingSpinner
from .screen import CANCEL, BusyScreen, Screen, ScrollScreen
from .select import Choice, SelectScreen, Separator
from .session import TuiSession
@@ -44,7 +43,6 @@ __all__ = [
"TypedConfirmDialog",
"ProgressScreen",
"Spinner",
"RingSpinner",
"BusyOverlay",
"CANCEL",
]
+200 -37
View File
@@ -1,16 +1,23 @@
"""The busy overlay: a centred ring spinner floated on top of everything else.
"""The busy skeleton: a titled placeholder card floated between screens while a load runs.
A :class:`BusyOverlay` is the model behind :meth:`~meshterm.ui.tui.session.TuiSession.
busy_overlay` — a :class:`~meshterm.ui.tui.ring.RingSpinner` with an optional caption beneath
it. Unlike a :class:`~meshterm.ui.tui.screen.Screen`, it is *not* part of the screen stack: the session
draws it as the top-most float, so it hovers over whatever is (or is not) on screen. It owns
no input and resolves nothing; the session shows it while an operation runs and drops it when
that operation returns.
busy_overlay` — a small *skeleton card* standing in for a screen the device is still
fetching: a title, the one-cell working :class:`~meshterm.ui.tui.spinner.Spinner` beside a
caption, and a Knight-Rider scanning-light bar standing in for the content still loading. Unlike a
:class:`~meshterm.ui.tui.screen.Screen` it is *not* part of the screen stack: the session
draws it as the top-most float, so it hovers over the gap between screens that a slow device
operation (Bluetooth most of all) opens — the caller shows it while the fetch runs and drops
it when that fetch returns.
Rather than pop in, it stays fully black for a short *hold* and then *fades in from black*
over its next fraction of a second (see :attr:`brightness`): an operation that finishes within
the hold shows nothing at all, and a longer one eases up out of the black rather than appearing
suddenly — so the ring never flashes and never pops in.
suddenly — so the card never flashes and never pops in.
Once up, it does not sit still. Beside the spinning chip a Knight-Rider scanning light sweeps
a two-row braille LED bar left→right and back, advanced on each :meth:`BusyOverlay.tick`: a red
head with a persistence-of-vision trail comet-tailing behind it over a dark-gray track. That
travelling light is what reads the card as *working* rather than as a frozen, broken bar.
"""
from __future__ import annotations
@@ -21,39 +28,91 @@ from rich.cells import cell_len
from rich.text import Text
from .render import render_to_ansi
from .ring import RingSpinner, dim_color
from .spinner import Spinner
#: The muted caption colour (the theme's ``muted``, as a hex so it can be dimmed for the fade).
#: The title line's colour (the theme's ``title.accent``, as hex so the fade can dim it).
_TITLE_COLOR = "bold #a5b4fc"
#: The working chip's colour (the theme's ``accent`` — the same beloved one-cell spinner).
_CHIP_COLOR = "bold #818cf8"
#: The caption colour (the theme's ``muted``, as a hex so it can be dimmed for the fade).
_CAPTION_COLOR = "#94a3b8"
#: The scanner's unlit-LED colour (the theme's ``track`` — a step below ``faint``, a dark
#: slate gray). Every lamp of the bar rests here; the moving light lifts lamps off it toward
#: red and they fade back as the trail decays, so an idle lamp reads as an unlit LED, not data.
_SCAN_TRACK = "#334155"
#: The lit-LED colours the moving light drives lamps toward: a vivid red (the theme's
#: ``hint.err``) for the body of the head, and a pale "highlighted" red at its very hottest
#: heart, so the core reads as white-hot the way a real LED bar's brightest lamp does.
_SCAN_RED = "#ef4444"
_SCAN_HOT = "#fca5a5"
#: The braille cell drawn for every lamp — dots 2,3,5,6, i.e. *two rows* of dots centred in the
#: cell, so the sweep reads as a slim two-row LED strip rather than a solid block.
_SCAN_GLYPH = "⠶"
#: How many lamps (terminal cells) wide the scanning bar is.
_SCAN_CELLS = 18
#: The per-tick persistence-of-vision fade: on each tick every lamp dims to this fraction of
#: its last brightness, so the head drags a comet trail that lags a few lamps behind it.
_SCAN_DECAY = 0.68
#: How many lamps the head advances per tick (its sweep speed) and the head's half-width in
#: lamps (the bright core lit around it before the trail takes over). Tuned so one pass across
#: the bar takes roughly a second at the session's ~0.06 s tick.
_SCAN_SPEED = 0.85
_SCAN_CORE = 1.2
class BusyOverlay:
"""A ring spinner (plus optional caption) drawn as the top-most floating layer.
"""A skeleton card (title, working chip + caption, placeholder rows) drawn as the top float.
Attributes:
spinner: The animated :class:`~meshterm.ui.tui.ring.RingSpinner` at the overlay's core.
message: An optional caption drawn, muted, beneath the ring.
started_at: The monotonic time the overlay was created, from which the hold + fade run.
hold: Seconds the ring stays fully black before the fade begins.
spinner: The animated one-cell :class:`~meshterm.ui.tui.spinner.Spinner` — the same
working chip used inline elsewhere — sitting beside the caption.
title: An optional heading for the screen being fetched (empty for a bare card).
message: An optional caption drawn beside the chip (e.g. "reading from …").
started_at: The monotonic time the card was created, from which the hold + fade run.
hold: Seconds the card stays fully black before the fade begins.
fade: The fade-in duration in seconds, after the hold.
"""
def __init__(
self, message: str = "", *, hold: float = 0.2, fade: float = 0.4
self,
message: str = "",
*,
title: str = "",
hold: float = 0.2,
fade: float = 0.4,
) -> None:
"""Create a busy overlay.
"""Create a busy skeleton card.
Args:
message: A short caption drawn beneath the ring (empty for a bare ring).
message: A short caption drawn beside the working chip (empty for a bare card).
title: An optional heading naming the screen being fetched.
hold: Seconds to stay fully black before fading in, so an operation that finishes
this fast shows nothing at all.
fade: Seconds over which the ring then brightens from black to full colour.
fade: Seconds over which the card then brightens from black to full colour.
"""
self.spinner = RingSpinner()
self.spinner = Spinner()
self.title = title
self.message = message
self.started_at = time.monotonic()
self.hold = max(0.0, hold)
self.fade = max(0.0, fade)
#: The scanning-light state driving the Knight-Rider bar: the head's fractional lamp
#: position, its travel direction (``+1`` right / ``-1`` left, bouncing off each end),
#: and the per-lamp brightness buffer whose slow decay on each :meth:`tick` is the
#: persistence-of-vision trail. Seeded with the head already stamped so the first
#: painted frame shows the light rather than a bare track.
self._pos = 0.0
self._dir = 1
self._glow = [0.0] * _SCAN_CELLS
self._stamp_core()
@property
def brightness(self) -> float:
@@ -72,42 +131,91 @@ class BusyOverlay:
return t * t * (3 - 2 * t) # smoothstep
def tick(self) -> None:
"""Advance the ring's comet one animation step."""
"""Advance the working chip one frame and step the scanning light along its sweep."""
self.spinner.tick()
self._advance()
def _advance(self) -> None:
"""Fade the whole trail one notch, move the head, and re-light the core around it.
The decay is the persistence-of-vision effect: every lamp dims toward the track, so the
lamps the head has already passed glow on for a few frames as a comet tail. The head then
steps :data:`_SCAN_SPEED` lamps along, reversing when it reaches either end (the
Knight-Rider bounce), and a fresh bright core is stamped at its new position.
"""
self._glow = [g * _SCAN_DECAY for g in self._glow]
self._pos += self._dir * _SCAN_SPEED
if self._pos >= _SCAN_CELLS - 1:
self._pos = float(_SCAN_CELLS - 1)
self._dir = -1
elif self._pos <= 0:
self._pos = 0.0
self._dir = 1
self._stamp_core()
def _stamp_core(self) -> None:
"""Light the lamps around the head to full, tapering off over :data:`_SCAN_CORE` lamps.
Combined with ``max`` so a freshly-lit core never dims a lamp the decaying trail has
left brighter — the head is always at least as bright as its own tail.
"""
for i in range(_SCAN_CELLS):
core = 1.0 - abs(i - self._pos) / _SCAN_CORE
if core > self._glow[i]:
self._glow[i] = core
def restart(self) -> None:
"""Restart the intro from scratch: rewind the fade ramp and the comet to their starts.
"""Restart the intro from scratch: rewind the fade ramp and the chip to their starts.
Called each time the ring is (re)exposed — its first appearance and every time it comes
Called each time the card is (re)exposed — its first appearance and every time it comes
back after a prompt covered it — so the black hold and fade-in play fresh every display
rather than the ring snapping back at full brightness mid-way through an operation.
rather than the card snapping back at full brightness mid-way through an operation.
"""
self.started_at = time.monotonic()
self.spinner.reset()
self._pos = 0.0
self._dir = 1
self._glow = [0.0] * _SCAN_CELLS
self._stamp_core()
def render(self) -> str:
"""Render the ring, plus any caption, as a centred ANSI block, at the current fade level.
"""Render the skeleton card as a centred ANSI block, at the current fade level.
The rows are pre-padded to a common width and joined into one multi-line
:class:`~rich.text.Text`, so the session's content-sized float centres a clean
rectangle over the screen.
The title, the chip + caption line, a spacer, and the placeholder rows are each padded
to a common width and joined into one multi-line :class:`~rich.text.Text`, so the
session's content-sized float centres a clean rectangle over the gap between screens.
Returns:
An ANSI string of the ring rows and an optional centred caption line.
An ANSI string of the card's rows.
"""
level = self.brightness
ring = self.spinner.render(level)
rows: list[Text] = ring.split("\n")
width = max((cell_len(row.plain) for row in rows), default=0)
lines: list[Text] = []
if self.title:
lines.append(Text(self.title, style=dim_color(_TITLE_COLOR, level)))
status = self.spinner.text(dim_color(_CHIP_COLOR, level))
if self.message:
width = max(width, cell_len(self.message))
block = Text("\n").join(_center_text(row, width) for row in rows)
if self.message:
block.append("\n")
caption = Text(self.message, style=dim_color(_CAPTION_COLOR, level))
block.append_text(_center_text(caption, width))
status.append(" ")
status.append(self.message, style=dim_color(_CAPTION_COLOR, level))
lines.append(status)
lines.append(Text("")) # a blank row between the caption and the scanning bar
lines.append(self._scanner_row(level))
width = max((cell_len(line.plain) for line in lines), default=0)
block = Text("\n").join(_center_text(line, width) for line in lines)
return render_to_ansi(block, width)
def _scanner_row(self, level: float) -> Text:
"""The scanning-light bar: one braille lamp per cell, coloured by its glow, then dimmed.
Every lamp is the same two-row braille glyph; only its colour moves. A lamp interpolates
from the unlit ``track`` gray up through vivid red to a pale hot core by its brightness
in :attr:`_glow` (the moving head plus its persistence-of-vision trail), and the whole
row is finally dimmed by ``level`` so the light eases in with the card's fade-from-black.
"""
row = Text()
for glow in self._glow:
row.append(_SCAN_GLYPH, style=dim_color(_scan_color(glow), level))
return row
def _center_text(text: Text, width: int) -> Text:
"""Return ``text`` padded with spaces so it is centred within ``width`` cells."""
@@ -117,3 +225,58 @@ def _center_text(text: Text, width: int) -> Text:
out.append_text(text)
out.append(" " * (pad - left))
return out
def _scan_color(glow: float) -> str:
"""Map a lamp's ``[0, 1]`` brightness to its colour: track gray → vivid red → hot core.
Two stops so the head reads as white-hot: the bottom three-quarters of the range walks the
unlit ``track`` up to full red, and the last quarter pushes red on toward the pale
``_SCAN_HOT`` so only the brightest heart of the head lightens past red.
"""
if glow <= 0.0:
return _SCAN_TRACK
if glow >= 1.0:
return _SCAN_HOT
if glow < 0.75:
return _lerp_hex(_SCAN_TRACK, _SCAN_RED, glow / 0.75)
return _lerp_hex(_SCAN_RED, _SCAN_HOT, (glow - 0.75) / 0.25)
def _lerp_hex(start: str, end: str, t: float) -> str:
"""Blend two ``#rrggbb`` colours: ``t=0`` returns ``start``, ``t=1`` returns ``end``."""
t = max(0.0, min(1.0, t))
channels = []
for lo, hi in ((1, 3), (3, 5), (5, 7)):
a = int(start[lo:hi], 16)
b = int(end[lo:hi], 16)
channels.append(round(a + (b - a) * t))
return "#{:02x}{:02x}{:02x}".format(*channels)
def dim_color(style: str, factor: float) -> str:
"""Scale a Rich hex-colour style toward black by ``factor`` (0 = black, 1 = unchanged).
Only the ``#rrggbb`` token is scaled; any attribute words (e.g. ``bold``) are preserved.
A style without a hex colour is returned untouched, as is any ``factor >= 1``.
Args:
style: A Rich style such as ``"#38bdf8"`` or ``"bold #ecfeff"``.
factor: The brightness multiplier, clamped to ``[0, 1]``.
Returns:
The style with its colour dimmed toward black.
"""
if factor >= 1.0:
return style
factor = max(0.0, factor)
parts = []
for token in style.split():
if token.startswith("#") and len(token) == 7:
r = round(int(token[1:3], 16) * factor)
g = round(int(token[3:5], 16) * factor)
b = round(int(token[5:7], 16) * factor)
parts.append(f"#{r:02x}{g:02x}{b:02x}")
else:
parts.append(token)
return " ".join(parts)
+70 -20
View File
@@ -8,16 +8,18 @@ dialog instead, so tool code stays identical across both front-ends (see
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional
from rich.console import Group
from rich.progress_bar import ProgressBar
from rich.table import Table
from rich.text import Text
from ..braillechart import meter
from .render import render_lines
from .screen import Screen
from .spinner import Spinner
if TYPE_CHECKING:
from .session import TuiSession
@@ -44,6 +46,15 @@ class ProgressScreen(Screen):
self.title = title
self._tasks: dict[int, _Task] = {}
self._next = 0
#: The one-cell working chip, spun by an animation timer (see :class:`TuiProgress`).
#: It is the liveness signal every row carries so a slow task — a trace that only
#: advances once, at the very end — reads as *working* rather than frozen while it
#: waits, which a static meter alone cannot show.
self.spinner = Spinner()
def tick(self) -> None:
"""Advance the working chip one frame (called from the dialog's animation timer)."""
self.spinner.tick()
def add_task(self, description: str, total: Optional[float] = None) -> int:
"""Add a task and return its id (mirrors ``rich.progress.Progress.add_task``)."""
@@ -74,26 +85,42 @@ class ProgressScreen(Screen):
task.total = total
def render_body(self, width: int) -> list[str]:
"""Render each task as ``description [bar] m/n``."""
bar_width = max(10, min(40, width - 24))
"""Render each task as ``chip description [meter] m/n``.
Every row leads with the animated working :attr:`spinner` chip — the app's one-cell
indicator — so the row is visibly alive whatever the bar is doing; it flips to a
green ``✓`` once the task completes. When a total is known, the bar is the app's
braille :func:`~meshterm.ui.braillechart.meter` — the one way MeshTerm draws a
proportion — a slim gauge filling a visible ``track`` and turning ``ok`` green at
full. A task with no known total (nothing to divide by) shows no bar at all — a dead,
never-moving track reads as broken — and leans on the spinning chip beside its running
count to signal progress.
"""
bar_width = max(10, min(40, width - 26))
table = Table.grid(padding=(0, 1))
table.add_column() # working chip
table.add_column() # description
table.add_column() # bar
table.add_column() # meter
table.add_column(justify="right") # counts
for task in self._tasks.values():
bar = ProgressBar(
total=task.total or None,
completed=task.completed,
width=bar_width,
complete_style="brand",
finished_style="ok",
if task.total:
done = task.completed >= task.total
bar = meter(
task.completed / task.total,
bar_width,
style="ok" if done else "brand",
slim=True,
track="track",
)
counts = f"{int(task.completed)}/{int(task.total)}"
else:
done = False
bar = Text("") # indeterminate: the chip carries the liveness, not a dead track
counts = str(int(task.completed))
chip = Text("✓", style="ok") if done else self.spinner.text("brand")
table.add_row(
chip, Text(task.description, style="accent"), bar, Text(counts, style="muted")
)
counts = (
f"{int(task.completed)}/{int(task.total)}"
if task.total
else str(int(task.completed))
)
table.add_row(Text(task.description, style="accent"), bar, Text(counts, style="muted"))
body = table if self._tasks else Text("starting…", style="muted")
return render_lines(Group(body), width)
@@ -107,27 +134,50 @@ class TuiProgress:
"""Context manager yielding a :class:`ProgressScreen` handle, pushed for its lifetime.
Presents the same ``with ... as progress:`` shape as ``make_progress`` so tools need
only swap the factory. Entering pushes the dialog; the ``add_task`` / ``advance`` /
``update`` methods mutate it and repaint; exiting pops it.
only swap the factory. Entering pushes the dialog and starts an animation timer that
keeps its working chip spinning; the ``add_task`` / ``advance`` / ``update`` methods
mutate it and repaint; exiting cancels the timer and pops it.
"""
session: "TuiSession"
title: str = "Working"
interval: float = 0.1
_screen: ProgressScreen = field(init=False, default=None) # type: ignore[assignment]
_ticker: "asyncio.Task[None] | None" = field(init=False, default=None)
def __enter__(self) -> ProgressScreen:
"""Push the progress dialog and return it as the handle."""
"""Push the progress dialog and start its animation timer."""
self._screen = ProgressScreen(self.title)
# Wrap add_task/advance/update so each repaints the session automatically.
for name in ("add_task", "advance", "update"):
setattr(self._screen, name, self._wrap(getattr(self._screen, name)))
self.session.push(self._screen)
# The task mutates the dialog only when it advances, which can be seconds apart (a
# trace advances just once, at the end). Without a heartbeat the chip would sit frozen
# between those beats and read as broken, so spin it on a timer, exactly as the busy
# splash does, for the dialog's whole lifetime.
self._ticker = asyncio.ensure_future(self._animate())
return self._screen
def __exit__(self, *exc: object) -> None:
"""Pop the progress dialog."""
"""Stop the animation timer and pop the progress dialog."""
if self._ticker is not None:
self._ticker.cancel()
self._ticker = None
self.session.pop(self._screen)
async def _animate(self) -> None:
"""Advance the dialog's working chip and repaint every :attr:`interval` seconds."""
try:
while True:
await asyncio.sleep(self.interval)
self._screen.tick()
self.session.invalidate()
except asyncio.CancelledError:
# Swallow the cancellation so the task finishes cleanly on its final loop cycle
# rather than being torn down while pending (a screen-corrupting loop warning).
pass
def _wrap(self, method): # type: ignore[no-untyped-def]
"""Wrap a mutating method so it repaints the session after running."""
-234
View File
@@ -1,234 +0,0 @@
"""A reusable ring spinner: a thick braille circle with a comet of dots chasing round it.
Where :class:`~meshterm.ui.tui.spinner.Spinner` is one animated cell, this is a larger,
attention-grabbing *area* animation — a bold circle drawn in Braille dots with a bright arc
that orbits the rim, its dots fading from a near-white head back through the blues. It exists
because Bluetooth operations lag noticeably more than serial ones, and a menu navigation that
talks to a BLE companion can sit on a blank screen for a beat; a big centred spinner makes
that wait read as deliberate work rather than a hang.
Like :class:`Spinner`, it is deliberately screen-agnostic: it holds a rotation phase, a
caller advances it with :meth:`tick` (typically from an animation timer), and :meth:`render`
returns a Rich renderable.
Braille geometry: a Braille cell packs a 2×4 grid of dots, and a terminal cell is about twice
as tall as it is wide, so a single Braille dot is *very nearly square* on screen (½ a cell
each way). That means a mathematical circle plotted in Braille-dot space also *looks* round,
which is the whole trick behind drawing a clean ring in text. Stroking the circle over a small
band of radii thickens it into a bold arc that reads unmistakably as a circle.
"""
from __future__ import annotations
import math
from typing import Optional
from rich.text import Text
#: Braille bit for each sub-dot position, indexed ``[y][x]`` within a 2-wide × 4-tall cell.
#: Adding these to the Braille block base (``U+2800``) yields the glyph for a set of dots.
_DOT_BITS = (
(0x01, 0x08), # top row: dots 1, 4
(0x02, 0x10), # second row: dots 2, 5
(0x04, 0x20), # third row: dots 3, 6
(0x40, 0x80), # bottom row: dots 7, 8
)
_BRAILLE_BASE = 0x2800
class RingSpinner:
"""An orbiting-comet ring drawn in thick Braille dots.
The full rim is drawn in a calm blue; a bright arc (the "comet") is drawn over it and
rotates one step per :meth:`tick`, its dots fading along a cyan→blue ramp from a near-white
head to a deep-blue tail, so a glowing swarm of dots appears to chase itself around the
circle.
"""
#: The resting rim colour: a muted blue the comet stands out against.
RIM_STYLE = "#2f5c96"
#: The comet's colour ramp, head (near-white cyan) → tail (deep blue). The bright head
#: catches the eye; the fade gives the orbiting dots a sense of motion and depth.
COMET_RAMP = (
"bold #ecfeff",
"bold #a5f3fc",
"#67e8f9",
"#38bdf8",
"#0ea5e9",
"#0284c7",
"#0369a1",
)
def __init__(
self,
*,
radius: float = 10.5,
thickness: float = 2.5,
comet: float = math.radians(130),
step: float = math.radians(16),
) -> None:
"""Configure a ring spinner.
Args:
radius: Circle radius in Braille dots (½-cell units). ~10.5 gives a bold 13×7-cell
ring that reads clearly as a circle.
thickness: Stroke width in Braille dots; the rim is drawn over this band of radii,
so ~2.5 gives a chunky, unmistakably-round arc rather than a thin outline.
comet: Angular length of the bright orbiting arc, in radians.
step: How far the comet advances each :meth:`tick`, in radians.
"""
self._radius = radius
self._thickness = max(1.0, thickness)
self._comet = comet
self._step = step
self._phase = 0.0
# Precompute the geometry once — it never changes between frames, only the phase does.
self._cols, self._rows, self._centre, self._cells = self._plot_rim()
def tick(self) -> None:
"""Advance the comet one step around the rim (wrapping at a full turn)."""
self._phase = (self._phase + self._step) % (2 * math.pi)
def reset(self) -> None:
"""Return the comet to its starting position."""
self._phase = 0.0
# --- geometry ------------------------------------------------------------
def _plot_rim(self) -> tuple[int, int, float, dict[tuple[int, int], int]]:
"""Stroke the thick rim into a Braille-cell grid, once, with exact 4-fold symmetry.
Only the first quadrant is plotted; each lit dot is then mirrored across both axes, so
the four quarters are identical by construction and no rounding asymmetry can creep in.
The canvas is rounded up to a multiple of 4 dots so the mirror axes land on Braille-cell
boundaries — that keeps the *packed cell grid* symmetric, not merely the raw dots.
Returns:
``(cols, rows, centre, cells)`` — the grid size in cells, the ring centre in the
square dot-space, and a map from each ``(col, row)`` cell to its OR'd Braille bits.
"""
half = (self._thickness - 1) / 2.0
outer = self._radius + half
pad = 1
dim = int(math.ceil((2 * outer + 2 * pad) / 4.0)) * 4 # multiple-of-4 dot canvas
centre = (dim - 1) / 2.0
edge = dim - 1
# Sample the quarter arc densely enough that neither the angular step nor the ½-dot
# radial step leaves a gap once mirrored.
quadrant = max(60, int((math.pi / 2) * (outer + 1) / 0.4))
radii = [self._radius + off for off in _frange(-half, half, 0.5)]
cells: dict[tuple[int, int], int] = {}
for i in range(quadrant + 1):
angle = (math.pi / 2) * i / quadrant
cos, sin = math.cos(angle), math.sin(angle)
for rr in radii:
px = int(round(centre + rr * cos))
py = int(round(centre - rr * sin))
# Mirror the one quadrant into all four for a perfectly symmetric ring.
for mx, my in ((px, py), (edge - px, py), (px, edge - py), (edge - px, edge - py)):
key = (mx // 2, my // 4)
cells[key] = cells.get(key, 0) | _DOT_BITS[my % 4][mx % 2]
# Report the *full* canvas size (not the lit bounding box): trimming to the lit extent
# would shave unequal padding off the sides and break the mirror symmetry.
return dim // 2, dim // 4, centre, cells
def _cell_angle(self, col: int, row: int) -> float:
"""The polar angle (from the ring centre) of a cell's centre, in ``[0, 2π)``.
Derived from geometry rather than the plotting pass so the comet sweeps smoothly
regardless of how a cell's dots were accumulated.
"""
cx = col * 2 + 0.5 # centre of the cell's 2-wide dot column
cy = row * 4 + 1.5 # centre of the cell's 4-tall dot row
return math.atan2(self._centre - cy, cx - self._centre) % (2 * math.pi)
def _comet_style(self, angle: float) -> Optional[str]:
"""The comet colour for a rim cell at ``angle``, or ``None`` if it is outside the arc.
Cells within ``comet`` radians *behind* the rotating head are lit; their position along
the arc picks a colour from :attr:`COMET_RAMP`, so the head glows near-white and the
tail fades into deep blue just before the plain rim resumes.
"""
behind = (self._phase - angle) % (2 * math.pi)
if behind > self._comet:
return None
step = self._comet / len(self.COMET_RAMP)
index = min(len(self.COMET_RAMP) - 1, int(behind / step))
return self.COMET_RAMP[index]
# --- rendering -----------------------------------------------------------
def render(self, brightness: float = 1.0) -> Text:
"""Render the current frame as a multi-line Rich :class:`~rich.text.Text`.
Each rim cell is drawn in the rim colour unless the comet is currently over it, in
which case it takes the comet's colour for its position along the arc.
Args:
brightness: A 0→1 dimmer applied to every colour, scaling it toward black. Used to
fade the ring in from black; ``1.0`` renders at full colour.
Returns:
The ring as one ``Text`` whose lines are the ring's rows.
"""
rim = dim_color(self.RIM_STYLE, brightness)
text = Text()
for row in range(self._rows):
if row:
text.append("\n")
for col in range(self._cols):
bits = self._cells.get((col, row))
if not bits:
text.append(" ")
continue
comet = self._comet_style(self._cell_angle(col, row))
style = dim_color(comet, brightness) if comet else rim
text.append(chr(_BRAILLE_BASE + bits), style=style)
return text
@property
def width(self) -> int:
"""The ring's width in terminal cells (its natural render width)."""
return self._cols
@property
def height(self) -> int:
"""The ring's height in terminal cells (its number of rows)."""
return self._rows
def _frange(start: float, stop: float, step: float) -> list[float]:
"""Return ``[start, start+step, …]`` up to and including ``stop`` (a float ``range``)."""
out = [start]
while out[-1] + step <= stop + 1e-9:
out.append(out[-1] + step)
return out
def dim_color(style: str, factor: float) -> str:
"""Scale a Rich hex-colour style toward black by ``factor`` (0 = black, 1 = unchanged).
Only the ``#rrggbb`` token is scaled; any attribute words (e.g. ``bold``) are preserved.
A style without a hex colour is returned untouched, as is any ``factor >= 1``.
Args:
style: A Rich style such as ``"#38bdf8"`` or ``"bold #ecfeff"``.
factor: The brightness multiplier, clamped to ``[0, 1]``.
Returns:
The style with its colour dimmed toward black.
"""
if factor >= 1.0:
return style
factor = max(0.0, factor)
parts = []
for token in style.split():
if token.startswith("#") and len(token) == 7:
r = round(int(token[1:3], 16) * factor)
g = round(int(token[3:5], 16) * factor)
b = round(int(token[5:7], 16) * factor)
parts.append(f"#{r:02x}{g:02x}{b:02x}")
else:
parts.append(token)
return " ".join(parts)
+22 -18
View File
@@ -120,7 +120,7 @@ class TuiSession:
self._app: Optional[Application] = None
self._input = input
self._output = output
# The top-most floating "working" overlay (a ring spinner), or None when idle. It is
# The top-most floating "working" overlay (a skeleton card), or None when idle. It is
# deliberately *not* on the screen stack: it hovers above every layer and is shown/
# hidden by busy_overlay, independent of whatever screens are pushed.
self._overlay: Optional[BusyOverlay] = None
@@ -172,9 +172,9 @@ class TuiSession:
def _expose_overlay(self) -> None:
"""Restart the busy overlay's fade whenever a stack change re-exposes it.
A prompt pushed over an active overlay hides the ring; popping back to an empty stack
A prompt pushed over an active overlay hides the card; popping back to an empty stack
re-exposes it. Restart the intro then so the black hold and fade-in replay fresh each
time the ring is shown, rather than snapping back at full brightness (see
time the card is shown, rather than snapping back at full brightness (see
:meth:`~meshterm.ui.tui.overlay.BusyOverlay.restart`).
"""
if self._overlay is not None and not self._stack:
@@ -564,36 +564,40 @@ class TuiSession:
self,
message: str = "",
*,
title: str = "",
interval: float = 0.06,
) -> AsyncIterator[BusyOverlay]:
"""Float an animated ring spinner on top of everything for the duration of a block.
"""Float a skeleton card on top of everything for the duration of a block.
Wrap a slow, screen-affecting operation — most usefully a device menu navigation,
which can otherwise sit on a blank frame while the companion answers — in::
async with session.busy_overlay("Talking to your companion…"):
async with session.busy_overlay("reading…", title="Nodes"):
await slow_work()
A background timer advances the ring, fades it in, and repaints while the block runs;
the overlay is always cleared and the timer cancelled on exit, even on error. The ring
fades in from black rather than popping in (see :attr:`BusyOverlay.brightness`), so a
quick operation only paints a near-black ring and it never appears suddenly. It is
drawn only in the gaps between screens (see :meth:`_overlay_visible`), so it announces
the wait without covering a prompt the user is interacting with.
The card stands in for the screen being fetched: a title, the one-cell working chip
beside the caption, and a Knight-Rider scanning-light bar. A background timer advances the chip,
fades the card in, and repaints while the block runs; it is always cleared and the
timer cancelled on exit, even on error. The card fades in from black rather than
popping in (see :attr:`BusyOverlay.brightness`), so a quick operation only paints a
near-black card and it never appears suddenly. It is drawn only in the gaps between
screens (see :meth:`_overlay_visible`), so it announces the wait without covering a
prompt the user is interacting with.
Args:
message: An optional caption drawn beneath the ring.
message: An optional caption drawn beside the working chip.
title: An optional heading naming the screen being fetched.
interval: Seconds between animation frames (also the fade's repaint cadence).
Yields:
The live :class:`BusyOverlay`, in case the caller wants to update its caption.
"""
# A nested busy_overlay keeps the outer one (the outermost wait owns the screen); its
# own body still runs, it just doesn't install a second ring.
# own body still runs, it just doesn't install a second card.
if self._overlay is not None:
yield self._overlay
return
overlay = BusyOverlay(message)
overlay = BusyOverlay(message, title=title)
self._overlay = overlay
if self._overlay_visible():
self.invalidate() # start the fade-in promptly, before the first tick
@@ -602,7 +606,7 @@ class TuiSession:
while True:
await asyncio.sleep(interval)
overlay.tick()
# Only repaint when the ring is actually on screen, so an overlay waiting
# Only repaint when the card is actually on screen, so an overlay waiting
# behind a live prompt doesn't churn that prompt's repaints for nothing.
if self._overlay_visible():
self.invalidate()
@@ -667,7 +671,7 @@ class TuiSession:
)
# The busy overlay is the last float, so it draws on top of the dialog float — the
# top of the z-order. It is a content-sized window (dont_extend_*) with no anchors, so
# the FloatContainer centres just its ring box over the screen rather than blanking it.
# the FloatContainer centres just its skeleton card over the screen rather than blanking it.
overlay_window = Window(
FormattedTextControl(self._render_overlay),
always_hide_cursor=True,
@@ -759,7 +763,7 @@ class TuiSession:
def _overlay_visible(self) -> bool:
"""Whether the busy overlay should be painted this frame.
Gated on an empty screen stack so the ring only appears in the "black screen" gaps a
Gated on an empty screen stack so the card only appears in the "black screen" gaps a
device operation opens between screens (a menu navigation before the tool's first
prompt, say) and never buries a dialog the user is meant to be reading. It also stays
unpainted through the overlay's initial hold (:attr:`BusyOverlay.brightness` is 0), so
@@ -768,7 +772,7 @@ class TuiSession:
return self._overlay is not None and not self._stack and self._overlay.brightness > 0
def _render_overlay(self) -> ANSI:
"""Render the busy overlay's ring (only when :meth:`_overlay_visible`)."""
"""Render the busy overlay's skeleton card (only when :meth:`_overlay_visible`)."""
if self._overlay is None:
return ANSI("")
return ANSI(self._overlay.render())
+3 -3
View File
@@ -753,8 +753,8 @@ async def open_tx_optimize(
"""Log in to the tuned node once per session, prompting only when needed.
Runs *before* the in-flight dialog goes up, so the password prompt floats
over the idle screen and the login command itself hides behind the ring
spinner — the same shape as every other pre-transmission device call.
over the idle screen and the login command itself hides behind the skeleton
card — the same shape as every other pre-transmission device call.
"""
nonlocal logged_in
if logged_in:
@@ -927,7 +927,7 @@ async def open_tx_optimize(
worker = screen._worker
if worker is not None and not worker.done():
# Esc mid-sweep: stop the search and let the optimizer's unwind restore the
# node's original power. The ring spinner covers that last device command.
# node's original power. The skeleton card covers that last device command.
worker.cancel()
async with ctx.ui.busy_overlay():
try:
+106 -39
View File
@@ -1074,6 +1074,35 @@ def test_progress_handle_matches_rich_api() -> None:
assert screen.render_body(60)
def test_progress_chip_animates_between_advances() -> None:
"""The working chip spins on tick alone, so a task that rarely advances still reads alive.
A trace advances just once, at the very end; the meter would sit motionless until then.
Ticking the dialog must change what it renders even with the task's count untouched.
"""
screen = ProgressScreen("trace")
screen.add_task("tracing", total=1) # will sit at 0/1 for the whole wait
before = "\n".join(screen.render_body(60))
screen.tick()
after = "\n".join(screen.render_body(60))
assert before != after # the chip moved even though nothing advanced
def test_progress_completed_task_shows_check_and_indeterminate_has_no_track() -> None:
"""A finished task flips its chip to ✓; an unknown-total task draws no dead track."""
from rich.text import Text as RichText
screen = ProgressScreen("work")
done = screen.add_task("tracing", total=2)
screen.advance(done, 2)
indet = screen.add_task("optimizing", total=None)
plain = RichText.from_ansi("\n".join(screen.render_body(60))).plain
assert "✓ tracing" in plain
# The indeterminate row carries only the spinning chip + its count, never a track glyph.
indet_line = next(line for line in plain.splitlines() if "optimizing" in line)
assert "⠶" not in indet_line and "⣿" not in indet_line
# --- session stack -----------------------------------------------------------
@@ -1162,58 +1191,96 @@ def test_session_stack_and_float_selection() -> None:
assert not session._has_float()
# --- ring spinner + busy overlay ---------------------------------------------
# --- busy skeleton card ------------------------------------------------------
def test_ring_spinner_draws_a_symmetric_styled_circle() -> None:
"""The ring renders a hollow circle: a rim in the rim colour and a bright comet arc."""
from meshterm.ui.tui.ring import RingSpinner
def test_busy_overlay_renders_title_chip_caption_and_scanner_row() -> None:
"""A titled, captioned card shows its heading, the working chip + caption, and the LED bar."""
import re
ring = RingSpinner()
text = ring.render()
styles = {span.style for span in text.spans}
assert RingSpinner.RIM_STYLE in styles # the resting rim
assert any(c in styles for c in RingSpinner.COMET_RAMP) # the bright comet arc
# It really is a hollow ring: the middle row has a blank interior between its two sides.
middle = text.plain.split("\n")[ring.height // 2]
assert middle.strip() and " " in middle.strip()
from meshterm.ui.tui.overlay import BusyOverlay, _SCAN_CELLS, _SCAN_GLYPH
overlay = BusyOverlay("reading from Waymarker…", title="Nodes", fade=0.0) # full bright at once
ansi = overlay.render()
assert "Nodes" in ansi # the heading naming the screen being fetched
assert "reading from Waymarker" in ansi # the caption beside the chip
assert overlay.spinner.frame in ansi # the one-cell working chip
# The scanning bar stands in for the content to come: one braille lamp per cell, each styled
# on its own, so strip ANSI before counting the run of lamps.
plain = re.sub(r"\x1b\[[0-9;]*m", "", ansi)
assert _SCAN_GLYPH * _SCAN_CELLS in plain
def test_ring_spinner_is_left_right_symmetric() -> None:
"""The quarter-plus-mirror construction makes every row a left-right mirror of itself."""
from meshterm.ui.tui.ring import RingSpinner
def test_busy_overlay_chip_ticks_with_the_animation() -> None:
"""The card's working chip is the reusable one-cell Spinner and advances on tick."""
from meshterm.ui.tui.overlay import BusyOverlay
from meshterm.ui.tui.spinner import Spinner
rows = RingSpinner().render().plain.split("\n")
# Blank out the moving comet by comparing dot presence, not colour: mirror the lit cells.
for row in rows:
lit = [ch != " " for ch in row]
assert lit == lit[::-1]
overlay = BusyOverlay()
assert isinstance(overlay.spinner, Spinner)
first = overlay.spinner.frame
overlay.tick()
assert overlay.spinner.frame != first
def test_ring_spinner_comet_rotates_on_tick() -> None:
"""Ticking moves the bright comet to a different set of cells (the dots chase round)."""
from meshterm.ui.tui.ring import RingSpinner
def test_busy_overlay_scanner_recolours_on_tick_without_reshaping() -> None:
"""The bar isn't static: the light moves, so ticking recolours it — but its shape holds.
ring = RingSpinner()
ramp = set(RingSpinner.COMET_RAMP)
Regression guard for the frozen-looking card — the chip spun but the bar sat dead-still.
The braille layout must stay put while the colouring (the moving light) shifts each tick.
"""
import re
def bright_cells(t) -> set[int]:
return {i for span in t.spans if span.style in ramp for i in range(span.start, span.end)}
from meshterm.ui.tui.overlay import BusyOverlay, _SCAN_GLYPH
first = bright_cells(ring.render())
overlay = BusyOverlay("reading…", title="Nodes", fade=0.0) # full bright, no fade to wait out
def lamp_rows(ansi: str) -> list[str]:
plain = re.sub(r"\x1b\[[0-9;]*m", "", ansi)
return [line for line in plain.splitlines() if _SCAN_GLYPH in line]
before = overlay.render()
for _ in range(3):
ring.tick()
assert bright_cells(ring.render()) != first # the comet advanced
overlay.tick()
after = overlay.render()
assert after != before # the light moved: the ANSI colouring differs
assert lamp_rows(after) == lamp_rows(before) # …but the lamps never shift shape
def test_busy_overlay_render_stacks_ring_over_caption() -> None:
"""A captioned overlay renders the ring rows plus a final caption line."""
def _scanner_head(overlay) -> int: # type: ignore[no-untyped-def]
"""The index of the brightest lamp — where the scanning light's head currently sits."""
glow = overlay._glow
return max(range(len(glow)), key=lambda i: glow[i])
def test_busy_overlay_scanner_leaves_a_persistence_of_vision_trail() -> None:
"""Lamps the head has passed keep glowing, fading — a comet tail, not a lone bright lamp."""
from meshterm.ui.tui.overlay import BusyOverlay
overlay = BusyOverlay("Talking to your companion…", fade=0.0) # full brightness at once
ansi = overlay.render()
assert "Talking to your companion" in ansi
assert ansi.count("\n") == overlay.spinner.height # ring rows + one caption line
overlay = BusyOverlay("reading…", title="Nodes", fade=0.0)
for _ in range(5): # sweep in off the left edge so a head and a tail both exist
overlay.tick()
head = _scanner_head(overlay)
glow = overlay._glow
assert glow[head] > 0.0 # a lit head
trail = [g for i, g in enumerate(glow) if i < head and g > 0.0] # lamps behind the head
assert trail # the head dragged a trail rather than leaving black behind it
assert max(trail) < glow[head] # and the trail is dimmer than the head it follows
def test_busy_overlay_scanner_bounces_off_both_ends() -> None:
"""The head sweeps to the right edge and back to the left — the Knight-Rider ping-pong."""
from meshterm.ui.tui.overlay import BusyOverlay, _SCAN_CELLS
overlay = BusyOverlay(fade=0.0)
heads = [_scanner_head(overlay)]
for _ in range(80): # long enough for at least one full there-and-back sweep
overlay.tick()
heads.append(_scanner_head(overlay))
assert max(heads) >= _SCAN_CELLS - 2 # reached the right end
assert min(heads) <= 1 # …and came back to the left end
def test_busy_overlay_holds_black_then_fades_in() -> None:
@@ -1230,7 +1297,7 @@ def test_busy_overlay_holds_black_then_fades_in() -> None:
def test_dim_color_scales_hex_toward_black() -> None:
"""The fade dimmer scales the hex channels and preserves attribute words like ``bold``."""
from meshterm.ui.tui.ring import dim_color
from meshterm.ui.tui.overlay import dim_color
assert dim_color("#38bdf8", 1.0) == "#38bdf8" # untouched at full brightness
assert dim_color("#ffffff", 0.0) == "#000000" # black at zero
@@ -1248,8 +1315,8 @@ def test_overlay_fade_restarts_when_re_exposed_after_a_prompt() -> None:
assert overlay.brightness == 1.0
screen = ScrollScreen(Text("prompt"))
session.push(screen) # a prompt covers the ring
session.pop(screen) # dismissed → ring re-exposed on the now-empty stack
session.push(screen) # a prompt covers the card
session.pop(screen) # dismissed → card re-exposed on the now-empty stack
assert overlay.brightness == 0.0 # the fade restarted from black