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
@@ -76,6 +76,8 @@ class BeefyPlayerApp(App[None]):
|
||||
Binding("down,j", "cursor_down", "down", show=False),
|
||||
Binding("g", "cursor_top", "top", show=False),
|
||||
Binding("shift+g", "cursor_bottom", "bottom", show=False),
|
||||
Binding("pageup", "page_up", "page up", show=False),
|
||||
Binding("pagedown", "page_down", "page down", show=False),
|
||||
Binding("enter", "tune", "tune in"),
|
||||
Binding("space", "toggle_play", "play/pause"),
|
||||
Binding("s", "stop", "stop", show=False),
|
||||
@@ -824,6 +826,19 @@ class BeefyPlayerApp(App[None]):
|
||||
self.index = min(len(self.view) - 1, self.index + 1)
|
||||
self.query_one(StationList).refresh()
|
||||
|
||||
def _page(self, delta: int) -> None:
|
||||
if not self.view:
|
||||
return
|
||||
page = max(1, self.query_one(StationList).size.height - 1)
|
||||
self.index = max(0, min(len(self.view) - 1, self.index + delta * page))
|
||||
self.query_one(StationList).refresh()
|
||||
|
||||
def action_page_up(self) -> None:
|
||||
self._page(-1)
|
||||
|
||||
def action_page_down(self) -> None:
|
||||
self._page(1)
|
||||
|
||||
def action_cursor_top(self) -> None:
|
||||
if not self.view:
|
||||
return
|
||||
|
||||
+24
-11
@@ -76,9 +76,6 @@ StationList {
|
||||
color: ansi_default;
|
||||
width: 1fr;
|
||||
height: 1fr;
|
||||
border: round ansi_bright_black;
|
||||
border-title-color: ansi_default;
|
||||
padding: 0 1;
|
||||
background: ansi_default;
|
||||
}
|
||||
|
||||
@@ -87,9 +84,6 @@ SchedulePanel {
|
||||
width: 1fr;
|
||||
height: 1fr;
|
||||
display: none;
|
||||
border: round ansi_bright_black;
|
||||
border-title-color: ansi_default;
|
||||
padding: 0 1;
|
||||
background: ansi_default;
|
||||
}
|
||||
|
||||
@@ -97,8 +91,30 @@ StagePanel {
|
||||
color: ansi_default;
|
||||
layout: vertical;
|
||||
width: 1fr;
|
||||
border: round ansi_bright_black;
|
||||
border-title-color: ansi_default;
|
||||
background: ansi_default;
|
||||
}
|
||||
|
||||
#stage_frame_top,
|
||||
#stage_frame_bottom {
|
||||
height: 1;
|
||||
background: ansi_default;
|
||||
}
|
||||
|
||||
#stage_middle {
|
||||
height: 1fr;
|
||||
background: ansi_default;
|
||||
}
|
||||
|
||||
#stage_frame_left,
|
||||
#stage_frame_right {
|
||||
width: 1;
|
||||
height: 1fr;
|
||||
background: ansi_default;
|
||||
}
|
||||
|
||||
#stage_body {
|
||||
width: 1fr;
|
||||
height: 1fr;
|
||||
padding: 0 1;
|
||||
background: ansi_default;
|
||||
}
|
||||
@@ -143,9 +159,6 @@ SpectrumPanel {
|
||||
color: ansi_default;
|
||||
height: 2fr;
|
||||
min-height: 6;
|
||||
border: round ansi_bright_black;
|
||||
border-title-color: ansi_default;
|
||||
padding: 0 1;
|
||||
background: ansi_default;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Pane frames: one grey box, with two corners fading out of it.
|
||||
|
||||
The panes draw their own frames instead of using Textual's ``border`` so the
|
||||
corners can be coloured individually. Two of them — the top right and the
|
||||
bottom left — run white → light grey → bright cyan → cyan along both arms;
|
||||
everything else, including the other two corners, is the border grey.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.text import Text
|
||||
from textual.widget import Widget
|
||||
|
||||
#: The frame itself: one grey, the same grey as the scrollbar thumb.
|
||||
EDGE = "bright_black"
|
||||
|
||||
#: A faded corner runs white → light grey → bright cyan → cyan, then settles
|
||||
#: back into the border grey.
|
||||
CORNER_GRADIENT = ("bright_white", "white", "bright_cyan", "cyan")
|
||||
|
||||
|
||||
def fade(distance: int) -> str:
|
||||
"""Colour ``distance`` cells away from a corner (0 is the corner itself)."""
|
||||
if 0 <= distance < len(CORNER_GRADIENT):
|
||||
return CORNER_GRADIENT[distance]
|
||||
return EDGE
|
||||
|
||||
|
||||
def side_style(row: int, rows: int, side: str = "left") -> str:
|
||||
"""Colour of a side cell: only two corners fade, so only one end glows."""
|
||||
if side == "right":
|
||||
return fade(row + 1) # fades down from the top-right corner
|
||||
return fade(rows - row) # fades up from the bottom-left corner
|
||||
|
||||
|
||||
def inner_size(width: int, height: int) -> tuple[int, int]:
|
||||
"""The content box inside the frame."""
|
||||
return max(1, width - 2), max(1, height - 2)
|
||||
|
||||
|
||||
def _edge_cells(width: int, faded_end: str = "right") -> list[tuple[str, str]]:
|
||||
"""Inner cells of an edge; only one end of it fades into a corner."""
|
||||
inner = max(0, width - 2)
|
||||
if faded_end == "left":
|
||||
return [("─", fade(column + 1)) for column in range(inner)]
|
||||
return [("─", fade(inner - column)) for column in range(inner)]
|
||||
|
||||
|
||||
def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
|
||||
"""Append cells, grouping neighbours that share a style into one span."""
|
||||
style: str | None = None
|
||||
run: list[str] = []
|
||||
for char, cell_style in cells:
|
||||
if cell_style != style:
|
||||
if run:
|
||||
line.append("".join(run), style=style)
|
||||
run, style = [], cell_style
|
||||
run.append(char)
|
||||
if run:
|
||||
line.append("".join(run), style=style)
|
||||
|
||||
|
||||
def top(width: int, title: str = "") -> Text:
|
||||
width = max(2, width)
|
||||
cells = _edge_cells(width, "right") # only the top-right corner fades
|
||||
if title:
|
||||
label = f" {title} "
|
||||
start = 1
|
||||
if start + cell_len(label) <= len(cells):
|
||||
for offset, char in enumerate(label):
|
||||
cells[start + offset] = (char, "bold")
|
||||
|
||||
line = Text(no_wrap=True)
|
||||
line.append("╭", style=EDGE)
|
||||
_paint(line, cells)
|
||||
line.append("╮", style=f"bold {fade(0)}")
|
||||
return line
|
||||
|
||||
|
||||
def bottom(width: int) -> Text:
|
||||
width = max(2, width)
|
||||
line = Text(no_wrap=True)
|
||||
line.append("╰", style=f"bold {fade(0)}")
|
||||
_paint(line, _edge_cells(width, "left")) # only the bottom-left fades
|
||||
line.append("╯", style=EDGE)
|
||||
return line
|
||||
|
||||
|
||||
def side() -> Text:
|
||||
return Text("│", style=EDGE)
|
||||
|
||||
|
||||
def side_row(row: int, rows: int, side: str = "left") -> Text:
|
||||
return Text("│", style=side_style(row, rows, side))
|
||||
|
||||
|
||||
def wrap(
|
||||
lines: list[Text], width: int, height: int, title: str = ""
|
||||
) -> RenderableType:
|
||||
"""Frame ``lines`` (already sized for the content box) in a pane."""
|
||||
width = max(4, width)
|
||||
height = max(2, height)
|
||||
inner_width, inner_height = inner_size(width, height)
|
||||
rows = len(lines[:inner_height])
|
||||
|
||||
framed: list[Text] = [top(width, title)]
|
||||
for index, line in enumerate(lines[:inner_height]):
|
||||
row = Text(no_wrap=True)
|
||||
row.append_text(side_row(index, rows, "left"))
|
||||
row.append_text(line)
|
||||
pad = inner_width - cell_len(line.plain)
|
||||
if pad > 0:
|
||||
row.append(" " * pad)
|
||||
row.append_text(side_row(index, rows, "right"))
|
||||
framed.append(row)
|
||||
while len(framed) < height - 1:
|
||||
index = len(framed) - 1
|
||||
row = Text(no_wrap=True)
|
||||
row.append_text(side_row(index, inner_height, "left"))
|
||||
row.append(" " * inner_width)
|
||||
row.append_text(side_row(index, inner_height, "right"))
|
||||
framed.append(row)
|
||||
framed.append(bottom(width))
|
||||
return Group(*framed[:height])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# frame pieces, for containers that cannot draw their own content
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class FrameTop(Widget):
|
||||
can_focus = False
|
||||
|
||||
def __init__(self, title: str = "", *, id: str | None = None) -> None:
|
||||
super().__init__(id=id)
|
||||
self.title = title
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return top(max(2, self.size.width), self.title)
|
||||
|
||||
|
||||
class FrameBottom(Widget):
|
||||
can_focus = False
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return bottom(max(2, self.size.width))
|
||||
|
||||
|
||||
class FrameSide(Widget):
|
||||
can_focus = False
|
||||
|
||||
def __init__(self, side: str = "left", *, id: str | None = None) -> None:
|
||||
super().__init__(id=id)
|
||||
self.side = side
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
rows = max(1, self.size.height)
|
||||
return Group(
|
||||
*[side_row(row, rows, self.side) for row in range(rows)]
|
||||
)
|
||||
@@ -74,6 +74,11 @@ class HeaderBar(Widget):
|
||||
lines: list[Text] = []
|
||||
for index, row in enumerate(hero):
|
||||
line = _hero_line(row, index, hero_width, gradient_fill)
|
||||
# Art rows are right-trimmed, so a row ending in a blank cell is a
|
||||
# column short; pad it back or the caption and clock drift left.
|
||||
short = hero_width - cell_len(line.plain)
|
||||
if short > 0:
|
||||
line.append(" " * short)
|
||||
if show_middle and text_offset <= index < text_offset + len(hero_lines):
|
||||
middle = hero_lines[index - text_offset]
|
||||
line.append(" " * 4)
|
||||
|
||||
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
|
||||
|
||||
HELP_ROWS: tuple[tuple[str, str], ...] = (
|
||||
("↑ / ↓ j / k", "move through the station list"),
|
||||
("pgup / pgdn", "page through the station list"),
|
||||
("enter", "tune in to the selected station"),
|
||||
("space", "play / pause the current station"),
|
||||
("s", "stop playback"),
|
||||
|
||||
@@ -30,24 +30,43 @@ class ProviderScreen(ModalScreen[str | None]):
|
||||
header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
|
||||
header.append(" · pick a station catalogue", style="dim")
|
||||
|
||||
name_width = (
|
||||
max(
|
||||
(
|
||||
len(PROVIDERS[slug].name)
|
||||
for slug in self.slugs
|
||||
if slug in PROVIDERS
|
||||
),
|
||||
default=12,
|
||||
)
|
||||
+ 2
|
||||
)
|
||||
|
||||
rows: list[Text] = []
|
||||
for index, slug in enumerate(self.slugs):
|
||||
provider = PROVIDERS[slug]
|
||||
selected = index == self.index
|
||||
row = Text(no_wrap=True)
|
||||
row.append("▶ " if selected else " ", style="bold magenta" if selected else "")
|
||||
row.append(f"{index + 1} ", style="dim")
|
||||
name_style = "bold underline" if selected else "bold"
|
||||
row.append(f"{provider.name:<12}", style=name_style)
|
||||
row.append(f" {provider.brand}", style="dim")
|
||||
row.append(
|
||||
"▶ " if selected else " ", style="bold magenta" if selected else ""
|
||||
)
|
||||
row.append(f"{index + 1:>2} ", style="dim")
|
||||
row.append(
|
||||
f"{truncate(provider.name, name_width - 2):<{name_width}}",
|
||||
style="bold underline" if selected else "bold",
|
||||
)
|
||||
row.append(truncate(provider.brand, 14), style="dim")
|
||||
if slug == self.current_id:
|
||||
row.append(" ●", style="bold green")
|
||||
rows.append(row)
|
||||
|
||||
detail = Text(no_wrap=True)
|
||||
detail.append(" ", style="")
|
||||
detail.append(truncate(provider.tagline, 54), style="italic dim")
|
||||
rows.append(detail)
|
||||
if selected:
|
||||
rows.append(
|
||||
Text(
|
||||
" " + truncate(provider.tagline, 54),
|
||||
style="italic dim",
|
||||
no_wrap=True,
|
||||
)
|
||||
)
|
||||
|
||||
current = PROVIDERS.get(self.current_id)
|
||||
footer = Text()
|
||||
@@ -57,7 +76,7 @@ class ProviderScreen(ModalScreen[str | None]):
|
||||
f"now: {current.name} — {current.station_source}\n", style="dim"
|
||||
)
|
||||
footer.append(
|
||||
"↑ ↓ choose · 1–9 jump · enter switch · esc cancel",
|
||||
"↑ ↓ choose · enter switch · esc cancel",
|
||||
style="dim",
|
||||
)
|
||||
return Group(header, Text(""), *rows, footer)
|
||||
|
||||
@@ -11,10 +11,11 @@ from __future__ import annotations
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
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 local_time, local_zone, state_glyph, truncate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -92,7 +93,8 @@ class SchedulePanel(Widget):
|
||||
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)
|
||||
width, height = frame.inner_size(width, height)
|
||||
|
||||
broadcasts = list(getattr(app, "schedule", ()) or ())
|
||||
loaded = bool(getattr(app, "schedule_loaded", False))
|
||||
@@ -134,4 +136,9 @@ class SchedulePanel(Widget):
|
||||
lines.append(Text(""))
|
||||
while len(lines) < height:
|
||||
lines.append(Text(""))
|
||||
return Group(*lines[:height])
|
||||
return frame.wrap(
|
||||
lines[:height],
|
||||
max(12, self.size.width),
|
||||
max(3, self.size.height),
|
||||
self.border_title,
|
||||
)
|
||||
@@ -7,11 +7,12 @@ from collections import deque
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
from textual.widget import Widget
|
||||
|
||||
from ..spectrum import Frame, SpectrumEngine
|
||||
from . import frame
|
||||
from .common import LEVELS, SPECTRUM_STOPS, gradient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -63,38 +64,40 @@ class SpectrumPanel(Widget):
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
frame = self.engine.snapshot()
|
||||
width = max(8, self.size.width)
|
||||
height = max(2, self.size.height)
|
||||
snapshot = self.engine.snapshot()
|
||||
outer_width, outer_height = max(12, self.size.width), max(4, self.size.height)
|
||||
width, height = frame.inner_size(outer_width, outer_height)
|
||||
mode = getattr(app, "visualizer", "both")
|
||||
phase = int(getattr(app, "tick_count", 0) // 2)
|
||||
dim = frame.status == "idle"
|
||||
self._record(frame, int(getattr(app, "tick_count", 0)))
|
||||
dim = snapshot.status == "idle"
|
||||
self._record(snapshot, int(getattr(app, "tick_count", 0)))
|
||||
|
||||
lines: list[Text]
|
||||
if mode == "wave":
|
||||
lines = self._wave(frame, width, height, phase, dim)
|
||||
lines = self._wave(snapshot, width, height, phase, dim)
|
||||
elif mode == "braille":
|
||||
lines = self._braille(frame, width, height, phase, dim)
|
||||
lines = self._braille(snapshot, width, height, phase, dim)
|
||||
elif mode == "waterfall":
|
||||
lines = self._waterfall(width, height, dim)
|
||||
elif mode == "radial":
|
||||
lines = self._radial(frame, width, height, phase, dim)
|
||||
lines = self._radial(snapshot, width, height, phase, dim)
|
||||
elif mode == "bars" or height < 8:
|
||||
lines = self._bars(frame, width, height, phase, dim)
|
||||
lines = self._bars(snapshot, width, height, phase, dim)
|
||||
else:
|
||||
# Default mode: a full-height mirrored spectrum. The top half
|
||||
# grows up from the centre line, the bottom half grows down from
|
||||
# it (same values, flipped rows, dimmed like a reflection).
|
||||
top_height = height // 2
|
||||
bottom_height = height - top_height
|
||||
lines = self._bars(frame, width, top_height, phase, dim)
|
||||
lines = self._bars(snapshot, width, top_height, phase, dim)
|
||||
mirrored = self._bars(
|
||||
frame, width, bottom_height, phase + 5, True, peaks=False
|
||||
snapshot, width, bottom_height, phase + 5, True, peaks=False
|
||||
)
|
||||
mirrored.reverse()
|
||||
lines.extend(mirrored)
|
||||
return Group(*lines)
|
||||
return frame.wrap(
|
||||
lines, outer_width, outer_height, self.border_title
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# history
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, cast
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.text import Text
|
||||
from textual.containers import Horizontal
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.css.query import NoMatches
|
||||
from textual.widget import Widget
|
||||
|
||||
@@ -21,6 +21,7 @@ from .common import (
|
||||
time_ago,
|
||||
truncate,
|
||||
)
|
||||
from .frame import FrameBottom, FrameSide, FrameTop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import BeefyPlayerApp
|
||||
@@ -36,10 +37,18 @@ class StagePanel(Widget):
|
||||
self.border_title = "ON AIR"
|
||||
|
||||
def compose(self):
|
||||
with Horizontal(id="stage_top"):
|
||||
yield ArtPane()
|
||||
yield StageInfo()
|
||||
yield HistoryPane()
|
||||
# A container cannot paint its own content, so the frame is made of
|
||||
# thin widgets: top, bottom, and a column either side of the body.
|
||||
yield FrameTop("ON AIR", id="stage_frame_top")
|
||||
with Horizontal(id="stage_middle"):
|
||||
yield FrameSide("left", id="stage_frame_left")
|
||||
with Vertical(id="stage_body"):
|
||||
with Horizontal(id="stage_top"):
|
||||
yield ArtPane()
|
||||
yield StageInfo()
|
||||
yield HistoryPane()
|
||||
yield FrameSide("right", id="stage_frame_right")
|
||||
yield FrameBottom(id="stage_frame_bottom")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.refresh_art()
|
||||
@@ -150,7 +159,9 @@ class StageInfo(Widget):
|
||||
if song.art_url and album.plain.strip():
|
||||
album.append(" ▣", style="cyan")
|
||||
else:
|
||||
artist = Text("waiting for track data…", style="italic yellow")
|
||||
# No track metadata from this catalogue: keep the line, leave it
|
||||
# blank rather than filling it with an apology.
|
||||
artist = Text("")
|
||||
fallback = station.last_playing or "tuning in"
|
||||
title = Text(marquee(fallback, width), style="bold")
|
||||
album = Text(truncate(station.genre_label, width), style="dim")
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""The BBS frames: grey edges, corners fading white → grey → cyan."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from beefy_player.widgets import frame
|
||||
|
||||
|
||||
def styles_of(line: Text) -> list[tuple[str, str]]:
|
||||
return [
|
||||
(line.plain[span.start : span.end], str(span.style)) for span in line.spans
|
||||
]
|
||||
|
||||
|
||||
def test_only_the_top_right_corner_fades_on_the_top_edge() -> None:
|
||||
spans = styles_of(frame.top(30))
|
||||
assert spans[0] == ("╭", "bright_black") # plain grey corner
|
||||
# the fade runs into the top-right corner: cyan, bright cyan, light grey
|
||||
assert [style for _text, style in spans[-5:-1]] == [
|
||||
"bright_black",
|
||||
"cyan",
|
||||
"bright_cyan",
|
||||
"white",
|
||||
]
|
||||
assert spans[-1] == ("╮", "bold bright_white")
|
||||
|
||||
|
||||
def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None:
|
||||
spans = styles_of(frame.bottom(30))
|
||||
assert spans[0] == ("╰", "bold bright_white")
|
||||
assert [style for _text, style in spans[1:4]] == ["white", "bright_cyan", "cyan"]
|
||||
assert spans[-1] == ("╯", "bright_black") # plain grey corner
|
||||
|
||||
|
||||
def test_the_sides_glow_towards_their_own_corner_only() -> None:
|
||||
# left column fades up from the bottom-left corner
|
||||
assert [frame.side_style(row, 10, "left") for row in range(10)] == [
|
||||
"bright_black",
|
||||
"bright_black",
|
||||
"bright_black",
|
||||
"bright_black",
|
||||
"bright_black",
|
||||
"bright_black",
|
||||
"bright_black",
|
||||
"cyan",
|
||||
"bright_cyan",
|
||||
"white",
|
||||
]
|
||||
# right column fades down from the top-right corner
|
||||
assert [frame.side_style(row, 10, "right") for row in range(10)] == [
|
||||
"white",
|
||||
"bright_cyan",
|
||||
"cyan",
|
||||
"bright_black",
|
||||
"bright_black",
|
||||
"bright_black",
|
||||
"bright_black",
|
||||
"bright_black",
|
||||
"bright_black",
|
||||
"bright_black",
|
||||
]
|
||||
|
||||
|
||||
def test_the_title_sits_after_the_left_fade() -> None:
|
||||
line = frame.top(30, "STATIONS")
|
||||
assert line.plain.startswith("╭─ STATIONS ─")
|
||||
assert "STATIONS" in line.plain
|
||||
# a pane too narrow for the title drops it rather than truncating
|
||||
assert "STATIONS" not in frame.top(10, "STATIONS").plain
|
||||
|
||||
|
||||
def test_wrap_frames_the_content_on_every_row() -> None:
|
||||
content = [Text("hello"), Text("world")]
|
||||
group = frame.wrap(content, 20, 6, "PANE")
|
||||
lines = [line.plain for line in group.renderables]
|
||||
assert len(lines) == 6
|
||||
assert lines[0].startswith("╭") and lines[-1].startswith("╰")
|
||||
assert all(len(line) == 20 for line in lines)
|
||||
assert all(line.startswith("│") and line.endswith("│") for line in lines[1:-1])
|
||||
assert lines[1].startswith("│hello")
|
||||
@@ -86,6 +86,22 @@ def test_the_font_still_spells_soma_the_way_the_original_logo_did() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", sorted(PROVIDERS))
|
||||
def test_the_caption_block_starts_on_one_column(slug: str) -> None:
|
||||
"""Art rows are right-trimmed; a row ending in a blank cell must not pull
|
||||
its caption a column to the left."""
|
||||
provider = PROVIDERS[slug]
|
||||
captions = provider.hero_lines
|
||||
offset = max(0, (len(provider.hero) - len(captions)) // 2)
|
||||
lines = render_header(200, height=len(provider.hero), provider=provider)
|
||||
lines = lines[offset : offset + len(captions)]
|
||||
columns = {
|
||||
line.index(caption)
|
||||
for line, caption in zip(lines, captions, strict=True)
|
||||
}
|
||||
assert len(columns) == 1, (slug, columns)
|
||||
|
||||
|
||||
def test_every_catalogue_has_a_three_row_wordmark() -> None:
|
||||
for slug, provider in PROVIDERS.items():
|
||||
assert len(provider.hero) == 3, slug
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""The station pane: paging, the position readout and the scrollbar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from beefy_player.app import BeefyPlayerApp
|
||||
from beefy_player.widgets.station_list import StationList
|
||||
|
||||
|
||||
def app_for(provider: str, *, offline: bool = True) -> BeefyPlayerApp:
|
||||
return BeefyPlayerApp(provider=provider, autoplay=False, offline=offline, audio=False)
|
||||
|
||||
|
||||
def test_thumb_geometry() -> None:
|
||||
# nothing to scroll: the whole track is the thumb
|
||||
assert StationList.thumb(10, 10, 0) == set(range(10))
|
||||
# top of a long list
|
||||
top = StationList.thumb(10, 100, 0)
|
||||
assert 0 in top and len(top) >= 1
|
||||
# bottom of the same list ends at the last row
|
||||
bottom = StationList.thumb(10, 100, 90)
|
||||
assert 9 in bottom
|
||||
# mid-list sits between the two
|
||||
middle = StationList.thumb(10, 100, 45)
|
||||
assert min(middle) > min(top) and max(middle) < max(bottom)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paging_moves_a_screen_and_back() -> None:
|
||||
app = app_for("somafm")
|
||||
async with app.run_test(size=(100, 26)) as pilot:
|
||||
await pilot.pause()
|
||||
pane = app.query_one(StationList)
|
||||
page = pane.size.height - 1
|
||||
start = app.index
|
||||
|
||||
await pilot.press("pagedown")
|
||||
await pilot.pause()
|
||||
assert app.index == min(start + page, len(app.view) - 1)
|
||||
|
||||
await pilot.press("pageup")
|
||||
await pilot.pause()
|
||||
assert app.index == start
|
||||
|
||||
# the bottom of the list clamps instead of wrapping
|
||||
await pilot.press("shift+g", "pagedown")
|
||||
await pilot.pause()
|
||||
assert app.index == len(app.view) - 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_title_reads_out_the_position() -> None:
|
||||
app = app_for("somafm")
|
||||
async with app.run_test(size=(100, 26)) as pilot:
|
||||
await pilot.pause()
|
||||
pane = app.query_one(StationList)
|
||||
assert re.fullmatch(r"STATIONS · \d+/\d+", pane.border_title)
|
||||
await pilot.press("down")
|
||||
await pilot.pause()
|
||||
first, total = pane.border_title.split(" · ")[1].split("/")
|
||||
assert int(total) == len(app.view)
|
||||
assert int(first) == app.index + 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_scrollbar_appears_only_when_the_list_overflows() -> None:
|
||||
app = app_for("somafm")
|
||||
async with app.run_test(size=(100, 26)) as pilot:
|
||||
await pilot.pause()
|
||||
pane = app.query_one(StationList)
|
||||
rows = [line.plain for line in pane.render().renderables]
|
||||
assert rows[0].startswith("╭") and rows[-1].startswith("╰")
|
||||
content = rows[1:-1]
|
||||
# thumb only, in the frame grey: blank cells elsewhere, no rail
|
||||
assert all(row[-2] in "▐ " for row in content)
|
||||
assert any(row[-2] == "▐" for row in content)
|
||||
|
||||
app = app_for("rinse") # three stations, no scrolling
|
||||
async with app.run_test(size=(100, 26)) as pilot:
|
||||
await pilot.pause()
|
||||
pane = app.query_one(StationList)
|
||||
rows = [line.plain for line in pane.render().renderables]
|
||||
assert rows[0].startswith("╭") and rows[-1].startswith("╰")
|
||||
assert sum("Rinse" in row or "Kool" in row or "SWU" in row for row in rows) == 3
|
||||
# three stations fit: no thumb in the content rows
|
||||
assert not any(row[-2] == "▐" for row in rows[1:-1])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_filter_keeps_the_readout_in_step() -> None:
|
||||
app = app_for("somafm")
|
||||
async with app.run_test(size=(100, 26)) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("slash")
|
||||
await pilot.pause()
|
||||
app.query_one("SearchInput").value = "drone"
|
||||
await pilot.pause()
|
||||
pane = app.query_one(StationList)
|
||||
assert pane.border_title.startswith("STATIONS · /drone · ")
|
||||
filtered = len(app.view)
|
||||
assert pane.border_title.endswith(f"/{filtered}")
|
||||
+7
-2
@@ -52,8 +52,13 @@ async def test_backgrounds_use_terminal_default_and_borders_are_ansi() -> None:
|
||||
assert widget.styles.background.ansi == -1, widget_type
|
||||
assert widget.styles.color.ansi == -1, widget_type
|
||||
|
||||
border = app.query_one(StationList).styles.border.top
|
||||
assert border[1].ansi is not None # an ANSI palette index, not RGB
|
||||
# panes draw their own frames now: dim edges, gradient corners
|
||||
pane = app.query_one(StationList)
|
||||
assert pane.styles.border.top[0] == "" # no framework border any more
|
||||
framed = pane.render().renderables
|
||||
corners = [str(span.style) for span in framed[0].spans]
|
||||
assert framed[0].plain.startswith("╭")
|
||||
assert any(style.strip() for style in corners)
|
||||
|
||||
await pilot.press("slash")
|
||||
await pilot.pause()
|
||||
|
||||
Reference in new issue
Block a user