feat(ui): draw pane frames, page keys and a scroll thumb

This commit is contained in:
beefcarver committed 2026-11-03 20:25:02 -05:00
1 parent 49f7efe0d2
commit 0219df454c
14 files changed
+567 -60

No files matched your search

+15
View File
@@ -76,6 +76,8 @@ class BeefyPlayerApp(App[None]):
Binding("down,j", "cursor_down", "down", show=False), Binding("down,j", "cursor_down", "down", show=False),
Binding("g", "cursor_top", "top", show=False), Binding("g", "cursor_top", "top", show=False),
Binding("shift+g", "cursor_bottom", "bottom", 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("enter", "tune", "tune in"),
Binding("space", "toggle_play", "play/pause"), Binding("space", "toggle_play", "play/pause"),
Binding("s", "stop", "stop", show=False), 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.index = min(len(self.view) - 1, self.index + 1)
self.query_one(StationList).refresh() 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: def action_cursor_top(self) -> None:
if not self.view: if not self.view:
return return
+24 -11
View File
@@ -76,9 +76,6 @@ StationList {
color: ansi_default; color: ansi_default;
width: 1fr; width: 1fr;
height: 1fr; height: 1fr;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 0 1;
background: ansi_default; background: ansi_default;
} }
@@ -87,9 +84,6 @@ SchedulePanel {
width: 1fr; width: 1fr;
height: 1fr; height: 1fr;
display: none; display: none;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 0 1;
background: ansi_default; background: ansi_default;
} }
@@ -97,8 +91,30 @@ StagePanel {
color: ansi_default; color: ansi_default;
layout: vertical; layout: vertical;
width: 1fr; width: 1fr;
border: round ansi_bright_black; background: ansi_default;
border-title-color: 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; padding: 0 1;
background: ansi_default; background: ansi_default;
} }
@@ -143,9 +159,6 @@ SpectrumPanel {
color: ansi_default; color: ansi_default;
height: 2fr; height: 2fr;
min-height: 6; min-height: 6;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 0 1;
background: ansi_default; background: ansi_default;
} }
+163
View File
@@ -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)]
)
+5
View File
@@ -74,6 +74,11 @@ class HeaderBar(Widget):
lines: list[Text] = [] lines: list[Text] = []
for index, row in enumerate(hero): for index, row in enumerate(hero):
line = _hero_line(row, index, hero_width, gradient_fill) 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): if show_middle and text_offset <= index < text_offset + len(hero_lines):
middle = hero_lines[index - text_offset] middle = hero_lines[index - text_offset]
line.append(" " * 4) line.append(" " * 4)
+1
View File
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
HELP_ROWS: tuple[tuple[str, str], ...] = ( HELP_ROWS: tuple[tuple[str, str], ...] = (
("↑ / ↓ j / k", "move through the station list"), ("↑ / ↓ j / k", "move through the station list"),
("pgup / pgdn", "page through the station list"),
("enter", "tune in to the selected station"), ("enter", "tune in to the selected station"),
("space", "play / pause the current station"), ("space", "play / pause the current station"),
("s", "stop playback"), ("s", "stop playback"),
+30 -11
View File
@@ -30,24 +30,43 @@ class ProviderScreen(ModalScreen[str | None]):
header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}") header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
header.append(" · pick a station catalogue", style="dim") 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] = [] rows: list[Text] = []
for index, slug in enumerate(self.slugs): for index, slug in enumerate(self.slugs):
provider = PROVIDERS[slug] provider = PROVIDERS[slug]
selected = index == self.index selected = index == self.index
row = Text(no_wrap=True) row = Text(no_wrap=True)
row.append("▶ " if selected else " ", style="bold magenta" if selected else "") row.append(
row.append(f"{index + 1} ", style="dim") "▶ " if selected else " ", style="bold magenta" if selected else ""
name_style = "bold underline" if selected else "bold" )
row.append(f"{provider.name:<12}", style=name_style) row.append(f"{index + 1:>2} ", style="dim")
row.append(f" {provider.brand}", 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: if slug == self.current_id:
row.append(" ●", style="bold green") row.append(" ●", style="bold green")
rows.append(row) rows.append(row)
if selected:
detail = Text(no_wrap=True) rows.append(
detail.append(" ", style="") Text(
detail.append(truncate(provider.tagline, 54), style="italic dim") " " + truncate(provider.tagline, 54),
rows.append(detail) style="italic dim",
no_wrap=True,
)
)
current = PROVIDERS.get(self.current_id) current = PROVIDERS.get(self.current_id)
footer = Text() footer = Text()
@@ -57,7 +76,7 @@ class ProviderScreen(ModalScreen[str | None]):
f"now: {current.name} — {current.station_source}\n", style="dim" f"now: {current.name} — {current.station_source}\n", style="dim"
) )
footer.append( footer.append(
"↑ ↓ choose · 1–9 jump · enter switch · esc cancel", "↑ ↓ choose · enter switch · esc cancel",
style="dim", style="dim",
) )
return Group(header, Text(""), *rows, footer) return Group(header, Text(""), *rows, footer)
+10 -3
View File
@@ -11,10 +11,11 @@ from __future__ import annotations
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType from rich.console import RenderableType
from rich.text import Text from rich.text import Text
from textual.widget import Widget from textual.widget import Widget
from . import frame
from .common import local_time, local_zone, state_glyph, truncate from .common import local_time, local_zone, state_glyph, truncate
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -92,7 +93,8 @@ class SchedulePanel(Widget):
def render(self) -> RenderableType: def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app) app = cast("BeefyPlayerApp", self.app)
width = max(12, self.size.width) 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 ()) broadcasts = list(getattr(app, "schedule", ()) or ())
loaded = bool(getattr(app, "schedule_loaded", False)) loaded = bool(getattr(app, "schedule_loaded", False))
@@ -134,4 +136,9 @@ class SchedulePanel(Widget):
lines.append(Text("")) lines.append(Text(""))
while len(lines) < height: while len(lines) < height:
lines.append(Text("")) 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,
)
+16 -13
View File
@@ -7,11 +7,12 @@ from collections import deque
from collections.abc import Sequence from collections.abc import Sequence
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType from rich.console import RenderableType
from rich.text import Text from rich.text import Text
from textual.widget import Widget from textual.widget import Widget
from ..spectrum import Frame, SpectrumEngine from ..spectrum import Frame, SpectrumEngine
from . import frame
from .common import LEVELS, SPECTRUM_STOPS, gradient from .common import LEVELS, SPECTRUM_STOPS, gradient
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -63,38 +64,40 @@ class SpectrumPanel(Widget):
def render(self) -> RenderableType: def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app) app = cast("BeefyPlayerApp", self.app)
frame = self.engine.snapshot() snapshot = self.engine.snapshot()
width = max(8, self.size.width) outer_width, outer_height = max(12, self.size.width), max(4, self.size.height)
height = max(2, self.size.height) width, height = frame.inner_size(outer_width, outer_height)
mode = getattr(app, "visualizer", "both") mode = getattr(app, "visualizer", "both")
phase = int(getattr(app, "tick_count", 0) // 2) phase = int(getattr(app, "tick_count", 0) // 2)
dim = frame.status == "idle" dim = snapshot.status == "idle"
self._record(frame, int(getattr(app, "tick_count", 0))) self._record(snapshot, int(getattr(app, "tick_count", 0)))
lines: list[Text] lines: list[Text]
if mode == "wave": if mode == "wave":
lines = self._wave(frame, width, height, phase, dim) lines = self._wave(snapshot, width, height, phase, dim)
elif mode == "braille": elif mode == "braille":
lines = self._braille(frame, width, height, phase, dim) lines = self._braille(snapshot, width, height, phase, dim)
elif mode == "waterfall": elif mode == "waterfall":
lines = self._waterfall(width, height, dim) lines = self._waterfall(width, height, dim)
elif mode == "radial": 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: elif mode == "bars" or height < 8:
lines = self._bars(frame, width, height, phase, dim) lines = self._bars(snapshot, width, height, phase, dim)
else: else:
# Default mode: a full-height mirrored spectrum. The top half # Default mode: a full-height mirrored spectrum. The top half
# grows up from the centre line, the bottom half grows down from # grows up from the centre line, the bottom half grows down from
# it (same values, flipped rows, dimmed like a reflection). # it (same values, flipped rows, dimmed like a reflection).
top_height = height // 2 top_height = height // 2
bottom_height = height - top_height 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( mirrored = self._bars(
frame, width, bottom_height, phase + 5, True, peaks=False snapshot, width, bottom_height, phase + 5, True, peaks=False
) )
mirrored.reverse() mirrored.reverse()
lines.extend(mirrored) lines.extend(mirrored)
return Group(*lines) return frame.wrap(
lines, outer_width, outer_height, self.border_title
)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# history # history
+13 -2
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType from rich.console import Group, RenderableType
from rich.text import Text from rich.text import Text
from textual.containers import Horizontal from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches from textual.css.query import NoMatches
from textual.widget import Widget from textual.widget import Widget
@@ -21,6 +21,7 @@ from .common import (
time_ago, time_ago,
truncate, truncate,
) )
from .frame import FrameBottom, FrameSide, FrameTop
if TYPE_CHECKING: if TYPE_CHECKING:
from ..app import BeefyPlayerApp from ..app import BeefyPlayerApp
@@ -36,10 +37,18 @@ class StagePanel(Widget):
self.border_title = "ON AIR" self.border_title = "ON AIR"
def compose(self): def compose(self):
# 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"): with Horizontal(id="stage_top"):
yield ArtPane() yield ArtPane()
yield StageInfo() yield StageInfo()
yield HistoryPane() yield HistoryPane()
yield FrameSide("right", id="stage_frame_right")
yield FrameBottom(id="stage_frame_bottom")
def on_mount(self) -> None: def on_mount(self) -> None:
self.refresh_art() self.refresh_art()
@@ -150,7 +159,9 @@ class StageInfo(Widget):
if song.art_url and album.plain.strip(): if song.art_url and album.plain.strip():
album.append(" ▣", style="cyan") album.append(" ▣", style="cyan")
else: 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" fallback = station.last_playing or "tuning in"
title = Text(marquee(fallback, width), style="bold") title = Text(marquee(fallback, width), style="bold")
album = Text(truncate(station.genre_label, width), style="dim") album = Text(truncate(station.genre_label, width), style="dim")
+82 -18
View File
@@ -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 __future__ import annotations
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from rich.cells import cell_len from rich.cells import cell_len
from rich.console import Group, RenderableType from rich.console import RenderableType
from rich.text import Text from rich.text import Text
from textual.widget import Widget from textual.widget import Widget
from . import frame
from .common import compact_count, truncate from .common import compact_count, truncate
if TYPE_CHECKING: if TYPE_CHECKING:
from ..app import BeefyPlayerApp from ..app import BeefyPlayerApp
#: Columns the scrollbar occupies (a gap plus the bar itself).
GUTTER = 2
class StationList(Widget): class StationList(Widget):
can_focus = False can_focus = False
@@ -22,34 +26,89 @@ class StationList(Widget):
super().__init__() super().__init__()
self.border_title = "STATIONS" self.border_title = "STATIONS"
# -- title: filter + position --------------------------------------- #
def set_filter(self, query: str) -> None: 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 "" 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: if self.border_title != title:
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: def on_mouse_scroll_down(self, event) -> None:
cast("BeefyPlayerApp", self.app).action_cursor_down() cast("BeefyPlayerApp", self.app).action_cursor_down()
def on_mouse_scroll_up(self, event) -> None: def on_mouse_scroll_up(self, event) -> None:
cast("BeefyPlayerApp", self.app).action_cursor_up() 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: def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app) app = cast("BeefyPlayerApp", self.app)
width = max(12, self.size.width) 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 view = app.view
if not view: if not view:
message = "no stations match" if app.query else "loading stations…" 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 first = 0
if len(view) > height: if total > inner_height:
first = max(0, min(app.index - height // 2, len(view) - 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] = [] lines: list[Text] = []
for offset in range(min(height, len(view))): for offset in range(min(inner_height, total)):
index = first + offset index = first + offset
station = view[index] station = view[index]
selected = index == app.index selected = index == app.index
@@ -64,32 +123,29 @@ class StationList(Widget):
count_text = "" count_text = ""
count_width = 0 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_text = f"{compact_count(station.listeners)} ♪"
count_width = cell_len(count_text) + 1 count_width = cell_len(count_text) + 1
prefix = marker + star + play + " " 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)) 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: if selected:
base = ""
marker_style = "magenta" marker_style = "magenta"
title_style = "bold underline" title_style = "bold underline"
count_style = "bold" count_style = "bold"
elif current: elif current:
base = ""
marker_style = "cyan" marker_style = "cyan"
title_style = "cyan" title_style = "cyan"
count_style = "dim" count_style = "dim"
else: else:
base = ""
marker_style = "" marker_style = ""
title_style = "" title_style = ""
count_style = "dim" count_style = "dim"
row = Text(no_wrap=True, style=base) row = Text(no_wrap=True)
row.append(marker, style=marker_style) row.append(marker, style=marker_style)
row.append(star, style="bold yellow" if station.favorite else "") row.append(star, style="bold yellow" if station.favorite else "")
row.append(play, style="cyan" if playing else "") row.append(play, style="cyan" if playing else "")
@@ -99,8 +155,16 @@ class StationList(Widget):
if count_text: if count_text:
row.append(count_text, style=count_style) row.append(count_text, style=count_style)
used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text) used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text)
if used < width: if used < content:
row.append(" " * (width - used)) 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) lines.append(row)
return Group(*lines) return frame.wrap(lines, width, height, self.border_title)
+81
View File
@@ -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")
+16
View File
@@ -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: def test_every_catalogue_has_a_three_row_wordmark() -> None:
for slug, provider in PROVIDERS.items(): for slug, provider in PROVIDERS.items():
assert len(provider.hero) == 3, slug assert len(provider.hero) == 3, slug
+104
View File
@@ -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
View File
@@ -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.background.ansi == -1, widget_type
assert widget.styles.color.ansi == -1, widget_type assert widget.styles.color.ansi == -1, widget_type
border = app.query_one(StationList).styles.border.top # panes draw their own frames now: dim edges, gradient corners
assert border[1].ansi is not None # an ANSI palette index, not RGB 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.press("slash")
await pilot.pause() await pilot.pause()