ui.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. """ANSI rendering for the minimal display."""
  2. from __future__ import annotations
  3. import os
  4. import re
  5. import shutil
  6. import sys
  7. _ESCAPES = re.compile(r"\x1b\[[0-9;]*m")
  8. def color_safe() -> bool:
  9. """ANSI is enabled only for a colouring-capable terminal."""
  10. try:
  11. tty_ok = sys.stdout.isatty()
  12. except AttributeError:
  13. return False
  14. return tty_ok and not os.environ.get("NO_COLOR")
  15. def terminal_width(default: int = 72) -> int:
  16. cols = shutil.get_terminal_size().columns
  17. return max(48, cols) if cols > 30 else default
  18. def visible_len(s: str) -> int:
  19. """Length of ``s`` without ANSI escapes — what the user actually sees."""
  20. return len(_ESCAPES.sub("", s))
  21. def fmt_time(seconds: float | None) -> str:
  22. if seconds is None or seconds < 0:
  23. return "--:--:--"
  24. total = int(seconds + 0.5)
  25. h, rem = divmod(total, 3600)
  26. m, s = divmod(rem, 60)
  27. return f"{h:02d}:{m:02d}:{s:02d}"
  28. class C: # noqa: N801
  29. """ANSI colour tokens; inert when disabled."""
  30. def __init__(self, on: bool = True):
  31. self.on = on
  32. self.reset = "\033[0m" if on else ""
  33. self.dim = "\033[90m" if on else ""
  34. self.cyan = "\033[1;96m" if on else ""
  35. self.green = "\033[92m" if on else ""
  36. self.amber = "\033[93m" if on else ""
  37. self.red = "\033[91m" if on else ""
  38. self.white = "\033[37m" if on else ""
  39. self.dark = "\033[38;5;6m" if on else ""
  40. self.vol = "\033[94m" if on else ""
  41. class UI:
  42. def __init__(self, color: bool, width: int, bar_width: int):
  43. self.c = C(color)
  44. self.width = max(52, width) # 52 keeps the bar row fitting with its time + volume
  45. self.bar_w = max(16, min(bar_width, self.width - 34))
  46. def _status(self, text: str, kind: str) -> str:
  47. if kind == "ok":
  48. return f"{self.c.green}{text}{self.c.reset}"
  49. if kind == "buffer":
  50. return f"{self.c.amber}{text}{self.c.reset}"
  51. if kind == "error":
  52. return f"{self.c.red}{text}{self.c.reset}"
  53. if kind == "pause":
  54. return f"{self.c.white}{text}{self.c.reset}"
  55. return f"{self.c.dim}{text}{self.c.reset}"
  56. def episode_line(self, number: int, status: str, kind: str) -> str:
  57. left = f"ASOT {number:04d}"
  58. max_status = self.width - len(left) - 1
  59. status = status[:max_status] if len(status) > max_status else status
  60. gap = self.width - len(left) - len(status) # >= 1 by construction
  61. return f"{self.c.cyan}{left}{self.c.reset}{' ' * gap}{self._status(status, kind)}"
  62. def song_line(self, song: str, date: str) -> str:
  63. prefix = " "
  64. d_raw = (date or "").strip()
  65. s_raw = (song or "").rstrip()
  66. if not s_raw or not s_raw.strip():
  67. s = f"{self.c.dim}(no track info){self.c.reset}"
  68. else:
  69. # reserve one column for the separator space before the date
  70. budget = max(0, self.width - len(prefix) - len(d_raw) - 1)
  71. if len(s_raw) > budget:
  72. s_raw = s_raw[:budget].rstrip()
  73. s = f"{self.c.white}{s_raw}{self.c.reset}"
  74. gap = self.width - len(prefix) - visible_len(s) - len(d_raw) # >= 1
  75. d = f"{self.c.amber}{d_raw}{self.c.reset}" if d_raw else ""
  76. return prefix + s + " " * gap + d
  77. def bar_line(self, position, length, volume, paused):
  78. """Player bar row. Exactly ``width`` characters in both colour modes."""
  79. w = self.bar_w
  80. filled = 0
  81. if length and length > 0 and position is not None and 0 <= position <= length:
  82. filled = int(round((position / length) * w))
  83. on = "\u2588" * filled
  84. off = "\u2591" * (w - filled)
  85. bar = f"[{self.c.green}{on}{self.c.reset}{self.c.dark}{off}{self.c.reset}]"
  86. pos_s = fmt_time(position)
  87. tot_s = fmt_time(length)
  88. vol_int = int(round(volume * 100))
  89. meter = ("█" * int(round(volume * 6)) + "░" * (6 - int(round(volume * 6))))
  90. vol_part = f"{vol_int:03d}% {meter}"
  91. right_time = f"{pos_s} / {tot_s}" if length and length > 0 else f"{pos_s} / ???"
  92. # gap is computed in *visible* characters, and includes the literal
  93. # single space between the time and the volume readout.
  94. gap = max(1, self.width - visible_len(bar) - len(right_time) - 1 - len(vol_part))
  95. return (f"{bar}{' ' * gap}{self.c.white}{right_time}{self.c.reset} "
  96. f"{self.c.vol}{vol_part}{self.c.reset}")
  97. def hint_line(self) -> str:
  98. text = "space play/pause ←/→ ±15s ↑/↓ volume m mute n next p prev q quit"
  99. if len(text) > self.width:
  100. text = text[:self.width]
  101. elif len(text) < self.width:
  102. text += " " * (self.width - len(text))
  103. return f"{self.c.dim}{text}{self.c.reset}"
  104. def render(
  105. self,
  106. number: int,
  107. song: str,
  108. date: str,
  109. status: str,
  110. kind: str,
  111. position,
  112. length,
  113. volume: float,
  114. paused: bool,
  115. ) -> str:
  116. lines = [
  117. self.episode_line(number, status, kind),
  118. self.song_line(song, date),
  119. self.bar_line(position, length, volume, paused),
  120. self.hint_line(),
  121. ]
  122. return "\033[H" + "\n".join(lines) + "\n"
  123. def plain_render(
  124. self,
  125. number: int,
  126. song: str,
  127. date: str,
  128. status: str,
  129. kind: str,
  130. position,
  131. length,
  132. volume: float,
  133. paused: bool,
  134. ) -> str:
  135. """Same layout without ANSI (non-tty / tests)."""
  136. lines = [
  137. self.episode_line(number, status, kind),
  138. self.song_line(song, date),
  139. self.bar_line(position, length, volume, paused),
  140. self.hint_line(),
  141. ]
  142. return "\n".join(lines) + "\n\n"