fix(ui): keep the help and catalogue menus inside the screen
This commit is contained in:
1 parent
43ddd8666b
commit
7ba2d50503
4 files changed
+151
-70
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user