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
@@ -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