Files
beefy-player/beefy_player/widgets/station_list.py
T

176 lines
6.1 KiB
Python

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