"""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)