diff --git a/CHANGELOG.md b/CHANGELOG.md index 3797ed6..978a96a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 («Новая загрузка обновления статистики»). diff --git a/README.md b/README.md index 2529cd6..cac8d4c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ TUI-приложение (Python 3.10+) для отображения потре - Детализация метрик конкретной модели (Enter в таблице моделей) - Офлайн-режим: при недоступности API показывает последние данные из кэша - Автообновление по интервалу из конфигурации (по умолчанию 60 с) -- Анимация-«собачка» и строка прогресса во время обновления статистики +- Полноэкранный индикатор загрузки: бегущая ASCII-собачка с затемнением фона - Экспорт истории в CSV/JSON, диагностика окружения (`doctor`) ## Интерфейс diff --git a/src/selectel_ml_tui/ui/app.py b/src/selectel_ml_tui/ui/app.py index ced99e5..f952080 100644 --- a/src/selectel_ml_tui/ui/app.py +++ b/src/selectel_ml_tui/ui/app.py @@ -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; } """ diff --git a/tests/test_tui.py b/tests/test_tui.py index 3c3570f..d5fa67b 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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: