From 5338ac8569ca47f459fe1fc8aedd42b6f81253bd Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Thu, 12 Mar 2026 05:00:24 +0000 Subject: [PATCH] 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. --- src/cleveragents/tui/widgets/throbber.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/cleveragents/tui/widgets/throbber.py b/src/cleveragents/tui/widgets/throbber.py index 2e0cd4b1..d7d06533 100644 --- a/src/cleveragents/tui/widgets/throbber.py +++ b/src/cleveragents/tui/widgets/throbber.py @@ -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]")