feat(ui): draw pane frames, page keys and a scroll thumb
This commit is contained in:
1 parent
49f7efe0d2
commit
0219df454c
14 files changed
+571
-64
No files matched your search
@@ -0,0 +1,81 @@
|
||||
"""The BBS frames: grey edges, corners fading white → grey → cyan."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from beefy_player.widgets import frame
|
||||
|
||||
|
||||
def styles_of(line: Text) -> list[tuple[str, str]]:
|
||||
return [
|
||||
(line.plain[span.start : span.end], str(span.style)) for span in line.spans
|
||||
]
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
assert spans[-1] == ("╮", "bold bright_white")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
# 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",
|
||||
]
|
||||
|
||||
|
||||
def test_the_title_sits_after_the_left_fade() -> None:
|
||||
line = frame.top(30, "STATIONS")
|
||||
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
|
||||
|
||||
|
||||
def test_wrap_frames_the_content_on_every_row() -> None:
|
||||
content = [Text("hello"), Text("world")]
|
||||
group = frame.wrap(content, 20, 6, "PANE")
|
||||
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")
|
||||
@@ -86,6 +86,22 @@ def test_the_font_still_spells_soma_the_way_the_original_logo_did() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", sorted(PROVIDERS))
|
||||
def test_the_caption_block_starts_on_one_column(slug: str) -> None:
|
||||
"""Art rows are right-trimmed; a row ending in a blank cell must not pull
|
||||
its caption a column to the left."""
|
||||
provider = PROVIDERS[slug]
|
||||
captions = provider.hero_lines
|
||||
offset = max(0, (len(provider.hero) - len(captions)) // 2)
|
||||
lines = render_header(200, height=len(provider.hero), provider=provider)
|
||||
lines = lines[offset : offset + len(captions)]
|
||||
columns = {
|
||||
line.index(caption)
|
||||
for line, caption in zip(lines, captions, strict=True)
|
||||
}
|
||||
assert len(columns) == 1, (slug, columns)
|
||||
|
||||
|
||||
def test_every_catalogue_has_a_three_row_wordmark() -> None:
|
||||
for slug, provider in PROVIDERS.items():
|
||||
assert len(provider.hero) == 3, slug
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""The station pane: paging, the position readout and the scrollbar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from beefy_player.app import BeefyPlayerApp
|
||||
from beefy_player.widgets.station_list import StationList
|
||||
|
||||
|
||||
def app_for(provider: str, *, offline: bool = True) -> BeefyPlayerApp:
|
||||
return BeefyPlayerApp(provider=provider, autoplay=False, offline=offline, audio=False)
|
||||
|
||||
|
||||
def test_thumb_geometry() -> None:
|
||||
# nothing to scroll: the whole track is the thumb
|
||||
assert StationList.thumb(10, 10, 0) == set(range(10))
|
||||
# top of a long list
|
||||
top = StationList.thumb(10, 100, 0)
|
||||
assert 0 in top and len(top) >= 1
|
||||
# bottom of the same list ends at the last row
|
||||
bottom = StationList.thumb(10, 100, 90)
|
||||
assert 9 in bottom
|
||||
# mid-list sits between the two
|
||||
middle = StationList.thumb(10, 100, 45)
|
||||
assert min(middle) > min(top) and max(middle) < max(bottom)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paging_moves_a_screen_and_back() -> None:
|
||||
app = app_for("somafm")
|
||||
async with app.run_test(size=(100, 26)) as pilot:
|
||||
await pilot.pause()
|
||||
pane = app.query_one(StationList)
|
||||
page = pane.size.height - 1
|
||||
start = app.index
|
||||
|
||||
await pilot.press("pagedown")
|
||||
await pilot.pause()
|
||||
assert app.index == min(start + page, len(app.view) - 1)
|
||||
|
||||
await pilot.press("pageup")
|
||||
await pilot.pause()
|
||||
assert app.index == start
|
||||
|
||||
# the bottom of the list clamps instead of wrapping
|
||||
await pilot.press("shift+g", "pagedown")
|
||||
await pilot.pause()
|
||||
assert app.index == len(app.view) - 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_title_reads_out_the_position() -> None:
|
||||
app = app_for("somafm")
|
||||
async with app.run_test(size=(100, 26)) as pilot:
|
||||
await pilot.pause()
|
||||
pane = app.query_one(StationList)
|
||||
assert re.fullmatch(r"STATIONS · \d+/\d+", pane.border_title)
|
||||
await pilot.press("down")
|
||||
await pilot.pause()
|
||||
first, total = pane.border_title.split(" · ")[1].split("/")
|
||||
assert int(total) == len(app.view)
|
||||
assert int(first) == app.index + 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_scrollbar_appears_only_when_the_list_overflows() -> None:
|
||||
app = app_for("somafm")
|
||||
async with app.run_test(size=(100, 26)) as pilot:
|
||||
await pilot.pause()
|
||||
pane = app.query_one(StationList)
|
||||
rows = [line.plain for line in pane.render().renderables]
|
||||
assert rows[0].startswith("╭") and rows[-1].startswith("╰")
|
||||
content = rows[1:-1]
|
||||
# thumb only, in the frame grey: blank cells elsewhere, no rail
|
||||
assert all(row[-2] in "▐ " for row in content)
|
||||
assert any(row[-2] == "▐" for row in content)
|
||||
|
||||
app = app_for("rinse") # three stations, no scrolling
|
||||
async with app.run_test(size=(100, 26)) as pilot:
|
||||
await pilot.pause()
|
||||
pane = app.query_one(StationList)
|
||||
rows = [line.plain for line in pane.render().renderables]
|
||||
assert rows[0].startswith("╭") and rows[-1].startswith("╰")
|
||||
assert sum("Rinse" in row or "Kool" in row or "SWU" in row for row in rows) == 3
|
||||
# three stations fit: no thumb in the content rows
|
||||
assert not any(row[-2] == "▐" for row in rows[1:-1])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_filter_keeps_the_readout_in_step() -> None:
|
||||
app = app_for("somafm")
|
||||
async with app.run_test(size=(100, 26)) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("slash")
|
||||
await pilot.pause()
|
||||
app.query_one("SearchInput").value = "drone"
|
||||
await pilot.pause()
|
||||
pane = app.query_one(StationList)
|
||||
assert pane.border_title.startswith("STATIONS · /drone · ")
|
||||
filtered = len(app.view)
|
||||
assert pane.border_title.endswith(f"/{filtered}")
|
||||
+7
-2
@@ -52,8 +52,13 @@ async def test_backgrounds_use_terminal_default_and_borders_are_ansi() -> None:
|
||||
assert widget.styles.background.ansi == -1, widget_type
|
||||
assert widget.styles.color.ansi == -1, widget_type
|
||||
|
||||
border = app.query_one(StationList).styles.border.top
|
||||
assert border[1].ansi is not None # an ANSI palette index, not RGB
|
||||
# panes draw their own frames now: dim edges, gradient corners
|
||||
pane = app.query_one(StationList)
|
||||
assert pane.styles.border.top[0] == "" # no framework border any more
|
||||
framed = pane.render().renderables
|
||||
corners = [str(span.style) for span in framed[0].spans]
|
||||
assert framed[0].plain.startswith("╭")
|
||||
assert any(style.strip() for style in corners)
|
||||
|
||||
await pilot.press("slash")
|
||||
await pilot.pause()
|
||||
|
||||
Reference in new issue
Block a user