"""Station list with favourites, live markers, a filter and a scrollbar.""" from __future__ import annotations from typing import TYPE_CHECKING, cast from rich.cells import cell_len from rich.console import RenderableType from rich.text import Text from textual.widget import Widget from . import frame from .common import active_stops, compact_count, truncate if TYPE_CHECKING: from ..app import BeefyPlayerApp #: Columns the scrollbar occupies (a gap plus the bar itself). GUTTER = 2 class StationList(Widget): can_focus = False def __init__(self) -> None: super().__init__() self.border_title = "STATIONS" # -- title: filter + position --------------------------------------- # def set_filter(self, query: str) -> None: self._sync_title(query) def _sync_title(self, query: str | None = None) -> None: """``STATIONS · /jazz · 3/12`` — the filter and where you are in it.""" if query is None: query = "" total = 0 index = 0 if self.is_mounted: app = cast("BeefyPlayerApp", self.app) query = app.query total = len(app.view) index = app.index safe_query = query.replace("[", "(").replace("]", ")") if query else "" parts = ["STATIONS"] if safe_query: parts.append(f"/{safe_query}") if total > 1: parts.append(f"{min(index + 1, total)}/{total}") title = " · ".join(parts) if self.border_title != title: self.border_title = title def refresh(self, *args, **kwargs): # type: ignore[override] # Every cursor move ends in a refresh, so this is the one place that # has to keep the title's position readout honest. if self.is_mounted: self._sync_title() return super().refresh(*args, **kwargs) def on_mouse_scroll_down(self, event) -> None: cast("BeefyPlayerApp", self.app).action_cursor_down() def on_mouse_scroll_up(self, event) -> None: cast("BeefyPlayerApp", self.app).action_cursor_up() # -- scrollbar geometry --------------------------------------------- # @staticmethod def thumb(height: int, total: int, first: int) -> set[int]: """Rows of the pane the scrollbar thumb covers.""" if total <= height: return set(range(height)) size = max(1, round(height * height / total)) span = max(0, total - height) top = 0 if span == 0 else round(first * (height - size) / span) return set(range(top, min(height, top + size))) def render(self) -> RenderableType: app = cast("BeefyPlayerApp", self.app) width = max(12, self.size.width) height = max(3, self.size.height) stops = active_stops(app) inner_width, inner_height = frame.inner_size(width, height) view = app.view if not view: if getattr(app, "search_pending", False): message = "searching the directory…" else: message = "no stations match" if app.query else "loading stations…" return frame.wrap( [Text(truncate(message, inner_width), style="dim italic")], width, height, self.border_title, stops, ) total = len(view) first = 0 if total > inner_height: first = max(0, min(app.index - inner_height // 2, total - inner_height)) scrollable = total > inner_height gutter = GUTTER if scrollable else 0 content = max(4, inner_width - gutter) thumb = ( self.thumb(inner_height, total, first) if scrollable else set() ) lines: list[Text] = [] for offset in range(min(inner_height, total)): index = first + offset station = view[index] selected = index == app.index current = app.current is not None and app.current.id == station.id playing = current and app.playing_state not in ("stopped", "error") marker = "▌" if selected else " " star = "★" if station.favorite else " " play = ( ("‖" if app.playing_state == "paused" else "▶") if current else " " ) count_text = "" count_width = 0 if content >= 26 and station.listeners > 0: count_text = f"{compact_count(station.listeners)} ♪" count_width = cell_len(count_text) + 1 prefix = marker + star + play + " " available = content - cell_len(prefix) - count_width title = truncate(station.title, max(4, available)) gap = max(0, content - cell_len(prefix) - cell_len(title) - count_width) if selected: marker_style = "magenta" title_style = "bold underline" count_style = "bold" elif current: marker_style = "cyan" title_style = "cyan" count_style = "dim" else: marker_style = "" title_style = "" count_style = "dim" row = Text(no_wrap=True) row.append(marker, style=marker_style) row.append(star, style="bold yellow" if station.favorite else "") row.append(play, style="cyan" if playing else "") row.append(" ") row.append(title, style=title_style) row.append(" " * gap) if count_text: row.append(count_text, style=count_style) used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text) if used < content: row.append(" " * (content - used)) if scrollable: # Just the thumb, in the frame's own grey: no rail. row.append(" ") row.append( "▐" if offset in thumb else " ", style=frame.EDGE, ) lines.append(row) return frame.wrap(lines, width, height, self.border_title, stops)