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:
1 parent
71b7d44263
commit
b508f2fe27
9 files changed
+433
-368
No files matched your search
+22
-7
@@ -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
@@ -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 ---------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user