diff options
| author | historia <historiavg@proton.me> | 2026-08-26 03:02:23 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-26 03:02:23 -0400 |
| commit | c147087c9d4707bffaeee58d390653637a21cce8 (patch) | |
| tree | b080c40eaa388609dea38c2cc413cb912aa7b4af /app/ui/runview.py | |
| parent | 8b5c8697740ff415cf7f1d03c9fb5a8c8851d420 (diff) | |
| download | tts-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.py | 188 |
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). |
