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