|
|
@@ -1,4 +1,4 @@
|
|
|
-"""ANSI rendering for the minimal display."""
|
|
|
+"""ANSI rendering for the minimal display (bottom-anchored, three lines)."""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
@@ -45,7 +45,7 @@ class C: # noqa: N801
|
|
|
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.cyan = "\033[1;96m" if on else "" # bold teal: title + progress
|
|
|
self.green = "\033[92m" if on else ""
|
|
|
self.amber = "\033[93m" if on else ""
|
|
|
self.red = "\033[91m" if on else ""
|
|
|
@@ -57,8 +57,13 @@ class C: # noqa: N801
|
|
|
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))
|
|
|
+ # 52 keeps the bar row fitting with its time + volume readouts
|
|
|
+ self.width = max(52, width)
|
|
|
+ try:
|
|
|
+ tty_ok = sys.stdout.isatty()
|
|
|
+ except AttributeError:
|
|
|
+ tty_ok = False
|
|
|
+ self.tty = bool(color and tty_ok)
|
|
|
|
|
|
def _status(self, text: str, kind: str) -> str:
|
|
|
if kind == "ok":
|
|
|
@@ -71,54 +76,60 @@ class UI:
|
|
|
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:
|
|
|
+ def title_line(self, number: int, status: str, kind: str, date: str) -> str:
|
|
|
+ """Header line: ``ASOT NNNN`` (bold teal), player status, and the
|
|
|
+ episode date. Exactly ``width`` visible chars in both colour modes."""
|
|
|
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
|
|
|
+ left_c = f"{self.c.cyan}{left}{self.c.reset}"
|
|
|
+ s = (status or "").strip()
|
|
|
+ d = (date or "").strip()
|
|
|
+ if s and d:
|
|
|
+ # layout: left + 1sp + status + gap(>=1) + date == width
|
|
|
+ budget = max(0, self.width - len(left) - 2 - len(d))
|
|
|
+ st = s[:budget]
|
|
|
+ if st:
|
|
|
+ dt_c = f"{self.c.amber}{d}{self.c.reset}"
|
|
|
+ gap = self.width - len(left) - 1 - len(st) - len(d) # >= 1
|
|
|
+ return f"{left_c} {self._status(st, kind)}{' ' * gap}{dt_c}"
|
|
|
+ if d:
|
|
|
+ dt = d[:max(0, self.width - len(left) - 1)]
|
|
|
+ if dt:
|
|
|
+ dt_c = f"{self.c.amber}{dt}{self.c.reset}"
|
|
|
+ gap = self.width - len(left) - len(dt) # >= 1
|
|
|
+ return f"{left_c}{' ' * gap}{dt_c}"
|
|
|
+ st = s[:max(0, self.width - len(left) - 1)]
|
|
|
+ if st:
|
|
|
+ gap = self.width - len(left) - 1 - len(st) # >= 0
|
|
|
+ return f"{left_c} {self._status(st, kind)}{' ' * gap}"
|
|
|
+ return f"{left_c}{' ' * (self.width - len(left))}"
|
|
|
|
|
|
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}]"
|
|
|
-
|
|
|
+ """Player bar row: elapsed/total on the left, the bar spanning all
|
|
|
+ remaining width (cyan fill), remaining time + volume on the right.
|
|
|
+ Exactly ``width`` visible chars in both colour modes."""
|
|
|
+ known = bool(length is not None and length > 0)
|
|
|
pos_s = fmt_time(position)
|
|
|
tot_s = fmt_time(length)
|
|
|
+ left_s = f"{pos_s} / {tot_s}"
|
|
|
+ rem_s = fmt_time(max(0.0, length - position)) if known else "---"
|
|
|
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} / ???"
|
|
|
+ right_s = f"{rem_s} {vol_part}"
|
|
|
+ # bar content = everything that is not left/right readouts, the two
|
|
|
+ # bracket chars and the 2+2 spaces around the bar
|
|
|
+ w = max(1, self.width - len(left_s) - len(right_s) - 6)
|
|
|
+ filled = 0
|
|
|
+ if known 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.cyan}{on}{self.c.reset}{self.c.dark}{off}{self.c.reset}]"
|
|
|
|
|
|
- # 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}")
|
|
|
+ return f"{left_s} {bar} {right_s}"
|
|
|
|
|
|
def hint_line(self) -> str:
|
|
|
- text = "space play/pause ←/→ ±15s ↑/↓ volume m mute n next p prev q quit"
|
|
|
+ text = "space pause +/- vol →/← ±10s ↑/↓ ±30s m mute n next p prev q quit"
|
|
|
if len(text) > self.width:
|
|
|
text = text[:self.width]
|
|
|
elif len(text) < self.width:
|
|
|
@@ -128,7 +139,6 @@ class UI:
|
|
|
def render(
|
|
|
self,
|
|
|
number: int,
|
|
|
- song: str,
|
|
|
date: str,
|
|
|
status: str,
|
|
|
kind: str,
|
|
|
@@ -138,17 +148,22 @@ class UI:
|
|
|
paused: bool,
|
|
|
) -> str:
|
|
|
lines = [
|
|
|
- self.episode_line(number, status, kind),
|
|
|
- self.song_line(song, date),
|
|
|
+ self.title_line(number, status, kind, date),
|
|
|
self.bar_line(position, length, volume, paused),
|
|
|
self.hint_line(),
|
|
|
]
|
|
|
- return "\033[H" + "\n".join(lines) + "\n"
|
|
|
+ # anchor the block to the bottom of the terminal; no trailing newline
|
|
|
+ # (it would scroll the screen when the last line sits at screen end)
|
|
|
+ prefix = ""
|
|
|
+ if self.tty:
|
|
|
+ rows = shutil.get_terminal_size().lines
|
|
|
+ start = max(1, rows - len(lines) + 1)
|
|
|
+ prefix = f"\033[{start};1H"
|
|
|
+ return prefix + "\n".join(lines) + ("\n" if not self.tty else "")
|
|
|
|
|
|
def plain_render(
|
|
|
self,
|
|
|
number: int,
|
|
|
- song: str,
|
|
|
date: str,
|
|
|
status: str,
|
|
|
kind: str,
|
|
|
@@ -159,9 +174,15 @@ class UI:
|
|
|
) -> str:
|
|
|
"""Same layout without ANSI (non-tty / tests)."""
|
|
|
lines = [
|
|
|
- self.episode_line(number, status, kind),
|
|
|
- self.song_line(song, date),
|
|
|
+ self.title_line(number, status, kind, date),
|
|
|
self.bar_line(position, length, volume, paused),
|
|
|
self.hint_line(),
|
|
|
]
|
|
|
return "\n".join(lines) + "\n\n"
|
|
|
+
|
|
|
+ def finish(self) -> str:
|
|
|
+ """Terminal reset written on exit: colour reset, clear screen, home.
|
|
|
+ Empty in plain / non-tty mode."""
|
|
|
+ if not self.tty:
|
|
|
+ return ""
|
|
|
+ return "\033[0m\033[H\033[J"
|