From f6a155718fe180d3c11aff639e955f8129c852cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 11:51:02 +0300 Subject: [PATCH] Add DogLoader animation on refresh (task #1) --- src/selectel_ml_tui/ui/app.py | 85 +++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/selectel_ml_tui/ui/app.py b/src/selectel_ml_tui/ui/app.py index 2d6880d..82d3586 100644 --- a/src/selectel_ml_tui/ui/app.py +++ b/src/selectel_ml_tui/ui/app.py @@ -13,8 +13,10 @@ from textual import work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical +from textual.css.query import NoMatches from textual.reactive import reactive from textual.screen import Screen +from textual.timer import Timer from textual.widgets import ( Button, DataTable, @@ -50,6 +52,51 @@ def _mask_token(value: str) -> str: return f"{value[:4]}…({len(value)})" +class DogLoader(Static): + """ASCII-«собачка» — анимация ожидания обновления статистики. + + Кадры сменяются по таймеру (виляет хвостом). Показывается как + центрированный overlay поверх дашборда на время загрузки данных. + """ + + FRAMES = ( + " /^ ^\\\n ( o.o ) ~\n \\ - / ~~~\n |_|", + " /^ ^\\\n ( o.o ) ~~~\n \\ - / ~~~~\n |_|", + " /^ ^\\\n ( o.o ) ~~\n \\ - / ~\n |_|", + ) + CAPTION = "Обновление статистики…" + INTERVAL = 0.15 + + frame = reactive(0) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._timer: Timer | None = None + + @property + def running(self) -> bool: + """Идёт ли анимация.""" + return self._timer is not None + + def start(self) -> None: + """Запустить анимацию (идемпотентно).""" + if self._timer is None: + self._timer = self.set_interval(self.INTERVAL, self._next_frame) + + def stop(self) -> None: + """Остановить анимацию (идемпотентно).""" + if self._timer is not None: + self._timer.pause() + self._timer = None + + def _next_frame(self) -> None: + self.frame = (self.frame + 1) % len(self.FRAMES) + + def render(self) -> Any: + art = self.FRAMES[self.frame % len(self.FRAMES)] + return f"{art}\n\n{self.CAPTION}" + + class ConsumptionBars(Static): """Unicode-бар-график потребления (столбцы).""" @@ -112,6 +159,8 @@ class DashboardScreen(Screen[Any]): yield Static("", id="metrics-panel", classes="panel") yield Static("По моделям", id="models-title") yield DataTable(id="models-table") + yield DogLoader(id="dog-loader") + yield Static("", id="progress-line") yield Footer() def on_mount(self) -> None: @@ -128,6 +177,22 @@ class DashboardScreen(Screen[Any]): finally: self.loading = False + def watch_loading(self, loading: bool) -> None: + """Показать/скрыть собачку и строку прогресса при обновлении.""" + try: + loader = self.query_one("#dog-loader", DogLoader) + line = self.query_one("#progress-line", Static) + except NoMatches: # watcher может сработать до монтирования детей + return + if loading: + loader.add_class("visible") + loader.start() + line.update("[b]🐕 Собачка обновляет статистику…[/]") + else: + loader.remove_class("visible") + loader.stop() + line.update("") + def watch_data(self, data: DashboardData) -> None: status = "offline" if data.offline else "online" amount = ( @@ -550,6 +615,26 @@ class SelectelMLTUI(App[Any]): padding: 1 2; border: round $primary; } + #dog-loader { + display: none; + layer: overlay; + width: auto; + height: auto; + padding: 1 4; + border: round $accent; + background: $surface; + content-align: center middle; + text-align: center; + } + #dog-loader.visible { + display: block; + align: center middle; + } + #progress-line { + dock: bottom; + height: 1; + text-align: center; + } """ def __init__(