From 8e8a73fc3687a45ef85916605bd79bbc544a3905 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 29 Aug 2026 23:36:45 -0400 Subject: [PATCH] Busy card: the chip already says "working", so the LED bar goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skeleton card floated between screens carried a Knight-Rider scanning bar under its caption: eighteen braille lamps, a red head and a persistence-of-vision trail sweeping back and forth. It drew the eye harder than the words did, on a card the reader is only ever passing through, so it and its colour ramp are gone — the one-cell spinner beside the caption is the whole animation, and the card is two lines. Co-Authored-By: Claude Opus 5 (1M context) --- meshterm/ui/tui/overlay.py | 144 ++++--------------------------------- meshterm/ui/tui/session.py | 4 +- tests/test_tui.py | 79 ++++---------------- 3 files changed, 29 insertions(+), 198 deletions(-) diff --git a/meshterm/ui/tui/overlay.py b/meshterm/ui/tui/overlay.py index 263b23b..f86286c 100644 --- a/meshterm/ui/tui/overlay.py +++ b/meshterm/ui/tui/overlay.py @@ -1,23 +1,22 @@ -"""The busy skeleton: a titled placeholder card floated between screens while a load runs. +"""The busy skeleton: a titled card floated between screens while a load runs. A :class:`BusyOverlay` is the model behind :meth:`~meshterm.ui.tui.session.TuiSession. 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. +fetching: a title and the one-cell working :class:`~meshterm.ui.tui.spinner.Spinner` +beside a caption. 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 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. +The spinning chip is the whole animation, and says everything the card has to say: the app is +working. It once shared the card with a scanning-light LED bar (JP, 2026-08-29) — eighteen +cells of travelling red that drew the eye harder than the words did, for a screen the reader +is only ever passing through. """ from __future__ import annotations @@ -39,37 +38,9 @@ _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 skeleton card (title, working chip + caption, placeholder rows) drawn as the top float. + """A skeleton card (title, working chip + caption) drawn as the session's top-most float. Attributes: spinner: The animated one-cell :class:`~meshterm.ui.tui.spinner.Spinner` — the same @@ -104,15 +75,6 @@ class BusyOverlay: 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: @@ -131,38 +93,8 @@ class BusyOverlay: return t * t * (3 - 2 * t) # smoothstep def tick(self) -> None: - """Advance the working chip one frame and step the scanning light along its sweep.""" + """Advance the working chip one frame — the card's only animation.""" 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 chip to their starts. @@ -173,17 +105,13 @@ class BusyOverlay: """ 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 skeleton card as a centred ANSI block, at the current fade level. - 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. + The title and the chip + caption line 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 card's rows. @@ -197,25 +125,10 @@ class BusyOverlay: 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.""" @@ -227,33 +140,6 @@ def _center_text(text: Text, width: int) -> Text: 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). diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index ccaa788..e83b661 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -1012,8 +1012,8 @@ class TuiSession: async with session.busy_overlay("reading…", title="Nodes"): await slow_work() - 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, + The card stands in for the screen being fetched: a title and the one-cell working + chip beside the caption. 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 diff --git a/tests/test_tui.py b/tests/test_tui.py index 6940e62..a1e2ba9 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -2313,21 +2313,26 @@ def test_default_text_prompt_is_the_full_frame_base() -> None: # --- busy skeleton card ------------------------------------------------------ -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.""" +def test_busy_overlay_renders_only_its_title_chip_and_caption() -> None: + """A titled, captioned card shows its heading and the working chip + caption — no more. + + The card used to carry a Knight-Rider scanning bar under the caption (JP, 2026-08-29); + the chip is the whole animation now, so the card is two lines and nothing travels + across it. + """ import re - from meshterm.ui.tui.overlay import BusyOverlay, _SCAN_CELLS, _SCAN_GLYPH + from meshterm.ui.tui.overlay import BusyOverlay 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 + plain = re.sub(r"\[[0-9;]*m", "", ansi) + rows = [line for line in plain.splitlines() if line.strip()] + assert len(rows) == 2 # the title and the chip line; no bar, no spacer row + assert "⠶" not in plain # the LED lamps are gone, not merely unlit def test_busy_overlay_chip_ticks_with_the_animation() -> None: @@ -2342,66 +2347,6 @@ def test_busy_overlay_chip_ticks_with_the_animation() -> None: assert overlay.spinner.frame != first -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. - - 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 - - from meshterm.ui.tui.overlay import BusyOverlay, _SCAN_GLYPH - - 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): - 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 _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("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: """Brightness is 0 through the hold, then climbs to full colour over the fade window.""" from meshterm.ui.tui.overlay import BusyOverlay