Bläddra i källkod

ui/main: erase the bar before printing, park the cursor above it

Printed reports (engine drops, tracebacks) landed at the bottom cursor, so
the anchored block redrew over them and the screen ended up with two bars.

- UI tracks the row the block starts on (start_line / block_start()) and
  every frame clears to the end of the screen before drawing, so no older
  frame can survive as a second bar.
- UI.clear_block() erases only the block and parks the cursor on the line
  above it; main._print_above() uses it to print the report there, after
  which the next frame redraws the bar at the bottom.
- Exit no longer clears the whole screen: finish() clears just the player
  block and returns the cursor to the last printed line.
- Engine errors from _load() now raise instead of sys.exit(2), so they are
  reported above the bar; main returns 2 on engine failure, 130 on Ctrl-C.
- Per-tick catch-all: an unexpected exception prints its traceback above the
  bar and playback drawing continues.

Tests: 54 (frame anchoring + clear, block clear parks cursor, exit clears
only the block, print-above in tty and plain mode).  Verified live in a pty:
killed mpv mid-play -> one report line on row 21, single bar on rows 22-24,
no full-screen clear; 'q' exit ends with [21;1H[J.
Mindaugas Jarmolovičius 2 dagar sedan
förälder
incheckning
d1c2d15bb1
3 ändrade filer med 178 tillägg och 55 borttagningar
  1. 71 48
      asot_player/main.py
  2. 25 7
      asot_player/ui.py
  3. 82 0
      tests/test_asot.py

+ 71 - 48
asot_player/main.py

@@ -8,6 +8,7 @@ import re
 import select
 import sys
 import time
+import traceback
 from pathlib import Path
 
 from asot_player.feed import Episode, fetch_episodes
@@ -180,8 +181,8 @@ class Player:
         try:
             self.engine.play(ep.url, seek_to=seek_to, muted=muted, volume=volume)
         except EngineError as e:
-            print(f"[asot] {reason}: {e}", file=sys.stderr)
-            sys.exit(2)
+            # let the caller report it above the bar and pick the exit code
+            raise EngineError(f"{reason}: {e}") from e
         self.msg_override = f"{reason.capitalize()} \u2192 ASOT {ep.number:04d}"
         self.msg_until = time.time() + 3.0
 
@@ -280,6 +281,15 @@ class Player:
 # entry point
 # ---------------------------------------------------------------------------
 
+def _print_above(ui, text: str):
+    """Report a problem without disturbing the bar: erase the player block,
+    print ``text`` at the line above it (the last line of normal output), and
+    leave the bottom free — the next frame redraws the bar there, so two bars
+    can never coexist."""
+    sys.stdout.write(ui.clear_block())
+    sys.stdout.write(text.rstrip("\n") + "\n")
+    sys.stdout.flush()
+
 def main():
     ap = argparse.ArgumentParser(
         prog="asot",
@@ -345,6 +355,7 @@ def main():
         msg = f"[asot] starting ASOT {start_ep.number:04d}"
     print(msg, file=sys.stderr)
 
+    rc = 0
     try:
         p._load(start_ep, reason="Starting",
                 seek_to=resume_pos if resume else 0.0)
@@ -354,59 +365,71 @@ def main():
         last_save = time.time()
         while not p.finished and tick < 50_000:
             t0 = time.time()
-
-            # keys (applied immediately)
-            keys = kb.drain() if kb is not None else []
-            for k in keys:
-                if p.finished:
-                    break
-                p.handle_key(k)
-
             try:
-                st = p._status()
-                drops = 0                      # healthy tick resets the budget
-            except EngineError as e:
-                # mpv died (stream drop / EOF race / network loss).  Do not
-                # crash: resume from the saved position, end only when the
-                # episode has truly finished.
-                drops += 1
-                print(f"[asot] playback dropped ({e.__class__.__name__}); "
-                      f"recovery attempt {drops}…", file=sys.stderr)
-                if drops > 5 or not p.recover():
-                    p.msg_override = "Playback failed — stream unavailable"
+                # keys (applied immediately)
+                keys = kb.drain() if kb is not None else []
+                for k in keys:
+                    if p.finished:
+                        break
+                    p.handle_key(k)
+
+                try:
+                    st = p._status()
+                    drops = 0              # healthy tick resets the budget
+                except EngineError as e:
+                    # mpv died (stream drop / EOF race / network loss): clear
+                    # the bar, say so above it, then resume from the saved
+                    # position — end only when the episode has truly finished.
+                    drops += 1
+                    _print_above(p.ui, f"[asot] playback dropped "
+                                       f"({e.__class__.__name__}); recovery "
+                                       f"attempt {drops}…")
+                    if drops > 5 or not p.recover():
+                        p.msg_override = "Playback failed — stream unavailable"
+                        p.msg_until = time.time() + 6.0
+                        break
+                    tick += 1                  # keep the global tick cap binding
+                    time.sleep(1.0)            # don't hammer a dead stream
+                    continue
+
+                # eof → next episode (auto-advance)
+                if st.state == "eof":
+                    if p.advance(1):
+                        continue
+                    p.msg_override = f"Finished — end of feed at ASOT {p.ep.number:04d}"
                     p.msg_until = time.time() + 6.0
+                    p.render_once(st)
+                    p.finished = True
                     break
-                tick += 1                      # keep the global tick cap binding
-                time.sleep(1.0)                # don't hammer a dead stream
-                continue
 
-            # eof → next episode (auto-advance)
-            if st.state == "eof":
-                if p.advance(1):
-                    continue
-                p.msg_override = f"Finished — end of feed at ASOT {p.ep.number:04d}"
-                p.msg_until = time.time() + 6.0
                 p.render_once(st)
-                p.finished = True
-                break
 
-            p.render_once(st)
-
-            changed = (abs(st.position - p.state.position) > 1.0
-                       or st.muted != p.state.muted
-                       or st.paused != p.state.paused)
-            if changed or time.time() - last_save >= 3.0:
-                p.record(st)
-                last_save = time.time()
-
-            elapsed = time.time() - t0
-            sleep_for = 0.25 if keys else 0.9
-            rem = sleep_for - elapsed
-            if rem > 0.1:
-                time.sleep(min(rem, 0.3))
-            tick += 1
+                changed = (abs(st.position - p.state.position) > 1.0
+                           or st.muted != p.state.muted
+                           or st.paused != p.state.paused)
+                if changed or time.time() - last_save >= 3.0:
+                    p.record(st)
+                    last_save = time.time()
+
+                elapsed = time.time() - t0
+                sleep_for = 0.25 if keys else 0.9
+                rem = sleep_for - elapsed
+                if rem > 0.1:
+                    time.sleep(min(rem, 0.3))
+                tick += 1
+            except Exception:
+                # anything unexpected: erase the bar, print the traceback at
+                # the line above it, then keep drawing the bar at the bottom
+                _print_above(p.ui, traceback.format_exc().rstrip())
+                tick += 1
+                time.sleep(1.0)
+                continue
     except EngineError as e:
-        print(f"[asot] playback error: {e}", file=sys.stderr)
+        _print_above(p.ui, f"[asot] playback error: {e}")
+        rc = 2
+    except KeyboardInterrupt:
+        _print_above(p.ui, "[asot] interrupted")
+        rc = 130
     finally:
         try:
             if kb is not None:

+ 25 - 7
asot_player/ui.py

@@ -64,6 +64,22 @@ class UI:
         except AttributeError:
             tty_ok = False
         self.tty = bool(tty_ok)
+        # screen row the player block starts on (tracked so messages and the
+        # exit cleanup can erase the block and land above it)
+        self.start_line = self.block_start() if self.tty else 1
+
+    def block_start(self, lines: int = 3) -> int:
+        """First row of the bottom-anchored player block (1-based)."""
+        rows = shutil.get_terminal_size().lines
+        return max(1, rows - lines + 1)
+
+    def clear_block(self) -> str:
+        """Erase the player block only, and park the cursor on the line just
+        above it — where normal terminal output continues.  Everything above
+        the block stays on screen."""
+        if not self.tty:
+            return ""
+        return f"\033[0m\033[{max(1, self.start_line - 1)};1H\033[J"
 
     def _status(self, text: str, kind: str) -> str:
         if kind == "ok":
@@ -154,12 +170,13 @@ class UI:
             self.hint_line(),
         ]
         # 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)
+        # (it would scroll the screen when the last line sits at screen end).
+        # Clear to end of screen first: printed messages / tracebacks may have
+        # left an older frame behind, and two bars must never coexist.
         prefix = ""
         if self.tty:
-            rows = shutil.get_terminal_size().lines
-            start = max(1, rows - len(lines) + 1)
-            prefix = f"\033[{start};1H"
+            self.start_line = self.block_start(len(lines))
+            prefix = f"\033[{self.start_line};1H\033[J"
         return prefix + "\n".join(lines) + ("\n" if not self.tty else "")
 
     def plain_render(
@@ -182,8 +199,9 @@ class UI:
         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."""
+        """Terminal reset written on exit: clear just the player block and put
+        the cursor back on the last printed line, where the shell continues.
+        The rest of the screen is left untouched.  Empty in plain mode."""
         if not self.tty:
             return ""
-        return "\033[0m\033[H\033[J"
+        return self.clear_block()

+ 82 - 0
tests/test_asot.py

@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import io
 import os
 import re
 import sys
@@ -14,6 +15,7 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)) + "/..")
 
 import asot_player.main as main_mod
 import asot_player.engine as engine_mod
+import asot_player.ui as ui_mod
 from asot_player.feed import Episode
 from asot_player.id3 import Meta, parse_id3
 from asot_player.engine import FakeEngine, MpEngine
@@ -478,6 +480,86 @@ class PlayerRecoverTests(unittest.TestCase):
         self.assertFalse(p.recover())
 
 
+# --------------------------------------------------------------------------
+# Bottom anchoring: the block erases itself cleanly (never two bars), reports
+# print on the line above it, and exit parks the cursor on that line
+# --------------------------------------------------------------------------
+
+def _tty_ui(rows: int = 24):
+    """A tty UI; terminal size is pinned by the test's patched size."""
+    u = UI(color=False, width=72, bar_width=40)
+    u.tty = True
+    u.start_line = u.block_start()
+    return u
+
+
+class BottomAnchorTests(unittest.TestCase):
+    def setUp(self):
+        self._size = ui_mod.shutil.get_terminal_size
+        ui_mod.shutil.get_terminal_size = lambda *a: os.terminal_size((80, 24))
+        self.ui = _tty_ui()
+
+    def tearDown(self):
+        ui_mod.shutil.get_terminal_size = self._size
+
+    def render(self):
+        return self.ui.render(1301, "01 Jan 2026", "PLAYING", "ok",
+                              10.0, 100.0, 0.8, False)
+
+    def test_render_anchors_and_clears_below(self):
+        out = self.render()
+        # 3 lines at rows-2, clearing everything below so an older frame or a
+        # printed traceback cannot survive as a second bar
+        self.assertTrue(out.startswith("\x1b[22;1H\x1b[J"), repr(out[:14]))
+        self.assertEqual(self.ui.start_line, 22)
+
+    def test_block_start_tracks_terminal_height(self):
+        self.assertEqual(self.ui.block_start(), 22)
+        ui_mod.shutil.get_terminal_size = lambda *a: os.terminal_size((80, 40))
+        self.assertEqual(self.ui.block_start(), 38)
+
+    def test_clear_block_parks_cursor_above_the_block(self):
+        self.render()
+        self.assertEqual(self.ui.clear_block(), "\x1b[0m\x1b[21;1H\x1b[J")
+
+    def test_finish_clears_only_the_block(self):
+        self.render()
+        fin = self.ui.finish()
+        self.assertEqual(fin, "\x1b[0m\x1b[21;1H\x1b[J")
+        self.assertNotIn("\x1b[H\x1b[J", fin)          # no full-screen clear
+
+    def test_non_tty_ui_writes_no_escapes(self):
+        u = UI(color=False, width=72, bar_width=40)
+        u.tty = False
+        self.assertEqual(u.clear_block(), "")
+        self.assertEqual(u.finish(), "")
+
+
+class PrintAboveTests(unittest.TestCase):
+    def test_plain_mode_writes_only_the_text(self):
+        u = UI(color=False, width=72, bar_width=40)     # non-tty
+        buf = io.StringIO()
+        real = sys.stdout
+        sys.stdout = buf
+        try:
+            main_mod._print_above(u, "boom\n")
+        finally:
+            sys.stdout = real
+        self.assertEqual(buf.getvalue(), "boom\n")
+
+    def test_tty_mode_clears_the_block_before_the_text(self):
+        u = _tty_ui()
+        u.render(1301, "01 Jan 2026", "PLAYING", "ok", 10.0, 100.0, 0.8, False)
+        buf = io.StringIO()
+        real = sys.stdout
+        sys.stdout = buf
+        try:
+            main_mod._print_above(u, "boom")
+        finally:
+            sys.stdout = real
+        self.assertEqual(buf.getvalue(), "\x1b[0m\x1b[21;1H\x1b[Jboom\n")
+
+
 # --------------------------------------------------------------------------
 # Keyboard restore: Linux termios has no TCSU — using it silently skipped the
 # restore (AttributeError swallowed), leaving the shell in cbreak mode.