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`) как индикатор загрузки —
центрированный overlay поверх дашборда с анимацией (виляет хвостом,
кадры меняются каждые 0,15 с) и подписью «Обновление статистики…».
- Индикация обновления в нижней строке (`#progress-line`, `dock: bottom`).
- Экран **Dashboard**: полноэкранный индикатор загрузки — бегущая ASCII-собачка
поверх всего окна с полупрозрачным затемнением (`$background 80%`),
панелью по центру (рамка `border: round`), дорожкой из 🐾 и подписью
«Обновление статистики…». Анимация: переставляет лапы и виляет хвостом
(4 кадра, 0,12 с).
- На время загрузки ввод блокируется (горячие клавиши и фокус), после
завершения — возвращается.
- Показывается при автообновлении по таймеру и при ручном обновлении (`r`);
скрывается по завершении загрузки.
### Исправлено
- Индикатор больше не конфликтует со встроенным `Widget.loading` Textual
(ранее из-за одноимённого reactive показывались встроенные «точки»).
### Примечание
- Реализует задачу #1 («Новая загрузка обновления статистики»).
+1 -1
View File
@@ -12,7 +12,7 @@ TUI-приложение (Python 3.10+) для отображения потре
- Детализация метрик конкретной модели (Enter в таблице моделей)
- Офлайн-режим: при недоступности API показывает последние данные из кэша
- Автообновление по интервалу из конфигурации (по умолчанию 60 с)
- Анимация-«собачка» и строка прогресса во время обновления статистики
- Полноэкранный индикатор загрузки: бегущая ASCII-собачка с затемнением фона
- Экспорт истории в CSV/JSON, диагностика окружения (`doctor`)
## Интерфейс
+67 -29
View File
@@ -9,11 +9,15 @@ from __future__ import annotations
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.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.events import InputEvent
from textual.reactive import reactive
from textual.screen import Screen
from textual.timer import Timer
@@ -53,19 +57,28 @@ def _mask_token(value: str) -> str:
class DogLoader(Static):
"""ASCII-«собачка» — анимация ожидания обновления статистики.
"""Полноэкранный индикатор загрузки: бегущая ASCII-собачка.
Кадры сменяются по таймеру (виляет хвостом). Показывается как
центрированный overlay поверх дашборда на время загрузки данных.
overlay поверх всего дашборда с полупрозрачным затемнением; собака
«бежит» (переставляет лапы, виляет хвостом), под ней — дорожка из 🐾.
Показывается на время обновления статистики и блокирует ввод.
"""
FRAMES = (
" /^ ^\\\n ( o.o ) ~\n \\ - / ~~~\n |_|",
" /^ ^\\\n ( o.o ) ~~~\n \\ - / ~~~~\n |_|",
" /^ ^\\\n ( o.o ) ~~\n \\ - / ~\n |_|",
BODY = (
" __",
" ____/ \\___",
" / oo \\____",
" | __ \\____",
" \\____/ \\____________/",
)
LEGS = (
(" || || \\ ~", " || || \\_/"),
(" // \\\\ \\ ~~", " // \\\\ \\_/"),
(" || || \\ ~", " || || \\_/"),
(" \\\\ // \\ ~~", " \\\\ // \\_/"),
)
CAPTION = "Обновление статистики…"
INTERVAL = 0.15
INTERVAL = 0.12
frame = reactive(0)
@@ -90,11 +103,36 @@ class DogLoader(Static):
self._timer = 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:
art = self.FRAMES[self.frame % len(self.FRAMES)]
return f"{art}\n\n{self.CAPTION}"
theme = self.app.current_theme
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):
@@ -160,7 +198,6 @@ class DashboardScreen(Screen[Any]):
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:
@@ -178,20 +215,28 @@ class DashboardScreen(Screen[Any]):
self.refreshing = False
def watch_refreshing(self, refreshing: bool) -> None:
"""Показать/скрыть собачку и строку прогресса при обновлении."""
"""Показать/скрыть полноэкранную собачку при обновлении."""
try:
loader = self.query_one("#dog-loader", DogLoader)
line = self.query_one("#progress-line", Static)
except NoMatches: # watcher может сработать до монтирования детей
return
if refreshing:
loader.add_class("visible")
loader.start()
line.update("[b]🐕 Собачка обновляет статистику…[/]")
self.set_focus(None)
else:
loader.remove_class("visible")
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:
status = "offline" if data.offline else "online"
@@ -617,23 +662,16 @@ class SelectelMLTUI(App[Any]):
}
#dog-loader {
display: none;
layer: overlay;
width: auto;
height: auto;
padding: 1 4;
border: round $accent;
background: $surface;
dock: top;
layer: _loading;
width: 100%;
height: 100%;
background: $background 80%;
content-align: center middle;
text-align: center;
}
#dog-loader.visible {
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 loader.display is False
assert not loader.running
line = app.screen.query_one("#progress-line", Static)
assert str(line.render()) == ""
# встроенный loading Textual не должен активироваться (иначе — точки)
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.display is True
assert loader.running
line = app.screen.query_one("#progress-line", Static)
assert "обновляет статистику" in str(line.render())
assert app.screen.loading is False
# overlay покрывает весь экран
assert loader.region == app.screen.region
release.set()
await pilot.pause(0.1)
assert not loader.has_class("visible")
assert loader.display is False
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: