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:
+11
-4
@@ -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 («Новая загрузка обновления статистики»).
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ TUI-приложение (Python 3.10+) для отображения потре
|
||||
- Детализация метрик конкретной модели (Enter в таблице моделей)
|
||||
- Офлайн-режим: при недоступности API показывает последние данные из кэша
|
||||
- Автообновление по интервалу из конфигурации (по умолчанию 60 с)
|
||||
- Анимация-«собачка» и строка прогресса во время обновления статистики
|
||||
- Полноэкранный индикатор загрузки: бегущая ASCII-собачка с затемнением фона
|
||||
- Экспорт истории в CSV/JSON, диагностика окружения (`doctor`)
|
||||
|
||||
## Интерфейс
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user