diff --git a/beefy_player/theme.tcss b/beefy_player/theme.tcss index cafdf81..4d40bbf 100644 --- a/beefy_player/theme.tcss +++ b/beefy_player/theme.tcss @@ -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; } diff --git a/beefy_player/widgets/help.py b/beefy_player/widgets/help.py index 94a7ae0..e5de41c 100644 --- a/beefy_player/widgets/help.py +++ b/beefy_player/widgets/help.py @@ -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) diff --git a/beefy_player/widgets/providers.py b/beefy_player/widgets/providers.py index 9ca29df..0a881bc 100644 --- a/beefy_player/widgets/providers.py +++ b/beefy_player/widgets/providers.py @@ -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 diff --git a/tests/test_schedule.py b/tests/test_schedule.py index e2b1811..b13a4f0 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -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)