feat(ui): add catalogue picker, schedule pane and per-provider hero

This commit is contained in:
beefcarver committed 2026-11-03 17:51:27 -05:00
1 parent 058d336a2c
commit b31ad3f6fb
16 files changed
+1211 -65

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Turn a logo into half-block ANSI art for the header hero.
The header hero is a handful of text rows, so an image has to survive a brutal
downsample: two pixels per cell (upper/lower half block), one ANSI colour per
half. Colours are quantised to the stock 16-colour ANSI palette on purpose —
the rest of the UI never emits RGB, and a hero built from palette names still
reads as the station's art in any terminal theme.
Usage:
python tools/ansi_art.py logo.png --cols 22 --rows 7
python tools/ansi_art.py --url https://tilderadio.org/logos/tilderadio.png \\
--cols 22 --rows 7 --module beefy_player/providers/tilderadio/hero.py
``--preview out.png`` draws what the cells will look like, which is much easier
to judge than a wall of ▀ and ▄.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from PIL import Image, ImageDraw
#: The stock xterm colours the names below resolve to in a terminal.
ANSI16: dict[str, tuple[int, int, int]] = {
"black": (0, 0, 0),
"red": (170, 0, 0),
"green": (0, 170, 0),
"yellow": (170, 85, 0),
"blue": (0, 0, 170),
"magenta": (170, 0, 170),
"cyan": (0, 170, 170),
"white": (170, 170, 170),
"bright_black": (85, 85, 85),
"bright_red": (255, 85, 85),
"bright_green": (85, 255, 85),
"bright_yellow": (255, 255, 85),
"bright_blue": (85, 85, 255),
"bright_magenta": (255, 85, 255),
"bright_cyan": (85, 255, 255),
"bright_white": (255, 255, 255),
}
#: Below this alpha a half-cell counts as empty (terminal background).
ALPHA_CUTOFF = 110
def nearest_colour(rgb: tuple[int, int, int]) -> str:
best, best_distance = "white", float("inf")
for name, reference in ANSI16.items():
distance = sum((a - b) ** 2 for a, b in zip(rgb, reference, strict=True))
if distance < best_distance:
best, best_distance = name, distance
return best
def load_image(path: Path | None, url: str | None) -> Image.Image:
if path is None and url:
import httpx
response = httpx.get(url, follow_redirects=True, timeout=30.0)
response.raise_for_status()
from io import BytesIO
image = Image.open(BytesIO(response.content))
elif path is not None:
image = Image.open(path)
else: # pragma: no cover - argparse enforces one of the two
raise SystemExit("pass an image path or --url")
image = image.convert("RGBA")
# Trim the transparent margin: the art should fill the box it is given.
opaque = image.getchannel("A").point(lambda value: 255 if value > 24 else 0)
box = opaque.getbbox()
return image.crop(box) if box else image
def art_rows(image: Image.Image, cols: int, rows: int) -> list[list[tuple[str, str, str]]]:
"""(glyph, foreground, background) per cell, two source pixels per cell."""
small = image.resize((cols, rows * 2), Image.LANCZOS)
pixels = small.load()
grid: list[list[tuple[str, str, str]]] = []
for row in range(rows):
cells: list[tuple[str, str, str]] = []
for column in range(cols):
top = pixels[column, row * 2]
bottom = pixels[column, row * 2 + 1]
top_ink = top[3] >= ALPHA_CUTOFF
bottom_ink = bottom[3] >= ALPHA_CUTOFF
if not top_ink and not bottom_ink:
cells.append((" ", "", ""))
elif top_ink and bottom_ink:
cells.append(("▀", nearest_colour(top[:3]), nearest_colour(bottom[:3])))
elif top_ink:
cells.append(("▀", nearest_colour(top[:3]), ""))
else:
cells.append(("▄", nearest_colour(bottom[:3]), ""))
grid.append(cells)
return grid
def markup(grid: list[list[tuple[str, str, str]]]) -> list[str]:
"""One rich-markup string per row, run-length encoded by style."""
lines: list[str] = []
for cells in grid:
line: list[str] = []
index = 0
while index < len(cells):
glyph, foreground, background = cells[index]
if not background and glyph == " ":
line.append(" ")
index += 1
continue
run = [glyph]
index += 1
while index < len(cells) and cells[index] == (glyph, foreground, background):
run.append(cells[index][0])
index += 1
style = " ".join(
part for part in (foreground, f"on {background}" if background else "") if part
)
body = "".join(run)
line.append(f"[{style}]{body}[/]" if style else body)
lines.append("".join(line).rstrip())
return lines
def preview(grid: list[list[tuple[str, str, str]]], path: Path, cell: tuple[int, int]) -> None:
width, height = cell
image = Image.new("RGB", (len(grid[0]) * width, len(grid) * height), (24, 24, 24))
draw = ImageDraw.Draw(image)
for row, cells in enumerate(grid):
for column, (glyph, foreground, background) in enumerate(cells):
x, y = column * width, row * height
if background:
draw.rectangle([x, y, x + width - 1, y + height - 1], fill=ANSI16[background])
if glyph == " ":
continue
colour = ANSI16.get(foreground, (140, 140, 140))
if glyph == "▀":
box = [x, y, x + width - 1, y + height // 2 - 1]
else:
box = [x, y + height // 2, x + width - 1, y + height - 1]
draw.rectangle(box, fill=colour)
image.resize((image.width * 3, image.height * 3), Image.NEAREST).save(path)
MODULE_TEMPLATE = '''"""Generated header art — do not edit by hand.
{provenance}
Regenerate with::
{command}
Each row is rich markup; ``▀`` is a half block whose foreground is the top
pixel and whose background is the pixel below it. Colours are stock ANSI
palette names, so the art keeps the terminal's own palette.
"""
from __future__ import annotations
HERO: tuple[str, ...] = (
{rows}
)
'''
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("image", nargs="?", type=Path, help="local image file")
parser.add_argument("--url", help="fetch the image from this URL instead")
parser.add_argument("--cols", type=int, default=22, help="terminal columns (default 22)")
parser.add_argument("--rows", type=int, default=7, help="terminal rows (default 7)")
parser.add_argument("--module", type=Path, help="write a Python module with HERO")
parser.add_argument("--preview", type=Path, help="write a PNG preview of the cells")
parser.add_argument("--provenance", default="", help="source note for the module header")
args = parser.parse_args(argv)
image = load_image(args.image, args.url)
grid = art_rows(image, args.cols, args.rows)
rows = markup(grid)
if args.preview:
preview(grid, args.preview, (10, 20))
if args.module:
source = args.provenance or args.url or str(args.image)
command = "uv run python tools/ansi_art.py " + " ".join(sys.argv[1:])
body = "\n".join(f" {row!r}," for row in rows)
args.module.write_text(
MODULE_TEMPLATE.format(
provenance=f"Source: {source}",
command=command,
rows=body,
),
encoding="utf-8",
)
print(f"wrote {args.module} ({len(rows)} rows x {args.cols} cols)")
else:
print("\n".join(rows))
return 0
if __name__ == "__main__":
raise SystemExit(main())