feat(palette): sixteen ANSI colour ladders, cycled with c

This commit is contained in:
beefcarver committed 2026-11-04 08:55:51 -05:00
1 parent 253b462df1
commit 43ddd8666b
14 files changed
+622 -111

No files matched your search

+6
View File
@@ -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 |
+32 -1
View File
@@ -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()
+10
View File
@@ -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()
+88
View File
@@ -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])
+27 -2
View File
@@ -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
+12 -13
View File
@@ -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)
+63 -41
View File
@@ -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)]
)
+11 -4
View File
@@ -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)
+4 -2
View File
@@ -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,
)
+13 -8
View File
@@ -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"
+4 -2
View File
@@ -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)
+51
View File
@@ -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)
+57 -38
View File
@@ -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
+244
View File
@@ -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