173 lines
6.0 KiB
Python
173 lines
6.0 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:
|
|
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)
|