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
+16
View File
@@ -2,6 +2,21 @@
Все заметные изменения проекта `selectel-ml-tui`. Все заметные изменения проекта `selectel-ml-tui`.
## [1.0.4] — 2026-09-03
Анимация-«собачка» при обновлении статистики.
### Добавлено
- Экран **Dashboard**: ASCII-«собачка» (`DogLoader`) как индикатор загрузки —
центрированный overlay поверх дашборда с анимацией (виляет хвостом,
кадры меняются каждые 0,15 с) и подписью «Обновление статистики…».
- Индикация обновления в нижней строке (`#progress-line`, `dock: bottom`).
- Показывается при автообновлении по таймеру и при ручном обновлении (`r`);
скрывается по завершении загрузки.
### Примечание
- Реализует задачу #1 («Новая загрузка обновления статистики»).
## [1.0.3] — 2026-08-19 ## [1.0.3] — 2026-08-19
Полноценный вывод данных из Gateway API ИИ-роутера (IAM). Полноценный вывод данных из Gateway API ИИ-роутера (IAM).
@@ -86,6 +101,7 @@
- ruff (flake8/isort/format), mypy strict — без ошибок. - ruff (flake8/isort/format), mypy strict — без ошибок.
- Подсказка при первом запуске без токена, FAQ и troubleshooting в README. - Подсказка при первом запуске без токена, FAQ и troubleshooting в README.
[1.0.4]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.4
[1.0.3]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.3 [1.0.3]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.3
[1.0.2]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.2 [1.0.2]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.2
[1.0.1]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.1 [1.0.1]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.1
+1
View File
@@ -12,6 +12,7 @@ TUI-приложение (Python 3.10+) для отображения потре
- Детализация метрик конкретной модели (Enter в таблице моделей) - Детализация метрик конкретной модели (Enter в таблице моделей)
- Офлайн-режим: при недоступности API показывает последние данные из кэша - Офлайн-режим: при недоступности API показывает последние данные из кэша
- Автообновление по интервалу из конфигурации (по умолчанию 60 с) - Автообновление по интервалу из конфигурации (по умолчанию 60 с)
- Анимация-«собачка» и строка прогресса во время обновления статистики
- Экспорт истории в CSV/JSON, диагностика окружения (`doctor`) - Экспорт истории в CSV/JSON, диагностика окружения (`doctor`)
## Интерфейс ## Интерфейс
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "selectel-ml-tui" name = "selectel-ml-tui"
version = "1.0.3" version = "1.0.4"
description = "TUI-приложение для отображения потребления ИИ-роутера Selectel" description = "TUI-приложение для отображения потребления ИИ-роутера Selectel"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+1 -1
View File
@@ -1,3 +1,3 @@
"""TUI-приложение для отображения потребления ИИ-роутера Selectel.""" """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.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.reactive import reactive from textual.reactive import reactive
from textual.screen import Screen from textual.screen import Screen
from textual.timer import Timer
from textual.widgets import ( from textual.widgets import (
Button, Button,
DataTable, DataTable,
@@ -50,6 +52,51 @@ def _mask_token(value: str) -> str:
return f"{value[:4]}…({len(value)})" 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): class ConsumptionBars(Static):
"""Unicode-бар-график потребления (столбцы).""" """Unicode-бар-график потребления (столбцы)."""
@@ -112,6 +159,8 @@ class DashboardScreen(Screen[Any]):
yield Static("", id="metrics-panel", classes="panel") yield Static("", id="metrics-panel", classes="panel")
yield Static("По моделям", id="models-title") yield Static("По моделям", id="models-title")
yield DataTable(id="models-table") yield DataTable(id="models-table")
yield DogLoader(id="dog-loader")
yield Static("", id="progress-line")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
@@ -128,6 +177,22 @@ class DashboardScreen(Screen[Any]):
finally: finally:
self.loading = False 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: def watch_data(self, data: DashboardData) -> None:
status = "offline" if data.offline else "online" status = "offline" if data.offline else "online"
amount = ( amount = (
@@ -550,6 +615,26 @@ class SelectelMLTUI(App[Any]):
padding: 1 2; padding: 1 2;
border: round $primary; 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__( def __init__(
+71 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import datetime as dt import datetime as dt
from decimal import Decimal from decimal import Decimal
@@ -19,7 +20,7 @@ from selectel_ml_tui.models import (
) )
from selectel_ml_tui.services import DashboardData from selectel_ml_tui.services import DashboardData
from selectel_ml_tui.storage import Storage from selectel_ml_tui.storage import Storage
from selectel_ml_tui.ui.app import SelectelMLTUI from selectel_ml_tui.ui.app import DogLoader, SelectelMLTUI
class FakeService: class FakeService:
@@ -34,6 +35,20 @@ class FakeService:
return self.data return self.data
class BlockingService:
"""Сервис-заглушка: блокирует refresh до снятия события."""
def __init__(self, data: DashboardData, release: asyncio.Event) -> None:
self.data = data
self.release = release
self.started = asyncio.Event()
async def refresh(self) -> DashboardData:
self.started.set()
await self.release.wait()
return self.data
def _dashboard_data() -> DashboardData: def _dashboard_data() -> DashboardData:
return DashboardData( return DashboardData(
month_consumption=Consumption.model_validate( month_consumption=Consumption.model_validate(
@@ -294,3 +309,58 @@ async def test_keys_screen(tmp_path) -> None:
await pilot.press("escape") await pilot.press("escape")
await pilot.pause() await pilot.pause()
assert app.screen.__class__.__name__ == "DashboardScreen" assert app.screen.__class__.__name__ == "DashboardScreen"
async def test_dog_loader_hidden_when_idle(tmp_path) -> None:
app = _make_app(tmp_path)
async with app.run_test() as pilot:
await pilot.pause()
loader = app.screen.query_one("#dog-loader", DogLoader)
assert not loader.has_class("visible")
assert not loader.running
line = app.screen.query_one("#progress-line", Static)
assert str(line.render()) == ""
async def test_dog_loader_shown_during_refresh(tmp_path) -> None:
release = asyncio.Event()
settings = Settings(refresh_interval=3600)
creds = Credentials(static_token="tok")
storage = Storage(tmp_path / "cache.db")
service = BlockingService(_dashboard_data(), release)
app = SelectelMLTUI(
settings=settings,
credentials=creds,
storage=storage,
service=service, # type: ignore[arg-type]
)
async with app.run_test() as pilot:
await service.started.wait()
await pilot.pause()
loader = app.screen.query_one("#dog-loader", DogLoader)
assert loader.has_class("visible")
assert loader.running
line = app.screen.query_one("#progress-line", Static)
assert "обновляет статистику" in str(line.render())
release.set()
await pilot.pause(0.1)
assert not loader.has_class("visible")
assert not loader.running
assert str(line.render()) == ""
async def test_dog_loader_animates(tmp_path) -> None:
app = _make_app(tmp_path)
async with app.run_test() as pilot:
await pilot.pause()
loader = app.screen.query_one("#dog-loader", DogLoader)
loader.add_class("visible")
loader.start()
assert loader.running
first = loader.render()
await pilot.pause(0.4)
second = loader.render()
assert first != second
loader.stop()
assert not loader.running