213 lines
6.1 KiB
Python
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)
|