diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index 9b2fb07..932cd71 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -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 …", 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() diff --git a/meshterm/ui/surface.py b/meshterm/ui/surface.py index 64385c7..5780048 100644 --- a/meshterm/ui/surface.py +++ b/meshterm/ui/surface.py @@ -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 --------------------------------------------------------------- diff --git a/meshterm/ui/tui/__init__.py b/meshterm/ui/tui/__init__.py index 5301f61..84bf384 100644 --- a/meshterm/ui/tui/__init__.py +++ b/meshterm/ui/tui/__init__.py @@ -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", ] diff --git a/meshterm/ui/tui/overlay.py b/meshterm/ui/tui/overlay.py index 06beff2..263b23b 100644 --- a/meshterm/ui/tui/overlay.py +++ b/meshterm/ui/tui/overlay.py @@ -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) diff --git a/meshterm/ui/tui/progress.py b/meshterm/ui/tui/progress.py index 0eb375f..0fb85cc 100644 --- a/meshterm/ui/tui/progress.py +++ b/meshterm/ui/tui/progress.py @@ -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.""" diff --git a/meshterm/ui/tui/ring.py b/meshterm/ui/tui/ring.py deleted file mode 100644 index 3a765c0..0000000 --- a/meshterm/ui/tui/ring.py +++ /dev/null @@ -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) diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 193870a..673d749 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -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()) diff --git a/meshterm/ui/tx_screen.py b/meshterm/ui/tx_screen.py index 88ccae2..1ba6374 100644 --- a/meshterm/ui/tx_screen.py +++ b/meshterm/ui/tx_screen.py @@ -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: diff --git a/tests/test_tui.py b/tests/test_tui.py index 347e168..7878b49 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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