Add dog ASCII animation on statistics refresh (task #1)

- DogLoader widget: ASCII dog animating while data loads, shown as a
  centered overlay and a docked progress line on the dashboard
- shown on timer auto-refresh and manual refresh (r)
- bump version to 1.0.4; CHANGELOG + README updated
- tests: hidden when idle, shown during refresh, frame animation
This commit is contained in:
2026-09-10 11:51:14 +03:00
parent 6caa021824
commit 9f25d37b6e
6 changed files with 175 additions and 3 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""TUI-приложение для отображения потребления ИИ-роутера Selectel."""
__version__ = "1.0.3"
__version__ = "1.0.4"
+85
View File
@@ -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__(