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:
1 parent
12577a1538
commit
19636e31e6
7 files changed
+647
-11
No files matched your search
+36
-8
@@ -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
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user