feat(ui): draw pane frames, page keys and a scroll thumb

This commit is contained in:
beefcarver committed 2026-11-03 20:25:02 -05:00
1 parent 49f7efe0d2
commit 0219df454c
14 files changed
+571 -64

No files matched your search

+15
View File
@@ -76,6 +76,8 @@ class BeefyPlayerApp(App[None]):
Binding("down,j", "cursor_down", "down", show=False),
Binding("g", "cursor_top", "top", show=False),
Binding("shift+g", "cursor_bottom", "bottom", show=False),
Binding("pageup", "page_up", "page up", show=False),
Binding("pagedown", "page_down", "page down", show=False),
Binding("enter", "tune", "tune in"),
Binding("space", "toggle_play", "play/pause"),
Binding("s", "stop", "stop", show=False),
@@ -824,6 +826,19 @@ class BeefyPlayerApp(App[None]):
self.index = min(len(self.view) - 1, self.index + 1)
self.query_one(StationList).refresh()
def _page(self, delta: int) -> None:
if not self.view:
return
page = max(1, self.query_one(StationList).size.height - 1)
self.index = max(0, min(len(self.view) - 1, self.index + delta * page))
self.query_one(StationList).refresh()
def action_page_up(self) -> None:
self._page(-1)
def action_page_down(self) -> None:
self._page(1)
def action_cursor_top(self) -> None:
if not self.view:
return
+24 -11
View File
@@ -76,9 +76,6 @@ StationList {
color: ansi_default;
width: 1fr;
height: 1fr;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 0 1;
background: ansi_default;
}
@@ -87,9 +84,6 @@ SchedulePanel {
width: 1fr;
height: 1fr;
display: none;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 0 1;
background: ansi_default;
}
@@ -97,8 +91,30 @@ StagePanel {
color: ansi_default;
layout: vertical;
width: 1fr;
border: round ansi_bright_black;
border-title-color: ansi_default;
background: ansi_default;
}
#stage_frame_top,
#stage_frame_bottom {
height: 1;
background: ansi_default;
}
#stage_middle {
height: 1fr;
background: ansi_default;
}
#stage_frame_left,
#stage_frame_right {
width: 1;
height: 1fr;
background: ansi_default;
}
#stage_body {
width: 1fr;
height: 1fr;
padding: 0 1;
background: ansi_default;
}
@@ -143,9 +159,6 @@ SpectrumPanel {
color: ansi_default;
height: 2fr;
min-height: 6;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 0 1;
background: ansi_default;
}
+163
View File
@@ -0,0 +1,163 @@
"""Pane frames: one grey box, with two corners fading out of it.
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.
"""
from __future__ import annotations
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
#: 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")
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 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
def inner_size(width: int, height: int) -> tuple[int, int]:
"""The content box inside the frame."""
return max(1, width - 2), max(1, height - 2)
def _edge_cells(width: int, faded_end: str = "right") -> 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)]
def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
"""Append cells, grouping neighbours that share a style into one span."""
style: str | None = None
run: list[str] = []
for char, cell_style in cells:
if cell_style != style:
if run:
line.append("".join(run), style=style)
run, style = [], cell_style
run.append(char)
if run:
line.append("".join(run), style=style)
def top(width: int, title: str = "") -> Text:
width = max(2, width)
cells = _edge_cells(width, "right") # only the top-right corner fades
if title:
label = f" {title} "
start = 1
if start + cell_len(label) <= len(cells):
for offset, char in enumerate(label):
cells[start + offset] = (char, "bold")
line = Text(no_wrap=True)
line.append("╭", style=EDGE)
_paint(line, cells)
line.append("╮", style=f"bold {fade(0)}")
return line
def bottom(width: int) -> 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=EDGE)
return line
def side() -> Text:
return Text("│", style=EDGE)
def side_row(row: int, rows: int, side: str = "left") -> Text:
return Text("│", style=side_style(row, rows, side))
def wrap(
lines: list[Text], width: int, height: int, title: str = ""
) -> RenderableType:
"""Frame ``lines`` (already sized for the content box) in a pane."""
width = max(4, width)
height = max(2, height)
inner_width, inner_height = inner_size(width, height)
rows = len(lines[:inner_height])
framed: list[Text] = [top(width, title)]
for index, line in enumerate(lines[:inner_height]):
row = Text(no_wrap=True)
row.append_text(side_row(index, rows, "left"))
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"))
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(" " * inner_width)
row.append_text(side_row(index, inner_height, "right"))
framed.append(row)
framed.append(bottom(width))
return Group(*framed[:height])
# --------------------------------------------------------------------------- #
# frame pieces, for containers that cannot draw their own content
# --------------------------------------------------------------------------- #
class FrameTop(Widget):
can_focus = False
def __init__(self, title: str = "", *, id: str | None = None) -> None:
super().__init__(id=id)
self.title = title
def render(self) -> RenderableType:
return top(max(2, self.size.width), self.title)
class FrameBottom(Widget):
can_focus = False
def render(self) -> RenderableType:
return bottom(max(2, self.size.width))
class FrameSide(Widget):
can_focus = False
def __init__(self, side: str = "left", *, id: str | None = None) -> None:
super().__init__(id=id)
self.side = side
def render(self) -> RenderableType:
rows = max(1, self.size.height)
return Group(
*[side_row(row, rows, self.side) for row in range(rows)]
)
+5
View File
@@ -74,6 +74,11 @@ class HeaderBar(Widget):
lines: list[Text] = []
for index, row in enumerate(hero):
line = _hero_line(row, index, hero_width, gradient_fill)
# 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)
if short > 0:
line.append(" " * short)
if show_middle and text_offset <= index < text_offset + len(hero_lines):
middle = hero_lines[index - text_offset]
line.append(" " * 4)
+1
View File
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
HELP_ROWS: tuple[tuple[str, str], ...] = (
("↑ / ↓ j / k", "move through the station list"),
("pgup / pgdn", "page through the station list"),
("enter", "tune in to the selected station"),
("space", "play / pause the current station"),
("s", "stop playback"),
+30 -11
View File
@@ -30,24 +30,43 @@ class ProviderScreen(ModalScreen[str | None]):
header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
header.append(" · pick a station catalogue", style="dim")
name_width = (
max(
(
len(PROVIDERS[slug].name)
for slug in self.slugs
if slug in PROVIDERS
),
default=12,
)
+ 2
)
rows: list[Text] = []
for index, slug in enumerate(self.slugs):
provider = PROVIDERS[slug]
selected = index == self.index
row = Text(no_wrap=True)
row.append("▶ " if selected else " ", style="bold magenta" if selected else "")
row.append(f"{index + 1} ", style="dim")
name_style = "bold underline" if selected else "bold"
row.append(f"{provider.name:<12}", style=name_style)
row.append(f" {provider.brand}", style="dim")
row.append(
"▶ " if selected else " ", style="bold magenta" if selected else ""
)
row.append(f"{index + 1:>2} ", style="dim")
row.append(
f"{truncate(provider.name, name_width - 2):<{name_width}}",
style="bold underline" if selected else "bold",
)
row.append(truncate(provider.brand, 14), style="dim")
if slug == self.current_id:
row.append(" ●", style="bold green")
rows.append(row)
detail = Text(no_wrap=True)
detail.append(" ", style="")
detail.append(truncate(provider.tagline, 54), style="italic dim")
rows.append(detail)
if selected:
rows.append(
Text(
" " + truncate(provider.tagline, 54),
style="italic dim",
no_wrap=True,
)
)
current = PROVIDERS.get(self.current_id)
footer = Text()
@@ -57,7 +76,7 @@ class ProviderScreen(ModalScreen[str | None]):
f"now: {current.name} — {current.station_source}\n", style="dim"
)
footer.append(
"↑ ↓ choose · 1–9 jump · enter switch · esc cancel",
"↑ ↓ choose · enter switch · esc cancel",
style="dim",
)
return Group(header, Text(""), *rows, footer)
+10 -3
View File
@@ -11,10 +11,11 @@ from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType
from rich.console import RenderableType
from rich.text import Text
from textual.widget import Widget
from . import frame
from .common import local_time, local_zone, state_glyph, truncate
if TYPE_CHECKING:
@@ -92,7 +93,8 @@ class SchedulePanel(Widget):
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
width = max(12, self.size.width)
height = max(1, self.size.height)
height = max(3, self.size.height)
width, height = frame.inner_size(width, height)
broadcasts = list(getattr(app, "schedule", ()) or ())
loaded = bool(getattr(app, "schedule_loaded", False))
@@ -134,4 +136,9 @@ class SchedulePanel(Widget):
lines.append(Text(""))
while len(lines) < height:
lines.append(Text(""))
return Group(*lines[:height])
return frame.wrap(
lines[:height],
max(12, self.size.width),
max(3, self.size.height),
self.border_title,
)
+16 -13
View File
@@ -7,11 +7,12 @@ from collections import deque
from collections.abc import Sequence
from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType
from rich.console import RenderableType
from rich.text import Text
from textual.widget import Widget
from ..spectrum import Frame, SpectrumEngine
from . import frame
from .common import LEVELS, SPECTRUM_STOPS, gradient
if TYPE_CHECKING:
@@ -63,38 +64,40 @@ class SpectrumPanel(Widget):
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
frame = self.engine.snapshot()
width = max(8, self.size.width)
height = max(2, self.size.height)
snapshot = self.engine.snapshot()
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")
phase = int(getattr(app, "tick_count", 0) // 2)
dim = frame.status == "idle"
self._record(frame, int(getattr(app, "tick_count", 0)))
dim = snapshot.status == "idle"
self._record(snapshot, int(getattr(app, "tick_count", 0)))
lines: list[Text]
if mode == "wave":
lines = self._wave(frame, width, height, phase, dim)
lines = self._wave(snapshot, width, height, phase, dim)
elif mode == "braille":
lines = self._braille(frame, width, height, phase, dim)
lines = self._braille(snapshot, width, height, phase, dim)
elif mode == "waterfall":
lines = self._waterfall(width, height, dim)
elif mode == "radial":
lines = self._radial(frame, width, height, phase, dim)
lines = self._radial(snapshot, width, height, phase, dim)
elif mode == "bars" or height < 8:
lines = self._bars(frame, width, height, phase, dim)
lines = self._bars(snapshot, width, height, phase, dim)
else:
# Default mode: a full-height mirrored spectrum. The top half
# grows up from the centre line, the bottom half grows down from
# it (same values, flipped rows, dimmed like a reflection).
top_height = height // 2
bottom_height = height - top_height
lines = self._bars(frame, width, top_height, phase, dim)
lines = self._bars(snapshot, width, top_height, phase, dim)
mirrored = self._bars(
frame, width, bottom_height, phase + 5, True, peaks=False
snapshot, width, bottom_height, phase + 5, True, peaks=False
)
mirrored.reverse()
lines.extend(mirrored)
return Group(*lines)
return frame.wrap(
lines, outer_width, outer_height, self.border_title
)
# ------------------------------------------------------------------ #
# history
+17 -6
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType
from rich.text import Text
from textual.containers import Horizontal
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.widget import Widget
@@ -21,6 +21,7 @@ from .common import (
time_ago,
truncate,
)
from .frame import FrameBottom, FrameSide, FrameTop
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
@@ -36,10 +37,18 @@ class StagePanel(Widget):
self.border_title = "ON AIR"
def compose(self):
with Horizontal(id="stage_top"):
yield ArtPane()
yield StageInfo()
yield HistoryPane()
# A container cannot paint its own content, so the frame is made of
# thin widgets: top, bottom, and a column either side of the body.
yield FrameTop("ON AIR", id="stage_frame_top")
with Horizontal(id="stage_middle"):
yield FrameSide("left", id="stage_frame_left")
with Vertical(id="stage_body"):
with Horizontal(id="stage_top"):
yield ArtPane()
yield StageInfo()
yield HistoryPane()
yield FrameSide("right", id="stage_frame_right")
yield FrameBottom(id="stage_frame_bottom")
def on_mount(self) -> None:
self.refresh_art()
@@ -150,7 +159,9 @@ class StageInfo(Widget):
if song.art_url and album.plain.strip():
album.append(" ▣", style="cyan")
else:
artist = Text("waiting for track data…", style="italic yellow")
# No track metadata from this catalogue: keep the line, leave it
# blank rather than filling it with an apology.
artist = Text("")
fallback = station.last_playing or "tuning in"
title = Text(marquee(fallback, width), style="bold")
album = Text(truncate(station.genre_label, width), style="dim")
+82 -18
View File
@@ -1,19 +1,23 @@
"""Station list with favourites, live markers and a filter."""
"""Station list with favourites, live markers, a filter and a scrollbar."""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.console import RenderableType
from rich.text import Text
from textual.widget import Widget
from . import frame
from .common import compact_count, truncate
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
#: Columns the scrollbar occupies (a gap plus the bar itself).
GUTTER = 2
class StationList(Widget):
can_focus = False
@@ -22,34 +26,89 @@ class StationList(Widget):
super().__init__()
self.border_title = "STATIONS"
# -- title: filter + position --------------------------------------- #
def set_filter(self, query: str) -> None:
self._sync_title(query)
def _sync_title(self, query: str | None = None) -> None:
"""``STATIONS · /jazz · 3/12`` — the filter and where you are in it."""
if query is None:
query = ""
total = 0
index = 0
if self.is_mounted:
app = cast("BeefyPlayerApp", self.app)
query = app.query
total = len(app.view)
index = app.index
safe_query = query.replace("[", "(").replace("]", ")") if query else ""
title = f"STATIONS · /{safe_query}" if safe_query else "STATIONS"
parts = ["STATIONS"]
if safe_query:
parts.append(f"/{safe_query}")
if total > 1:
parts.append(f"{min(index + 1, total)}/{total}")
title = " · ".join(parts)
if self.border_title != title:
self.border_title = title
def refresh(self, *args, **kwargs): # type: ignore[override]
# Every cursor move ends in a refresh, so this is the one place that
# has to keep the title's position readout honest.
if self.is_mounted:
self._sync_title()
return super().refresh(*args, **kwargs)
def on_mouse_scroll_down(self, event) -> None:
cast("BeefyPlayerApp", self.app).action_cursor_down()
def on_mouse_scroll_up(self, event) -> None:
cast("BeefyPlayerApp", self.app).action_cursor_up()
# -- scrollbar geometry --------------------------------------------- #
@staticmethod
def thumb(height: int, total: int, first: int) -> set[int]:
"""Rows of the pane the scrollbar thumb covers."""
if total <= height:
return set(range(height))
size = max(1, round(height * height / total))
span = max(0, total - height)
top = 0 if span == 0 else round(first * (height - size) / span)
return set(range(top, min(height, top + size)))
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
width = max(12, self.size.width)
height = max(1, self.size.height)
height = max(3, self.size.height)
inner_width, inner_height = frame.inner_size(width, height)
view = app.view
if not view:
message = "no stations match" if app.query else "loading stations…"
return Group(Text(truncate(message, width), style="dim italic"))
return frame.wrap(
[Text(truncate(message, inner_width), style="dim italic")],
width,
height,
self.border_title,
)
total = len(view)
first = 0
if len(view) > height:
first = max(0, min(app.index - height // 2, len(view) - height))
if total > inner_height:
first = max(0, min(app.index - inner_height // 2, total - inner_height))
scrollable = total > inner_height
gutter = GUTTER if scrollable else 0
content = max(4, inner_width - gutter)
thumb = (
self.thumb(inner_height, total, first) if scrollable else set()
)
lines: list[Text] = []
for offset in range(min(height, len(view))):
for offset in range(min(inner_height, total)):
index = first + offset
station = view[index]
selected = index == app.index
@@ -64,32 +123,29 @@ class StationList(Widget):
count_text = ""
count_width = 0
if width >= 26 and station.listeners > 0:
if content >= 26 and station.listeners > 0:
count_text = f"{compact_count(station.listeners)} ♪"
count_width = cell_len(count_text) + 1
prefix = marker + star + play + " "
available = width - cell_len(prefix) - count_width
available = content - cell_len(prefix) - count_width
title = truncate(station.title, max(4, available))
gap = max(0, width - cell_len(prefix) - cell_len(title) - count_width)
gap = max(0, content - cell_len(prefix) - cell_len(title) - count_width)
if selected:
base = ""
marker_style = "magenta"
title_style = "bold underline"
count_style = "bold"
elif current:
base = ""
marker_style = "cyan"
title_style = "cyan"
count_style = "dim"
else:
base = ""
marker_style = ""
title_style = ""
count_style = "dim"
row = Text(no_wrap=True, style=base)
row = Text(no_wrap=True)
row.append(marker, style=marker_style)
row.append(star, style="bold yellow" if station.favorite else "")
row.append(play, style="cyan" if playing else "")
@@ -99,8 +155,16 @@ class StationList(Widget):
if count_text:
row.append(count_text, style=count_style)
used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text)
if used < width:
row.append(" " * (width - used))
if used < content:
row.append(" " * (content - used))
if scrollable:
# Just the thumb, in the frame's own grey: no rail.
row.append(" ")
row.append(
"▐" if offset in thumb else " ",
style=frame.EDGE,
)
lines.append(row)
return Group(*lines)
return frame.wrap(lines, width, height, self.border_title)
+81
View File
@@ -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")
+16
View File
@@ -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
+104
View File
@@ -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
View File
@@ -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()