fix(ui): keep the help and catalogue menus inside the screen

This commit is contained in:
beefcarver committed 2026-11-04 08:56:04 -05:00
1 parent 43ddd8666b
commit 7ba2d50503
4 files changed
+151 -70

No files matched your search

+18
View File
@@ -178,19 +178,37 @@ ModalScreen {
#providers {
color: ansi_default;
width: 66;
max-width: 100%;
height: auto;
max-height: 100%;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 1 2;
overflow-y: auto;
scrollbar-size-vertical: 1;
scrollbar-color: ansi_bright_black;
scrollbar-background: ansi_default;
background: ansi_default;
}
#providers-body {
color: ansi_default;
height: auto;
background: ansi_default;
}
#help {
color: ansi_default;
width: 78;
max-width: 100%;
height: auto;
max-height: 100%;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 1 2;
overflow-y: auto;
scrollbar-size-vertical: 1;
scrollbar-color: ansi_bright_black;
scrollbar-background: ansi_default;
background: ansi_default;
}
+82 -53
View File
@@ -2,23 +2,28 @@
from __future__ import annotations
import sys
import textwrap
from typing import TYPE_CHECKING, cast
import contextlib
from typing import TYPE_CHECKING, ClassVar, cast
from rich.console import Group
from rich.table import Table
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Static
from .. import __version__
from ..palettes import PALETTE_ORDER, PALETTES
from ..providers import PROVIDERS, available_providers
from .common import SPECTRUM_STOPS, gradient
from .common import active_stops, gradient
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
#: Where the project lives (shown at the foot of the help screen).
URL = "https://tildeforge.dev/beefcarver/beefy-player"
HELP_ROWS: tuple[tuple[str, str], ...] = (
("↑ / ↓ j / k", "move through the station list"),
("pgup / pgdn", "page through the station list"),
@@ -35,6 +40,7 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
"v",
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
),
("c", "cycle the colour palette (listed below)"),
("r", "refresh stations, schedule and song history"),
("p", "switch station catalogue (SomaFM / tilderadio)"),
("g / G", "jump to the top / bottom of the list"),
@@ -42,42 +48,10 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
("q", "quit"),
)
ABOUT_LINUX = (
(
"The spectrum captures your actual PulseAudio/PipeWire output with "
"parec (SYNC); otherwise it falls back to a muted ffmpeg stream tap "
"(FFT, can drift) or a synthesized display (SIM)."
),
(
"The desktop picks us up over MPRIS as a "
"beefyplayer media player — try `playerctl -l` / `playerctl metadata`."
),
)
ABOUT_MACOS = (
(
"The spectrum captures your Mac's real audio output (SYNC) with a "
"native Core Audio process tap — nothing to install. macOS asks for "
"System Audio Recording permission the first time; without it the "
"analyser falls back to a muted ffmpeg stream tap (FFT, can drift) "
"or a synthesized display (SIM)."
),
(
"mpv publishes what is playing to the macOS Now Playing session, so "
"Control Center and the keyboard media keys control this app."
),
)
class HelpScreen(ModalScreen[None]):
def compose(self) -> ComposeResult:
app = cast("BeefyPlayerApp", self.app)
provider = app.provider
about = (
f"Stations and song history come from {provider.name} "
f"({provider.station_source} + {provider.history_source}).",
) + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
catalogues = Table.grid(padding=(0, 2), expand=False)
catalogues.add_column(style="bold cyan", no_wrap=True, justify="right")
catalogues.add_column(style="bold", no_wrap=True)
@@ -90,14 +64,39 @@ class HelpScreen(ModalScreen[None]):
marker, slug, f"{catalogue.name} — {pane}"
)
pane_note = Text()
if provider.has_schedule:
pane_note.append(
f"{provider.name} is a single stream, so the left pane is its "
"broadcast schedule; the station filter and favourites only "
"apply to multi-station catalogues.",
style="dim italic",
)
# -- palettes, two per row so sixteen still fit ------------------ #
names = list(PALETTE_ORDER)
name_width = max(len(name) for name in names)
def swatch(name: str) -> Text:
stops = PALETTES[name]
block = Text(no_wrap=True)
for step in range(8):
block.append("█", style=gradient(stops, step / 7))
return block
def entry(name: str, first: bool) -> Text:
line = Text(no_wrap=True)
if not first:
line.append(" ")
line.append("● " if name == app.palette else " ", style="bold cyan")
line.append(f"{name:<{name_width}}", style="bold")
line.append(" ")
line.append_text(swatch(name))
return line
palette_rows: list[Text] = []
for index in range(0, len(names), 2):
row = entry(names[index], True)
if index + 1 < len(names):
row.append_text(entry(names[index + 1], False))
palette_rows.append(row)
palette_title = Text()
palette_title.append("PALETTES", style="bold cyan")
palette_title.append(
" c cycles them · remembered between runs", style="dim"
)
catalogue_title = Text()
catalogue_title.append("CATALOGUES", style="bold cyan")
@@ -112,28 +111,58 @@ class HelpScreen(ModalScreen[None]):
table.add_row(key, description)
header = Text()
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
header.append("BEEFY PLAYER", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
header.append(" keyboard & credits", style="dim")
header.append("◤ ", style=f"bold {gradient(active_stops(self.app), 0.9)}")
header.append("BEEFY PLAYER", style=f"bold {gradient(active_stops(self.app), 0.55)}")
header.append(" keyboard shortcuts", style="dim")
header.append(" · ↑ ↓ / pgup pgdn scroll", style="dim")
footer = Text()
for paragraph in about:
for line in textwrap.wrap(paragraph, width=70):
footer.append(line + "\n", style="dim")
# One attribution line: version and where the project lives.
footer = Text(no_wrap=True)
footer.append(f"beefy player {__version__}", style="bold")
footer.append(" · ")
footer.append(
URL, style=f"dim link {URL}"
)
body = Group(
header,
Text(""),
table,
Text(""),
pane_note,
catalogue_title,
catalogues,
Text(""),
palette_title,
*palette_rows,
Text(""),
footer,
)
yield Static(body, id="help")
# The body outgrows a small terminal; the scroll container keeps it
# readable (and gives the box its border).
with VerticalScroll(id="help"):
yield Static(body, id="help-body")
#: Keys that scroll the page instead of closing it (help outgrows a 24-row
#: terminal, so it has to be readable there).
SCROLL_KEYS: ClassVar[dict[str, str]] = {
"up": "scroll_up",
"k": "scroll_up",
"down": "scroll_down",
"j": "scroll_down",
"pageup": "scroll_page_up",
"pagedown": "scroll_page_down",
"home": "scroll_home",
"g": "scroll_home",
"end": "scroll_end",
"shift+g": "scroll_end",
}
def on_key(self, event) -> None:
event.stop()
method = self.SCROLL_KEYS.get(event.key)
if method is not None:
body = self.query_one("#help")
with contextlib.suppress(Exception):
getattr(body, method)(animate=False)
return
self.dismiss(None)
+29 -17
View File
@@ -5,11 +5,12 @@ from __future__ import annotations
from rich.console import Group
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Static
from ..providers import PROVIDERS, available_providers
from .common import SPECTRUM_STOPS, gradient, truncate
from .common import active_stops, gradient, truncate
class ProviderScreen(ModalScreen[str | None]):
@@ -22,12 +23,19 @@ class ProviderScreen(ModalScreen[str | None]):
self.index = self.slugs.index(current_id) if current_id in self.slugs else 0
def compose(self) -> ComposeResult:
yield Static(self._body(), id="providers")
# A scroll container keeps thirteen catalogues navigable on a short
# terminal; the body is given its exact line count so it measures.
with VerticalScroll(id="providers"):
yield Static(self._body(), id="providers-body")
def on_mount(self) -> None:
body = self.query_one("#providers-body", Static)
body.styles.height = len(self.slugs) + 5 # heading, blank, rows, footer
def _body(self) -> Group:
header = Text()
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
header.append("◤ ", style=f"bold {gradient(active_stops(self.app), 0.9)}")
header.append("CATALOGUES", style=f"bold {gradient(active_stops(self.app), 0.55)}")
header.append(" · pick a station catalogue", style="dim")
name_width = (
@@ -55,26 +63,20 @@ class ProviderScreen(ModalScreen[str | None]):
f"{truncate(provider.name, name_width - 2):<{name_width}}",
style="bold underline" if selected else "bold",
)
row.append(truncate(provider.brand, 14), style="dim")
row.append(" ")
# marker(2) + number(5) + name + gap(2) + tagline + current dot(3)
room = max(8, 58 - 2 - 5 - name_width - 2 - 3)
row.append(truncate(provider.tagline, room), style="italic dim")
if slug == self.current_id:
row.append(" ●", style="bold green")
rows.append(row)
if selected:
rows.append(
Text(
" " + truncate(provider.tagline, 54),
style="italic dim",
no_wrap=True,
)
)
current = PROVIDERS.get(self.current_id)
footer = Text()
footer.append("\n")
if current is not None:
footer.append(
f"now: {current.name} — {current.station_source}\n", style="dim"
)
line = truncate(f"now: {current.name} — {current.station_source}", 58)
footer.append(line + "\n", style="dim")
footer.append(
"↑ ↓ choose · enter switch · esc cancel",
style="dim",
@@ -85,7 +87,17 @@ class ProviderScreen(ModalScreen[str | None]):
def _move(self, delta: int) -> None:
self.index = (self.index + delta) % len(self.slugs)
self.query_one("#providers", Static).update(self._body())
body = self.query_one("#providers-body", Static)
body.update(self._body())
box = self.query_one("#providers", VerticalScroll)
def follow() -> None:
visible = max(1, box.size.height)
target = max(0, 2 + self.index - (visible - 3) // 2)
box.scroll_to(y=target, animate=False)
box.call_after_refresh(follow)
def on_key(self, event) -> None:
key = event.key
+22
View File
@@ -164,3 +164,25 @@ def test_upcoming_rows_count_the_shows_they_had_to_hide() -> None:
listed = sum(1 for line in lines if "show " in line)
hidden = int(lines[-1].split("+")[1].split()[0])
assert listed + hidden == 30
@pytest.mark.asyncio
async def test_long_show_names_stay_inside_the_pane() -> None:
"""Regression: the schedule's row maths used to overshoot by two cells."""
app = BeefyPlayerApp(provider="tilderadio", offline=True, audio=False)
async with app.run_test(size=(80, 30)) as pilot:
await pilot.pause()
now = datetime.now(timezone.utc)
app.schedule = [
Broadcast(
name="A Very Long Show Name That Would Never Fit In The Pane",
start=now + timedelta(hours=index + 1),
end=now + timedelta(hours=index + 2),
)
for index in range(6)
]
app.schedule_loaded = True
panel = app.query_one(SchedulePanel)
rows = [line.plain for line in panel.render().renderables]
assert all(len(row) == panel.size.width for row in rows)
assert all(row.startswith(("│", "╭", "╰")) for row in rows)