22
33from __future__ import annotations
44
5+ import asyncio
56import time
67from collections .abc import Callable
8+ from collections .abc import Iterator
9+ from contextlib import contextmanager
710from urllib .parse import urlparse
811from dataclasses import dataclass , field
912from typing import Any , Self
1619from rich .text import Text
1720
1821from sendspin .discovery import DiscoveredServer
22+ from sendspin .utils import create_task
1923
2024
2125class _RefreshableLayout :
@@ -31,6 +35,10 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR
3135
3236# Duration in seconds to highlight a pressed shortcut
3337SHORTCUT_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