diff --git a/beefy_player/app.py b/beefy_player/app.py index 5886f2a..80290d0 100644 --- a/beefy_player/app.py +++ b/beefy_player/app.py @@ -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 diff --git a/beefy_player/theme.tcss b/beefy_player/theme.tcss index 7fe3d21..cafdf81 100644 --- a/beefy_player/theme.tcss +++ b/beefy_player/theme.tcss @@ -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; } diff --git a/beefy_player/widgets/frame.py b/beefy_player/widgets/frame.py new file mode 100644 index 0000000..61e16d7 --- /dev/null +++ b/beefy_player/widgets/frame.py @@ -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)] + ) diff --git a/beefy_player/widgets/header.py b/beefy_player/widgets/header.py index 66c7b80..c0b0800 100644 --- a/beefy_player/widgets/header.py +++ b/beefy_player/widgets/header.py @@ -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) diff --git a/beefy_player/widgets/help.py b/beefy_player/widgets/help.py index fa2d642..94a7ae0 100644 --- a/beefy_player/widgets/help.py +++ b/beefy_player/widgets/help.py @@ -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"), diff --git a/beefy_player/widgets/providers.py b/beefy_player/widgets/providers.py index 385db73..9ca29df 100644 --- a/beefy_player/widgets/providers.py +++ b/beefy_player/widgets/providers.py @@ -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) diff --git a/beefy_player/widgets/schedule.py b/beefy_player/widgets/schedule.py index b1639ce..9972685 100644 --- a/beefy_player/widgets/schedule.py +++ b/beefy_player/widgets/schedule.py @@ -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, + ) diff --git a/beefy_player/widgets/spectrum.py b/beefy_player/widgets/spectrum.py index 5ea34b9..8a0aa74 100644 --- a/beefy_player/widgets/spectrum.py +++ b/beefy_player/widgets/spectrum.py @@ -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 diff --git a/beefy_player/widgets/stage.py b/beefy_player/widgets/stage.py index 1889ad0..0fa5f4a 100644 --- a/beefy_player/widgets/stage.py +++ b/beefy_player/widgets/stage.py @@ -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") diff --git a/beefy_player/widgets/station_list.py b/beefy_player/widgets/station_list.py index 214a820..c7a456c 100644 --- a/beefy_player/widgets/station_list.py +++ b/beefy_player/widgets/station_list.py @@ -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) diff --git a/tests/test_frame.py b/tests/test_frame.py new file mode 100644 index 0000000..a1d89e1 --- /dev/null +++ b/tests/test_frame.py @@ -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") diff --git a/tests/test_header.py b/tests/test_header.py index be6e1c1..5dfbef8 100644 --- a/tests/test_header.py +++ b/tests/test_header.py @@ -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 diff --git a/tests/test_station_list.py b/tests/test_station_list.py new file mode 100644 index 0000000..b79467b --- /dev/null +++ b/tests/test_station_list.py @@ -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}") diff --git a/tests/test_theme.py b/tests/test_theme.py index 29f8cc3..bd13a41 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -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()