feat(ui): draw pane frames, page keys and a scroll thumb
This commit is contained in:
1 parent
49f7efe0d2
commit
0219df454c
14 files changed
+571
-64
No files matched your search
@@ -1,19 +1,23 @@
|
||||
"""Station list with favourites, live markers and a filter."""
|
||||
"""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 Group, RenderableType
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
from textual.widget import Widget
|
||||
|
||||
from . import frame
|
||||
from .common import 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
|
||||
@@ -22,34 +26,89 @@ class StationList(Widget):
|
||||
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 ""
|
||||
title = f"STATIONS · /{safe_query}" if safe_query else "STATIONS"
|
||||
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(1, self.size.height)
|
||||
height = max(3, self.size.height)
|
||||
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 Group(Text(truncate(message, width), style="dim italic"))
|
||||
return frame.wrap(
|
||||
[Text(truncate(message, inner_width), style="dim italic")],
|
||||
width,
|
||||
height,
|
||||
self.border_title,
|
||||
)
|
||||
|
||||
total = len(view)
|
||||
first = 0
|
||||
if len(view) > height:
|
||||
first = max(0, min(app.index - height // 2, len(view) - height))
|
||||
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(height, len(view))):
|
||||
for offset in range(min(inner_height, total)):
|
||||
index = first + offset
|
||||
station = view[index]
|
||||
selected = index == app.index
|
||||
@@ -64,32 +123,29 @@ class StationList(Widget):
|
||||
|
||||
count_text = ""
|
||||
count_width = 0
|
||||
if width >= 26 and station.listeners > 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 = width - cell_len(prefix) - count_width
|
||||
available = content - cell_len(prefix) - count_width
|
||||
title = truncate(station.title, max(4, available))
|
||||
gap = max(0, width - cell_len(prefix) - cell_len(title) - count_width)
|
||||
gap = max(0, content - cell_len(prefix) - cell_len(title) - count_width)
|
||||
|
||||
if selected:
|
||||
base = ""
|
||||
marker_style = "magenta"
|
||||
title_style = "bold underline"
|
||||
count_style = "bold"
|
||||
elif current:
|
||||
base = ""
|
||||
marker_style = "cyan"
|
||||
title_style = "cyan"
|
||||
count_style = "dim"
|
||||
else:
|
||||
base = ""
|
||||
marker_style = ""
|
||||
title_style = ""
|
||||
count_style = "dim"
|
||||
|
||||
row = Text(no_wrap=True, style=base)
|
||||
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 "")
|
||||
@@ -99,8 +155,16 @@ class StationList(Widget):
|
||||
if count_text:
|
||||
row.append(count_text, style=count_style)
|
||||
used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text)
|
||||
if used < width:
|
||||
row.append(" " * (width - used))
|
||||
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 Group(*lines)
|
||||
return frame.wrap(lines, width, height, self.border_title)
|
||||
Reference in new issue
Block a user