feat(ui): add catalogue picker, schedule pane and per-provider hero
This commit is contained in:
1 parent
058d336a2c
commit
b31ad3f6fb
16 files changed
+1211
-65
No files matched your search
@@ -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())
|
||||
Reference in new issue
Block a user