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 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-06 04:28:08 +02:00
parent ced2e9a889
commit d596a747a4
6 changed files with 50 additions and 3 deletions

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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"

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)