Ver código fonte

ui: bottom-anchored layout, remapped keys, cyan full-width bar

Layout changes:
  * display block now anchored to the bottom of the terminal (cursor
    positioned at rows-2); on exit the screen is cleared
      (\033[0m\033[H\033[J) and the terminal reset.
  * first two lines merged into one: "ASOT NNNN" (bold teal) + player
    status, with the episode date right-aligned. Song title removed from
    the header.
  * player bar spans all remaining width between fixed readouts:
    elapsed/total on the left, remaining time + volume readout
    (unchanged format) on the right; progress blocks are cyan instead of
    green. Bar width is computed per line from the actual readout lengths
    so it fills whether length is known or not.
  * hint line updated to the new mapping.

Key changes: up/down seek +/-30s, left/right seek +/-10s, +/− change
volume. space pause, m mute, n/p episode nav and q quit unchanged.

Verified with a real pty run (anchor row observed at rows-2) and all 35
unit tests green (every line exactly `width` visible chars, colour and
plain modes).
Mindaugas Jarmolovičius 1 semana atrás
pai
commit
a86f50de63
3 arquivos alterados com 99 adições e 72 exclusões
  1. 16 9
      asot_player/main.py
  2. 69 48
      asot_player/ui.py
  3. 14 15
      tests/test_asot.py

+ 16 - 9
asot_player/main.py

@@ -197,14 +197,18 @@ class Player:
             self._engine_cmd(lambda: self.engine.set_pause(not st.paused))
         elif key in ("m", "M"):              # mute
             self._engine_cmd(lambda: self.engine.set_mute(not st.muted))
-        elif key == "up":                    # volume +
+        elif key == "+":                     # volume up
             self._engine_cmd(lambda: self.engine.set_volume(min(1.0, st.volume + 0.1)))
-        elif key == "down":                  # volume -
+        elif key == "-":                     # volume down
             self._engine_cmd(lambda: self.engine.set_volume(max(0.0, st.volume - 0.1)))
-        elif key == "right":                 # forward 15s
-            self._engine_cmd(lambda: self.engine.seek(st.position + 15.0))
-        elif key == "left":                  # back 15s
-            self._engine_cmd(lambda: self.engine.seek(st.position - 15.0))
+        elif key == "up":                    # forward 30s
+            self._engine_cmd(lambda: self.engine.seek(st.position + 30.0))
+        elif key == "down":                  # back 30s
+            self._engine_cmd(lambda: self.engine.seek(st.position - 30.0))
+        elif key == "right":                 # forward 10s
+            self._engine_cmd(lambda: self.engine.seek(st.position + 10.0))
+        elif key == "left":                  # back 10s
+            self._engine_cmd(lambda: self.engine.seek(st.position - 10.0))
         elif key == "n":
             self.advance(+1)
         elif key == "p":
@@ -226,11 +230,10 @@ class Player:
             status, kind = "Connecting…", "buffer"
         else:
             status, kind = "PLAYING", "ok"
-        song = self.meta.display
         date = self.ep.date_str if self.ep is not None else ""
         out = self.ui.render(
             self.ep.number if self.ep else 0,
-            song, date, status, kind,
+            date, status, kind,
             st.position, st.length, st.volume, st.paused)
         sys.stdout.write(out)
         sys.stdout.flush()
@@ -358,7 +361,6 @@ def main():
             if rem > 0.1:
                 time.sleep(min(rem, 0.3))
             tick += 1
-        sys.stdout.write("\033[H")
     except EngineError as e:
         print(f"[asot] playback error: {e}", file=sys.stderr)
     finally:
@@ -375,6 +377,11 @@ def main():
             p.state.save()
         except Exception:
             pass
+        try:
+            sys.stdout.write(ui.finish())
+            sys.stdout.flush()
+        except Exception:
+            pass
     return 0
 
 

+ 69 - 48
asot_player/ui.py

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

+ 14 - 15
tests/test_asot.py

@@ -3,7 +3,9 @@
 from __future__ import annotations
 
 import os
+import re
 import sys
+import re
 import tempfile
 import unittest
 from unittest.mock import Mock
@@ -408,10 +410,10 @@ class StateTests(unittest.TestCase):
 # UI: every line is exactly `width` visible characters in both colour modes
 # --------------------------------------------------------------------------
 
-def rendered_lines(ui, song, date, status, kind, position, length, volume):
-    out = ui.render(1298, song, date, status, kind, position, length, volume, False)
-    assert out.startswith("\x1b[H")
-    return out[len("\x1b[H"):].split("\n")  # last element is "" (trailing newline)
+def rendered_lines(ui, date, status, kind, position, length, volume):
+    out = ui.render(1298, date, status, kind, position, length, volume, False)
+    body = re.sub(r"^\x1b\[\d+;1H", "", out).rstrip("\n")  # strip prefix + trailing LF
+    return body.split("\n")
 
 
 class UITests(unittest.TestCase):
@@ -423,11 +425,9 @@ class UITests(unittest.TestCase):
 
     def test_line_lengths_exact(self):
         for ui, w in self.widths_and_colors():
-            lines = [ui.episode_line(1298, "PLAYING", "ok"),
-                     ui.episode_line(1298, "Finished — end of feed at ASOT 1301", "info"),
-                     ui.song_line("", ""),
-                     ui.song_line("Armin van Buuren - A State of Trance (2026) Extra Long",
-                                  "07 Feb 2026"),
+            lines = [ui.title_line(1298, "PLAYING", "ok", "07 Feb 2026"),
+                     ui.title_line(1298, "Finished — end of feed at ASOT 1301", "info", ""),
+                     ui.title_line(1298, "", "ok", ""),
                      ui.bar_line(None, None, 0.5, False),
                      ui.bar_line(0.0, 0.0, 0.5, False),
                      ui.bar_line(1234.5, 7200.0, 0.8, True),
@@ -437,18 +437,17 @@ class UITests(unittest.TestCase):
 
     def test_rendered_block_lengths_exact(self):
         for ui, w in self.widths_and_colors():
-            body = rendered_lines(ui, "Armin - A Long Song Title That Keeps Going",
-                                  "07 Feb 2026", "Buffering", "buffer",
-                                  333.5, 7200.0, 0.85)
-            for ln in body[:-1]:
+            body = rendered_lines(ui, "07 Feb 2026", "Buffering", "buffer",
+                      333.5, 7200.0, 0.85)
+            for ln in body:
                 self.assertEqual(visible_len(ln), w, f"visible len != width: {ln!r}")
 
     def test_plain_mode_no_escapes(self):
         ui = UI(False, 80, 40)
-        out = ui.plain_render(9, "s", "d", "ok", "ok", 1.0, 10.0, 0.5, False)
+        out = ui.plain_render(9, "d", "ok", "ok", 1.0, 10.0, 0.5, False)
         self.assertNotIn("\x1b", out)
         lines = [l for l in out.splitlines() if l]
-        self.assertEqual(len(lines), 4)
+        self.assertEqual(len(lines), 3)
         self.assertTrue(all(len(l) == 80 for l in lines))