Full-screen dog loader overlay with dim background and input blocking

- DogLoader now covers the whole window (dock top + _loading layer),
  dims the dashboard behind it ($background 80%), draws a centered
  rounded panel with a running ASCII dog and a 🐾 paw trail
- block keyboard bindings (check_action) and drop focus while refreshing;
  restore focus to models table afterwards
- removed the docked progress line (covered by the overlay)
- docs updated; tests: full-screen coverage + input blocking
This commit is contained in:
2026-09-10 13:30:00 +03:00
parent 148e0a9391
commit d7d146e586
4 changed files with 109 additions and 39 deletions
+11 -4
View File
@@ -7,13 +7,20 @@
Анимация-«собачка» при обновлении статистики. Анимация-«собачка» при обновлении статистики.
### Добавлено ### Добавлено
- Экран **Dashboard**: ASCII-«собачка» (`DogLoader`) как индикатор загрузки — - Экран **Dashboard**: полноэкранный индикатор загрузки — бегущая ASCII-собачка
центрированный overlay поверх дашборда с анимацией (виляет хвостом, поверх всего окна с полупрозрачным затемнением (`$background 80%`),
кадры меняются каждые 0,15 с) и подписью «Обновление статистики…». панелью по центру (рамка `border: round`), дорожкой из 🐾 и подписью
- Индикация обновления в нижней строке (`#progress-line`, `dock: bottom`). «Обновление статистики…». Анимация: переставляет лапы и виляет хвостом
(4 кадра, 0,12 с).
- На время загрузки ввод блокируется (горячие клавиши и фокус), после
завершения — возвращается.
- Показывается при автообновлении по таймеру и при ручном обновлении (`r`); - Показывается при автообновлении по таймеру и при ручном обновлении (`r`);
скрывается по завершении загрузки. скрывается по завершении загрузки.
### Исправлено
- Индикатор больше не конфликтует со встроенным `Widget.loading` Textual
(ранее из-за одноимённого reactive показывались встроенные «точки»).
### Примечание ### Примечание
- Реализует задачу #1 («Новая загрузка обновления статистики»). - Реализует задачу #1 («Новая загрузка обновления статистики»).
+1 -1
View File
@@ -12,7 +12,7 @@ TUI-приложение (Python 3.10+) для отображения потре
- Детализация метрик конкретной модели (Enter в таблице моделей) - Детализация метрик конкретной модели (Enter в таблице моделей)
- Офлайн-режим: при недоступности API показывает последние данные из кэша - Офлайн-режим: при недоступности API показывает последние данные из кэша
- Автообновление по интервалу из конфигурации (по умолчанию 60 с) - Автообновление по интервалу из конфигурации (по умолчанию 60 с)
- Анимация-«собачка» и строка прогресса во время обновления статистики - Полноэкранный индикатор загрузки: бегущая ASCII-собачка с затемнением фона
- Экспорт истории в CSV/JSON, диагностика окружения (`doctor`) - Экспорт истории в CSV/JSON, диагностика окружения (`doctor`)
## Интерфейс ## Интерфейс
+67 -29
View File
@@ -9,11 +9,15 @@ from __future__ import annotations
from typing import Any from typing import Any
from textual import work from rich.align import Align
from rich.panel import Panel
from rich.text import Text
from textual import on, 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.css.query import NoMatches
from textual.events import InputEvent
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.timer import Timer
@@ -53,19 +57,28 @@ def _mask_token(value: str) -> str:
class DogLoader(Static): class DogLoader(Static):
"""ASCII-«собачка» — анимация ожидания обновления статистики. """Полноэкранный индикатор загрузки: бегущая ASCII-собачка.
Кадры сменяются по таймеру (виляет хвостом). Показывается как overlay поверх всего дашборда с полупрозрачным затемнением; собака
центрированный overlay поверх дашборда на время загрузки данных. «бежит» (переставляет лапы, виляет хвостом), под ней — дорожка из 🐾.
Показывается на время обновления статистики и блокирует ввод.
""" """
FRAMES = ( BODY = (
" /^ ^\\\n ( o.o ) ~\n \\ - / ~~~\n |_|", " __",
" /^ ^\\\n ( o.o ) ~~~\n \\ - / ~~~~\n |_|", " ____/ \\___",
" /^ ^\\\n ( o.o ) ~~\n \\ - / ~\n |_|", " / oo \\____",
" | __ \\____",
" \\____/ \\____________/",
)
LEGS = (
(" || || \\ ~", " || || \\_/"),
(" // \\\\ \\ ~~", " // \\\\ \\_/"),
(" || || \\ ~", " || || \\_/"),
(" \\\\ // \\ ~~", " \\\\ // \\_/"),
) )
CAPTION = "Обновление статистики…" CAPTION = "Обновление статистики…"
INTERVAL = 0.15 INTERVAL = 0.12
frame = reactive(0) frame = reactive(0)
@@ -90,11 +103,36 @@ class DogLoader(Static):
self._timer = None self._timer = None
def _next_frame(self) -> None: def _next_frame(self) -> None:
self.frame = (self.frame + 1) % len(self.FRAMES) self.frame = (self.frame + 1) % len(self.LEGS)
def _art(self) -> str:
index = self.frame % len(self.LEGS)
legs1, legs2 = self.LEGS[index]
return "\n".join([*self.BODY, legs1, legs2])
def _paws(self) -> str:
index = self.frame % len(self.LEGS)
return " ".join("🐾" if (i + index) % 2 == 0 else " " for i in range(8))
def render(self) -> Any: def render(self) -> Any:
art = self.FRAMES[self.frame % len(self.FRAMES)] theme = self.app.current_theme
return f"{art}\n\n{self.CAPTION}" body = Text(self._art(), style=f"bold {theme.accent}", justify="center")
paws = Text(self._paws(), style=str(theme.secondary), justify="center")
caption = Text(self.CAPTION, style=f"bold {theme.foreground}", justify="center")
content = Text("\n").join([body, Text(), paws, Text(), caption])
return Panel(
Align.center(content, vertical="middle"),
title="[b]🐕 selectel-ml-tui[/b]",
border_style=str(theme.primary),
padding=(1, 4),
)
@on(InputEvent)
def _block_input(self, event: InputEvent) -> None:
"""Перехватить ввод, пока показывается индикатор загрузки."""
if self.has_class("visible"):
event.stop()
event.prevent_default()
class ConsumptionBars(Static): class ConsumptionBars(Static):
@@ -160,7 +198,6 @@ class DashboardScreen(Screen[Any]):
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 DogLoader(id="dog-loader")
yield Static("", id="progress-line")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
@@ -178,20 +215,28 @@ class DashboardScreen(Screen[Any]):
self.refreshing = False self.refreshing = False
def watch_refreshing(self, refreshing: bool) -> None: def watch_refreshing(self, refreshing: bool) -> None:
"""Показать/скрыть собачку и строку прогресса при обновлении.""" """Показать/скрыть полноэкранную собачку при обновлении."""
try: try:
loader = self.query_one("#dog-loader", DogLoader) loader = self.query_one("#dog-loader", DogLoader)
line = self.query_one("#progress-line", Static)
except NoMatches: # watcher может сработать до монтирования детей except NoMatches: # watcher может сработать до монтирования детей
return return
if refreshing: if refreshing:
loader.add_class("visible") loader.add_class("visible")
loader.start() loader.start()
line.update("[b]🐕 Собачка обновляет статистику…[/]") self.set_focus(None)
else: else:
loader.remove_class("visible") loader.remove_class("visible")
loader.stop() loader.stop()
line.update("") try:
self.query_one("#models-table", DataTable).focus()
except NoMatches: # pragma: no cover - на всякий случай
pass
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
"""Заблокировать горячие клавиши на время обновления."""
if self.refreshing:
return False
return super().check_action(action, parameters)
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"
@@ -617,23 +662,16 @@ class SelectelMLTUI(App[Any]):
} }
#dog-loader { #dog-loader {
display: none; display: none;
layer: overlay; dock: top;
width: auto; layer: _loading;
height: auto; width: 100%;
padding: 1 4; height: 100%;
border: round $accent; background: $background 80%;
background: $surface;
content-align: center middle; content-align: center middle;
text-align: center; text-align: center;
} }
#dog-loader.visible { #dog-loader.visible {
display: block; display: block;
align: center middle;
}
#progress-line {
dock: bottom;
height: 1;
text-align: center;
} }
""" """
+30 -5
View File
@@ -319,8 +319,6 @@ async def test_dog_loader_hidden_when_idle(tmp_path) -> None:
assert not loader.has_class("visible") assert not loader.has_class("visible")
assert loader.display is False assert loader.display is False
assert not loader.running assert not loader.running
line = app.screen.query_one("#progress-line", Static)
assert str(line.render()) == ""
# встроенный loading Textual не должен активироваться (иначе — точки) # встроенный loading Textual не должен активироваться (иначе — точки)
assert app.screen.loading is False assert app.screen.loading is False
@@ -344,16 +342,43 @@ async def test_dog_loader_shown_during_refresh(tmp_path) -> None:
assert loader.has_class("visible") assert loader.has_class("visible")
assert loader.display is True assert loader.display is True
assert loader.running assert loader.running
line = app.screen.query_one("#progress-line", Static)
assert "обновляет статистику" in str(line.render())
assert app.screen.loading is False assert app.screen.loading is False
# overlay покрывает весь экран
assert loader.region == app.screen.region
release.set() release.set()
await pilot.pause(0.1) await pilot.pause(0.1)
assert not loader.has_class("visible") assert not loader.has_class("visible")
assert loader.display is False assert loader.display is False
assert not loader.running assert not loader.running
assert str(line.render()) == ""
async def test_input_blocked_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()
# во время загрузки горячие клавиши заблокированы
await pilot.press("h")
await pilot.pause()
assert app.screen.__class__.__name__ == "DashboardScreen"
release.set()
await pilot.pause(0.1)
# после загрузки навигация снова работает
await pilot.press("h")
await pilot.pause()
assert app.screen.__class__.__name__ == "HistoryScreen"
async def test_dog_loader_animates(tmp_path) -> None: async def test_dog_loader_animates(tmp_path) -> None: