Spin the delivery glyph while a direct message awaits its ack
Replace the static ⏳ hourglass on an in-flight direct message with the reusable tui.Spinner, animated for the whole wait-for-ack window: - _delivery_glyph is now a method returning the spinner's current frame when acked is None (✅/❌ once resolved); the _SENDING constant is gone. - _spin_while wraps the awaited send and retry, running a background ticker that advances the shared spinner and repaints, then cancels-and-awaits it (same no-stray-task discipline as the startup splash). Only one send or retry is ever in flight (both gated by _sending), so one spinner suffices. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
101b48bc71
commit
fb3c7a15e2
2 files changed
+101
-17
No files matched your search
+59
-15
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Awaitable, Callable, Optional
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.text import Text
|
||||
@@ -28,6 +28,7 @@ from .theme import snr_style
|
||||
from .tui.prompt import _LineEditor
|
||||
from .tui.render import render_lines
|
||||
from .tui.screen import CANCEL, Screen
|
||||
from .tui.spinner import Spinner
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
@@ -59,9 +60,14 @@ _SENDER_COLORS = (
|
||||
_SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,19}):[ \t]+(.*)$", re.DOTALL)
|
||||
|
||||
|
||||
#: Delivery-state indicators for an outbound direct message, shown at the end of its line:
|
||||
#: awaiting the ack, acknowledged, or transmitted-but-unacknowledged (retryable via Ctrl-R).
|
||||
_SENDING, _DELIVERED, _FAILED = "⏳", "✅", "❌"
|
||||
#: Delivery-state indicators for a *resolved* outbound direct message, shown at the end of
|
||||
#: its line: acknowledged, or transmitted-but-unacknowledged (retryable via Ctrl-R). While
|
||||
#: the ack is still pending the line shows an animated spinner instead (see
|
||||
#: :meth:`ChatScreen._delivery_glyph`).
|
||||
_DELIVERED, _FAILED = "✅", "❌"
|
||||
|
||||
#: Seconds between spinner frames on a message that is still awaiting its ack.
|
||||
_SPINNER_INTERVAL = 0.12
|
||||
|
||||
#: How many UTF-8 bytes a single outgoing message may carry, by conversation kind. MeshCore's
|
||||
#: LoRa payload caps a direct message at 150 bytes and an (unscoped) channel broadcast at 130;
|
||||
@@ -79,13 +85,6 @@ _BYTES_YELLOW = "bold #fde047"
|
||||
_BYTES_ORANGE = "bold #ff9500"
|
||||
|
||||
|
||||
def _delivery_glyph(acked: Optional[bool]) -> str:
|
||||
"""Map an outbound direct message's ``acked`` state to its delivery emoji."""
|
||||
if acked is None:
|
||||
return _SENDING
|
||||
return _DELIVERED if acked else _FAILED
|
||||
|
||||
|
||||
def _split_channel_sender(text: str) -> tuple[Optional[str], str]:
|
||||
"""Split a channel message into ``(sender_name, body)`` when it carries a name prefix.
|
||||
|
||||
@@ -152,6 +151,10 @@ class ChatScreen(Screen):
|
||||
self._session = session
|
||||
self._editor = _LineEditor()
|
||||
self._sending = False
|
||||
# Cycled while a direct message is in flight, so its trailing glyph spins (rather than
|
||||
# a static hourglass) until the ack resolves. Shared across messages: only one send or
|
||||
# retry is ever in flight at a time (both gated by ``_sending``).
|
||||
self._spinner = Spinner()
|
||||
self._status = ""
|
||||
self._stick = True # keep the newest message in view until the user scrolls up
|
||||
# Channel-only reply selection: index of the highlighted message (or None when the
|
||||
@@ -354,17 +357,29 @@ class ChatScreen(Screen):
|
||||
line.append(f"{stamp:%H:%M} ", style="brand" if selected else "muted")
|
||||
line.append(body, style="brand" if selected else None)
|
||||
if message.outbound:
|
||||
# Direct messages track per-message delivery (⏳ → ✅/❌, retryable); channel
|
||||
# Direct messages track per-message delivery (spin → ✅/❌, retryable); channel
|
||||
# broadcasts have no ack, so only flag one that failed to leave the companion.
|
||||
if message.is_channel:
|
||||
if message.acked is False:
|
||||
line.append(" ⚠ no ack", style="warn")
|
||||
else:
|
||||
line.append(f" {_delivery_glyph(message.acked)}")
|
||||
line.append(" ")
|
||||
line.append(self._delivery_glyph(message.acked))
|
||||
if message.snr is not None:
|
||||
line.append(f" {message.snr:+.0f} dB", style=snr_style(message.snr))
|
||||
return line
|
||||
|
||||
def _delivery_glyph(self, acked: Optional[bool]) -> Text:
|
||||
"""Map an outbound direct message's ``acked`` state to its trailing glyph.
|
||||
|
||||
A message still awaiting its ack (``acked is None``) shows the current spinner frame,
|
||||
animated by :meth:`_spin_while` for as long as the send is in flight; a resolved one
|
||||
shows the delivered ✅ or the unacknowledged ❌.
|
||||
"""
|
||||
if acked is None:
|
||||
return self._spinner.text()
|
||||
return Text(_DELIVERED if acked else _FAILED)
|
||||
|
||||
def _reply_banner(self) -> Text:
|
||||
"""One-line cue shown above the input when a message is picked to reply to."""
|
||||
message = self._messages[self._selected]
|
||||
@@ -549,6 +564,35 @@ class ChatScreen(Screen):
|
||||
self._stick = True
|
||||
self._session.invalidate()
|
||||
|
||||
async def _spin_while(self, coro: Awaitable[Any]) -> Any:
|
||||
"""Await ``coro`` while animating the delivery spinner on the in-flight message.
|
||||
|
||||
A background timer advances the shared spinner and repaints every
|
||||
:data:`_SPINNER_INTERVAL` seconds, so a pending message's trailing glyph spins until
|
||||
the ack resolves. The timer is always cancelled (and awaited, so it can't outlive the
|
||||
send as a stray pending task) before returning. The spinner is cosmetic, so any hiccup
|
||||
in the animation is swallowed rather than allowed to break the send.
|
||||
"""
|
||||
self._spinner.reset()
|
||||
|
||||
async def animate() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(_SPINNER_INTERVAL)
|
||||
self._spinner.tick()
|
||||
self._session.invalidate()
|
||||
|
||||
ticker = asyncio.ensure_future(animate())
|
||||
try:
|
||||
return await coro
|
||||
finally:
|
||||
ticker.cancel()
|
||||
try:
|
||||
await ticker
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001 - a spinner hiccup must never break a send
|
||||
pass
|
||||
|
||||
async def _send_direct(self, pending: ChatMessage) -> None:
|
||||
"""Await delivery of the optimistic ``pending`` bubble, swapping in the stored row.
|
||||
|
||||
@@ -558,7 +602,7 @@ class ChatScreen(Screen):
|
||||
the error inline, matching how a failed send has always surfaced.
|
||||
"""
|
||||
try:
|
||||
message = await self._send(pending.text)
|
||||
message = await self._spin_while(self._send(pending.text))
|
||||
except Exception as exc: # noqa: BLE001 - report inline, keep the chat alive
|
||||
self._discard(pending)
|
||||
self._status = f"send failed: {exc}"
|
||||
@@ -594,7 +638,7 @@ class ChatScreen(Screen):
|
||||
"""Drive a retry to completion, refreshing the message's delivery state in place."""
|
||||
assert self._resend is not None
|
||||
try:
|
||||
await self._resend(message)
|
||||
await self._spin_while(self._resend(message))
|
||||
except Exception as exc: # noqa: BLE001 - report inline, keep the chat alive
|
||||
message.acked = False
|
||||
self._status = f"retry failed: {exc}"
|
||||
|
||||
+42
-2
@@ -486,6 +486,41 @@ async def test_chat_screen_enter_sends_and_appends() -> None:
|
||||
assert session.invalidations > 0
|
||||
|
||||
|
||||
async def test_direct_send_spins_until_the_ack_resolves(monkeypatch) -> None:
|
||||
"""A pending direct message spins while the send is in flight, then settles on ✅."""
|
||||
from meshterm.ui.tui.spinner import Spinner
|
||||
|
||||
monkeypatch.setattr("meshterm.ui.chat._SPINNER_INTERVAL", 0.005)
|
||||
session = _StubSession()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def send(text: str) -> ChatMessage:
|
||||
await release.wait() # hold the send open so we can watch the glyph spin
|
||||
return ChatMessage(text=text, outbound=True, peer="d4e5f6a7", acked=True)
|
||||
|
||||
screen = _screen(session, send=send)
|
||||
for ch in "hi":
|
||||
screen.handle("text", ch)
|
||||
screen.handle("enter")
|
||||
|
||||
# While the send is held open the trailing glyph is a live spinner frame, and it advances.
|
||||
await asyncio.sleep(0.03)
|
||||
first = screen._spinner.frame
|
||||
joined = "\n".join(screen.render_body(60))
|
||||
assert first in Spinner.BRAILLE and first in joined and "⏳" not in joined
|
||||
await asyncio.sleep(0.03)
|
||||
assert screen._spinner.frame != first # the animation is actually running
|
||||
|
||||
# Once the ack lands, the spinner is gone and the message shows its delivered glyph.
|
||||
release.set()
|
||||
for _ in range(100): # let _send_direct unwind (ticker cancel + swap) before asserting
|
||||
await asyncio.sleep(0.005)
|
||||
if screen._messages[-1].acked is not None:
|
||||
break
|
||||
assert screen._messages[-1].acked is True
|
||||
assert "✅" in "\n".join(screen.render_body(60))
|
||||
|
||||
|
||||
def test_byte_counter_shows_used_over_limit_and_colors_only_used() -> None:
|
||||
"""The compose bar shows ``used/limit`` with only the used count styled (the max is muted)."""
|
||||
from rich.text import Text
|
||||
@@ -561,7 +596,9 @@ async def test_over_limit_message_is_not_sent_and_buffer_is_kept() -> None:
|
||||
|
||||
|
||||
def test_chat_screen_shows_delivery_glyphs() -> None:
|
||||
"""Each outbound direct message ends with its delivery emoji (⏳ / ✅ / ❌)."""
|
||||
"""Outbound direct messages end with delivery glyphs: a spinner, then ✅ / ❌."""
|
||||
from meshterm.ui.tui.spinner import Spinner
|
||||
|
||||
messages = [
|
||||
ChatMessage(text="delivered", outbound=True, peer="d4e5f6a7", acked=True),
|
||||
ChatMessage(text="dropped", outbound=True, peer="d4e5f6a7", acked=False),
|
||||
@@ -570,7 +607,10 @@ def test_chat_screen_shows_delivery_glyphs() -> None:
|
||||
screen = _screen(_StubSession(), send=None, messages=messages)
|
||||
|
||||
joined = "\n".join(screen.render_body(60))
|
||||
assert "✅" in joined and "❌" in joined and "⏳" in joined
|
||||
# A message still awaiting its ack spins (a Braille frame) rather than showing the old
|
||||
# static hourglass; resolved ones show ✅ / ❌.
|
||||
assert "✅" in joined and "❌" in joined
|
||||
assert Spinner.BRAILLE[0] in joined and "⏳" not in joined
|
||||
# A failed message advertises the retry shortcut in the footer hint.
|
||||
assert "Ctrl-R" in screen.footer_hint
|
||||
|
||||
|
||||
Reference in new issue
Block a user