Files
beefy-player/beefy_player/widgets/common.py
T
beefcarver e30ca09627 refactor!: rename somafm-tui to beefy-player
The app is now a general internet-radio player rather than a SomaFM client, so the package, console script, MPRIS identity, mpv namespaces, macOS tap names, XDG directories and docs all move to beefy-player.

Environment variables are now BEEFY_* (the old SOMAFM_* spellings are still honoured) and an existing ~/.config/somafm-tui/config.json is migrated on first run, so favourites and volume survive the rename.

The visible interface is untouched: same layout, same logo, same keys.
2026-11-03 16:10:25 -05:00

213 lines
6.1 KiB
Python

"""Small rendering helpers shared by the widgets.
Every colour in the UI is an ANSI palette name (``cyan``, ``bright_magenta``,
``dim`` …), never an RGB value. That way the TUI adopts whatever colours the
terminal is configured with, and nothing sets a background colour, so
transparent terminals stay transparent.
"""
from __future__ import annotations
import hashlib
import math
import os
import time
from datetime import datetime, timezone, tzinfo
from functools import lru_cache
from zoneinfo import ZoneInfo
from rich.cells import cell_len
from rich.text import Text
LOCALTIME_PATH = "/etc/localtime"
_UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"}
LEVELS = " ▁▂▃▄▅▆▇█"
# Bottom-to-top spectrum colours, picked from the terminal's stock ANSI palette.
SPECTRUM_STOPS = (
"cyan",
"blue",
"magenta",
"bright_magenta",
"yellow",
"bright_yellow",
"red",
)
ART_STOPS = ("blue", "magenta", "bright_magenta", "yellow", "cyan", "green")
STATE_STYLE = {
"playing": ("●", "green"),
"loading": ("◌", "yellow"),
"buffering": ("◌", "yellow"),
"paused": ("❚❚", "magenta"),
"stopped": ("○", "bright_black"),
"error": ("✕", "red"),
}
def truncate(text: str, width: int) -> str:
if width <= 0:
return ""
if cell_len(text) <= width:
return text
if width == 1:
return "…"
kept: list[str] = []
used = 0
for char in text:
char_width = cell_len(char)
if used + char_width > width - 1:
break
kept.append(char)
used += char_width
return "".join(kept) + "…"
def fit(text: str, width: int) -> str:
clipped = truncate(text, width)
return clipped + " " * max(0, width - cell_len(clipped))
def compact_count(value: int) -> str:
if value <= 0:
return "—"
if value >= 1_000_000:
return f"{value / 1_000_000:.1f}M"
if value >= 1_000:
return f"{value / 1_000:.1f}k"
return str(value)
def time_ago(moment: datetime | None, now: datetime | None = None) -> str:
if moment is None:
return ""
now = now or datetime.now(timezone.utc)
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
seconds = max(0, int((now - moment).total_seconds()))
if seconds < 60:
return f"{seconds}s ago"
if seconds < 3600:
return f"{seconds // 60}m ago"
if seconds < 86_400:
return f"{seconds // 3600}h ago"
return f"{seconds // 86_400}d ago"
def _zone_from_file(path: str) -> tzinfo | None:
try:
with open(path, "rb") as handle:
return ZoneInfo.from_file(handle)
except (OSError, ValueError):
return None
@lru_cache(maxsize=1)
def local_zone() -> tzinfo:
"""Return the user's local timezone.
A nix-shell (or an FHS environment) can inherit a build-time ``TZ=UTC``,
which would otherwise make Python show UTC. Prefer an explicit non-UTC
``TZ``, then the system's ``/etc/localtime`` (the host's configured zone),
and only then Python's idea of local time.
"""
tz_name = (os.environ.get("TZ") or "").strip()
if tz_name and tz_name.upper() not in _UTC_NAMES:
try:
return ZoneInfo(tz_name)
except Exception:
pass
zone = _zone_from_file(LOCALTIME_PATH)
if zone is not None:
return zone
if tz_name:
try:
return ZoneInfo(tz_name)
except Exception:
pass
return datetime.now().astimezone().tzinfo or timezone.utc
def _local(moment: datetime | None) -> datetime:
zone = local_zone()
if moment is None:
return datetime.now(zone)
if moment.tzinfo is None:
return moment.replace(tzinfo=zone)
return moment.astimezone(zone)
def clock_time(moment: datetime | None = None) -> str:
return _local(moment).strftime("%H:%M:%S")
def date_label(moment: datetime | None = None) -> str:
return _local(moment).strftime("%a %d %b")
def local_time(moment: datetime | None) -> str:
if moment is None:
return "--:--"
return _local(moment).strftime("%H:%M")
def gradient(stops: tuple[str, ...], position: float) -> str:
"""Pick a colour from a stock palette (no RGB interpolation)."""
if not stops:
return "white"
position = max(0.0, min(1.0, position))
index = min(len(stops) - 1, int(position * len(stops)))
return stops[index]
def marquee(
text: str, width: int, now: float | None = None, speed: float = 7.0, gap: int = 5
) -> str:
if width <= 0:
return ""
if cell_len(text) <= width:
return text
now = time.monotonic() if now is None else now
cycle = text + " " * gap
length = max(1, cell_len(cycle))
offset = int(now * speed) % length
return truncate(cycle[offset:] + cycle[:offset], width)
def state_glyph(state: str) -> tuple[str, str]:
return STATE_STYLE.get(state, STATE_STYLE["stopped"])
def art_lines(key: str, width: int, height: int) -> list[Text]:
"""Procedural album art as a solid ANSI-colour mosaic.
Filled block glyphs are used (never cell backgrounds), so the cover is
opaque while everything around it stays transparent.
"""
width = max(1, width)
height = max(1, height)
seed = int(hashlib.sha1(key.encode("utf-8")).hexdigest()[:12], 16)
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
ny = row / max(1, height - 1)
for column in range(width):
nx = column / max(1, width - 1)
wave = 0.5 + 0.5 * math.sin(nx * 2.9 + ny * 2.1 + (seed % 11))
position = (nx * 0.55 + (1.0 - ny) * 0.30 + wave * 0.15) % 1.0
colour = gradient(ART_STOPS, position)
# Mostly solid blocks with a little texture, like a tiny cover.
glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓"
line.append(glyph, style=colour)
lines.append(line)
return lines
def rule(width: int, color: str = "bright_black", char: str = "─") -> Text:
return Text(char * max(0, width), style=color)