Skip to content

Commit 4d98e44

Browse files
Coalesce TUI refreshes (#160)
## Summary - replace eager `Live.refresh()` calls with a coalesced refresh scheduler in the TUI - batch related metadata and group state updates so one server update produces one redraw - keep idle resize handling by polling for terminal size changes without reintroducing constant refreshes ## Verification - `uv run --with ruff ruff check sendspin/tui/ui.py sendspin/tui/app.py` - `uv run python -m compileall sendspin/tui` - targeted async smoke test covering batched updates, playback refreshes, and idle resize refreshes Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
1 parent 82de656 commit 4d98e44

2 files changed

Lines changed: 130 additions & 27 deletions

File tree

‎sendspin/tui/app.py‎

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -602,15 +602,18 @@ def _handle_metadata_update(self, payload: ServerStatePayload) -> None:
602602
assert self._ui is not None
603603
state = self._state
604604
ui = self._ui
605-
if payload.metadata is not None and state.update_metadata(payload.metadata):
605+
if payload.metadata is None or not state.update_metadata(payload.metadata):
606+
return
607+
608+
with ui.batch_update():
606609
ui.set_metadata(
607610
title=state.title,
608611
artist=state.artist,
609612
album=state.album,
610613
)
611614
ui.set_progress(state.track_progress, state.track_duration)
612615
ui.set_repeat_shuffle(state.repeat_mode, state.shuffle)
613-
ui.add_event(state.describe())
616+
ui.add_event(state.describe())
614617

615618
def _handle_group_update(self, payload: GroupUpdateServerPayload) -> None:
616619
"""Handle group update messages."""
@@ -626,33 +629,36 @@ def _handle_group_update(self, payload: GroupUpdateServerPayload) -> None:
626629

627630
if payload.group_name:
628631
ui.add_event(f"Group name: {payload.group_name}")
629-
ui.set_group_name(payload.group_name)
630-
if payload.playback_state:
631-
state.playback_state = payload.playback_state
632-
ui.set_playback_state(payload.playback_state)
633-
ui.add_event(f"Playback state: {payload.playback_state.value}")
632+
with ui.batch_update():
633+
ui.set_group_name(payload.group_name)
634+
if payload.playback_state:
635+
state.playback_state = payload.playback_state
636+
ui.set_playback_state(payload.playback_state)
637+
ui.add_event(f"Playback state: {payload.playback_state.value}")
634638

635639
def _handle_server_state(self, payload: ServerStatePayload) -> None:
636640
"""Handle server/state messages with controller state."""
637641
assert self._ui is not None
638642
state = self._state
639643
ui = self._ui
640-
if payload.controller:
641-
controller = payload.controller
642-
state.supported_commands = set(controller.supported_commands)
643-
644-
volume_changed = controller.volume != state.volume
645-
mute_changed = controller.muted != state.muted
646-
647-
if volume_changed:
648-
state.volume = controller.volume
649-
ui.add_event(f"Volume: {controller.volume}%")
650-
if mute_changed:
651-
state.muted = controller.muted
652-
ui.add_event("Muted" if controller.muted else "Unmuted")
653-
654-
if volume_changed or mute_changed:
655-
ui.set_volume(state.volume, muted=state.muted)
644+
if not payload.controller:
645+
return
646+
647+
controller = payload.controller
648+
state.supported_commands = set(controller.supported_commands)
649+
650+
volume_changed = controller.volume != state.volume
651+
mute_changed = controller.muted != state.muted
652+
653+
if volume_changed:
654+
state.volume = controller.volume
655+
ui.add_event(f"Volume: {controller.volume}%")
656+
if mute_changed:
657+
state.muted = controller.muted
658+
ui.add_event("Muted" if controller.muted else "Unmuted")
659+
660+
if volume_changed or mute_changed:
661+
ui.set_volume(state.volume, muted=state.muted)
656662

657663
def _handle_server_command(self, payload: ServerCommandPayload) -> None:
658664
"""Handle server/command messages for player volume/mute control."""

‎sendspin/tui/ui.py‎

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
from __future__ import annotations
44

5+
import asyncio
56
import time
67
from collections.abc import Callable
8+
from collections.abc import Iterator
9+
from contextlib import contextmanager
710
from urllib.parse import urlparse
811
from dataclasses import dataclass, field
912
from typing import Any, Self
@@ -16,6 +19,7 @@
1619
from rich.text import Text
1720

1821
from sendspin.discovery import DiscoveredServer
22+
from sendspin.utils import create_task
1923

2024

2125
class _RefreshableLayout:
@@ -31,6 +35,10 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR
3135

3236
# Duration in seconds to highlight a pressed shortcut
3337
SHORTCUT_HIGHLIGHT_DURATION = 0.15
38+
REFRESH_COALESCE_DELAY = 1 / 30
39+
PLAYBACK_REFRESH_INTERVAL = 0.25
40+
HIGHLIGHT_REFRESH_INTERVAL = 0.05
41+
RESIZE_POLL_INTERVAL = 0.25
3442

3543

3644
@dataclass
@@ -105,6 +113,11 @@ def __init__(
105113
self._live: Live | None = None
106114
self._running = False
107115
self._panel_cache: dict[str, tuple[tuple[Any, ...], Panel]] = {}
116+
self._dirty = False
117+
self._batch_depth = 0
118+
self._refresh_event = asyncio.Event()
119+
self._refresh_task: asyncio.Task[None] | None = None
120+
self._last_console_size: tuple[int, int] | None = None
108121

109122
@property
110123
def state(self) -> UIState:
@@ -127,6 +140,70 @@ def _is_highlighted(self, shortcut: str) -> bool:
127140
elapsed = time.monotonic() - self._state.highlight_time
128141
return elapsed < SHORTCUT_HIGHLIGHT_DURATION
129142

143+
def _has_active_highlight(self) -> bool:
144+
"""Check if any shortcut highlight animation is still active."""
145+
if self._state.highlighted_shortcut is None:
146+
return False
147+
elapsed = time.monotonic() - self._state.highlight_time
148+
return elapsed < SHORTCUT_HIGHLIGHT_DURATION
149+
150+
def _needs_playback_refresh(self) -> bool:
151+
"""Check if the progress panel needs periodic refreshes."""
152+
return (
153+
self._state.playback_state == PlaybackStateType.PLAYING
154+
and self._state.progress_updated_at > 0
155+
and (self._state.track_duration_ms or 0) > 0
156+
)
157+
158+
def _next_refresh_interval(self) -> float | None:
159+
"""Return the next periodic refresh interval, if any."""
160+
intervals: list[float] = []
161+
if self._needs_playback_refresh():
162+
intervals.append(PLAYBACK_REFRESH_INTERVAL)
163+
if self._has_active_highlight():
164+
intervals.append(HIGHLIGHT_REFRESH_INTERVAL)
165+
return min(intervals) if intervals else None
166+
167+
def _flush_refresh(self, *, force: bool = False) -> None:
168+
"""Refresh the live display if the UI is dirty or animating."""
169+
if self._live is None or not (self._dirty or force):
170+
return
171+
self._update_console_size()
172+
self._dirty = False
173+
self._live.refresh()
174+
175+
def _update_console_size(self) -> bool:
176+
"""Track terminal dimensions and report when they change."""
177+
size = self._console.size
178+
current_size = (size.width, size.height)
179+
changed = self._last_console_size is not None and current_size != self._last_console_size
180+
self._last_console_size = current_size
181+
return changed
182+
183+
async def _refresh_loop(self) -> None:
184+
"""Coalesce dirty updates and drive the few animations the UI uses."""
185+
event = self._refresh_event
186+
187+
while self._running:
188+
animation_interval = None if self._dirty else self._next_refresh_interval()
189+
interval = (
190+
REFRESH_COALESCE_DELAY
191+
if self._dirty
192+
else animation_interval or RESIZE_POLL_INTERVAL
193+
)
194+
195+
try:
196+
await asyncio.wait_for(event.wait(), timeout=interval)
197+
except TimeoutError:
198+
event.clear()
199+
if self._update_console_size():
200+
self._dirty = True
201+
self._flush_refresh(force=animation_interval is not None and not self._dirty)
202+
except asyncio.CancelledError:
203+
break
204+
else:
205+
event.clear()
206+
130207
def _shortcut_style(self, shortcut: str) -> str:
131208
"""Get the style for a shortcut key."""
132209
return "bold yellow reverse" if self._is_highlighted(shortcut) else "bold cyan"
@@ -650,9 +727,23 @@ def add_event(self, _message: str) -> None:
650727
"""Add an event (no-op, events panel removed)."""
651728

652729
def refresh(self) -> None:
653-
"""Request a UI refresh."""
654-
if self._live is not None:
655-
self._live.refresh()
730+
"""Request a coalesced UI refresh."""
731+
self._dirty = True
732+
if self._live is None or self._batch_depth > 0:
733+
return
734+
735+
self._refresh_event.set()
736+
737+
@contextmanager
738+
def batch_update(self) -> Iterator[None]:
739+
"""Delay rendering until a related group of state updates completes."""
740+
self._batch_depth += 1
741+
try:
742+
yield
743+
finally:
744+
self._batch_depth -= 1
745+
if self._batch_depth == 0 and self._dirty:
746+
self.refresh()
656747

657748
def set_connected(self, url: str) -> None:
658749
"""Update connection status to connected."""
@@ -792,18 +883,24 @@ def get_selected_server(self) -> DiscoveredServer | None:
792883
def start(self) -> None:
793884
"""Start the live display."""
794885
self._console.clear()
886+
self._update_console_size()
795887
self._live = Live(
796888
_RefreshableLayout(self),
797889
console=self._console,
798-
refresh_per_second=4,
890+
auto_refresh=False,
799891
screen=True,
800892
)
801893
self._live.start()
802894
self._running = True
895+
self._refresh_task = create_task(self._refresh_loop(), name="sendspin-ui-refresh")
896+
self.refresh()
803897

804898
def stop(self) -> None:
805899
"""Stop the live display."""
806900
self._running = False
901+
if self._refresh_task is not None:
902+
self._refresh_task.cancel()
903+
self._refresh_task = None
807904
if self._live is not None:
808905
self._live.stop()
809906
self._live = None

0 commit comments

Comments
 (0)