Float a fading ring spinner over laggy device navigations

Menu navigation over a slow link (Bluetooth especially, but serial too)
can sit on a blank frame while the companion answers — the menu screen is
popped and the tool's first prompt not yet pushed. Fill that gap with a
top-of-z-order spinner so the wait reads as work, not a hang.

- RingSpinner: a reusable, screen-agnostic braille circle with a cyan->blue
  comet chasing the rim. Plotted a quarter-arc and mirrored for exact 4-fold
  symmetry; stroked over a band of radii so it reads clearly as a circle.
- BusyOverlay + TuiSession.busy_overlay: draw the ring as the top-most float,
  centered, only in the gaps between screens (empty stack) so it never buries
  a prompt. It holds black for 200ms then fades in over 400ms, so quick work
  shows nothing and longer work never pops in. The fade restarts every time
  the ring is re-exposed (pop/reset), not just on first display.
- Wire it into menu navigation and the post-splash monitor resume for any
  real device link (mock simulator opts out); surfaced via ctx.ui.busy_overlay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-09 21:33:43 -04:00
1 parent 12577a1538
commit 19636e31e6
7 files changed
+647 -11

No files matched your search

+36 -8
View File
@@ -15,8 +15,8 @@ import os
import sys
import threading
import time
from contextlib import contextmanager
from typing import Iterator, Optional
from contextlib import asynccontextmanager, contextmanager
from typing import AsyncIterator, Iterator, Optional
from rich.logging import RichHandler
from rich.text import Text
@@ -379,7 +379,11 @@ async def _startup(ctx: AppContext) -> bool:
ctx.ble_override = None
if probed.get("device_id") == chosen.stable_id:
ctx.adopt_device(probed["device"])
await _resume_monitor(ctx)
# 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):
await _resume_monitor(ctx)
return True
@@ -418,6 +422,25 @@ async def _resume_monitor(ctx: AppContext) -> None:
pass
@asynccontextmanager
async def _busy_over_link(ctx: AppContext) -> AsyncIterator[None]:
"""Float the ring-spinner 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
answers instantly, so ``active_transport`` is ``None``) opts out. The overlay's own reveal
delay still suppresses a flash on genuinely quick operations, so this never flickers.
Args:
ctx: The shared application context (read for the active transport and UI surface).
"""
if ctx.active_transport is not None:
async with ctx.ui.busy_overlay():
yield
else:
yield
async def _run_selection(ctx: AppContext, name: str) -> None:
"""Gather parameters for, execute, and present a single tool from the menu.
@@ -440,11 +463,16 @@ async def _run_selection(ctx: AppContext, name: str) -> None:
return
try:
params = await tool.prompt_params(ctx)
if params is None: # user cancelled a prompt
ctx.ui.discard()
return
result = await tool.execute(ctx, params)
# 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
# as work, not a hang. The overlay only paints between screens, so prompts show through.
async with _busy_over_link(ctx):
params = await tool.prompt_params(ctx)
if params is None: # user cancelled a prompt
ctx.ui.discard()
return
result = await tool.execute(ctx, params)
except Exception as exc: # noqa: BLE001 - surface errors without crashing the menu
if is_connection_lost(exc):
ctx.ui.discard() # drop the half-built output; the watcher will prompt to reconnect
+20 -1
View File
@@ -15,7 +15,8 @@ only), so :class:`PlainUi` leaves them unsupported.
from __future__ import annotations
from typing import Any, Callable, Optional
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Callable, Optional
from rich.console import Console, Group, RenderableType
from rich.text import Text
@@ -53,6 +54,15 @@ 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.
In the interactive menu this shows the top-most ring spinner (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)."""
raise NotImplementedError
async def select(
self, title: str, items: list, *, default: Any = None, wrap: bool = True
) -> Any:
@@ -174,6 +184,11 @@ 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."""
yield
def _no_prompt(self) -> RuntimeError:
"""Build the error raised if a rich prompt is reached on the non-interactive path."""
return RuntimeError("interactive prompts are only available in the menu")
@@ -343,6 +358,10 @@ 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)
# --- input ---------------------------------------------------------------
async def select(
+4
View File
@@ -14,8 +14,10 @@ Public API:
from __future__ import annotations
from .overlay import BusyOverlay
from .progress import ProgressScreen
from .prompt import AutocompleteScreen, ConfirmScreen, ReconnectDialog, TextScreen
from .ring import RingSpinner
from .screen import CANCEL, BusyScreen, Screen, ScrollScreen
from .select import Choice, SelectScreen, Separator
from .session import TuiSession
@@ -35,5 +37,7 @@ __all__ = [
"ReconnectDialog",
"ProgressScreen",
"Spinner",
"RingSpinner",
"BusyOverlay",
"CANCEL",
]
+119
View File
@@ -0,0 +1,119 @@
"""The busy overlay: a centred ring spinner floated on top of everything else.
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.
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.
"""
from __future__ import annotations
import time
from rich.cells import cell_len
from rich.text import Text
from .render import render_to_ansi
from .ring import RingSpinner, dim_color
#: The muted caption colour (the theme's ``muted``, as a hex so it can be dimmed for the fade).
_CAPTION_COLOR = "#94a3b8"
class BusyOverlay:
"""A ring spinner (plus optional caption) drawn as the top-most floating layer.
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.
fade: The fade-in duration in seconds, after the hold.
"""
def __init__(
self, message: str = "", *, hold: float = 0.2, fade: float = 0.4
) -> None:
"""Create a busy overlay.
Args:
message: A short caption drawn beneath the ring (empty for a bare ring).
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.
"""
self.spinner = RingSpinner()
self.message = message
self.started_at = time.monotonic()
self.hold = max(0.0, hold)
self.fade = max(0.0, fade)
@property
def brightness(self) -> float:
"""The current fade level in ``[0, 1]``: 0 through the :attr:`hold`, then easing to 1.
Stays exactly 0 for the first :attr:`hold` seconds (nothing paints), then a smoothstep
easing over :attr:`fade` gives a gentle glow up out of the black rather than a linear
ramp.
"""
elapsed = time.monotonic() - self.started_at - self.hold
if elapsed <= 0:
return 0.0
if self.fade <= 0:
return 1.0
t = min(1.0, elapsed / self.fade)
return t * t * (3 - 2 * t) # smoothstep
def tick(self) -> None:
"""Advance the ring's comet one animation step."""
self.spinner.tick()
def restart(self) -> None:
"""Restart the intro from scratch: rewind the fade ramp and the comet to their starts.
Called each time the ring 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.
"""
self.started_at = time.monotonic()
self.spinner.reset()
def render(self) -> str:
"""Render the ring, plus any caption, 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.
Returns:
An ANSI string of the ring rows and an optional centred caption line.
"""
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)
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))
return render_to_ansi(block, width)
def _center_text(text: Text, width: int) -> Text:
"""Return ``text`` padded with spaces so it is centred within ``width`` cells."""
pad = max(0, width - cell_len(text.plain))
left = pad // 2
out = Text(" " * left)
out.append_text(text)
out.append(" " * (pad - left))
return out
+234
View File
@@ -0,0 +1,234 @@
"""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)
+114 -2
View File
@@ -12,7 +12,8 @@ and pop it — the push/await/pop model behind every prompt.
from __future__ import annotations
import asyncio
from typing import Any, Callable, Optional
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Callable, Optional
from prompt_toolkit.application import Application
from prompt_toolkit.filters import Condition
@@ -25,6 +26,7 @@ from rich.console import RenderableType
from rich.text import Text
from . import frame
from .overlay import BusyOverlay
from .progress import TuiProgress
from .prompt import (
AutocompleteScreen,
@@ -91,6 +93,10 @@ 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
# 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
# --- stack ---------------------------------------------------------------
@@ -112,6 +118,7 @@ class TuiSession:
self._stack.pop()
elif screen in self._stack:
self._stack.remove(screen)
self._expose_overlay()
self.invalidate()
def reset(self) -> None:
@@ -123,8 +130,20 @@ class TuiSession:
(their callers pop them in ``finally``), so dropping any stragglers here is safe.
"""
self._stack.clear()
self._expose_overlay()
self.invalidate()
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
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
:meth:`~meshterm.ui.tui.overlay.BusyOverlay.restart`).
"""
if self._overlay is not None and not self._stack:
self._overlay.restart()
def invalidate(self) -> None:
"""Request a repaint if the application is running."""
if self._app is not None:
@@ -444,6 +463,68 @@ class TuiSession:
"""Return a progress context manager backed by a pushed :class:`ProgressScreen`."""
return TuiProgress(self, title)
@asynccontextmanager
async def busy_overlay(
self,
message: str = "",
*,
interval: float = 0.06,
) -> AsyncIterator[BusyOverlay]:
"""Float an animated ring spinner 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…"):
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.
Args:
message: An optional caption drawn beneath the ring.
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.
if self._overlay is not None:
yield self._overlay
return
overlay = BusyOverlay(message)
self._overlay = overlay
if self._overlay_visible():
self.invalidate() # start the fade-in promptly, before the first tick
async def animate() -> None:
while True:
await asyncio.sleep(interval)
overlay.tick()
# Only repaint when the ring 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()
ticker = asyncio.ensure_future(animate())
try:
yield overlay
finally:
ticker.cancel()
try:
await ticker
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - a cosmetic overlay must never break a flow
pass
self._overlay = None
self.invalidate()
# --- application lifecycle ------------------------------------------------
async def run(self, main: Any) -> None:
@@ -488,6 +569,15 @@ class TuiSession:
float_window = Window(
FormattedTextControl(self._render_float), always_hide_cursor=True
)
# 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.
overlay_window = Window(
FormattedTextControl(self._render_overlay),
always_hide_cursor=True,
dont_extend_width=True,
dont_extend_height=True,
)
root = FloatContainer(
content=base_window,
floats=[
@@ -495,7 +585,12 @@ class TuiSession:
ConditionalContainer(
float_window, filter=Condition(self._has_float)
)
)
),
Float(
ConditionalContainer(
overlay_window, filter=Condition(self._overlay_visible)
)
),
],
)
return Application(
@@ -567,6 +662,23 @@ class TuiSession:
cols, rows = self._size()
return ANSI(frame.compose_dialog(self.top, cols, rows))
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
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
an operation that finishes within the hold shows nothing and never flashes.
"""
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`)."""
if self._overlay is None:
return ANSI("")
return ANSI(self._overlay.render())
# --- input ---------------------------------------------------------------
def _key_bindings(self) -> KeyBindings:
+120
View File
@@ -951,3 +951,123 @@ def test_session_stack_and_float_selection() -> None:
session.pop(dialog)
assert session.top is base
assert not session._has_float()
# --- ring spinner + busy overlay ---------------------------------------------
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
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()
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
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]
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
ring = RingSpinner()
ramp = set(RingSpinner.COMET_RAMP)
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)}
first = bright_cells(ring.render())
for _ in range(3):
ring.tick()
assert bright_cells(ring.render()) != first # the comet advanced
def test_busy_overlay_render_stacks_ring_over_caption() -> None:
"""A captioned overlay renders the ring rows plus a final caption line."""
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
def test_busy_overlay_holds_black_then_fades_in() -> None:
"""Brightness is 0 through the hold, then climbs to full colour over the fade window."""
from meshterm.ui.tui.overlay import BusyOverlay
overlay = BusyOverlay(hold=0.1, fade=0.2)
assert overlay.brightness == 0.0 # nothing paints during the hold
overlay.started_at -= 0.1 # to the very end of the hold
assert overlay.brightness < 0.2 # only now beginning to glow up from black
overlay.started_at -= 0.2 # past the full fade window
assert overlay.brightness == 1.0 # fully lit
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
assert dim_color("#38bdf8", 1.0) == "#38bdf8" # untouched at full brightness
assert dim_color("#ffffff", 0.0) == "#000000" # black at zero
assert dim_color("bold #ffffff", 0.5) == "bold #808080" # keeps 'bold', halves the colour
def test_overlay_fade_restarts_when_re_exposed_after_a_prompt() -> None:
"""Popping back to an empty stack replays the black-hold + fade, not a full-bright snap."""
from meshterm.ui.tui.overlay import BusyOverlay
session = TuiSession()
overlay = BusyOverlay()
session._overlay = overlay
overlay.started_at -= 10 # pretend the intro already finished
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
assert overlay.brightness == 0.0 # the fade restarted from black
async def test_session_busy_overlay_shows_between_screens_and_clears() -> None:
"""The overlay floats while a block runs on an empty stack, and is dropped afterwards."""
with create_pipe_input() as inp:
session = TuiSession(input=inp, output=DummyOutput())
seen = {}
async def main() -> None:
async with session.busy_overlay("working…"):
await asyncio.sleep(0.05) # still within the initial hold
seen["hidden_during_hold"] = not session._overlay_visible()
await asyncio.sleep(0.3) # past the 200ms hold: the ring has faded in
seen["active"] = session._overlay is not None
seen["visible_empty_stack"] = session._overlay_visible()
seen["rendered"] = bool(session._render_overlay().value.strip())
# With a screen on the stack the ring stays hidden so it can't bury a prompt.
session.push(ScrollScreen(Text("prompt"), title="p"))
seen["hidden_over_screen"] = not session._overlay_visible()
session.pop()
await asyncio.wait_for(session.run(main()), timeout=5)
assert seen["hidden_during_hold"] is True
assert seen["active"] is True
assert seen["visible_empty_stack"] is True
assert seen["rendered"] is True
assert seen["hidden_over_screen"] is True
assert session._overlay is None # cleared on exit