From d7d146e586d5d19b00e2f1906e0c19cb5ff97431 Mon Sep 17 00:00:00 2001 From: Selectel ML Tokens Date: Thu, 10 Sep 2026 13:30:00 +0300 Subject: [PATCH] Full-screen dog loader overlay with dim background and input blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 15 ++++-- README.md | 2 +- src/selectel_ml_tui/ui/app.py | 96 ++++++++++++++++++++++++----------- tests/test_tui.py | 35 +++++++++++-- 4 files changed, 109 insertions(+), 39 deletions(-) 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: