feat(bassdrive): add the drum & bass relay and a shared wordmark font

This commit is contained in:
beefcarver committed 2026-11-03 18:44:40 -05:00
1 parent 84ec3b08f6
commit 50a9a735f7
14 files changed
+1166 -79

No files matched your search

+2
View File
@@ -46,6 +46,8 @@ class Broadcast:
name: str
dj: str = ""
#: Where the broadcast comes from ("Poznan, Poland"), when the feed says.
location: str = ""
start: datetime | None = None
end: datetime | None = None
#: True for the broadcast that is on air right now.
+3 -1
View File
@@ -17,6 +17,7 @@ Adding a catalogue is meant to be a small, contained change:
from __future__ import annotations
from .base import RadioProvider
from .bassdrive import BassdriveProvider
from .somafm import SomaFMProvider
from .tilderadio import TilderadioProvider
@@ -30,7 +31,8 @@ __all__ = [
#: Every catalogue the app can talk to, keyed by its slug.
PROVIDERS: dict[str, RadioProvider] = {
provider.id: provider for provider in (SomaFMProvider(), TilderadioProvider())
provider.id: provider
for provider in (SomaFMProvider(), TilderadioProvider(), BassdriveProvider())
}
#: Provider used when nothing else is configured.
@@ -0,0 +1,76 @@
"""Bassdrive: worldwide drum & bass, on air since the dial-up days.
One stream with three mounts, a schedule full of live DJ shows and a
``display-title`` that names the show rather than the track. Everything
Bassdrive-specific — the Icecast status schema, the homepage schedule grid, the
rolling show history and the bundled fallback — lives in this package; the app
only sees :class:`BassdriveProvider`.
"""
from __future__ import annotations
import httpx
from ... import config
from ...models import Broadcast, Song, Station
from ..base import RadioProvider
from . import api, fallback
from . import hero as hero_art
__all__ = ["BassdriveProvider"]
class BassdriveProvider(RadioProvider):
id = "bassdrive"
name = "Bassdrive"
homepage = "bassdrive.com"
brand = "BASSDRIVE"
tagline = "worldwide drum & bass · live shows"
station_source = "bassdrive.com + icecast status"
history_source = "icecast status (rolling)"
# The wordmark in the station's own two blues, in the shared half-block
# font; the caption is the station's own description.
hero = hero_art.HERO
hero_lines = (
"BASSDRIVE",
"WORLDWIDE DRUM & BASS",
"LIVE SHOWS 24/7",
)
# One stream and a schedule full of DJ slots: the left pane is the schedule.
has_schedule = True
# DJ-mix station: the rotation is not a Bandcamp catalogue.
bandcamp = False
@property
def user_agent(self) -> str:
return f"{config.USER_AGENT}; +https://bassdrive.com"
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
return [await api.fetch_station(client)]
def load_cached_stations(self) -> list[Station]:
return api.load_cached_station()
def fallback_stations(self) -> list[Station]:
return fallback.fallback_stations()
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
return await api.fetch_songs(client, station_id)
def load_cached_history(self, station_id: str) -> list[Song]:
return api.load_cached_songs(station_id)
async def fetch_schedule(
self, client: httpx.AsyncClient
) -> list[Broadcast]:
return await api.fetch_schedule(client)
def load_cached_schedule(self) -> list[Broadcast]:
return api.load_cached_schedule()
def fallback_urls(self, station: Station) -> list[str]:
return fallback.known_stream_urls()
+517
View File
@@ -0,0 +1,517 @@
"""Bassdrive's public endpoints, and nothing but.
* ``https://ice.bassdrive.net/status-json.xsl`` -> Icecast status: every mount
(mp3/aac+, bitrates), the listener counts and the current ``display-title``,
which for a DJ-mix stream is the *show*: ``"Ben XO :: LIVE :: Night Shift
Over"``.
* ``http://www.bassdrive.com/`` -> the homepage carries the whole week's
schedule, every slot stamped with an absolute ``<local-time datetime=...>``.
A DJ-mix station has no track history: Icecast reports one title and forgets
it. So "now playing" is the show (mapped onto ``Station.dj``/``presenter`` and
a :class:`Song`), and the history is a rolling record of the shows we have
heard since the app started — the unit a listener actually cares about here.
"""
from __future__ import annotations
import asyncio
import html as html_module
import re
import time
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import httpx
from ... import config
from ...models import Broadcast, Song, Station, parse_date, to_int
from ..base import fetch_json
PROVIDER_ID = "bassdrive"
STATUS_URL = "https://ice.bassdrive.net/status-json.xsl"
SCHEDULE_URL = "https://www.bassdrive.com/"
DEFAULT_STATION_ID = "bassdrive"
STATION_IMAGE = "https://www.bassdrive.com/images/logo.png"
DESCRIPTION = "worldwide drum & bass radio — live DJ shows around the clock"
GENRE = "drum & bass|jungle"
WEEK = timedelta(days=7)
#: Upcoming slots kept in the pane (a week is plenty for a schedule).
SCHEDULE_LIMIT = 40
#: Shows remembered in LAST PLAYED.
HISTORY_LIMIT = 20
# --------------------------------------------------------------------------- #
# status-json -> Station / Song
# --------------------------------------------------------------------------- #
def _sources(payload: Any) -> list[dict[str, Any]]:
stats = payload.get("icestats") if isinstance(payload, dict) else None
source = stats.get("source") if isinstance(stats, dict) else None
if isinstance(source, dict):
return [source]
if isinstance(source, list):
return [entry for entry in source if isinstance(entry, dict)]
return []
def _quality(bitrate: int) -> str:
if bitrate >= 128:
return "highest"
if bitrate >= 48:
return "high"
return "low" if bitrate else ""
def playlists_from_status(payload: Any) -> list[dict[str, str]]:
playlists: list[dict[str, str]] = []
seen: set[str] = set()
for source in _sources(payload):
url = source.get("listenurl")
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
continue
if url in seen:
continue
seen.add(url)
bitrate = to_int(source.get("bitrate"))
kind = str(source.get("server_type") or "").lower()
fmt = "mp3" if "mpeg" in kind else "aacp" if "aac" in kind else ""
playlists.append(
{
"url": url,
"format": fmt,
"quality": _quality(bitrate),
"bitrate": str(bitrate) if bitrate else "",
}
)
return playlists
def status_listeners(payload: Any) -> int:
"""Mounts split the audience, so the station's figure is their sum."""
return sum(to_int(source.get("listeners")) for source in _sources(payload))
def display_title(payload: Any) -> str:
for source in _sources(payload):
for key in ("display-title", "title"):
value = source.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def split_title(raw: str) -> tuple[str, str, bool]:
"""``"Ben XO :: LIVE :: Night Shift Over [promo]"`` -> DJ, show, live.
Bassdrive's title convention is ``DJ :: LIVE :: Show``, with promotional
brackets tacked on the end — but it is not enforced: plenty of shows go out
as ``"Sohl Patrol LIVE"``, so a trailing ``LIVE`` counts too. Anything
unexpected degrades to "the whole string is the show".
"""
cleaned = re.sub(r"\[[^\]]*\]", " ", raw)
parts = [part.strip() for part in cleaned.split("::") if part.strip()]
live = any(part.casefold() == "live" for part in parts)
parts = [part for part in parts if part.casefold() != "live"]
if parts:
trailing = re.search(r"(?i)\s+live$", parts[-1])
if trailing:
live = True
trimmed = parts[-1][: trailing.start()].strip()
parts = parts[:-1] + ([trimmed] if trimmed else [])
if not parts:
return "", raw.strip(), live
if len(parts) == 1:
return "", parts[0], live
return parts[0], parts[-1], live
def song_from_title(raw: str) -> Song | None:
dj, show, _live = split_title(raw)
if not (dj or show):
return None
# With no DJ in the title the station stands in as the "artist": a panel
# that says "Unknown artist" for every show would look broken.
return Song(artist=dj or "Bassdrive", title=show)
def _same_show(entry: Broadcast, show: str) -> bool:
return bool(show) and entry.name.strip().casefold() == show.strip().casefold()
def _slot_for(schedule: Iterable[Broadcast], show: str) -> Broadcast | None:
if not show:
return None
return next((entry for entry in schedule if _same_show(entry, show)), None)
def resolve_now(
payload: Any,
schedule: Iterable[Broadcast] = (),
now: datetime | None = None,
) -> tuple[str, str, bool, Broadcast | None]:
"""Who is on, according to the stream *and* the schedule.
The display-title often forgets the ``LIVE`` marker; a show whose slot
covers now counts as live even when the title is just its name.
"""
now = now or datetime.now(timezone.utc)
schedule = list(schedule)
dj, show, live = split_title(display_title(payload))
slot = _slot_for(schedule, show)
if (
slot is not None
and slot.start is not None
and slot.end is not None
and slot.start <= now < slot.end
):
live = True
return dj, show, live, slot
def station_from_status(
payload: Any, schedule: Iterable[Broadcast] = ()
) -> Station | None:
playlists = playlists_from_status(payload)
raw = display_title(payload)
if not playlists and not raw:
return None
dj, show, live, slot = resolve_now(payload, schedule)
location = slot.location if slot is not None else ""
if live:
presenter = f"live from {location}" if location else "live now"
else:
presenter = "rotation · drum & bass"
station = Station(
id=DEFAULT_STATION_ID,
title="Bassdrive",
description=DESCRIPTION,
genre=GENRE,
dj=dj,
presenter=presenter,
image=STATION_IMAGE,
listeners=status_listeners(payload),
playlists=playlists,
provider=PROVIDER_ID,
)
if show:
# The "artist" of a show-based stream is the station itself.
station.now_playing = Song(artist=dj or "Bassdrive", title=show)
station.last_playing = raw
return station
def live_broadcast(
payload: Any,
schedule: Iterable[Broadcast],
now: datetime | None = None,
) -> Broadcast | None:
dj, show, live, slot = resolve_now(payload, schedule, now)
if not live or not show:
return None
if slot is not None:
return Broadcast(
name=show,
dj=dj,
location=slot.location,
start=slot.start,
end=slot.end,
live=True,
)
return Broadcast(name=show, dj=dj, live=True)
# --------------------------------------------------------------------------- #
# the homepage schedule
# --------------------------------------------------------------------------- #
_SPAN_LOCATION = re.compile(r'<span class="location">(.*?)</span>', re.S)
def _text(value: str) -> str:
return re.sub(r"\s+", " ", html_module.unescape(re.sub(r"<[^>]+>", " ", value))).strip()
def parse_schedule(page: str) -> list[Broadcast]:
"""Pull the weekly grid out of the homepage, one ``<li>`` per slot."""
broadcasts: list[Broadcast] = []
for chunk in re.split(r"<li\b", page):
if 'class="title"' not in chunk or 'class="time"' not in chunk:
continue
stamps = re.findall(r'datetime="([^"]+)"', chunk)
if len(stamps) < 2:
continue
start = parse_date(stamps[0])
end = parse_date(stamps[1])
if start is None:
continue
title = re.search(r'<p class="title">(.*?)</p>', chunk, re.S)
if title is None:
continue
block = title.group(1)
location_match = _SPAN_LOCATION.search(block)
location = _text(location_match.group(1)) if location_match else ""
name = _text(_SPAN_LOCATION.sub(" ", block))
if not name:
continue
broadcasts.append(
Broadcast(name=name, location=location, start=start, end=end)
)
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
return broadcasts
def roll_forward(
entries: Iterable[Broadcast], now: datetime, limit: int = SCHEDULE_LIMIT
) -> list[Broadcast]:
"""The homepage only shows one week; repeat it so the pane is never empty."""
upcoming: list[Broadcast] = []
for entry in entries:
start, end = entry.start, entry.end
if start is None:
continue
for _ in range(3):
if end is None or end >= now:
break
start += WEEK
end = end + WEEK if end is not None else None
if end is not None and end < now:
continue
upcoming.append(
Broadcast(
name=entry.name,
dj=entry.dj,
location=entry.location,
start=start,
end=end,
)
)
upcoming.sort(key=lambda item: item.start or now)
return upcoming[:limit]
# --------------------------------------------------------------------------- #
# caching
# --------------------------------------------------------------------------- #
#: One homepage fetch serves both the station and the schedule worker; the
#: lock keeps a cold start from downloading the same grid twice at once.
_MEMO_TTL = 120.0
_schedule_memo: tuple[float, list[Broadcast]] | None = None
_schedule_lock = asyncio.Lock()
def _memo_schedule() -> list[Broadcast]:
if _schedule_memo is None:
return []
fetched, entries = _schedule_memo
if time.time() - fetched > _MEMO_TTL:
return []
return list(entries)
def _remember_schedule(entries: list[Broadcast]) -> None:
global _schedule_memo
_schedule_memo = (time.time(), entries)
def reset_schedule_memo() -> None:
"""Forget the in-process schedule (the tests use this)."""
global _schedule_memo
_schedule_memo = None
def _songs_cache_path(station_id: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or DEFAULT_STATION_ID)
return config.SONGS_CACHE_DIR / f"{PROVIDER_ID}-{safe}.json"
async def schedule_entries(client: httpx.AsyncClient) -> list[Broadcast]:
"""The weekly grid: memo, then the homepage, then the cached copy."""
remembered = _memo_schedule()
if remembered:
return remembered
async with _schedule_lock:
remembered = _memo_schedule()
if remembered:
return remembered
try:
response = await client.get(
SCHEDULE_URL, headers={"Accept": "text/html"}
)
response.raise_for_status()
entries = parse_schedule(response.text)
except httpx.HTTPError:
entries = []
if not entries:
return load_cached_schedule()
_remember_schedule(entries)
config.write_json(
config.schedule_cache_path(PROVIDER_ID),
{
"fetched": time.time(),
"schedule": [
{
"name": item.name,
"location": item.location,
"start": item.start.isoformat() if item.start else "",
"end": item.end.isoformat() if item.end else "",
}
for item in entries
],
},
)
return entries
def load_cached_station() -> list[Station]:
raw = config.read_json(config.channels_cache_path(PROVIDER_ID))
status = raw.get("status") if isinstance(raw, dict) else None
if status is None:
return []
station = station_from_status(status, load_cached_schedule())
return [station] if station is not None else []
def load_cached_schedule() -> list[Broadcast]:
raw = config.read_json(config.schedule_cache_path(PROVIDER_ID))
entries = raw.get("schedule") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
return _schedule_from_cache(entries)
def _schedule_from_cache(entries: list[Any]) -> list[Broadcast]:
broadcasts: list[Broadcast] = []
for entry in entries:
if not isinstance(entry, dict):
continue
start = parse_date(entry.get("start"))
end = parse_date(entry.get("end"))
name = str(entry.get("name") or "").strip()
if not name or start is None:
continue
broadcasts.append(
Broadcast(
name=name,
location=str(entry.get("location") or "").strip(),
start=start,
end=end,
)
)
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
return broadcasts
def load_cached_songs(station_id: str) -> list[Song]:
raw = config.read_json(_songs_cache_path(station_id))
entries = raw.get("songs") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
songs: list[Song] = []
for entry in entries:
if not isinstance(entry, dict):
continue
song = Song(
title=str(entry.get("title") or "").strip(),
artist=str(entry.get("artist") or "").strip(),
played_at=parse_date(entry.get("played_at")),
)
if song.title or song.artist:
songs.append(song)
return songs
def _songs_to_cache(songs: Iterable[Song]) -> list[dict[str, Any]]:
return [
{
"title": song.title,
"artist": song.artist,
"played_at": song.played_at.isoformat() if song.played_at else "",
}
for song in songs
]
# --------------------------------------------------------------------------- #
# fetching
# --------------------------------------------------------------------------- #
async def fetch_station(client: httpx.AsyncClient) -> Station:
try:
payload = await fetch_json(client, STATUS_URL)
except RuntimeError as exc:
raise RuntimeError(f"could not fetch the Bassdrive status: {exc}") from None
# The schedule tells the station *where* a live show is coming from, so a
# cold start fetches it here; the schedule worker then reuses the memo.
schedule = load_cached_schedule() or await schedule_entries(client)
station = station_from_status(payload, schedule)
if station is None:
raise RuntimeError("Bassdrive returned no station data")
config.write_json(
config.channels_cache_path(PROVIDER_ID),
{"fetched": time.time(), "status": payload},
)
return station
async def fetch_songs(client: httpx.AsyncClient, station_id: str) -> list[Song]:
try:
payload = await fetch_json(client, STATUS_URL, attempts=1)
except RuntimeError:
return load_cached_songs(station_id)
current = song_from_title(display_title(payload))
if current is None:
return load_cached_songs(station_id)
history = load_cached_songs(station_id)
now = datetime.now(timezone.utc)
if history and history[0].key() == current.key():
# Same show still on air: keep the time we first heard it, so the pane
# says how long it has been running, not how long since the last poll.
current.played_at = history[0].played_at
history = [current, *history[1:]]
else:
current.played_at = now
history = [current] + [song for song in history if song.key() != current.key()]
history = history[:HISTORY_LIMIT]
config.write_json(
_songs_cache_path(station_id),
{"fetched": time.time(), "songs": _songs_to_cache(history)},
)
return history
async def fetch_schedule(client: httpx.AsyncClient) -> list[Broadcast]:
now = datetime.now(timezone.utc)
live: Broadcast | None = None
try:
status = await fetch_json(client, STATUS_URL, attempts=1)
except RuntimeError:
status = None
entries = await schedule_entries(client)
upcoming = roll_forward(entries, now)
if status is not None:
live = live_broadcast(status, entries, now)
if live is not None:
key = live.key()
upcoming = [item for item in upcoming if item.key() != key]
return [live, *upcoming]
return upcoming
@@ -0,0 +1,56 @@
"""Bundled station, so a cold offline start still makes sense.
Bassdrive is one station with three mounts and a handful of mirror hosts; the
streams below are the ones its own ``.m3u`` files list. The live status-json
always wins when it is reachable.
"""
from __future__ import annotations
from typing import Any
# (url, format, quality, bitrate)
_MOUNTS: tuple[tuple[str, str, str, str], ...] = (
("https://ice.bassdrive.net:443/stream", "mp3", "highest", "128"),
("https://ice.bassdrive.net:443/stream56", "aacp", "high", "56"),
("https://ice.bassdrive.net:443/stream32", "aacp", "low", "32"),
)
#: Alternative servers from bassdrive.m3u, tried when the primary is down.
_MIRRORS: tuple[str, ...] = (
"http://chi.bassdrive.co:80",
"http://bassdrive.radioca.st:8702",
"http://stream.bassdrive.uk:8200",
"http://au.bassdrive.co:8000",
)
_SERVER_TYPE = {"mp3": "audio/mpeg", "aacp": "audio/aacp"}
def fallback_status() -> dict[str, Any]:
"""A ``status-json.xsl``-shaped payload, so the normal parser can eat it."""
return {
"icestats": {
"source": [
{
"listenurl": url,
"bitrate": int(bitrate),
"server_type": _SERVER_TYPE.get(fmt, "audio/mpeg"),
"listeners": 0,
"display-title": "",
}
for url, fmt, _quality, bitrate in _MOUNTS
]
}
}
def fallback_stations() -> list[Any]:
from .api import station_from_status
station = station_from_status(fallback_status())
return [station] if station is not None else []
def known_stream_urls() -> list[str]:
return [url for url, _format, _quality, _bitrate in _MOUNTS] + list(_MIRRORS)
+14
View File
@@ -0,0 +1,14 @@
"""Bassdrive's header hero: its wordmark in the station's two blues.
The logo paints "BASS" in the pale blue and "DRIVE" in the deeper one; those
map onto the stock palette's bright cyan and cyan, so the wordmark still reads
as Bassdrive without hard-coded RGB.
"""
from __future__ import annotations
from ..wordmark import render
HERO: tuple[str, ...] = render(
"BASSDRIVE", ["bright_cyan"] * 4 + ["cyan"] * 5
)
+5 -6
View File
@@ -1,17 +1,16 @@
"""SomaFM's header hero: the block-letter wordmark and the three lines beside it.
The glyphs are plain half-blocks; the header paints them with the app's
The glyphs come from the shared half-block font
(:mod:`beefy_player.providers.wordmark`); the header paints them with the app's
spectrum gradient (``hero_gradient``), so the logo takes on the terminal's own
colours instead of carrying RGB values around.
"""
from __future__ import annotations
LOGO: tuple[str, ...] = (
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
"▀▀▀▄ █ █ █ █ █ █▀▀█",
"▄▄▄▀ ▀▄▄▀ █ █ █ █",
)
from ..wordmark import render
LOGO: tuple[str, ...] = render("SOMAFM")
TAGLINES: tuple[str, ...] = (
"SOMA FM RADIO",
+6 -19
View File
@@ -1,24 +1,11 @@
"""Generated header art — do not edit by hand.
"""tilderadio's header hero: its name in the shared half-block font.
Source: https://tilderadio.org/logos/tilderadio.png
Regenerate with::
uv run python tools/ansi_art.py --url https://tilderadio.org/logos/tilderadio.png --cols 22 --rows 7 --module beefy_player/providers/tilderadio/hero.py
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.
No colours of its own — the wordmark inherits the terminal's default
foreground, so it looks like part of the chrome rather than a logo pasted in.
"""
from __future__ import annotations
HERO: tuple[str, ...] = (
' [bright_black]▄[/][bright_black on bright_black]▀▀[/][bright_black]▀[/]',
' [bright_black]▄▄[/][bright_black on bright_black]▀▀▀[/][bright_black]▄▄▄▄▄▄▄▄▄▄▄[/]',
'[bright_black]▄[/][bright_black on bright_black]▀[/][bright_black on black]▀▀▀▀▀▀▀▀▀▀▀[/][bright_black on bright_black]▀▀▀▀▀▀[/][bright_black on black]▀[/][bright_black on bright_black]▀[/][bright_black]▄[/]',
'[bright_black on bright_black]▀▀[/][black on black]▀▀▀▀▀▀▀▀▀▀[/][bright_black on bright_black]▀▀[/][bright_black]▀[/] [bright_black on bright_black]▀▀▀▀[/]',
'[bright_black on bright_black]▀▀[/][black on black]▀[/][black on bright_black]▀▀[/][black on green]▀[/][black on bright_black]▀▀[/][black on black]▀▀[/][black on bright_black]▀[/][black on black]▀▀[/][bright_black on black]▀[/][bright_black on bright_black]▀[/][bright_black]▄▄[/][bright_black on bright_black]▀▀[/][bright_black on black]▀[/][bright_black on bright_black]▀▀[/]',
'[bright_black on bright_black]▀▀[/][black on black]▀[/][green on green]▀[/][green on black]▀▀▀[/][green on green]▀▀▀▀[/][bright_black on black]▀[/][black on black]▀▀▀[/][bright_black on bright_black]▀[/][bright_black on black]▀[/][bright_black on bright_black]▀▀[/][black on black]▀[/][bright_black on bright_black]▀▀[/]',
'[bright_black]▀▀[/][bright_black on bright_black]▀[/][black on bright_black]▀▀[/][bright_black on bright_black]▀[/][black on bright_black]▀▀▀▀▀[/][bright_black on bright_black]▀[/][black on bright_black]▀[/][bright_black on bright_black]▀[/][black on bright_black]▀[/][bright_black on bright_black]▀▀▀[/][black on bright_black]▀[/][bright_black on bright_black]▀[/][bright_black]▀▀[/]',
)
from ..wordmark import render
HERO: tuple[str, ...] = render("TILDERADIO")
+91
View File
@@ -0,0 +1,91 @@
"""A half-block wordmark font for the header hero.
Every shipped catalogue draws its brand the same way: four-column capitals,
six pixels tall, emitted as three rows of half blocks (``▀``/``▄``/``█``) — the
same shape as the original SomaFM logo. Catalogues differ only in the word
they spell and the ANSI colours they paint it with, so the font lives here and
each provider's ``hero.py`` is one line of data.
Glyphs are 6x4 bitmaps (``M`` is 6x5). Two pixel rows become one text row, so
a wordmark is always three rows tall and the header keeps a fixed height.
"""
from __future__ import annotations
from collections.abc import Sequence
#: 6-row bitmap per capital; all rows of one glyph are the same width.
GLYPHS: dict[str, tuple[str, ...]] = {
"A": (".##.", "#..#", "####", "#..#", "#..#", "#..#"),
"B": ("###.", "#..#", "###.", "#..#", "#..#", "###."),
"D": ("###.", "#..#", "#..#", "#..#", "#..#", "###."),
"E": ("####", "#...", "###.", "#...", "#...", "####"),
"F": ("####", "#...", "###.", "#...", "#...", "#..."),
"I": ("###", ".#.", ".#.", ".#.", ".#.", "###"),
"L": ("#...", "#...", "#...", "#...", "#...", "####"),
# 5 wide on purpose: the M is the odd one out in the original wordmark.
"M": ("#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#"),
"O": (".##.", "#..#", "#..#", "#..#", "#..#", ".##."),
"R": ("###.", "#..#", "###.", "#.#.", "#..#", "#..#"),
"S": (".###", "#...", "###.", "...#", "...#", "###."),
"T": ("###", ".#.", ".#.", ".#.", ".#.", ".#."),
"V": ("#..#", "#..#", "#..#", "#..#", ".##.", ".##."),
}
ROWS = 3
_GAP = " "
def _cell(top: str, bottom: str) -> str:
if top == "#" and bottom == "#":
return "█"
if top == "#":
return "▀"
if bottom == "#":
return "▄"
return " "
def _glyph_rows(char: str) -> tuple[str, ...]:
bitmap = GLYPHS.get(char)
if bitmap is None:
raise KeyError(f"no wordmark glyph for {char!r}")
return tuple(
"".join(_cell(bitmap[row * 2][col], bitmap[row * 2 + 1][col]) for col in range(len(bitmap[0])))
for row in range(ROWS)
)
def render(text: str, styles: Sequence[str | None] | None = None) -> tuple[str, ...]:
"""Spell ``text`` as three rows of half blocks.
``styles`` gives one rich-markup style per character (``None`` leaves it in
the terminal's default colour). Contiguous runs of the same style share a
markup span, so ``render("BASSDRIVE", ...)`` paints the first four letters
and the last five separately.
"""
if styles is not None and len(styles) != len(text):
raise ValueError("styles must provide one entry per character")
glyphs = [_glyph_rows(char.upper()) for char in text]
rows: list[str] = []
for row in range(ROWS):
cells: list[tuple[str | None, str]] = []
for index, glyph in enumerate(glyphs):
style = styles[index] if styles is not None else None
if index:
cells.append((style, _GAP))
cells.extend((style, char) for char in glyph[row])
while cells and cells[-1][1] == " ":
cells.pop()
segments: list[tuple[str | None, str]] = []
for style, char in cells:
if segments and segments[-1][0] == style:
segments[-1] = (style, segments[-1][1] + char)
else:
segments.append((style, char))
rows.append(
"".join(f"[{style}]{body}[/]" if style else body for style, body in segments)
)
return tuple(rows)