feat(palette): sixteen ANSI colour ladders, cycled with c
This commit is contained in:
1 parent
253b462df1
commit
43ddd8666b
14 files changed
+622
-111
No files matched your search
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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])
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user