diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index 541eef0..08d0ae5 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -15,8 +15,8 @@ import os import sys import threading import time -from contextlib import contextmanager -from typing import Iterator, Optional +from contextlib import asynccontextmanager, contextmanager +from typing import AsyncIterator, Iterator, Optional from rich.logging import RichHandler from rich.text import Text @@ -379,7 +379,11 @@ async def _startup(ctx: AppContext) -> bool: ctx.ble_override = None if probed.get("device_id") == chosen.stable_id: ctx.adopt_device(probed["device"]) - await _resume_monitor(ctx) + # Between the device splash and the first menu paint, resuming background listening opens + # the radio — a slow, silent step that would otherwise leave the screen blank for a beat. + # Float the ring spinner across that gap on any real link (see _busy_over_link). + async with _busy_over_link(ctx): + await _resume_monitor(ctx) return True @@ -418,6 +422,25 @@ async def _resume_monitor(ctx: AppContext) -> None: pass +@asynccontextmanager +async def _busy_over_link(ctx: AppContext) -> AsyncIterator[None]: + """Float the ring-spinner overlay for the wrapped block, on any real device link. + + Bluetooth is the worst offender, but serial navigation has a perceptible lag too, so the + overlay is installed for either transport. Only the mock simulator (which has no link and + answers instantly, so ``active_transport`` is ``None``) opts out. The overlay's own reveal + delay still suppresses a flash on genuinely quick operations, so this never flickers. + + Args: + ctx: The shared application context (read for the active transport and UI surface). + """ + if ctx.active_transport is not None: + async with ctx.ui.busy_overlay(): + yield + else: + yield + + async def _run_selection(ctx: AppContext, name: str) -> None: """Gather parameters for, execute, and present a single tool from the menu. @@ -440,11 +463,16 @@ async def _run_selection(ctx: AppContext, name: str) -> None: return try: - params = await tool.prompt_params(ctx) - if params is None: # user cancelled a prompt - ctx.ui.discard() - return - result = await tool.execute(ctx, params) + # Opening a tool can sit on a blank frame while the companion answers (the menu has + # been popped, its first prompt not yet pushed) — very noticeable over Bluetooth, but + # perceptible on serial too. Float the ring spinner through that gap so the wait reads + # as work, not a hang. The overlay only paints between screens, so prompts show through. + async with _busy_over_link(ctx): + params = await tool.prompt_params(ctx) + if params is None: # user cancelled a prompt + ctx.ui.discard() + return + result = await tool.execute(ctx, params) except Exception as exc: # noqa: BLE001 - surface errors without crashing the menu if is_connection_lost(exc): ctx.ui.discard() # drop the half-built output; the watcher will prompt to reconnect diff --git a/meshterm/ui/surface.py b/meshterm/ui/surface.py index 9c51e7f..4c5fa0a 100644 --- a/meshterm/ui/surface.py +++ b/meshterm/ui/surface.py @@ -15,7 +15,8 @@ only), so :class:`PlainUi` leaves them unsupported. from __future__ import annotations -from typing import Any, Callable, Optional +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator, Callable, Optional from rich.console import Console, Group, RenderableType from rich.text import Text @@ -53,6 +54,15 @@ class Ui: """Return a progress context manager exposing ``add_task``/``advance``/``update``.""" raise NotImplementedError + def busy_overlay(self, message: str = ""): # noqa: ANN201 - async CM, varies by backend + """Return an async context manager that floats a "working" spinner while its block runs. + + In the interactive menu this shows the top-most ring spinner (see + :meth:`~meshterm.ui.tui.session.TuiSession.busy_overlay`), used to cover the lag of a + Bluetooth operation that would otherwise leave the screen blank. In scripted CLI mode + it does nothing (there is no full-screen surface to float over).""" + raise NotImplementedError + async def select( self, title: str, items: list, *, default: Any = None, wrap: bool = True ) -> Any: @@ -174,6 +184,11 @@ class PlainUi(Ui): return make_progress(self.console) + @asynccontextmanager + async def busy_overlay(self, message: str = "") -> AsyncIterator[None]: + """Do nothing: the scripted CLI has no full-screen surface to float a spinner over.""" + yield + def _no_prompt(self) -> RuntimeError: """Build the error raised if a rich prompt is reached on the non-interactive path.""" return RuntimeError("interactive prompts are only available in the menu") @@ -343,6 +358,10 @@ class TuiUi(Ui): """Return a progress dialog context manager for the session.""" return self.session.progress(title) + def busy_overlay(self, message: str = ""): # noqa: ANN201 + """Float the session's top-most ring spinner while the wrapped block runs.""" + return self.session.busy_overlay(message) + # --- input --------------------------------------------------------------- async def select( diff --git a/meshterm/ui/tui/__init__.py b/meshterm/ui/tui/__init__.py index c1f06ee..7241c54 100644 --- a/meshterm/ui/tui/__init__.py +++ b/meshterm/ui/tui/__init__.py @@ -14,8 +14,10 @@ Public API: from __future__ import annotations +from .overlay import BusyOverlay from .progress import ProgressScreen from .prompt import AutocompleteScreen, ConfirmScreen, ReconnectDialog, TextScreen +from .ring import RingSpinner from .screen import CANCEL, BusyScreen, Screen, ScrollScreen from .select import Choice, SelectScreen, Separator from .session import TuiSession @@ -35,5 +37,7 @@ __all__ = [ "ReconnectDialog", "ProgressScreen", "Spinner", + "RingSpinner", + "BusyOverlay", "CANCEL", ] diff --git a/meshterm/ui/tui/overlay.py b/meshterm/ui/tui/overlay.py new file mode 100644 index 0000000..06beff2 --- /dev/null +++ b/meshterm/ui/tui/overlay.py @@ -0,0 +1,119 @@ +"""The busy overlay: a centred ring spinner floated on top of everything else. + +A :class:`BusyOverlay` is the model behind :meth:`~meshterm.ui.tui.session.TuiSession. +busy_overlay` — a :class:`~meshterm.ui.tui.ring.RingSpinner` with an optional caption beneath +it. Unlike a :class:`~meshterm.ui.tui.screen.Screen`, it is *not* part of the screen stack: the session +draws it as the top-most float, so it hovers over whatever is (or is not) on screen. It owns +no input and resolves nothing; the session shows it while an operation runs and drops it when +that operation returns. + +Rather than pop in, it stays fully black for a short *hold* and then *fades in from black* +over its next fraction of a second (see :attr:`brightness`): an operation that finishes within +the hold shows nothing at all, and a longer one eases up out of the black rather than appearing +suddenly — so the ring never flashes and never pops in. +""" + +from __future__ import annotations + +import time + +from rich.cells import cell_len +from rich.text import Text + +from .render import render_to_ansi +from .ring import RingSpinner, dim_color + +#: The muted caption colour (the theme's ``muted``, as a hex so it can be dimmed for the fade). +_CAPTION_COLOR = "#94a3b8" + + +class BusyOverlay: + """A ring spinner (plus optional caption) drawn as the top-most floating layer. + + Attributes: + spinner: The animated :class:`~meshterm.ui.tui.ring.RingSpinner` at the overlay's core. + message: An optional caption drawn, muted, beneath the ring. + started_at: The monotonic time the overlay was created, from which the hold + fade run. + hold: Seconds the ring stays fully black before the fade begins. + fade: The fade-in duration in seconds, after the hold. + """ + + def __init__( + self, message: str = "", *, hold: float = 0.2, fade: float = 0.4 + ) -> None: + """Create a busy overlay. + + Args: + message: A short caption drawn beneath the ring (empty for a bare ring). + hold: Seconds to stay fully black before fading in, so an operation that finishes + this fast shows nothing at all. + fade: Seconds over which the ring then brightens from black to full colour. + """ + self.spinner = RingSpinner() + self.message = message + self.started_at = time.monotonic() + self.hold = max(0.0, hold) + self.fade = max(0.0, fade) + + @property + def brightness(self) -> float: + """The current fade level in ``[0, 1]``: 0 through the :attr:`hold`, then easing to 1. + + Stays exactly 0 for the first :attr:`hold` seconds (nothing paints), then a smoothstep + easing over :attr:`fade` gives a gentle glow up out of the black rather than a linear + ramp. + """ + elapsed = time.monotonic() - self.started_at - self.hold + if elapsed <= 0: + return 0.0 + if self.fade <= 0: + return 1.0 + t = min(1.0, elapsed / self.fade) + return t * t * (3 - 2 * t) # smoothstep + + def tick(self) -> None: + """Advance the ring's comet one animation step.""" + self.spinner.tick() + + def restart(self) -> None: + """Restart the intro from scratch: rewind the fade ramp and the comet to their starts. + + Called each time the ring is (re)exposed — its first appearance and every time it comes + back after a prompt covered it — so the black hold and fade-in play fresh every display + rather than the ring snapping back at full brightness mid-way through an operation. + """ + self.started_at = time.monotonic() + self.spinner.reset() + + def render(self) -> str: + """Render the ring, plus any caption, as a centred ANSI block, at the current fade level. + + The rows are pre-padded to a common width and joined into one multi-line + :class:`~rich.text.Text`, so the session's content-sized float centres a clean + rectangle over the screen. + + Returns: + An ANSI string of the ring rows and an optional centred caption line. + """ + level = self.brightness + ring = self.spinner.render(level) + rows: list[Text] = ring.split("\n") + width = max((cell_len(row.plain) for row in rows), default=0) + if self.message: + width = max(width, cell_len(self.message)) + block = Text("\n").join(_center_text(row, width) for row in rows) + if self.message: + block.append("\n") + caption = Text(self.message, style=dim_color(_CAPTION_COLOR, level)) + block.append_text(_center_text(caption, width)) + return render_to_ansi(block, width) + + +def _center_text(text: Text, width: int) -> Text: + """Return ``text`` padded with spaces so it is centred within ``width`` cells.""" + pad = max(0, width - cell_len(text.plain)) + left = pad // 2 + out = Text(" " * left) + out.append_text(text) + out.append(" " * (pad - left)) + return out diff --git a/meshterm/ui/tui/ring.py b/meshterm/ui/tui/ring.py new file mode 100644 index 0000000..3a765c0 --- /dev/null +++ b/meshterm/ui/tui/ring.py @@ -0,0 +1,234 @@ +"""A reusable ring spinner: a thick braille circle with a comet of dots chasing round it. + +Where :class:`~meshterm.ui.tui.spinner.Spinner` is one animated cell, this is a larger, +attention-grabbing *area* animation — a bold circle drawn in Braille dots with a bright arc +that orbits the rim, its dots fading from a near-white head back through the blues. It exists +because Bluetooth operations lag noticeably more than serial ones, and a menu navigation that +talks to a BLE companion can sit on a blank screen for a beat; a big centred spinner makes +that wait read as deliberate work rather than a hang. + +Like :class:`Spinner`, it is deliberately screen-agnostic: it holds a rotation phase, a +caller advances it with :meth:`tick` (typically from an animation timer), and :meth:`render` +returns a Rich renderable. + +Braille geometry: a Braille cell packs a 2×4 grid of dots, and a terminal cell is about twice +as tall as it is wide, so a single Braille dot is *very nearly square* on screen (½ a cell +each way). That means a mathematical circle plotted in Braille-dot space also *looks* round, +which is the whole trick behind drawing a clean ring in text. Stroking the circle over a small +band of radii thickens it into a bold arc that reads unmistakably as a circle. +""" + +from __future__ import annotations + +import math +from typing import Optional + +from rich.text import Text + +#: Braille bit for each sub-dot position, indexed ``[y][x]`` within a 2-wide × 4-tall cell. +#: Adding these to the Braille block base (``U+2800``) yields the glyph for a set of dots. +_DOT_BITS = ( + (0x01, 0x08), # top row: dots 1, 4 + (0x02, 0x10), # second row: dots 2, 5 + (0x04, 0x20), # third row: dots 3, 6 + (0x40, 0x80), # bottom row: dots 7, 8 +) +_BRAILLE_BASE = 0x2800 + + +class RingSpinner: + """An orbiting-comet ring drawn in thick Braille dots. + + The full rim is drawn in a calm blue; a bright arc (the "comet") is drawn over it and + rotates one step per :meth:`tick`, its dots fading along a cyan→blue ramp from a near-white + head to a deep-blue tail, so a glowing swarm of dots appears to chase itself around the + circle. + """ + + #: The resting rim colour: a muted blue the comet stands out against. + RIM_STYLE = "#2f5c96" + + #: The comet's colour ramp, head (near-white cyan) → tail (deep blue). The bright head + #: catches the eye; the fade gives the orbiting dots a sense of motion and depth. + COMET_RAMP = ( + "bold #ecfeff", + "bold #a5f3fc", + "#67e8f9", + "#38bdf8", + "#0ea5e9", + "#0284c7", + "#0369a1", + ) + + def __init__( + self, + *, + radius: float = 10.5, + thickness: float = 2.5, + comet: float = math.radians(130), + step: float = math.radians(16), + ) -> None: + """Configure a ring spinner. + + Args: + radius: Circle radius in Braille dots (½-cell units). ~10.5 gives a bold 13×7-cell + ring that reads clearly as a circle. + thickness: Stroke width in Braille dots; the rim is drawn over this band of radii, + so ~2.5 gives a chunky, unmistakably-round arc rather than a thin outline. + comet: Angular length of the bright orbiting arc, in radians. + step: How far the comet advances each :meth:`tick`, in radians. + """ + self._radius = radius + self._thickness = max(1.0, thickness) + self._comet = comet + self._step = step + self._phase = 0.0 + # Precompute the geometry once — it never changes between frames, only the phase does. + self._cols, self._rows, self._centre, self._cells = self._plot_rim() + + def tick(self) -> None: + """Advance the comet one step around the rim (wrapping at a full turn).""" + self._phase = (self._phase + self._step) % (2 * math.pi) + + def reset(self) -> None: + """Return the comet to its starting position.""" + self._phase = 0.0 + + # --- geometry ------------------------------------------------------------ + + def _plot_rim(self) -> tuple[int, int, float, dict[tuple[int, int], int]]: + """Stroke the thick rim into a Braille-cell grid, once, with exact 4-fold symmetry. + + Only the first quadrant is plotted; each lit dot is then mirrored across both axes, so + the four quarters are identical by construction and no rounding asymmetry can creep in. + The canvas is rounded up to a multiple of 4 dots so the mirror axes land on Braille-cell + boundaries — that keeps the *packed cell grid* symmetric, not merely the raw dots. + + Returns: + ``(cols, rows, centre, cells)`` — the grid size in cells, the ring centre in the + square dot-space, and a map from each ``(col, row)`` cell to its OR'd Braille bits. + """ + half = (self._thickness - 1) / 2.0 + outer = self._radius + half + pad = 1 + dim = int(math.ceil((2 * outer + 2 * pad) / 4.0)) * 4 # multiple-of-4 dot canvas + centre = (dim - 1) / 2.0 + edge = dim - 1 + # Sample the quarter arc densely enough that neither the angular step nor the ½-dot + # radial step leaves a gap once mirrored. + quadrant = max(60, int((math.pi / 2) * (outer + 1) / 0.4)) + radii = [self._radius + off for off in _frange(-half, half, 0.5)] + cells: dict[tuple[int, int], int] = {} + for i in range(quadrant + 1): + angle = (math.pi / 2) * i / quadrant + cos, sin = math.cos(angle), math.sin(angle) + for rr in radii: + px = int(round(centre + rr * cos)) + py = int(round(centre - rr * sin)) + # Mirror the one quadrant into all four for a perfectly symmetric ring. + for mx, my in ((px, py), (edge - px, py), (px, edge - py), (edge - px, edge - py)): + key = (mx // 2, my // 4) + cells[key] = cells.get(key, 0) | _DOT_BITS[my % 4][mx % 2] + # Report the *full* canvas size (not the lit bounding box): trimming to the lit extent + # would shave unequal padding off the sides and break the mirror symmetry. + return dim // 2, dim // 4, centre, cells + + def _cell_angle(self, col: int, row: int) -> float: + """The polar angle (from the ring centre) of a cell's centre, in ``[0, 2π)``. + + Derived from geometry rather than the plotting pass so the comet sweeps smoothly + regardless of how a cell's dots were accumulated. + """ + cx = col * 2 + 0.5 # centre of the cell's 2-wide dot column + cy = row * 4 + 1.5 # centre of the cell's 4-tall dot row + return math.atan2(self._centre - cy, cx - self._centre) % (2 * math.pi) + + def _comet_style(self, angle: float) -> Optional[str]: + """The comet colour for a rim cell at ``angle``, or ``None`` if it is outside the arc. + + Cells within ``comet`` radians *behind* the rotating head are lit; their position along + the arc picks a colour from :attr:`COMET_RAMP`, so the head glows near-white and the + tail fades into deep blue just before the plain rim resumes. + """ + behind = (self._phase - angle) % (2 * math.pi) + if behind > self._comet: + return None + step = self._comet / len(self.COMET_RAMP) + index = min(len(self.COMET_RAMP) - 1, int(behind / step)) + return self.COMET_RAMP[index] + + # --- rendering ----------------------------------------------------------- + + def render(self, brightness: float = 1.0) -> Text: + """Render the current frame as a multi-line Rich :class:`~rich.text.Text`. + + Each rim cell is drawn in the rim colour unless the comet is currently over it, in + which case it takes the comet's colour for its position along the arc. + + Args: + brightness: A 0→1 dimmer applied to every colour, scaling it toward black. Used to + fade the ring in from black; ``1.0`` renders at full colour. + + Returns: + The ring as one ``Text`` whose lines are the ring's rows. + """ + rim = dim_color(self.RIM_STYLE, brightness) + text = Text() + for row in range(self._rows): + if row: + text.append("\n") + for col in range(self._cols): + bits = self._cells.get((col, row)) + if not bits: + text.append(" ") + continue + comet = self._comet_style(self._cell_angle(col, row)) + style = dim_color(comet, brightness) if comet else rim + text.append(chr(_BRAILLE_BASE + bits), style=style) + return text + + @property + def width(self) -> int: + """The ring's width in terminal cells (its natural render width).""" + return self._cols + + @property + def height(self) -> int: + """The ring's height in terminal cells (its number of rows).""" + return self._rows + + +def _frange(start: float, stop: float, step: float) -> list[float]: + """Return ``[start, start+step, …]`` up to and including ``stop`` (a float ``range``).""" + out = [start] + while out[-1] + step <= stop + 1e-9: + out.append(out[-1] + step) + return out + + +def dim_color(style: str, factor: float) -> str: + """Scale a Rich hex-colour style toward black by ``factor`` (0 = black, 1 = unchanged). + + Only the ``#rrggbb`` token is scaled; any attribute words (e.g. ``bold``) are preserved. + A style without a hex colour is returned untouched, as is any ``factor >= 1``. + + Args: + style: A Rich style such as ``"#38bdf8"`` or ``"bold #ecfeff"``. + factor: The brightness multiplier, clamped to ``[0, 1]``. + + Returns: + The style with its colour dimmed toward black. + """ + if factor >= 1.0: + return style + factor = max(0.0, factor) + parts = [] + for token in style.split(): + if token.startswith("#") and len(token) == 7: + r = round(int(token[1:3], 16) * factor) + g = round(int(token[3:5], 16) * factor) + b = round(int(token[5:7], 16) * factor) + parts.append(f"#{r:02x}{g:02x}{b:02x}") + else: + parts.append(token) + return " ".join(parts) diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 56d3557..8147139 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -12,7 +12,8 @@ and pop it — the push/await/pop model behind every prompt. from __future__ import annotations import asyncio -from typing import Any, Callable, Optional +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator, Callable, Optional from prompt_toolkit.application import Application from prompt_toolkit.filters import Condition @@ -25,6 +26,7 @@ from rich.console import RenderableType from rich.text import Text from . import frame +from .overlay import BusyOverlay from .progress import TuiProgress from .prompt import ( AutocompleteScreen, @@ -91,6 +93,10 @@ class TuiSession: self._app: Optional[Application] = None self._input = input self._output = output + # The top-most floating "working" overlay (a ring spinner), or None when idle. It is + # deliberately *not* on the screen stack: it hovers above every layer and is shown/ + # hidden by busy_overlay, independent of whatever screens are pushed. + self._overlay: Optional[BusyOverlay] = None # --- stack --------------------------------------------------------------- @@ -112,6 +118,7 @@ class TuiSession: self._stack.pop() elif screen in self._stack: self._stack.remove(screen) + self._expose_overlay() self.invalidate() def reset(self) -> None: @@ -123,8 +130,20 @@ class TuiSession: (their callers pop them in ``finally``), so dropping any stragglers here is safe. """ self._stack.clear() + self._expose_overlay() self.invalidate() + def _expose_overlay(self) -> None: + """Restart the busy overlay's fade whenever a stack change re-exposes it. + + A prompt pushed over an active overlay hides the ring; popping back to an empty stack + re-exposes it. Restart the intro then so the black hold and fade-in replay fresh each + time the ring is shown, rather than snapping back at full brightness (see + :meth:`~meshterm.ui.tui.overlay.BusyOverlay.restart`). + """ + if self._overlay is not None and not self._stack: + self._overlay.restart() + def invalidate(self) -> None: """Request a repaint if the application is running.""" if self._app is not None: @@ -444,6 +463,68 @@ class TuiSession: """Return a progress context manager backed by a pushed :class:`ProgressScreen`.""" return TuiProgress(self, title) + @asynccontextmanager + async def busy_overlay( + self, + message: str = "", + *, + interval: float = 0.06, + ) -> AsyncIterator[BusyOverlay]: + """Float an animated ring spinner on top of everything for the duration of a block. + + Wrap a slow, screen-affecting operation — most usefully a device menu navigation, + which can otherwise sit on a blank frame while the companion answers — in:: + + async with session.busy_overlay("Talking to your companion…"): + await slow_work() + + A background timer advances the ring, fades it in, and repaints while the block runs; + the overlay is always cleared and the timer cancelled on exit, even on error. The ring + fades in from black rather than popping in (see :attr:`BusyOverlay.brightness`), so a + quick operation only paints a near-black ring and it never appears suddenly. It is + drawn only in the gaps between screens (see :meth:`_overlay_visible`), so it announces + the wait without covering a prompt the user is interacting with. + + Args: + message: An optional caption drawn beneath the ring. + interval: Seconds between animation frames (also the fade's repaint cadence). + + Yields: + The live :class:`BusyOverlay`, in case the caller wants to update its caption. + """ + # A nested busy_overlay keeps the outer one (the outermost wait owns the screen); its + # own body still runs, it just doesn't install a second ring. + if self._overlay is not None: + yield self._overlay + return + overlay = BusyOverlay(message) + self._overlay = overlay + if self._overlay_visible(): + self.invalidate() # start the fade-in promptly, before the first tick + + async def animate() -> None: + while True: + await asyncio.sleep(interval) + overlay.tick() + # Only repaint when the ring is actually on screen, so an overlay waiting + # behind a live prompt doesn't churn that prompt's repaints for nothing. + if self._overlay_visible(): + self.invalidate() + + ticker = asyncio.ensure_future(animate()) + try: + yield overlay + finally: + ticker.cancel() + try: + await ticker + except asyncio.CancelledError: + pass + except Exception: # noqa: BLE001 - a cosmetic overlay must never break a flow + pass + self._overlay = None + self.invalidate() + # --- application lifecycle ------------------------------------------------ async def run(self, main: Any) -> None: @@ -488,6 +569,15 @@ class TuiSession: float_window = Window( FormattedTextControl(self._render_float), always_hide_cursor=True ) + # The busy overlay is the last float, so it draws on top of the dialog float — the + # top of the z-order. It is a content-sized window (dont_extend_*) with no anchors, so + # the FloatContainer centres just its ring box over the screen rather than blanking it. + overlay_window = Window( + FormattedTextControl(self._render_overlay), + always_hide_cursor=True, + dont_extend_width=True, + dont_extend_height=True, + ) root = FloatContainer( content=base_window, floats=[ @@ -495,7 +585,12 @@ class TuiSession: ConditionalContainer( float_window, filter=Condition(self._has_float) ) - ) + ), + Float( + ConditionalContainer( + overlay_window, filter=Condition(self._overlay_visible) + ) + ), ], ) return Application( @@ -567,6 +662,23 @@ class TuiSession: cols, rows = self._size() return ANSI(frame.compose_dialog(self.top, cols, rows)) + def _overlay_visible(self) -> bool: + """Whether the busy overlay should be painted this frame. + + Gated on an empty screen stack so the ring only appears in the "black screen" gaps a + device operation opens between screens (a menu navigation before the tool's first + prompt, say) and never buries a dialog the user is meant to be reading. It also stays + unpainted through the overlay's initial hold (:attr:`BusyOverlay.brightness` is 0), so + an operation that finishes within the hold shows nothing and never flashes. + """ + return self._overlay is not None and not self._stack and self._overlay.brightness > 0 + + def _render_overlay(self) -> ANSI: + """Render the busy overlay's ring (only when :meth:`_overlay_visible`).""" + if self._overlay is None: + return ANSI("") + return ANSI(self._overlay.render()) + # --- input --------------------------------------------------------------- def _key_bindings(self) -> KeyBindings: diff --git a/tests/test_tui.py b/tests/test_tui.py index 49d4eb6..05d58b5 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -951,3 +951,123 @@ def test_session_stack_and_float_selection() -> None: session.pop(dialog) assert session.top is base assert not session._has_float() + + +# --- ring spinner + busy overlay --------------------------------------------- + + +def test_ring_spinner_draws_a_symmetric_styled_circle() -> None: + """The ring renders a hollow circle: a rim in the rim colour and a bright comet arc.""" + from meshterm.ui.tui.ring import RingSpinner + + ring = RingSpinner() + text = ring.render() + styles = {span.style for span in text.spans} + assert RingSpinner.RIM_STYLE in styles # the resting rim + assert any(c in styles for c in RingSpinner.COMET_RAMP) # the bright comet arc + # It really is a hollow ring: the middle row has a blank interior between its two sides. + middle = text.plain.split("\n")[ring.height // 2] + assert middle.strip() and " " in middle.strip() + + +def test_ring_spinner_is_left_right_symmetric() -> None: + """The quarter-plus-mirror construction makes every row a left-right mirror of itself.""" + from meshterm.ui.tui.ring import RingSpinner + + rows = RingSpinner().render().plain.split("\n") + # Blank out the moving comet by comparing dot presence, not colour: mirror the lit cells. + for row in rows: + lit = [ch != " " for ch in row] + assert lit == lit[::-1] + + +def test_ring_spinner_comet_rotates_on_tick() -> None: + """Ticking moves the bright comet to a different set of cells (the dots chase round).""" + from meshterm.ui.tui.ring import RingSpinner + + ring = RingSpinner() + ramp = set(RingSpinner.COMET_RAMP) + + def bright_cells(t) -> set[int]: + return {i for span in t.spans if span.style in ramp for i in range(span.start, span.end)} + + first = bright_cells(ring.render()) + for _ in range(3): + ring.tick() + assert bright_cells(ring.render()) != first # the comet advanced + + +def test_busy_overlay_render_stacks_ring_over_caption() -> None: + """A captioned overlay renders the ring rows plus a final caption line.""" + from meshterm.ui.tui.overlay import BusyOverlay + + overlay = BusyOverlay("Talking to your companion…", fade=0.0) # full brightness at once + ansi = overlay.render() + assert "Talking to your companion" in ansi + assert ansi.count("\n") == overlay.spinner.height # ring rows + one caption line + + +def test_busy_overlay_holds_black_then_fades_in() -> None: + """Brightness is 0 through the hold, then climbs to full colour over the fade window.""" + from meshterm.ui.tui.overlay import BusyOverlay + + overlay = BusyOverlay(hold=0.1, fade=0.2) + assert overlay.brightness == 0.0 # nothing paints during the hold + overlay.started_at -= 0.1 # to the very end of the hold + assert overlay.brightness < 0.2 # only now beginning to glow up from black + overlay.started_at -= 0.2 # past the full fade window + assert overlay.brightness == 1.0 # fully lit + + +def test_dim_color_scales_hex_toward_black() -> None: + """The fade dimmer scales the hex channels and preserves attribute words like ``bold``.""" + from meshterm.ui.tui.ring import dim_color + + assert dim_color("#38bdf8", 1.0) == "#38bdf8" # untouched at full brightness + assert dim_color("#ffffff", 0.0) == "#000000" # black at zero + assert dim_color("bold #ffffff", 0.5) == "bold #808080" # keeps 'bold', halves the colour + + +def test_overlay_fade_restarts_when_re_exposed_after_a_prompt() -> None: + """Popping back to an empty stack replays the black-hold + fade, not a full-bright snap.""" + from meshterm.ui.tui.overlay import BusyOverlay + + session = TuiSession() + overlay = BusyOverlay() + session._overlay = overlay + overlay.started_at -= 10 # pretend the intro already finished + assert overlay.brightness == 1.0 + + screen = ScrollScreen(Text("prompt")) + session.push(screen) # a prompt covers the ring + session.pop(screen) # dismissed → ring re-exposed on the now-empty stack + assert overlay.brightness == 0.0 # the fade restarted from black + + +async def test_session_busy_overlay_shows_between_screens_and_clears() -> None: + """The overlay floats while a block runs on an empty stack, and is dropped afterwards.""" + with create_pipe_input() as inp: + session = TuiSession(input=inp, output=DummyOutput()) + seen = {} + + async def main() -> None: + async with session.busy_overlay("working…"): + await asyncio.sleep(0.05) # still within the initial hold + seen["hidden_during_hold"] = not session._overlay_visible() + await asyncio.sleep(0.3) # past the 200ms hold: the ring has faded in + seen["active"] = session._overlay is not None + seen["visible_empty_stack"] = session._overlay_visible() + seen["rendered"] = bool(session._render_overlay().value.strip()) + # With a screen on the stack the ring stays hidden so it can't bury a prompt. + session.push(ScrollScreen(Text("prompt"), title="p")) + seen["hidden_over_screen"] = not session._overlay_visible() + session.pop() + + await asyncio.wait_for(session.run(main()), timeout=5) + + assert seen["hidden_during_hold"] is True + assert seen["active"] is True + assert seen["visible_empty_stack"] is True + assert seen["rendered"] is True + assert seen["hidden_over_screen"] is True + assert session._overlay is None # cleared on exit