#!/usr/bin/env python3 """The full-screen run view: server boot + conversion on one screen. Replaces the old plain-console drop after "Generate!": instead of dumping the user into scrolling log output, this widget keeps them in the TUI and shows the two processes that matter — the TTS server (top) and the conversion (bottom, with a chunk progress bar and elapsed time). The screen is fed by two threads the widget spawns: * the worker runs the same code the console path runs — ``backends.servers.start`` (when the conversion needs to boot a managed server; its progress events stream in as they happen) followed by ``audiobook.convert`` with a ``progress`` callback — so behavior is identical to the CLI, only the presentation differs. When the run does not boot a server itself (one is already running locally, or a remote ``api_url``), it probes the target first and reports it "running" or "down" through the same event stream; * a monitor polls the server URL while the conversion runs and reports when it stops answering. Esc and 'q' do the same thing everywhere: a confirmation to cancel processing, then (when this run started the server) a confirmation to shut it down. On the finished screen the behavior follows the convert form's "Stop server and exit" toggle: ON stops the server automatically, quits the whole TUI, and prints the results summary to the real terminal after curses closes; OFF waits for a key press and returns to the hub menu with the server still running. Errors (the server exits while booting, the server stops mid-conversion, a chunk fails and the book aborts) put the corresponding state into error and wait for a key press, so the failure is never scrolled away. """ import contextlib import threading import time from dataclasses import dataclass from datetime import datetime from typing import Callable, List, Optional from backends import common, servers from ui import tui from ui import viewkit 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. _SERVER_STATES = { "starting": ("starting", "warn"), "ready": ("ready", "ok"), "processing": ("processing", "ok"), "stopping": ("stopping", "warn"), "down": ("not responding", "err"), "error": ("error", "err"), "stopped": ("stopped", "info"), } # Poll cadence for the server monitor (seconds). _MONITOR_INTERVAL = 2.0 class _LogAppender(viewkit.LineSplitter): """A file-like that appends redirected console output to the run's log. The run view owns the screen, so anything a conversion prints to stdout/stderr outside the progress events would otherwise be swallowed silently; this mirrors it line by line (\\n and \\r — see viewkit.LineSplitter) into the run's dated log file (RunConfig.log_path, the audiobook_ day stream), prefixed with the same timestamp format the converter's log records use. Best-effort: write errors are swallowed, and an empty path disables logging. """ def __init__(self, path: str): super().__init__(self._append_line) self._path = path def _append_line(self, line: str) -> None: if not self._path or not line.strip(): return try: with open(self._path, "a", encoding="utf-8") as logf: logf.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} - {line}\n") except OSError: pass @dataclass class RunConfig: """Everything the run view needs to execute one conversion. BACKEND/BACKEND_LABEL identify the chosen backend (label for display); KWARGS are the converter keyword arguments the hub collected (voice, clone, output format, api_url, ...); BOOK_FILES/PLANNED carry the pre-flight overwrite result so the questions are not asked again. SERVER_NAME/SERVER_URL/SERVER_IDENTITY describe the TTS server the conversion talks to (the name is the backends.ServerSpec name; the URL is what the monitor polls). AUTOSTART_SPEC, when not None, is the ServerSpec the worker boots first (the hub only sets it when the server is not already running). RESTART_FIRST marks an autostart of a different kind: the managed qwen server is up but hosting another model than this run selected, so the worker stops it and boots the spec again (with the new model's argv) before converting. LOG_PATH names the converter's log file for the error screen's "details" hint. NOTICE is an optional warning line shown under the progress panel (e.g. a foreign server holding the managed port). MODEL_LABEL/VOICE_LABEL are display-only picks for the run view's header rows — the chosen model and voice, drawn between the Server and Status rows and hidden when the run has none (not converter kwargs). STOP_AND_EXIT ("Stop server and exit after generating") skips the finished screen entirely: the server is stopped automatically, the TUI quits, and the results are printed to the real terminal after curses closes. """ backend: str backend_label: str kwargs: dict book_files: list planned: list server_name: Optional[str] = None server_url: Optional[str] = None server_identity: Optional[str] = None autostart_spec: object = None restart_first: bool = False log_path: str = "" notice: str = "" model_label: str = "" voice_label: str = "" stop_and_exit: bool = False class RunView(ScreenView): """Draws and drives one conversion run; see the module docstring.""" def __init__(self, scr, config: RunConfig, clock: Callable[[], float] = time.time): super().__init__(scr, clock=clock) self.config = config # -- state ----------------------------------------------------- self.phase = "boot" # boot | convert | done | error | cancelled self.server = "starting" self.server_message = "" self.log_tail: List[str] = [] self.book: Optional[tuple] = None # (index, total, name, model) self.chapter: Optional[tuple] = None # (index, total) self.chunk_done = 0 self.chunk_total = 0 self.book_results: List[tuple] = [] # (name, ok, files, error, model) self.error_message = "" self._book_error = "" # current book's failure reason (results row) self.started_server = False # cancelled/cancelling/finished_at: base self.boot_started: Optional[float] = None self.convert_started: Optional[float] = None self.stop_started: Optional[float] = None self.server_log_path = "" self.boot_hint = "" # known-crash hint from an exited/timeout boot # -- threads --------------------------------------------------- self._monitor_stop = threading.Event() self._worker = threading.Thread(target=self._worker_main, daemon=True) # ------------------------------------------------------------------ # Event handling (pure state transitions; no drawing) # ------------------------------------------------------------------ def handle_event(self, event: dict) -> None: """Fold one worker/monitor event into the view state.""" kind = event.get("kind") if kind == "starting": self.phase = "boot" self.server = "starting" self.boot_started = self._now() self.started_server = True self.server_log_path = event.get("log_path") or "" elif kind == "running": self.server = "ready" self.boot_started = self.boot_started or self._now() elif kind == "ready": self.server = "ready" elif kind in ("exited", "timeout"): self.server = "error" self.server_message = { "exited": f"server exited with code " f"{event.get('returncode')}", "timeout": "server did not become ready in time", }[kind] self.log_tail = list(event.get("log_tail") or []) self.boot_hint = event.get("hint") or "" self._record_boot_failure() self._finish("error") elif kind == "cancelled": self.cancelled = True if self.server in ("starting", "ready", "processing"): self.server = "stopped" self._finish("cancelled") elif kind == "server_down": # The monitor fires this mid-conversion; the worker's boot-time # probe of a server this run did not start fires it too. Both # mean the same thing: the target is not answering. if self.phase not in _TERMINAL: self.server = "down" elif kind == "server_stopped": self.server = "stopped" elif kind == "book": self.phase = "convert" self.book = (event.get("index"), event.get("total"), event.get("name") or "", event.get("model")) self.chapter = None self.chunk_done = 0 self.chunk_total = 0 # A new book starts with a clean slate: a failure message from # the previous book (an earlier model of an "All" run) must not # linger under this one's progress. self.error_message = "" self._book_error = "" self.convert_started = self.convert_started or self._now() if self.server == "ready": self.server = "processing" elif kind == "chapter": self.chapter = (event.get("index"), event.get("total")) self.chunk_done = 0 self.chunk_total = 0 elif kind == "chunks": self.chunk_total = event.get("total") or 0 self.chunk_done = 0 elif kind == "chunk_done": self.chunk_done = event.get("chunk") or self.chunk_done self.chunk_total = event.get("total") or self.chunk_total if self.server in ("ready", "processing"): self.server = "processing" elif kind == "chunk_failed": # The converter emits book_done(ok=False) for a chunk failure # with no error of its own, so remember the reason here for # that results row. The live message names the model (an "All" # run stamps its events) and the server's error detail. detail = event.get("error") or "" self._book_error = detail or (f"chunk {event.get('chunk')}/" f"{event.get('total')} failed") message = self._book_error if detail: message = (f"chunk {event.get('chunk')}/" f"{event.get('total')} failed — {detail}") if event.get("model"): message = f"{event['model']}: {message}" self.error_message = message if self.server in ("ready", "processing"): self.server = "ready" elif kind == "book_done": self.book_results.append((event.get("name") or "?", bool(event.get("ok")), list(event.get("files") or []), "" if event.get("ok") else (event.get("error") or self._book_error), event.get("model"))) elif kind == "book_failed": self.book_results.append((event.get("name") or "?", False, list(event.get("files") or []), event.get("error") or "conversion failed", event.get("model"))) self.error_message = self.error_message or \ (event.get("error") or "conversion failed") elif kind == "done": ok = event.get("ok") or 0 total = event.get("total") or 0 if event.get("cancelled"): self.cancelled = True self._finish("cancelled") elif total == 0 and ok == 0 and not self.error_message: # An empty run (no books found, or all skipped): a clean # no-op, not a failure — there was nothing that could fail. self._finish("done") elif total and ok >= total and not self.error_message: self._finish("done") else: self.error_message = self._failure_summary(total) or \ self.error_message or \ f"{total - ok} of {total} book(s) failed" self._finish("error") elif kind == "error": self.error_message = str(event.get("message") or "error") self._finish("error") elif kind == "worker_exit": if self.phase not in _TERMINAL: self.error_message = self.error_message or \ "the conversion ended unexpectedly" self._finish("error") # ------------------------------------------------------------------ # Threads # ------------------------------------------------------------------ def _worker_main(self) -> None: """Boot the server (when asked) and run the conversion.""" import audiobook config = self.config try: with contextlib.redirect_stdout(_LogAppender(config.log_path)): if config.autostart_spec is not None: if config.restart_first: # The managed qwen server hosts another model than # this run selected: stop it so the new model's # argv can boot on the same port. servers.stop(config.autostart_spec.name) ok = servers.start(config.autostart_spec, progress=self._queue.put, cancel=self._cancel) if not ok: if self._cancel.is_set() and self.phase != "error": self._queue.put({"kind": "cancelled"}) return if self._cancel.is_set(): self._queue.put({"kind": "cancelled"}) return # When this run does not boot the server itself, the boot # events never fire — report the target's state so the # server panel moves past "starting" (or shows "not # responding" for a target that never answers). if config.autostart_spec is None and config.server_url: if common.server_running(config.server_url): self._queue.put({"kind": "running", "name": config.server_name or "", "url": config.server_url}) else: self._queue.put({"kind": "server_down"}) # book_files/planned travel on the config fields; dropping # any stray duplicates from kwargs keeps convert()'s call # binding unambiguous. kwargs = {key: value for key, value in config.kwargs.items() if key not in ("book_files", "planned")} audiobook.convert(backend=config.backend, progress=self._queue.put, cancel=self._cancel, book_files=config.book_files, planned=config.planned, **kwargs) except Exception as exc: # noqa: BLE001 - reported to the view self._queue.put({"kind": "error", "message": f"{exc}"}) # The view points failures at the dated log; a crash that # happens before the converter configures logging (e.g. bad # arguments) must still leave its trace there. if self.config.log_path: try: with open(self.config.log_path, "a", encoding="utf-8") as logf: logf.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} - " f"ERROR - {exc}\n") except (OSError, ValueError): pass finally: self._queue.put({"kind": "worker_exit"}) def _monitor_main(self) -> None: """Watch the server URL while converting; report when it drops.""" url = self.config.server_url if not url: return # Give a booting server the full start window before judging it. while not self._monitor_stop.wait(_MONITOR_INTERVAL): if self.phase in _TERMINAL: return if self.phase != "convert": continue if not common.server_running(url): self._queue.put({"kind": "server_down"}) return # ------------------------------------------------------------------ # Main loop # ------------------------------------------------------------------ def run(self) -> bool: """Run the view until the user leaves the terminal screen. Returns True only when the run should end with the whole TUI quitting — the "Stop server and exit" path, which stops the server automatically and records the results as a post-TUI notice. Every other exit (a key press on the summary screen, the Esc cancel flow) lands back on the hub menu. """ return super().run() # ScreenView hooks ------------------------------------------------- def _start_workers(self) -> None: super()._start_workers() monitor = threading.Thread(target=self._monitor_main, daemon=True) monitor.start() 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. Returns True when the run view should return to the menu (the run is over); False when the user changed their mind and the run keeps going. """ self._blocking() try: answer = tui.confirm(self.scr, "Cancel processing?", default=False, cancel_value=False) finally: self._nonblocking() if not answer: return False self.cancelling = True self._cancel.set() # Wind the worker down BEFORE offering the server stop: killing the # server under a still-running request turns the cancellation into # request failures (reported as "failed" instead of "cancelled"). # The join is best-effort — a wedged worker delays but cannot veto # the flow below. self._join_worker() # When this run booted the server, offer to shut it down too (the # boot path kills it itself when cancelled before ready); by now # the worker is done (or wedged beyond saving), so nothing further # is mid-request from this view's side. self._blocking() try: self._confirm_stop_server() finally: self._nonblocking() self._drain() self.render() # One more key press acknowledges the final screen. self._blocking() try: self.scr.getch() except KeyboardInterrupt: pass return True def _confirm_stop_server(self) -> None: """Ask whether to stop the server this run started (Esc-cancel path). The stop runs on a background thread while the screen keeps redrawing the server panel — showing "stopping" with an elapsed clock, mirroring the boot screen — so the SIGTERM grace period never freezes the TUI. Returns once the server is gone. """ if not self.started_server or self._server_stopped_confirmed: return self._server_stopped_confirmed = True name = self.config.server_name if not name or not servers.alive(name): return self._blocking() try: answer = tui.confirm(self.scr, f"Stop the '{name}' server now?", default=True, cancel_value=False) finally: self._nonblocking() if not answer: return self._stop_server_now() def _stop_server_now(self) -> None: """Stop the managed server while the screen keeps repainting. Shared by the Esc-cancel flow and the stop-and-exit path: the stop runs on a background thread and the view drains/render at redraw cadence until it reports done. """ name = self.config.server_name self.server = "stopping" self.stop_started = self._now() done = threading.Event() def _stop() -> None: try: with contextlib.redirect_stdout( _LogAppender(self.config.log_path)): servers.stop(name) finally: self._queue.put({"kind": "server_stopped"}) done.set() threading.Thread(target=_stop, daemon=True).start() while not done.wait(_DRAW_TIMEOUT_MS / 1000.0): self._drain() self.render() self._drain() self.render() def _auto_stop_and_exit(self) -> bool: """The "Stop server and exit" path. No prompts and no key waits: stop the managed server this run started (if any), record the results summary as a post-TUI notice (printed to the real terminal once curses closes), and report "quit" to the hub. An unmanaged/external server is left alone. """ if self.started_server: name = self.config.server_name if name and servers.alive(name): self._stop_server_now() common.record_post_tui_notice(self._summary_text()) return True def _record_boot_failure(self) -> None: """Write a boot failure into the dated run log. The boot's output lives in the server's own log and its events reached only this view, so without this the dated log the failure pointers name would stay empty — "Full details in the log file" must never point at a blank file. Best-effort: write errors are swallowed (the error screen still carries everything). """ path = self.config.log_path if not path: return stamp = f"{datetime.now():%Y-%m-%d %H:%M:%S}" try: with open(path, "a", encoding="utf-8") as logf: logf.write(f"{stamp} - ERROR - {self.server_message}\n") if self.boot_hint: logf.write(f"{stamp} - WARNING - hint: " f"{self.boot_hint}\n") if self.server_log_path: logf.write(f"{stamp} - INFO - the server's own output " f"is in {self.server_log_path}\n") except (OSError, ValueError): pass def _summary_text(self) -> str: """The results summary printed after the TUI exits. Output directory, one line per book with its generated file names and OK/FAIL status (plus the failure detail), a success count, and the total elapsed time. A run that died before any book started (a failed boot, a refused connection) names the reason, the known crash hint, and the server's own log, because "No books were converted" alone would hide why. A failed run ends with the converter's log file path, where the details behind the [FAIL] lines live. """ from converter.converter import AUDIOBOOKS_FOLDER lines = ["Audiobook generation finished", f"Output directory: {AUDIOBOOKS_FOLDER}"] ok_count = 0 for name, ok, files, error, model in self.book_results: ok_count += 1 if ok else 0 label = f"{name} — {model}" if model else name lines.append(f"{'[OK]' if ok else '[FAIL]'} {label}" + (f": {', '.join(files)}" if files else "")) if not ok and error: lines.append(f" {error}") total = len(self.book_results) if total: lines.append(f"{ok_count} of {total} book(s) generated " f"successfully") else: lines.append("No books were converted") if self.phase == "error": reason = self.error_message or self.server_message if reason: lines.append(f"Failure: {reason}") if self.boot_hint: lines.append(f"hint: {self.boot_hint}") if self.server_log_path: lines.append(f"server log: {self.server_log_path}") started = self.convert_started or self.boot_started finished = self.finished_at or self._now() elapsed = finished - (started if started is not None else finished) lines.append(f"Elapsed time: {_format_elapsed(elapsed)}") if (self.phase == "error" or ok_count < total) \ and self.config.log_path: lines.append(f"Full details in the log file: " f"{self.config.log_path}") return "\n".join(lines) def _failure_summary(self, total: int) -> str: """The run-level failure line for the terminal "done" screen. Names every book that produced no audiobook (the generating model on an "All" run, the book file otherwise) instead of leaving a stale per-chunk message as the run's headline. Capped so the two detail lines stay readable; the [FAIL] result rows below carry the full list with each failure's reason. Empty when the results say every book succeeded (the count comes from the events, not the rows — see the caller's fallback). """ failed = [(model or name) for name, ok, _files, _error, model in self.book_results if not ok] if not failed: return "" shown = ", ".join(failed[:5]) if len(failed) > 5: shown += f", … +{len(failed) - 5} more" return f"{len(failed)} of {total} book(s) failed: {shown}" _server_stopped_confirmed = False # ------------------------------------------------------------------ # Drawing # ------------------------------------------------------------------ def render(self) -> None: """Repaint the whole screen from the current state.""" curses, theme = self.curses, self.theme scr = self.scr scr.erase() height, width = scr.getmaxyx() if height < 16 or width < 46: _text(scr, theme, height // 2, 2, "Terminal too small", curses.A_BOLD) scr.refresh() return _box(scr, curses, theme, height, width) _text(scr, theme, 0, 2, " Generating audiobooks ", theme["title"]) inner_x = 3 label_w = 9 # "Server", "Status", "Chunk", "Elapsed" value_x = inner_x + label_w + 1 value_w = width - value_x - 3 # -- server panel ------------------------------------------------ y = 2 url = self.config.server_url or "not managed" _text(scr, theme, y, inner_x, "Server".ljust(label_w), theme["dim"]) _text(scr, theme, y, value_x, _fit(f"{self.config.backend_label} @ {url}", value_w), theme["body"]) y += 1 # The model/voice picks the form recorded (display-only; hidden # when the backend run has none — faster has no model pick, and # voice-design runs pick no voice). if self.config.model_label: _text(scr, theme, y, inner_x, "Model".ljust(label_w), theme["dim"]) _text(scr, theme, y, value_x, _fit(self.config.model_label, value_w), theme["body"]) y += 1 if self.config.voice_label: _text(scr, theme, y, inner_x, "Voice".ljust(label_w), theme["dim"]) _text(scr, theme, y, value_x, _fit(self.config.voice_label, value_w), theme["body"]) y += 1 state_text, state_kind = _SERVER_STATES.get( self.server, (self.server, "info")) if self.server == "starting" and self.boot_started is not None: state_text += f" ({int(self._now() - self.boot_started)}s)" elif self.server == "stopping" and self.stop_started is not None: state_text += f" ({int(self._now() - self.stop_started)}s)" if self.config.autostart_spec is None and self.server == "ready": state_text += " (not started by this run)" _text(scr, theme, y, inner_x, "Status".ljust(label_w), theme["dim"]) _text(scr, theme, y, value_x, _fit(state_text, value_w), theme.get(state_kind, theme["body"])) y += 2 # -- separator --------------------------------------------------- _sep(scr, curses, theme, y, width) y += 2 if self.phase in _TERMINAL: y = self._draw_summary(scr, theme, y, inner_x, label_w, value_x, value_w, width) else: y = self._draw_progress(scr, theme, y, inner_x, label_w, value_x, value_w, width) # -- footer ------------------------------------------------------ if self.cancelling and self.phase not in _TERMINAL: footer = "cancelling..." elif self.server == "stopping": name = self.config.server_name or "server" footer = f"stopping the {name} server..." elif self.phase in _TERMINAL: footer = "press any key to return to the menu" else: footer = "Esc or q: cancel" _text(scr, theme, height - 2, 2, _fit(footer, width - 4), theme["dim"]) scr.refresh() def _draw_progress(self, scr, theme, y, inner_x, label_w, value_x, value_w, width) -> int: """The live panel: book, chapter, chunk bar, elapsed, message.""" # Book line ("— model" appended while an "All" run generates with # a specific model, e.g. "1/6 dune.epub — qwen3_tts_..._q8_0") if self.book is not None: index, total, name, model = self.book book_text = f"{index}/{total} {name}" if model: book_text += f" — {model}" else: book_text = "waiting..." if self.phase == "convert" else "-" _text(scr, theme, y, inner_x, "Book".ljust(label_w), theme["dim"]) _text(scr, theme, y, value_x, _fit(book_text, value_w), theme["body"]) y += 1 # Chapter line (only while a multi-chapter book is converting) if self.chapter is not None: _text(scr, theme, y, inner_x, "Chapter".ljust(label_w), theme["dim"]) _text(scr, theme, y, value_x, _fit(f"{self.chapter[0]}/{self.chapter[1]}", value_w), theme["body"]) y += 1 # Chunk bar bar_label = "Chunk".ljust(label_w) _text(scr, theme, y, inner_x, bar_label, theme["dim"]) bar_x = value_x bar_room = max(10, value_w - 12) filled = 0 if self.chunk_total: filled = round(bar_room * self.chunk_done / self.chunk_total) filled = max(0, min(bar_room, filled)) try: scr.addstr(y, bar_x, " " * filled, theme["bar"]) except Exception: pass _text(scr, theme, y, bar_x + bar_room + 1, f"{self.chunk_done}/{self.chunk_total or '?'}", theme["accent"]) y += 1 # Elapsed started = self.convert_started or self.boot_started or self._now() _text(scr, theme, y, inner_x, "Elapsed".ljust(label_w), theme["dim"]) _text(scr, theme, y, value_x, _format_elapsed(self._now() - started), theme["body"]) y += 2 # Message line (last error / current activity) if self.error_message: _text(scr, theme, y, inner_x, _fit(self.error_message, width - inner_x - 3), theme["err"]) y += 1 elif self.server == "down": _text(scr, theme, y, inner_x, _fit("the server is not responding; the conversion " "will fail", width - inner_x - 3), theme["err"]) y += 1 elif self.config.notice: _text(scr, theme, y, inner_x, _fit(self.config.notice, width - inner_x - 3), theme["warn"]) y += 1 elif self.server_log_path and self.phase == "boot": _text(scr, theme, y, inner_x, _fit(f"loading the model can take a while — log: " f"{self.server_log_path}", width - inner_x - 3), theme["dim"]) y += 1 return y def _draw_summary(self, scr, theme, y, inner_x, label_w, value_x, value_w, width) -> int: """The terminal panel: result, per-book lines, error detail.""" if self.phase == "done": result, kind = "completed", "ok" elif self.phase == "cancelled": result, kind = "cancelled", "warn" else: result, kind = "failed", "err" _text(scr, theme, y, inner_x, "Result".ljust(label_w), theme["dim"]) _text(scr, theme, y, value_x, _fit(result, value_w), theme.get(kind, theme["body"])) y += 1 # Failed rows first (stable sort keeps each group in completion # order): with an "All" run's dozens of results the failures must # not require scrolling to find. A failed row carries its reason # (the server's error, remembered from the chunk_failed event). rows = sorted(self.book_results, key=lambda result: 1 if result[1] else 0) for name, ok, _files, error, model in rows[:5]: mark = "[OK] " if ok else "[FAIL]" label = f"{name} — {model}" if model else name if not ok and error: label = f"{label}: {error}" _text(scr, theme, y, value_x, _fit(f"{mark} {label}", value_w), theme["ok"] if ok else theme["err"]) y += 1 if len(rows) > 5: _text(scr, theme, y, value_x, _fit(f"... and {len(rows) - 5} more", value_w), theme["dim"]) y += 1 if self.phase == "error": detail = self.error_message or self.server_message if detail: for line in _wrap(detail, width - inner_x - 3)[:2]: _text(scr, theme, y, inner_x, line, theme["err"]) y += 1 if self.boot_hint: for line in _wrap(self.boot_hint, width - inner_x - 3)[:2]: _text(scr, theme, y, inner_x, line, theme["warn"]) y += 1 if self.log_tail: for line in self.log_tail[:3]: _text(scr, theme, y, inner_x, _fit(line.strip() or " ", width - inner_x - 3), theme["dim"]) y += 1 if self.server_log_path: _text(scr, theme, y, inner_x, _fit(f"server log: {self.server_log_path}", width - inner_x - 3), theme["dim"]) y += 1 if self.config.log_path: _text(scr, theme, y, inner_x, _fit(f"details: {self.config.log_path}", width - inner_x - 3), theme["dim"]) y += 1 elif self.phase == "cancelled": _text(scr, theme, y, inner_x, "no audiobook was produced for the cancelled book", theme["dim"]) y += 1 return y # --------------------------------------------------------------------------- # Small drawing/formatting helpers (module-level for testability) # --------------------------------------------------------------------------- def run(scr, config: RunConfig) -> bool: """Enter the run view (called inside curses.wrapper by the hub). Returns True when the stop-and-exit toggle fired — see ``RunView.run``. """ view = RunView(scr, config) return view.run()