"""ANSI rendering for the minimal display.""" from __future__ import annotations import os import re import shutil import sys _ESCAPES = re.compile(r"\x1b\[[0-9;]*m") def color_safe() -> bool: """ANSI is enabled only for a colouring-capable terminal.""" try: tty_ok = sys.stdout.isatty() except AttributeError: return False return tty_ok and not os.environ.get("NO_COLOR") def terminal_width(default: int = 72) -> int: cols = shutil.get_terminal_size().columns return max(48, cols) if cols > 30 else default def visible_len(s: str) -> int: """Length of ``s`` without ANSI escapes — what the user actually sees.""" return len(_ESCAPES.sub("", s)) def fmt_time(seconds: float | None) -> str: if seconds is None or seconds < 0: return "--:--:--" total = int(seconds + 0.5) h, rem = divmod(total, 3600) m, s = divmod(rem, 60) return f"{h:02d}:{m:02d}:{s:02d}" class C: # noqa: N801 """ANSI colour tokens; inert when disabled.""" def __init__(self, on: bool = True): self.on = on self.reset = "\033[0m" if on else "" self.dim = "\033[90m" if on else "" self.cyan = "\033[1;96m" if on else "" self.green = "\033[92m" if on else "" self.amber = "\033[93m" if on else "" self.red = "\033[91m" if on else "" self.white = "\033[37m" if on else "" self.dark = "\033[38;5;6m" if on else "" self.vol = "\033[94m" if on else "" class UI: def __init__(self, color: bool, width: int, bar_width: int): self.c = C(color) self.width = max(52, width) # 52 keeps the bar row fitting with its time + volume self.bar_w = max(16, min(bar_width, self.width - 34)) def _status(self, text: str, kind: str) -> str: if kind == "ok": return f"{self.c.green}{text}{self.c.reset}" if kind == "buffer": return f"{self.c.amber}{text}{self.c.reset}" if kind == "error": return f"{self.c.red}{text}{self.c.reset}" if kind == "pause": return f"{self.c.white}{text}{self.c.reset}" return f"{self.c.dim}{text}{self.c.reset}" def episode_line(self, number: int, status: str, kind: str) -> str: left = f"ASOT {number:04d}" max_status = self.width - len(left) - 1 status = status[:max_status] if len(status) > max_status else status gap = self.width - len(left) - len(status) # >= 1 by construction return f"{self.c.cyan}{left}{self.c.reset}{' ' * gap}{self._status(status, kind)}" def song_line(self, song: str, date: str) -> str: prefix = " " d_raw = (date or "").strip() s_raw = (song or "").rstrip() if not s_raw or not s_raw.strip(): s = f"{self.c.dim}(no track info){self.c.reset}" else: # reserve one column for the separator space before the date budget = max(0, self.width - len(prefix) - len(d_raw) - 1) if len(s_raw) > budget: s_raw = s_raw[:budget].rstrip() s = f"{self.c.white}{s_raw}{self.c.reset}" gap = self.width - len(prefix) - visible_len(s) - len(d_raw) # >= 1 d = f"{self.c.amber}{d_raw}{self.c.reset}" if d_raw else "" return prefix + s + " " * gap + d def bar_line(self, position, length, volume, paused): """Player bar row. Exactly ``width`` characters in both colour modes.""" w = self.bar_w filled = 0 if length and length > 0 and position is not None and 0 <= position <= length: filled = int(round((position / length) * w)) on = "\u2588" * filled off = "\u2591" * (w - filled) bar = f"[{self.c.green}{on}{self.c.reset}{self.c.dark}{off}{self.c.reset}]" pos_s = fmt_time(position) tot_s = fmt_time(length) vol_int = int(round(volume * 100)) meter = ("█" * int(round(volume * 6)) + "░" * (6 - int(round(volume * 6)))) vol_part = f"{vol_int:03d}% {meter}" right_time = f"{pos_s} / {tot_s}" if length and length > 0 else f"{pos_s} / ???" # gap is computed in *visible* characters, and includes the literal # single space between the time and the volume readout. gap = max(1, self.width - visible_len(bar) - len(right_time) - 1 - len(vol_part)) return (f"{bar}{' ' * gap}{self.c.white}{right_time}{self.c.reset} " f"{self.c.vol}{vol_part}{self.c.reset}") def hint_line(self) -> str: text = "space play/pause ←/→ ±15s ↑/↓ volume m mute n next p prev q quit" if len(text) > self.width: text = text[:self.width] elif len(text) < self.width: text += " " * (self.width - len(text)) return f"{self.c.dim}{text}{self.c.reset}" def render( self, number: int, song: str, date: str, status: str, kind: str, position, length, volume: float, paused: bool, ) -> str: lines = [ self.episode_line(number, status, kind), self.song_line(song, date), self.bar_line(position, length, volume, paused), self.hint_line(), ] return "\033[H" + "\n".join(lines) + "\n" def plain_render( self, number: int, song: str, date: str, status: str, kind: str, position, length, volume: float, paused: bool, ) -> str: """Same layout without ANSI (non-tty / tests).""" lines = [ self.episode_line(number, status, kind), self.song_line(song, date), self.bar_line(position, length, volume, paused), self.hint_line(), ] return "\n".join(lines) + "\n\n"