From 43ddd8666b03bb4657229f8a3cb5a25805bfb796 Mon Sep 17 00:00:00 2001 From: beefcarver Date: Wed, 4 Nov 2026 08:55:51 -0500 Subject: [PATCH] feat(palette): sixteen ANSI colour ladders, cycled with c --- README.md | 6 + beefy_player/app.py | 33 +++- beefy_player/config.py | 10 ++ beefy_player/palettes.py | 88 ++++++++++ beefy_player/widgets/art.py | 29 +++- beefy_player/widgets/common.py | 25 ++- beefy_player/widgets/frame.py | 104 +++++++----- beefy_player/widgets/header.py | 15 +- beefy_player/widgets/schedule.py | 6 +- beefy_player/widgets/spectrum.py | 21 ++- beefy_player/widgets/station_list.py | 6 +- tests/test_app.py | 51 ++++++ tests/test_frame.py | 95 ++++++----- tests/test_palettes.py | 244 +++++++++++++++++++++++++++ 14 files changed, 622 insertions(+), 111 deletions(-) create mode 100644 beefy_player/palettes.py create mode 100644 tests/test_palettes.py diff --git a/README.md b/README.md index eaee379..4d0ccf6 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,11 @@ implemented here from public endpoints and open protocols. lock screens, `playerctl` and the media keys pick it up. On macOS the audio engine (mpv) publishes to the system **Now Playing** session, so Control Center, the menu bar and the keyboard media keys drive the app the same way. +- **Sixteen palettes** — every colour comes from one ladder of ANSI names per + theme: the spectrum analyser, the album-art mosaic, the provider wordmarks + and the pane corners all draw from it. `c` cycles spectrum, rainbow, ocean, + ember, mint, sunset, violet, acid, neon, sand, blood, forest, ice, steel, + candy and mono; the choice is remembered. - **Theme-aware** — every accent is emitted as native ANSI (not RGB), and the background is the terminal default (`SGR 49`), so transparent terminals stay transparent and the TUI adopts your shell/terminal colour scheme. @@ -350,6 +355,7 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty. | `/` | filter by title, genre, description | | `esc` | clear filter / close search | | `v` | visualiser: mirror → bars → wave → waterfall → radial → braille | +| `c` | colour palette (16 of them, remembered) | | `r` | refresh stations, schedule + song history | | `p` | switch station catalogue (SomaFM / tilderadio)| | `g` / `G` | top / bottom | diff --git a/beefy_player/app.py b/beefy_player/app.py index 8d20458..104a137 100644 --- a/beefy_player/app.py +++ b/beefy_player/app.py @@ -26,10 +26,12 @@ from textual.widgets import Input from . import artwork, bandcamp, config from .mediasession import create_media_session, media_actions_queue from .models import Broadcast, Station, parse_last_playing +from .palettes import PALETTE_ORDER from .player import Player from .providers import get_provider from .providers.base import is_direct_stream, make_client from .spectrum import SpectrumEngine +from .widgets.art import ArtPane from .widgets.header import HeaderBar from .widgets.help import HelpScreen from .widgets.providers import ProviderScreen @@ -92,6 +94,7 @@ class BeefyPlayerApp(App[None]): Binding("slash", "search", "filter"), Binding("escape", "escape", "close", show=False), Binding("v", "visualizer", "visualiser"), + Binding("c", "palette", "palette"), Binding("r", "refresh", "refresh"), Binding("p", "provider", "catalogue"), Binding("question_mark", "help", "help"), @@ -147,6 +150,8 @@ class BeefyPlayerApp(App[None]): self.playing_state = "stopped" self.volume = self.config.volume self.visualizer = self.config.visualizer + #: Colour palette name (see :mod:`beefy_player.palettes`). + self.palette = self.config.palette self.tick_count = 0 self.notice = "" @@ -956,6 +961,20 @@ class BeefyPlayerApp(App[None]): self._set_notice(f"visualiser: {self.visualizer}", 2) self.query_one(SpectrumPanel).refresh() + def action_palette(self) -> None: + order = list(PALETTE_ORDER) + index = order.index(self.palette) if self.palette in order else 0 + self.palette = order[(index + 1) % len(order)] + self.config.palette = self.palette + self._save_config() + self._set_notice(f"palette: {self.palette}", 2) + # Frames, spectrum, mosaic and wordmarks all read the palette live, so + # a repaint of every widget is all it takes. (``self.query`` is the + # station filter here, hence ``self.screen.query``.) + for widget in self.screen.query("*"): + with contextlib.suppress(Exception): + widget.refresh() + def action_refresh(self) -> None: self._last_history_fetch = 0.0 self._bandcamp_key = "" @@ -972,12 +991,23 @@ class BeefyPlayerApp(App[None]): self._fetch_history(self.current.id) def action_provider(self) -> None: + self._suspend_artwork() self.push_screen(ProviderScreen(self.provider.id), self._provider_chosen) def _provider_chosen(self, provider_id: str | None) -> None: + self._resume_artwork() if provider_id: self._switch_catalogue(provider_id) + def _suspend_artwork(self) -> None: + """Hide the inline artwork: terminal images would sit over a menu.""" + with contextlib.suppress(NoMatches): + self.query_one(ArtPane).suspend() + + def _resume_artwork(self) -> None: + with contextlib.suppress(NoMatches): + self.query_one(ArtPane).resume() + def _cancel_workers(self) -> None: for group in ("stream", "history", "stations", "schedule", "bandcamp", "artwork"): with contextlib.suppress(Exception): @@ -1051,7 +1081,8 @@ class BeefyPlayerApp(App[None]): self._refresh_schedule() def action_help(self) -> None: - self.push_screen(HelpScreen()) + self._suspend_artwork() + self.push_screen(HelpScreen(), lambda _result: self._resume_artwork()) def action_quit(self) -> None: self._cleanup() diff --git a/beefy_player/config.py b/beefy_player/config.py index f0b852b..e8336f3 100644 --- a/beefy_player/config.py +++ b/beefy_player/config.py @@ -26,6 +26,7 @@ from pathlib import Path from typing import Any from . import __version__ +from .palettes import DEFAULT_PALETTE, PALETTES #: Directory name for config, cache and runtime files. APP_NAME = "beefy-player" @@ -72,6 +73,9 @@ LEGACY_CONFIG_PATH = _xdg("XDG_CONFIG_HOME", ".config") / LEGACY_APP_NAME / "con VISUALIZERS = ("both", "bars", "wave", "waterfall", "radial", "braille") +#: Colour palettes the UI can draw with (see :mod:`beefy_player.palettes`). +PALETTE_NAMES = tuple(PALETTES) + def channels_cache_path(provider_id: str) -> Path: """Where a catalogue caches its last good station list. @@ -141,6 +145,8 @@ class Config: last_stations: dict[str, str] = field(default_factory=dict) volume: int = 70 visualizer: str = "both" + #: Colour palette name — spectrum, ocean, ember, … + palette: str = DEFAULT_PALETTE autoplay: bool = False #: Which catalogue to use by default (see :mod:`beefy_player.providers`). provider: str = "somafm" @@ -181,6 +187,10 @@ class Config: if visualizer in VISUALIZERS: config.visualizer = visualizer + palette = raw.get("palette") + if isinstance(palette, str) and palette in PALETTE_NAMES: + config.palette = palette + provider = raw.get("provider") if isinstance(provider, str) and provider.strip(): config.provider = provider.strip().lower() diff --git a/beefy_player/palettes.py b/beefy_player/palettes.py new file mode 100644 index 0000000..1dcbfb9 --- /dev/null +++ b/beefy_player/palettes.py @@ -0,0 +1,88 @@ +"""The colour palettes everything in the UI draws from. + +Every colour in Beefy Player is a stock ANSI palette *name*, never RGB, so the +TUI adopts whatever colours the terminal is configured with. A palette here is +therefore a short ladder of those names — the "gradient" is a quantised walk +from the quiet end to the hot end, not an interpolation. + +The active palette drives the spectrum analyser, the album-art mosaic, the +provider wordmarks and the pane corners; `c` cycles through them and the +choice is remembered in the config file. +""" + +from __future__ import annotations + +#: Dark → hot ladders of ANSI names. Sixteen of them: seven hue gradients, a +#: few near-monochromes, and the original spectrum. +PALETTES: dict[str, tuple[str, ...]] = { + "spectrum": ( + "cyan", + "blue", + "magenta", + "bright_magenta", + "yellow", + "bright_yellow", + "red", + ), + "rainbow": ( + "red", + "bright_yellow", + "bright_green", + "bright_cyan", + "bright_blue", + "bright_magenta", + ), + "ocean": ( + "bright_black", + "blue", + "bright_blue", + "cyan", + "bright_cyan", + "bright_white", + ), + "ember": ( + "bright_black", + "red", + "bright_red", + "yellow", + "bright_yellow", + "bright_white", + ), + "mint": ( + "bright_black", + "green", + "bright_green", + "cyan", + "bright_cyan", + "bright_white", + ), + "sunset": ("magenta", "bright_magenta", "red", "bright_red", "yellow"), + "violet": ("blue", "bright_blue", "magenta", "bright_magenta", "bright_white"), + "acid": ("bright_black", "green", "bright_green", "yellow", "bright_yellow"), + "neon": ( + "bright_magenta", + "bright_red", + "bright_yellow", + "bright_green", + "bright_cyan", + ), + "sand": ("bright_black", "yellow", "bright_yellow", "white", "bright_white"), + "blood": ("bright_black", "red", "bright_red", "bright_magenta"), + "forest": ("bright_black", "green", "bright_green", "yellow", "bright_white"), + "ice": ("bright_white", "bright_cyan", "cyan", "bright_blue", "blue"), + "steel": ("bright_black", "white", "bright_white", "bright_cyan"), + "candy": ("magenta", "bright_magenta", "bright_cyan", "bright_yellow"), + "mono": ("bright_black", "white", "bright_white"), +} + +#: Cycle order for the palette key. +PALETTE_ORDER: tuple[str, ...] = tuple(PALETTES) + +#: What you get until you press `c`. +DEFAULT_PALETTE = "spectrum" + + +def palette_stops(name: str | None) -> tuple[str, ...]: + """The ladder for ``name``, falling back to the default palette.""" + key = (name or "").strip().lower() + return PALETTES.get(key, PALETTES[DEFAULT_PALETTE]) diff --git a/beefy_player/widgets/art.py b/beefy_player/widgets/art.py index 237344a..86e0657 100644 --- a/beefy_player/widgets/art.py +++ b/beefy_player/widgets/art.py @@ -22,7 +22,7 @@ from textual.strip import Strip from textual.widget import Widget from .. import artwork -from .common import art_lines +from .common import active_stops, art_lines _NULL_STYLE = Style() _NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),) @@ -308,6 +308,8 @@ class ArtPane(Widget): self._last_data: bytes | None = None self._last_size: tuple[int, int] = (0, 0) self._mosaic_key = "" + #: True while a modal is up: the inline image would sit over the menu. + self._suspended = False # ------------------------------------------------------------------ # # mosaic background @@ -320,7 +322,12 @@ class ArtPane(Widget): return Text("") width = max(1, self.size.width) height = max(1, self.size.height) - return Group(*art_lines(_art_key(app), width, height)) + if artwork.supported() and app.artwork_bytes is not None: + # A real image occupies this box (letterboxed, so not all of it). + # Leave the space blank: the patterned mosaic behind a scaled + # picture reads as noise around its edges. + return Group(*[Text(" " * width) for _ in range(height)]) + return Group(*art_lines(_art_key(app), width, height, active_stops(app))) # ------------------------------------------------------------------ # # image overlay @@ -337,9 +344,27 @@ class ArtPane(Widget): def on_unmount(self) -> None: self._drop_image_widget() + def suspend(self) -> None: + """Take the inline image down so a modal is not drawn underneath it. + + Kitty and iTerm2 paint images into cells the terminal owns; Textual + repainting the menu over them does not remove them, so the artwork has + to be deleted and re-emitted rather than covered. + """ + self._suspended = True + self._drop_image_widget() + self.refresh(layout=True) + + def resume(self) -> None: + """Put the artwork back after the modal closes.""" + self._suspended = False + self.sync_image(force=True) + def sync_image(self, *, force: bool = False) -> None: # Keep the mosaic behind the image in sync with the current track. self.refresh() + if self._suspended: + return app = cast("BeefyPlayerApp", self.app) station = app.current diff --git a/beefy_player/widgets/common.py b/beefy_player/widgets/common.py index 22623c0..cc4c62f 100644 --- a/beefy_player/widgets/common.py +++ b/beefy_player/widgets/common.py @@ -19,23 +19,20 @@ from zoneinfo import ZoneInfo from rich.cells import cell_len from rich.text import Text +from ..palettes import ( + DEFAULT_PALETTE, + palette_stops, +) + LOCALTIME_PATH = "/etc/localtime" _UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"} LEVELS = " ▁▂▃▄▅▆▇█" -# Bottom-to-top spectrum colours, picked from the terminal's stock ANSI palette. -SPECTRUM_STOPS = ( - "cyan", - "blue", - "magenta", - "bright_magenta", - "yellow", - "bright_yellow", - "red", -) +def active_stops(app: object) -> tuple[str, ...]: + """The colour ladder the app is currently drawing with.""" + return palette_stops(getattr(app, "palette", DEFAULT_PALETTE)) -ART_STOPS = ("blue", "magenta", "bright_magenta", "yellow", "cyan", "green") STATE_STYLE = { "playing": ("●", "green"), @@ -182,7 +179,9 @@ def state_glyph(state: str) -> tuple[str, str]: return STATE_STYLE.get(state, STATE_STYLE["stopped"]) -def art_lines(key: str, width: int, height: int) -> list[Text]: +def art_lines( + key: str, width: int, height: int, stops: tuple[str, ...] +) -> list[Text]: """Procedural album art as a solid ANSI-colour mosaic. Filled block glyphs are used (never cell backgrounds), so the cover is @@ -200,7 +199,7 @@ def art_lines(key: str, width: int, height: int) -> list[Text]: nx = column / max(1, width - 1) wave = 0.5 + 0.5 * math.sin(nx * 2.9 + ny * 2.1 + (seed % 11)) position = (nx * 0.55 + (1.0 - ny) * 0.30 + wave * 0.15) % 1.0 - colour = gradient(ART_STOPS, position) + colour = gradient(stops, position) # Mostly solid blocks with a little texture, like a tiny cover. glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓" line.append(glyph, style=colour) diff --git a/beefy_player/widgets/frame.py b/beefy_player/widgets/frame.py index 61e16d7..9cf53ac 100644 --- a/beefy_player/widgets/frame.py +++ b/beefy_player/widgets/frame.py @@ -1,9 +1,12 @@ -"""Pane frames: one grey box, with two corners fading out of it. +"""Pane frames: one grey box whose corners glow in the active palette. 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. +corners can be coloured. Two of them — the top right and the bottom left — +run a short fade out of the palette's hot end and back into the border grey; +everything else, including the other two corners, is that grey. + +The ladder is whatever palette the app is on (``c`` cycles them), which is why +every function here takes ``stops`` rather than reading a module constant. """ from __future__ import annotations @@ -13,26 +16,30 @@ from rich.console import Group, RenderableType from rich.text import Text from textual.widget import Widget +from ..palettes import DEFAULT_PALETTE, PALETTES +from .common import active_stops + #: 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") +#: How many cells a corner fade reaches into the edge. +FADE_CELLS = 4 -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 corner_fade(distance: int, stops: tuple[str, ...] = ()) -> str: + """Colour ``distance`` cells out from a corner; 0 is the corner glyph. - -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 + The fade is short — four cells — and walks backwards down the palette from + its hot end, so the corner glows in the palette's brightest colour and then + hands over to the plain border grey. + """ + if not stops: + stops = PALETTES[DEFAULT_PALETTE] + distance = max(0, distance) + if distance >= FADE_CELLS: + return EDGE + index = len(stops) - 1 - distance + return stops[index] if index >= 0 else EDGE def inner_size(width: int, height: int) -> tuple[int, int]: @@ -40,12 +47,12 @@ def inner_size(width: int, height: int) -> tuple[int, int]: return max(1, width - 2), max(1, height - 2) -def _edge_cells(width: int, faded_end: str = "right") -> list[tuple[str, str]]: +def _edge_cells(width: int, faded_end: str, stops: tuple[str, ...]) -> 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)] + return [("─", corner_fade(column + 1, stops)) for column in range(inner)] + return [("─", corner_fade(inner - column, stops)) for column in range(inner)] def _paint(line: Text, cells: list[tuple[str, str]]) -> None: @@ -62,9 +69,9 @@ def _paint(line: Text, cells: list[tuple[str, str]]) -> None: line.append("".join(run), style=style) -def top(width: int, title: str = "") -> Text: +def top(width: int, title: str = "", stops: tuple[str, ...] = ()) -> Text: width = max(2, width) - cells = _edge_cells(width, "right") # only the top-right corner fades + cells = _edge_cells(width, "right", stops) # only the top-right corner fades if title: label = f" {title} " start = 1 @@ -75,29 +82,38 @@ def top(width: int, title: str = "") -> Text: line = Text(no_wrap=True) line.append("╭", style=EDGE) _paint(line, cells) - line.append("╮", style=f"bold {fade(0)}") + line.append("╮", style=f"bold {corner_fade(0, stops)}") return line -def bottom(width: int) -> Text: +def bottom(width: int, stops: tuple[str, ...] = ()) -> 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=f"bold {corner_fade(0, stops)}") + _paint(line, _edge_cells(width, "left", stops)) # only the bottom-left fades line.append("╯", style=EDGE) return line -def side() -> Text: - return Text("│", style=EDGE) +def side_style(row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()) -> str: + """Colour of a side cell: only two corners fade, so only one end glows.""" + if side == "right": + return corner_fade(row + 1, stops) # fades down from the top-right corner + return corner_fade(rows - row, stops) # fades up from the bottom-left corner -def side_row(row: int, rows: int, side: str = "left") -> Text: - return Text("│", style=side_style(row, rows, side)) +def side_row( + row: int, rows: int, side: str = "left", stops: tuple[str, ...] = () +) -> Text: + return Text("│", style=side_style(row, rows, side, stops)) def wrap( - lines: list[Text], width: int, height: int, title: str = "" + lines: list[Text], + width: int, + height: int, + title: str = "", + stops: tuple[str, ...] = (), ) -> RenderableType: """Frame ``lines`` (already sized for the content box) in a pane.""" width = max(4, width) @@ -105,24 +121,29 @@ def wrap( inner_width, inner_height = inner_size(width, height) rows = len(lines[:inner_height]) - framed: list[Text] = [top(width, title)] + framed: list[Text] = [top(width, title, stops)] for index, line in enumerate(lines[:inner_height]): + if cell_len(line.plain) > inner_width: + # A pane must never spill over its neighbour: trim, whatever the + # caller asked for. + line = line.copy() + line.truncate(inner_width, overflow="ellipsis") row = Text(no_wrap=True) - row.append_text(side_row(index, rows, "left")) + row.append_text(side_row(index, rows, "left", stops)) 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")) + row.append_text(side_row(index, rows, "right", stops)) 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_text(side_row(index, inner_height, "left", stops)) row.append(" " * inner_width) - row.append_text(side_row(index, inner_height, "right")) + row.append_text(side_row(index, inner_height, "right", stops)) framed.append(row) - framed.append(bottom(width)) + framed.append(bottom(width, stops)) return Group(*framed[:height]) @@ -139,14 +160,14 @@ class FrameTop(Widget): self.title = title def render(self) -> RenderableType: - return top(max(2, self.size.width), self.title) + return top(max(2, self.size.width), self.title, active_stops(self.app)) class FrameBottom(Widget): can_focus = False def render(self) -> RenderableType: - return bottom(max(2, self.size.width)) + return bottom(max(2, self.size.width), active_stops(self.app)) class FrameSide(Widget): @@ -158,6 +179,7 @@ class FrameSide(Widget): def render(self) -> RenderableType: rows = max(1, self.size.height) + stops = active_stops(self.app) return Group( - *[side_row(row, rows, self.side) for row in range(rows)] + *[side_row(row, rows, self.side, stops) for row in range(rows)] ) diff --git a/beefy_player/widgets/header.py b/beefy_player/widgets/header.py index c0b0800..08342b7 100644 --- a/beefy_player/widgets/header.py +++ b/beefy_player/widgets/header.py @@ -10,7 +10,7 @@ from rich.text import Text from textual.widget import Widget from .common import ( - SPECTRUM_STOPS, + active_stops, clock_time, date_label, gradient, @@ -22,7 +22,13 @@ if TYPE_CHECKING: from ..app import BeefyPlayerApp -def _hero_line(markup: str, index: int, width: int, gradient_fill: bool) -> Text: +def _hero_line( + markup: str, + index: int, + width: int, + gradient_fill: bool, + stops: tuple[str, ...], +) -> Text: """One row of the hero: spectrum-painted glyphs, or the art's own colours.""" if not gradient_fill: line = Text.from_markup(markup) @@ -32,7 +38,7 @@ def _hero_line(markup: str, index: int, width: int, gradient_fill: bool) -> Text line = Text(no_wrap=True) for column, char in enumerate(markup): position = (column / max(1, width - 1) + index * 0.055) % 1.0 - line.append(char, style=f"bold {gradient(SPECTRUM_STOPS, position)}") + line.append(char, style=f"bold {gradient(stops, position)}") return line @@ -45,6 +51,7 @@ class HeaderBar(Widget): hero = tuple(getattr(provider, "hero", ()) or ()) hero_lines = tuple(getattr(provider, "hero_lines", ()) or ()) gradient_fill = bool(getattr(provider, "hero_gradient", False)) + stops = active_stops(app) width = max(24, self.size.width) height = max(1, self.size.height) @@ -73,7 +80,7 @@ class HeaderBar(Widget): lines: list[Text] = [] for index, row in enumerate(hero): - line = _hero_line(row, index, hero_width, gradient_fill) + line = _hero_line(row, index, hero_width, gradient_fill, stops) # 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) diff --git a/beefy_player/widgets/schedule.py b/beefy_player/widgets/schedule.py index 9972685..d1f9921 100644 --- a/beefy_player/widgets/schedule.py +++ b/beefy_player/widgets/schedule.py @@ -16,7 +16,7 @@ from rich.text import Text from textual.widget import Widget from . import frame -from .common import local_time, local_zone, state_glyph, truncate +from .common import active_stops, local_time, local_zone, state_glyph, truncate if TYPE_CHECKING: from ..app import BeefyPlayerApp @@ -68,7 +68,7 @@ def _upcoming_rows( row.append(" ") row.append(local_time(item.start), style="bold") row.append(" ") - row.append(truncate(item.display_name, max(4, width - 7))) + row.append(truncate(item.display_name, max(4, width - 9))) rows.append((True, row)) if len(rows) > budget: @@ -94,6 +94,7 @@ class SchedulePanel(Widget): app = cast("BeefyPlayerApp", self.app) width = max(12, self.size.width) height = max(3, self.size.height) + stops = active_stops(app) width, height = frame.inner_size(width, height) broadcasts = list(getattr(app, "schedule", ()) or ()) @@ -141,4 +142,5 @@ class SchedulePanel(Widget): max(12, self.size.width), max(3, self.size.height), self.border_title, + stops, ) diff --git a/beefy_player/widgets/spectrum.py b/beefy_player/widgets/spectrum.py index 8a0aa74..fc4ac52 100644 --- a/beefy_player/widgets/spectrum.py +++ b/beefy_player/widgets/spectrum.py @@ -11,9 +11,10 @@ from rich.console import RenderableType from rich.text import Text from textual.widget import Widget +from ..palettes import palette_stops from ..spectrum import Frame, SpectrumEngine from . import frame -from .common import LEVELS, SPECTRUM_STOPS, gradient +from .common import LEVELS, active_stops, gradient if TYPE_CHECKING: from ..app import BeefyPlayerApp @@ -48,7 +49,9 @@ class SpectrumPanel(Widget): super().__init__() self.engine = engine self.border_title = "SPECTRUM" - self._palettes: dict[tuple[int, int, bool], list[str]] = {} + #: The active colour ladder; ``render()`` refreshes it each frame. + self._stops: tuple[str, ...] = palette_stops(None) + self._palettes: dict[tuple, list[str]] = {} #: Recent band frames, newest last, for the waterfall display. Filled #: even while another mode is showing so switching to it has history. self._history: deque[tuple[float, ...]] = deque(maxlen=WATERFALL_HISTORY) @@ -65,6 +68,7 @@ class SpectrumPanel(Widget): def render(self) -> RenderableType: app = cast("BeefyPlayerApp", self.app) snapshot = self.engine.snapshot() + self._stops = active_stops(app) 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") @@ -96,7 +100,7 @@ class SpectrumPanel(Widget): mirrored.reverse() lines.extend(mirrored) return frame.wrap( - lines, outer_width, outer_height, self.border_title + lines, outer_width, outer_height, self.border_title, self._stops ) # ------------------------------------------------------------------ # @@ -127,14 +131,15 @@ class SpectrumPanel(Widget): return float(values[low]) * (1.0 - fraction) + float(values[high]) * fraction def _row_palette(self, height: int, phase: int, dim: bool) -> list[str]: - key = (height, phase % 12, dim) + stops = self._stops + key = (height, phase % 12, dim, stops) cached = self._palettes.get(key) if cached is not None: return cached palette: list[str] = [] for row in range(height): position = (height - 1 - row) / max(1, height - 1) - colour = gradient(SPECTRUM_STOPS, position) + colour = gradient(stops, position) style = f"bold {colour}" if (row + phase) % 7 == 0 else colour if dim: style = f"dim {style}" @@ -331,7 +336,7 @@ class SpectrumPanel(Widget): level = int(value * (len(WATERFALL_LEVELS) - 1)) if level <= 0: continue - style = gradient(SPECTRUM_STOPS, value) + style = gradient(self._stops, value) if faded: style = f"dim {style}" elif age == 1: @@ -392,11 +397,11 @@ class SpectrumPanel(Widget): if depth < 0.06: grid[row][column] = "█" styles[row][column] = ("dim " if dim else "") + gradient( - SPECTRUM_STOPS, edge + self._stops, edge ) elif depth < 0.15: grid[row][column] = "▓" - styles[row][column] = "dim " + gradient(SPECTRUM_STOPS, edge) + styles[row][column] = "dim " + gradient(self._stops, edge) elif depth < 0.25: grid[row][column] = "▒" styles[row][column] = "dim" diff --git a/beefy_player/widgets/station_list.py b/beefy_player/widgets/station_list.py index c7a456c..e9a8b43 100644 --- a/beefy_player/widgets/station_list.py +++ b/beefy_player/widgets/station_list.py @@ -10,7 +10,7 @@ from rich.text import Text from textual.widget import Widget from . import frame -from .common import compact_count, truncate +from .common import active_stops, compact_count, truncate if TYPE_CHECKING: from ..app import BeefyPlayerApp @@ -83,6 +83,7 @@ class StationList(Widget): app = cast("BeefyPlayerApp", self.app) width = max(12, self.size.width) height = max(3, self.size.height) + stops = active_stops(app) inner_width, inner_height = frame.inner_size(width, height) view = app.view @@ -93,6 +94,7 @@ class StationList(Widget): width, height, self.border_title, + stops, ) total = len(view) @@ -167,4 +169,4 @@ class StationList(Widget): ) lines.append(row) - return frame.wrap(lines, width, height, self.border_title) + return frame.wrap(lines, width, height, self.border_title, stops) diff --git a/tests/test_app.py b/tests/test_app.py index 2d6bfda..4690d6c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -9,6 +9,7 @@ import pytest from beefy_player import config from beefy_player.app import BeefyPlayerApp from beefy_player.models import Song +from beefy_player.widgets.art import ArtPane from beefy_player.widgets.help import HelpScreen from beefy_player.widgets.stage import StagePanel from beefy_player.widgets.station_list import StationList @@ -259,3 +260,53 @@ async def test_media_session_commands_drive_the_app() -> None: app.media_actions.put("volume:25") await pilot.pause(0.2) assert app.volume == 25 + + +@pytest.mark.asyncio +async def test_menus_take_the_inline_artwork_down_and_put_it_back(monkeypatch) -> None: + """Terminal images sit over a modal unless the image is deleted first.""" + calls: list[str] = [] + monkeypatch.setattr(ArtPane, "suspend", lambda self: calls.append("suspend")) + monkeypatch.setattr(ArtPane, "resume", lambda self: calls.append("resume")) + + app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + + await pilot.press("question_mark") + await pilot.pause() + assert calls == ["suspend"] + await pilot.press("x") + await pilot.pause() + assert calls == ["suspend", "resume"] + + await pilot.press("p") + await pilot.pause() + assert calls[-1] == "suspend" + await pilot.press("escape") + await pilot.pause() + assert calls[-1] == "resume" + + +@pytest.mark.asyncio +async def test_the_mosaic_gives_way_to_a_real_image(monkeypatch) -> None: + """The pattern must not peek out around a letterboxed picture.""" + from beefy_player.widgets import art as art_module + + monkeypatch.setattr(art_module.artwork, "supported", lambda: True) + + app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) + async with app.run_test(size=(110, 34)) as pilot: + await pilot.pause() + pane = app.query_one(ArtPane) + assert pane.display, "the artwork pane should be on screen at this size" + + app.artwork_bytes = None + patterned = [line.plain for line in pane.render().renderables] + assert patterned and any(line.strip() for line in patterned) + + app.artwork_bytes = b"pretend this is a png" + blank = [line.plain for line in pane.render().renderables] + assert len(blank) == len(patterned) + assert len(blank[0]) == len(patterned[0]) + assert all(not line.strip() for line in blank) diff --git a/tests/test_frame.py b/tests/test_frame.py index a1d89e1..00124a3 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -4,8 +4,13 @@ from __future__ import annotations from rich.text import Text +from beefy_player.palettes import PALETTES from beefy_player.widgets import frame +#: The original ladder, used as a fixed reference in these tests. +STOPS = PALETTES["spectrum"] +HOT = STOPS[-1] # the corner colour + def styles_of(line: Text) -> list[tuple[str, str]]: return [ @@ -14,68 +19,82 @@ def styles_of(line: Text) -> list[tuple[str, str]]: 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", + spans = styles_of(frame.top(30, stops=STOPS)) + assert spans[0] == ("╭", frame.EDGE) # plain grey corner + # the fade runs into the top-right corner, hot end last + assert [style for _text, style in spans[-4:-1]] == [ + STOPS[-4], + STOPS[-3], + STOPS[-2], ] - assert spans[-1] == ("╮", "bold bright_white") + assert spans[-1] == ("╮", f"bold {HOT}") 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 + spans = styles_of(frame.bottom(30, STOPS)) + assert spans[0] == ("╰", f"bold {HOT}") + assert [style for _text, style in spans[1:4]] == [ + STOPS[-2], + STOPS[-3], + STOPS[-4], + ] + assert spans[-1] == ("╯", frame.EDGE) # 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", + assert [frame.side_style(row, 10, "left", STOPS) for row in range(10)] == [ + frame.EDGE, + frame.EDGE, + frame.EDGE, + frame.EDGE, + frame.EDGE, + frame.EDGE, + frame.EDGE, + STOPS[-4], + STOPS[-3], + STOPS[-2], ] # 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", + assert [frame.side_style(row, 10, "right", STOPS) for row in range(10)] == [ + STOPS[-2], + STOPS[-3], + STOPS[-4], + frame.EDGE, + frame.EDGE, + frame.EDGE, + frame.EDGE, + frame.EDGE, + frame.EDGE, + frame.EDGE, ] def test_the_title_sits_after_the_left_fade() -> None: - line = frame.top(30, "STATIONS") + line = frame.top(30, "STATIONS", STOPS) 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 + assert "STATIONS" not in frame.top(10, "STATIONS", STOPS).plain def test_wrap_frames_the_content_on_every_row() -> None: content = [Text("hello"), Text("world")] - group = frame.wrap(content, 20, 6, "PANE") + group = frame.wrap(content, 20, 6, "PANE", STOPS) 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") + + +def test_content_wider_than_the_pane_is_trimmed_not_spilled() -> None: + """A pane must never draw over its neighbour.""" + long_line = Text("x" * 40) + group = frame.wrap([long_line], 20, 4, "PANE", STOPS) + lines = [line.plain for line in group.renderables] + assert all(len(line) == 20 for line in lines) + assert lines[1].endswith("│") + # the original renderable is left untouched + assert long_line.plain == "x" * 40 diff --git a/tests/test_palettes.py b/tests/test_palettes.py new file mode 100644 index 0000000..dad2b55 --- /dev/null +++ b/tests/test_palettes.py @@ -0,0 +1,244 @@ +"""The colour palettes: sixteen ladders of ANSI names, and the `c` key.""" + +from __future__ import annotations + +import html +import re + +import pytest + +from beefy_player import config +from beefy_player.app import BeefyPlayerApp +from beefy_player.palettes import ( + DEFAULT_PALETTE, + PALETTE_ORDER, + PALETTES, + palette_stops, +) +from beefy_player.widgets import frame +from beefy_player.widgets.help import HelpScreen +from beefy_player.widgets.station_list import StationList + +ANSI_NAMES = { + "black", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white", + "bright_black", + "bright_red", + "bright_green", + "bright_yellow", + "bright_blue", + "bright_magenta", + "bright_cyan", + "bright_white", +} + + +def test_there_are_sixteen_palettes_of_ansi_names() -> None: + assert len(PALETTES) == 16 + assert len(PALETTE_ORDER) == 16 + assert len(set(PALETTE_ORDER)) == 16 + assert DEFAULT_PALETTE in PALETTES + + for name, stops in PALETTES.items(): + assert len(stops) >= 3, name + assert set(stops) <= ANSI_NAMES, (name, set(stops) - ANSI_NAMES) + # a ladder, not a list: the quiet end must differ from the hot end + assert stops[0] != stops[-1], name + + +def test_a_broken_palette_name_falls_back_to_the_default() -> None: + assert palette_stops("mono") == PALETTES["mono"] + assert palette_stops(" MONO ") == PALETTES["mono"] + assert palette_stops("no-such-palette") == PALETTES[DEFAULT_PALETTE] + assert palette_stops(None) == PALETTES[DEFAULT_PALETTE] + + +def test_the_config_remembers_the_palette() -> None: + assert config.Config.load().palette == DEFAULT_PALETTE + config.Config(palette="ember").save() + assert config.Config.load().palette == "ember" + + +def test_an_unknown_palette_in_the_config_is_ignored() -> None: + config.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + config.CONFIG_PATH.write_text('{"palette": "chartreuse"}') + assert config.Config.load().palette == DEFAULT_PALETTE + + +@pytest.mark.asyncio +async def test_the_c_key_cycles_and_remembers_the_palette() -> None: + app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + start = app.palette + assert start == DEFAULT_PALETTE + + await pilot.press("c") + await pilot.pause() + expected = PALETTE_ORDER[(PALETTE_ORDER.index(start) + 1) % len(PALETTE_ORDER)] + assert app.palette == expected + assert app.config.palette == expected + assert "palette" in app.notice + + # the choice survives a restart + config.Config(palette=app.palette).save() + assert config.Config.load().palette == expected + + +@pytest.mark.asyncio +async def test_the_pane_corners_follow_the_palette() -> None: + app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + pane = app.query_one(StationList) + + app.palette = "mono" + mono = [str(span.style) for span in pane.render().renderables[0].spans] + app.palette = "ember" + ember = [str(span.style) for span in pane.render().renderables[0].spans] + + assert mono != ember + assert any("bright_white" in style for style in mono) + # ember's hot end is white too, but its quiet end is not + assert any(PALETTES["ember"][-2] in style for style in ember) + + +@pytest.mark.asyncio +async def test_the_spectrum_rows_use_the_active_palette() -> None: + app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + spectrum = app.query_one("SpectrumPanel") + + def colours(styles: list[str]) -> set[str]: + return { + style.replace("dim ", "").replace("bold ", "").strip() + for style in styles + } + + app.palette = "mono" + spectrum.refresh() + await pilot.pause() + assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["mono"]) + + app.palette = "neon" + spectrum.refresh() + await pilot.pause() + assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["neon"]) + + +def test_the_corner_fade_walks_back_down_the_ladder() -> None: + stops = PALETTES["spectrum"] + assert frame.corner_fade(0, stops) == stops[-1] + assert frame.corner_fade(1, stops) == stops[-2] + assert frame.corner_fade(3, stops) == stops[-4] + # the fade is short: past four cells it is just the border grey + assert frame.corner_fade(4, stops) == frame.EDGE + + +@pytest.mark.asyncio +async def test_the_help_scrolls_when_the_terminal_is_short() -> None: + """The help page is ~56 rows: it must be readable on a 24-row screen.""" + app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + await pilot.press("question_mark") + await pilot.pause() + body = app.screen.query_one("#help") + assert body.size.height < body.virtual_size.height + assert body.max_scroll_y > 0 + assert body.scroll_y == 0 + + await pilot.press("pagedown") + await pilot.pause() + assert body.scroll_y > 0 + + await pilot.press("end") + await pilot.pause() + assert body.scroll_y == body.max_scroll_y + + await pilot.press("home") + await pilot.pause() + assert body.scroll_y == 0 + + await pilot.press("x") + await pilot.pause() + assert not isinstance(app.screen, HelpScreen) + + +@pytest.mark.asyncio +async def test_a_tall_terminal_needs_no_scrolling() -> None: + app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) + async with app.run_test(size=(120, 70)) as pilot: + await pilot.pause() + await pilot.press("question_mark") + await pilot.pause() + body = app.screen.query_one("#help") + assert body.max_scroll_y == 0 + + +@pytest.mark.asyncio +async def test_the_help_lists_every_palette_and_marks_the_active_one() -> None: + app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) + async with app.run_test(size=(120, 64)) as pilot: + await pilot.pause() + app.palette = "ocean" + await pilot.press("question_mark") + await pilot.pause() + text = html.unescape(app.export_screenshot()).replace("\xa0", " ") + text = re.sub(r"<[^>]+>", "", text) + + assert "PALETTES" in text + assert "c cycles them" in text + for name in PALETTE_ORDER: + assert name in text, name + assert "● ocean" in text + # each palette shows a swatch of eight blocks + assert "████████" in text + + +@pytest.mark.asyncio +async def test_the_help_footer_attributes_the_build() -> None: + """One line: the version and the project URL — no credits essay.""" + from beefy_player import __version__ + + app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) + async with app.run_test(size=(110, 70)) as pilot: + await pilot.pause() + await pilot.press("question_mark") + await pilot.pause() + body = app.screen.query_one("#help") + body.scroll_end(animate=False) + await pilot.pause() + + text = html.unescape(app.export_screenshot()).replace("\xa0", " ") + text = re.sub(r"<[^>]+>", "", text) + + assert f"beefy player {__version__}" in text + assert "https://tildeforge.dev/beefcarver/beefy-player" in text + # the old explanatory blurb is gone + assert "playerctl" not in text + assert "parec" not in text + assert "Stations and song history come from" not in text + + +@pytest.mark.parametrize("slug", ["bassdrive", "somafm"]) +@pytest.mark.asyncio +async def test_the_help_has_no_pane_explainer(slug: str) -> None: + """No catalogue gets a paragraph about its left pane.""" + app = BeefyPlayerApp(provider=slug, autoplay=False, offline=True, audio=False) + async with app.run_test(size=(110, 60)) as pilot: + await pilot.pause() + await pilot.press("question_mark") + await pilot.pause() + text = html.unescape(app.export_screenshot()).replace("\xa0", " ") + text = re.sub(r"<[^>]+>", "", text) + assert "is a single stream" not in text + assert "multi-station catalogues" not in text + assert "CATALOGUES" in text