From d596a747a4a9596d83eb109d166fb141fb1acb09 Mon Sep 17 00:00:00 2001 From: shuki Date: Fri, 6 Mar 2026 04:28:08 +0200 Subject: [PATCH] Add animated spinner to OperationLog using Rich Spinner Uses rich.spinner.Spinner with set_interval refresh instead of Textual's LoadingIndicator which caused rendering failures. Spinner shows dots animation while running, changes to checkmark on finish. Co-Authored-By: Claude Opus 4.6 --- tui/gniza.tcss | 13 ++++++++++++- tui/screens/backup.py | 2 ++ tui/screens/remotes.py | 1 + tui/screens/restore.py | 1 + tui/screens/retention.py | 2 ++ tui/widgets/operation_log.py | 34 ++++++++++++++++++++++++++++++++-- 6 files changed, 50 insertions(+), 3 deletions(-) diff --git a/tui/gniza.tcss b/tui/gniza.tcss index c511c2d..accb7ed 100644 --- a/tui/gniza.tcss +++ b/tui/gniza.tcss @@ -193,10 +193,21 @@ SelectionList { border: thick $accent; } +#ol-header { + height: auto; + margin: 0 0 1 0; +} + #ol-title { text-style: bold; color: #00cc00; - margin: 0 0 1 0; + width: 1fr; +} + +#ol-spinner { + width: auto; + min-width: 3; + height: 1; } #ol-log { diff --git a/tui/screens/backup.py b/tui/screens/backup.py index a927c0a..77f90ce 100644 --- a/tui/screens/backup.py +++ b/tui/screens/backup.py @@ -77,6 +77,7 @@ class BackupScreen(Screen): log_screen.write("\n[green]Backup completed successfully.[/green]") else: log_screen.write(f"\n[red]Backup failed (exit code {rc}).[/red]") + log_screen.finish() @work async def _do_backup_all(self) -> None: @@ -87,6 +88,7 @@ class BackupScreen(Screen): log_screen.write("\n[green]All backups completed.[/green]") else: log_screen.write(f"\n[red]Backup failed (exit code {rc}).[/red]") + log_screen.finish() def action_go_back(self) -> None: self.app.pop_screen() diff --git a/tui/screens/remotes.py b/tui/screens/remotes.py index e742b4e..2666c0b 100644 --- a/tui/screens/remotes.py +++ b/tui/screens/remotes.py @@ -94,6 +94,7 @@ class RemotesScreen(Screen): log_screen.write("\n[green]Connection test passed.[/green]") else: log_screen.write(f"\n[red]Connection test failed (exit code {rc}).[/red]") + log_screen.finish() def _delete_remote(self, name: str) -> None: conf = CONFIG_DIR / "remotes.d" / f"{name}.conf" diff --git a/tui/screens/restore.py b/tui/screens/restore.py index 9b186c7..25ef505 100644 --- a/tui/screens/restore.py +++ b/tui/screens/restore.py @@ -122,6 +122,7 @@ class RestoreScreen(Screen): log_screen.write("\n[green]Restore completed successfully.[/green]") else: log_screen.write(f"\n[red]Restore failed (exit code {rc}).[/red]") + log_screen.finish() def action_go_back(self) -> None: self.app.pop_screen() diff --git a/tui/screens/retention.py b/tui/screens/retention.py index 0a99d96..25ee310 100644 --- a/tui/screens/retention.py +++ b/tui/screens/retention.py @@ -75,6 +75,7 @@ class RetentionScreen(Screen): log_screen.write("\n[green]Cleanup completed.[/green]") else: log_screen.write(f"\n[red]Cleanup failed (exit code {rc}).[/red]") + log_screen.finish() @work async def _do_cleanup_all(self) -> None: @@ -85,6 +86,7 @@ class RetentionScreen(Screen): log_screen.write("\n[green]All cleanups completed.[/green]") else: log_screen.write(f"\n[red]Cleanup failed (exit code {rc}).[/red]") + log_screen.finish() def action_go_back(self) -> None: self.app.pop_screen() diff --git a/tui/widgets/operation_log.py b/tui/widgets/operation_log.py index 1b1176a..7c06cb3 100644 --- a/tui/widgets/operation_log.py +++ b/tui/widgets/operation_log.py @@ -1,10 +1,32 @@ import asyncio +from rich.spinner import Spinner as RichSpinner from rich.text import Text from textual.app import ComposeResult from textual.screen import ModalScreen from textual.widgets import RichLog, Button, Static -from textual.containers import Vertical +from textual.containers import Vertical, Horizontal +from textual.timer import Timer + + +class SpinnerWidget(Static): + """Animated spinner using Rich's Spinner renderable.""" + + def __init__(self, style: str = "dots", **kwargs): + super().__init__("", **kwargs) + self._spinner = RichSpinner(style) + self._timer: Timer | None = None + + def on_mount(self) -> None: + self._timer = self.set_interval(1 / 12, self._tick) + + def _tick(self) -> None: + self.update(self._spinner) + + def stop(self) -> None: + if self._timer: + self._timer.stop() + self.update("✅") class OperationLog(ModalScreen[None]): @@ -19,7 +41,9 @@ class OperationLog(ModalScreen[None]): def compose(self) -> ComposeResult: with Vertical(id="op-log"): - yield Static(self._title, id="ol-title") + with Horizontal(id="ol-header"): + yield Static(self._title, id="ol-title") + yield SpinnerWidget(id="ol-spinner") yield RichLog(id="ol-log", wrap=True, highlight=True, markup=True) yield Button("Close", variant="primary", id="ol-close") @@ -43,6 +67,12 @@ class OperationLog(ModalScreen[None]): else: log.write(text) + def finish(self) -> None: + try: + self.query_one("#ol-spinner", SpinnerWidget).stop() + except Exception: + pass + def write(self, text: str) -> None: if not self._mounted_event.is_set(): self._buffer.append(text)