fix(tui): guard Throbber DOM queries against unmounted state

- Import NoMatches from textual.css.query
- Add _is_mounted guard in _start_animation() to prevent timer creation
  outside a Textual app context (eliminates RuntimeWarning for unawaited
  Timer coroutines)
- Wrap query_one() calls in _tick_rainbow() and _rotate_quote() with
  try/except NoMatches so they gracefully no-op when the widget's
  #throbber-content child is not in the DOM
- Broadened _start_animation() exception handler to catch both
  RuntimeError and NoMatches

Fixes the unit_tests CI failure on BDD scenario 'Throbber activates
with quotes style' (features/tui_mainscreen.feature:72) which raised
textual.css.query.NoMatches when _rotate_quote() called query_one()
on an unmounted widget.
This commit is contained in:
2026-03-12 05:00:24 +00:00
parent 1d5fbb10c8
commit 5338ac8569
+14 -4
View File
@@ -13,6 +13,7 @@ import random
from enum import Enum
from textual.app import ComposeResult
from textual.css.query import NoMatches
from textual.reactive import reactive
from textual.timer import Timer
from textual.widget import Widget
@@ -95,6 +96,8 @@ class Throbber(Widget):
def _start_animation(self) -> None:
self._stop_animation()
if not self._is_mounted:
return
try:
if self.style_mode == ThrobberStyle.RAINBOW:
self._timer = self.set_interval(_RAINBOW_FPS, self._tick_rainbow)
@@ -103,8 +106,9 @@ class Throbber(Widget):
self._quote_timer = self.set_interval(
_QUOTE_INTERVAL, self._rotate_quote
)
except RuntimeError:
# No event loop — running outside of a Textual app context
except (RuntimeError, NoMatches):
# No event loop or widget not mounted — running outside of a
# Textual app context (e.g. unit/BDD tests).
pass
def _stop_animation(self) -> None:
@@ -117,6 +121,10 @@ class Throbber(Widget):
def _tick_rainbow(self) -> None:
"""Advance the rainbow gradient by one step."""
try:
content = self.query_one("#throbber-content", Static)
except NoMatches:
return
width = self.size.width or 40
gradient_len = len(RAINBOW_GRADIENT)
segments: list[str] = []
@@ -124,7 +132,6 @@ class Throbber(Widget):
color_idx = (i + self._offset) % gradient_len
color = RAINBOW_GRADIENT[color_idx]
segments.append(f"[{color}]{_GRADIENT_CHAR}[/]")
content = self.query_one("#throbber-content", Static)
content.update("".join(segments))
self._offset += 1
@@ -136,5 +143,8 @@ class Throbber(Widget):
self._quote_index % len(self._shuffled_quotes)
]
self._quote_index += 1
content = self.query_one("#throbber-content", Static)
try:
content = self.query_one("#throbber-content", Static)
except NoMatches:
return
content.update(f"[italic]{self._current_quote}[/italic]")