145 lines
5.5 KiB
Python
145 lines
5.5 KiB
Python
"""Header: the catalogue's hero, the clock and a live ticker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, cast
|
|
|
|
from rich.cells import cell_len
|
|
from rich.console import Group, RenderableType
|
|
from rich.text import Text
|
|
from textual.widget import Widget
|
|
|
|
from .common import (
|
|
active_stops,
|
|
clock_time,
|
|
date_label,
|
|
gradient,
|
|
marquee,
|
|
state_glyph,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from ..app import BeefyPlayerApp
|
|
|
|
|
|
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)
|
|
line.no_wrap = True
|
|
return line
|
|
|
|
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(stops, position)}")
|
|
return line
|
|
|
|
|
|
class HeaderBar(Widget):
|
|
can_focus = False
|
|
|
|
def render(self) -> RenderableType:
|
|
app = cast("BeefyPlayerApp", self.app)
|
|
provider = getattr(app, "provider", None)
|
|
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)
|
|
state = app.playing_state
|
|
glyph, colour = state_glyph(state)
|
|
|
|
# Measure what the hero *shows*: the rows are rich markup, so their
|
|
# raw length says nothing about the columns they occupy.
|
|
hero_width = max(
|
|
(cell_len(Text.from_markup(row).plain) for row in hero), default=0
|
|
)
|
|
middle_width = max((cell_len(row) for row in hero_lines), default=0)
|
|
right = (clock_time(), date_label(), f"vol {app.volume}%")
|
|
right_width = max(cell_len(item) for item in right)
|
|
|
|
show_middle = bool(hero_lines) and width >= hero_width + middle_width + 8
|
|
show_right = width >= hero_width + right_width + 8
|
|
if show_middle and show_right:
|
|
show_middle = (
|
|
width >= hero_width + 4 + middle_width + 2 + right_width
|
|
)
|
|
|
|
# Centre the short text block against however tall the hero is: a
|
|
# three-line caption beside a seven-row picture sits in the middle.
|
|
text_offset = max(0, (len(hero) - len(hero_lines)) // 2)
|
|
|
|
lines: list[Text] = []
|
|
for index, row in enumerate(hero):
|
|
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)
|
|
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)
|
|
line.append(middle, style="dim")
|
|
# Keep the right block on one column even though the middle
|
|
# lines differ in length ("TILDERADIO" is shorter than
|
|
# "STREAMED BY / FOR USERS OF THE").
|
|
line.append(" " * max(0, middle_width - cell_len(middle)))
|
|
if show_right and text_offset <= index < text_offset + len(right):
|
|
used = hero_width
|
|
if show_middle:
|
|
used += 4 + middle_width
|
|
# Right-align the block *and* every item inside it: the clock,
|
|
# the date and the volume each keep their own length, so pad
|
|
# the shortest ones instead of left-aligning the whole block.
|
|
pad = max(1, width - used - right_width)
|
|
item = right[index - text_offset]
|
|
line.append(" " * pad)
|
|
line.append(" " * max(0, right_width - cell_len(item)))
|
|
style = "bold" if index == text_offset else "dim"
|
|
line.append(item, style=style)
|
|
lines.append(line)
|
|
|
|
ticker: Text | None = None
|
|
if height >= 4:
|
|
ticker = Text(no_wrap=True)
|
|
prefix = f"{glyph} {state.upper():<8} "
|
|
ticker.append(prefix, style=f"bold {colour}")
|
|
station = app.current
|
|
if app.notice:
|
|
text = app.notice
|
|
text_style = "italic yellow"
|
|
elif station is not None:
|
|
song = station.now_playing
|
|
if song is not None:
|
|
text = (
|
|
f"{station.title} · "
|
|
f"{song.display_artist} — {song.display_title}"
|
|
)
|
|
else:
|
|
text = f"{station.title} · {station.description}"
|
|
text_style = ""
|
|
else:
|
|
text = "Pick a station — press enter to tune in"
|
|
text_style = "italic dim"
|
|
ticker.append(
|
|
marquee(text, max(4, width - cell_len(prefix))), style=text_style
|
|
)
|
|
|
|
# A short terminal loses the bottom of the hero before it loses the
|
|
# ticker: the ticker carries the state, the art can be cropped.
|
|
if ticker is not None:
|
|
if len(lines) >= height:
|
|
lines = lines[: height - 1]
|
|
lines.append(ticker)
|
|
return Group(*lines[:height])
|