aboutsummaryrefslogtreecommitdiff
path: root/app/ui/runview.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 03:02:23 -0400
committerhistoria <historiavg@proton.me>2026-08-26 03:02:23 -0400
commitc147087c9d4707bffaeee58d390653637a21cce8 (patch)
treeb080c40eaa388609dea38c2cc413cb912aa7b4af /app/ui/runview.py
parent8b5c8697740ff415cf7f1d03c9fb5a8c8851d420 (diff)
downloadtts-audiobook-generator-c147087c9d4707bffaeee58d390653637a21cce8.tar.gz
refactor: put shared ui screen code into ui.viewkit
Diffstat (limited to 'app/ui/runview.py')
-rw-r--r--app/ui/runview.py188
1 files changed, 30 insertions, 158 deletions
diff --git a/app/ui/runview.py b/app/ui/runview.py
index 7151ecb..a954499 100644
--- a/app/ui/runview.py
+++ b/app/ui/runview.py
@@ -32,16 +32,16 @@ import contextlib
import io
import threading
import time
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from datetime import datetime
-from queue import Empty, Queue
from typing import Callable, List, Optional
from backends import common, servers
from ui import tui
-
-# Terminal states: the run is over and the screen waits for a key.
-_TERMINAL = ("done", "error", "cancelled")
+from ui.viewkit import (TERMINAL_PHASES as _TERMINAL,
+ DRAW_TIMEOUT_MS as _DRAW_TIMEOUT_MS,
+ ScreenView, _box, _fit, _format_elapsed, _sep,
+ _text, _wrap)
# Server panel states -> (text, theme kind) with the elapsed clock added
# while booting.
@@ -55,8 +55,7 @@ _SERVER_STATES = {
"stopped": ("stopped", "info"),
}
-# Redraw cadence / poll cadence (milliseconds / seconds).
-_DRAW_TIMEOUT_MS = 250
+# Poll cadence for the server monitor (seconds).
_MONITOR_INTERVAL = 2.0
@@ -95,17 +94,13 @@ class RunConfig:
stop_and_exit: bool = False
-class RunView:
+class RunView(ScreenView):
"""Draws and drives one conversion run; see the module docstring."""
def __init__(self, scr, config: RunConfig,
clock: Callable[[], float] = time.time):
- import curses
- self.curses = curses
- self.scr = scr
+ super().__init__(scr, clock=clock)
self.config = config
- self.theme = tui._ensure_theme(curses)
- self._clock = clock
# -- state -----------------------------------------------------
self.phase = "boot" # boot | convert | done | error | cancelled
self.server = "starting"
@@ -126,8 +121,6 @@ class RunView:
self.stop_started: Optional[float] = None
self.server_log_path = ""
# -- threads ---------------------------------------------------
- self._queue: Queue = Queue()
- self._cancel = threading.Event()
self._monitor_stop = threading.Event()
self._worker = threading.Thread(target=self._worker_main,
daemon=True)
@@ -228,15 +221,6 @@ class RunView:
"the conversion ended unexpectedly"
self._finish("error")
- def _finish(self, phase: str) -> None:
- """Enter a terminal phase, freezing the elapsed clock."""
- self.phase = phase
- if self.finished_at is None:
- self.finished_at = self._now()
-
- def _now(self) -> float:
- return self._clock()
-
# ------------------------------------------------------------------
# Threads
# ------------------------------------------------------------------
@@ -313,57 +297,30 @@ class RunView:
notice. Every other exit (a key press on the summary screen, the Esc
cancel flow) lands back on the hub menu.
"""
- scr = self.scr
- try:
- self.scr.timeout(_DRAW_TIMEOUT_MS)
- except Exception:
- pass
- self._worker.start()
+ return super().run()
+
+ # ScreenView hooks -------------------------------------------------
+
+ def _start_workers(self) -> None:
+ super()._start_workers()
monitor = threading.Thread(target=self._monitor_main, daemon=True)
monitor.start()
- try:
- while True:
- self._drain()
- # The stop-and-exit setting never waits for a key: leave as
- # soon as the run ends (an explicit Esc cancel keeps its own
- # interactive flow instead).
- if self.config.stop_and_exit and self.phase in _TERMINAL \
- and self.phase != "cancelled":
- return self._auto_stop_and_exit()
- self.render()
- key = self._get_key()
- if key is None:
- continue
- if self.phase in _TERMINAL:
- return False
- if key in (27, ord("q"), 3) and not self.cancelling:
- if self._prompt_cancel():
- return False
- finally:
- self._monitor_stop.set()
- self._cancel.set()
- # Leave the screen blocking again: the timed redraw getch must
- # not make later hub dialogs (e.g. tui.flash) dismiss themselves.
- self._blocking()
-
- def _get_key(self) -> Optional[int]:
- """One key from the screen (None on the redraw timeout)."""
- try:
- key = self.scr.getch()
- except KeyboardInterrupt:
- return 3
- if key == -1:
- return None
- return key
-
- def _drain(self) -> None:
- """Fold every queued event into the state."""
- while True:
- try:
- event = self._queue.get_nowait()
- except Empty:
- return
- self.handle_event(event)
+
+ def _early_exit(self):
+ # The stop-and-exit setting never waits for a key: leave as
+ # soon as the run ends (an explicit Esc cancel keeps its own
+ # interactive flow instead).
+ if self.config.stop_and_exit and self.phase in _TERMINAL \
+ and self.phase != "cancelled":
+ return self._auto_stop_and_exit()
+ return None
+
+ def _terminal_result(self) -> bool:
+ return False
+
+ def _on_stop(self) -> None:
+ self._monitor_stop.set()
+ super()._on_stop()
def _prompt_cancel(self) -> bool:
"""The Esc/q flow: confirm cancel, then confirm stopping the server.
@@ -494,20 +451,6 @@ class RunView:
lines.append(f"Elapsed time: {_format_elapsed(elapsed)}")
return "\n".join(lines)
- def _blocking(self) -> None:
- """Make getch block (used while a confirm dialog owns the screen)."""
- try:
- self.scr.timeout(-1)
- except Exception:
- pass
-
- def _nonblocking(self) -> None:
- """Restore the redraw-cadence getch timeout."""
- try:
- self.scr.timeout(_DRAW_TIMEOUT_MS)
- except Exception:
- pass
-
_server_stopped_confirmed = False
# ------------------------------------------------------------------
@@ -700,77 +643,6 @@ class RunView:
# Small drawing/formatting helpers (module-level for testability)
# ---------------------------------------------------------------------------
-def _text(scr, theme, y, x, text, attr) -> None:
- """addstr wrapper that ignores out-of-bounds errors."""
- try:
- scr.addstr(y, x, text, attr)
- except Exception:
- pass
-
-
-def _box(scr, curses, theme, height, width) -> None:
- """Draw the full-screen frame."""
- border = theme["border"]
- try:
- scr.addch(0, 0, curses.ACS_ULCORNER, border)
- scr.addch(0, width - 1, curses.ACS_URCORNER, border)
- scr.addch(height - 1, 0, curses.ACS_LLCORNER, border)
- scr.addch(height - 1, width - 1, curses.ACS_LRCORNER, border)
- scr.hline(0, 1, curses.ACS_HLINE, width - 2, border)
- scr.hline(height - 1, 1, curses.ACS_HLINE, width - 2, border)
- for y in range(1, height - 1):
- scr.addch(y, 0, curses.ACS_VLINE, border)
- scr.addch(y, width - 1, curses.ACS_VLINE, border)
- except Exception:
- pass
-
-
-def _sep(scr, curses, theme, y, width) -> None:
- """A horizontal separator line inside the frame."""
- try:
- scr.addch(y, 0, curses.ACS_LTEE, theme["border"])
- scr.addch(y, width - 1, curses.ACS_RTEE, theme["border"])
- scr.hline(y, 1, curses.ACS_HLINE, width - 2, theme["dim"])
- except Exception:
- pass
-
-
-def _fit(text: str, width: int) -> str:
- """Truncate TEXT to WIDTH columns, appending '~' when cut."""
- if width < 1:
- return ""
- if len(text) <= width:
- return text
- return text[: max(0, width - 1)] + "~"
-
-
-def _wrap(text: str, width: int) -> List[str]:
- """Greedy word wrap (no textwrap dependency on curses chars)."""
- lines: List[str] = []
- current = ""
- for word in text.split():
- candidate = f"{current} {word}".strip()
- if len(candidate) <= max(10, width):
- current = candidate
- else:
- if current:
- lines.append(current)
- current = word
- if current:
- lines.append(current)
- return lines
-
-
-def _format_elapsed(seconds: float) -> str:
- """Format a duration as H:MM:SS / M:SS."""
- seconds = max(0, int(seconds))
- hours, remainder = divmod(seconds, 3600)
- minutes, secs = divmod(remainder, 60)
- if hours:
- return f"{hours}:{minutes:02d}:{secs:02d}"
- return f"{minutes}:{secs:02d}"
-
-
def run(scr, config: RunConfig) -> bool:
"""Enter the run view (called inside curses.wrapper by the hub).