aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-25 14:37:16 -0400
committerhistoria <historiavg@proton.me>2026-08-25 14:37:16 -0400
commit061406b608c5a77b33259506648e0166367b2b9f (patch)
treec18633630d160016af285389a629da53a3a4161c /app
parent0badc06550ed2e46c7c4b9f83db755737ffc0412 (diff)
downloadtts-audiobook-generator-061406b608c5a77b33259506648e0166367b2b9f.tar.gz
feat: simpler menu for start/stop backend servers
Diffstat (limited to 'app')
-rw-r--r--app/tests/test_hub.py50
-rw-r--r--app/tests/test_taskview.py60
-rw-r--r--app/tests/test_tui.py28
-rw-r--r--app/ui/hub.py161
-rw-r--r--app/ui/taskview.py23
-rw-r--r--app/ui/tui.py3
6 files changed, 261 insertions, 64 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 68d0f34..9842148 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -323,7 +323,8 @@ class SubmenuStatusTableTests(unittest.TestCase):
"""First picker screen of every flow repeats the backend status table.
Entries themselves stay clean: the configure-backends menu lists flat
- actions, and the Start/Stop menu offers only installed backends.
+ actions, and the Start/Stop menu offers only installed backends'
+ servers, showing running/stopped inline instead of the table.
"""
def _capture_menu(self, captured):
@@ -580,25 +581,30 @@ class SubmenuStatusTableTests(unittest.TestCase):
def test_server_menu_lists_only_installed_backends(self):
captured = {}
installed = BackendStatus("audiocpp", "audio.cpp", installed=True,
- configured=True)
+ configured=True,
+ servers=[ServerSpec("audiocpp", "http://127.0.0.1:8080", [])])
remote = BackendStatus("qwen", "qwen-tts", installed=False,
configured=False, running=True)
gone = BackendStatus("faster", "faster-qwen3-tts", installed=False,
configured=False)
with patch.object(hub.tui, "menu",
self._capture_menu(captured)), \
+ patch.object(hub.common, "server_running",
+ return_value=False), \
patch.object(hub, "detect_all",
return_value=[installed, remote, gone]), \
patch.object(hub.shutil, "which", return_value="/x"):
result = hub._Hub(None).screen_server()
self.assertIs(result, tui.Wizard.BACK)
- # Only the installed backend is offered; a running external server
- # (remote) can't be stopped from here and must not appear.
- self.assertEqual([label for label, _ in captured["options"]],
+ # Only the installed backend's server is offered; a running external
+ # server (remote) can't be stopped from here and must not appear.
+ self.assertEqual([opt[0] for opt in captured["options"]],
["audio.cpp"])
- # The status table still shows all three, states included.
- self.assertEqual([row[0] for row in captured["table_rows"]],
- ["audio.cpp", "qwen-tts", "faster-qwen3-tts"])
+ # The running/stopped state lives in the status table above the
+ # menu (not on the entries, whose colors the selection bar covers).
+ self.assertEqual(captured["table_title"], "Server status")
+ self.assertEqual(captured["table_rows"],
+ [("audio.cpp", "stopped", "err", "body")])
def test_server_menu_flashes_when_nothing_installed(self):
flashed = []
@@ -1961,17 +1967,29 @@ class HubNavigationTests(unittest.TestCase):
status = BackendStatus("qwen", "qwen-tts", installed=True,
configured=True, servers=specs)
registry = [self._info("qwen", "qwen-tts")]
- with patch.object(hub.common, "server_running", return_value=False):
- titles = self._drive(
- ["server", "qwen", "qwen-clone", tui.Wizard.BACK,
- tui.Wizard.BACK, tui.Wizard.BACK, tui.Wizard.BACK],
- [status], registry)
+ titles = []
+ script = ["server", specs[0], tui.Wizard.BACK, tui.Wizard.BACK]
+
+ def menu(stdscr, title, options, **kwargs):
+ titles.append(title)
+ return script.pop(0)
+
+ with patch.object(hub, "REGISTRY", registry), \
+ patch.object(hub, "detect_all", return_value=[status]), \
+ patch.object(hub.tui, "menu", menu), \
+ patch.object(hub.common, "server_running",
+ return_value=False), \
+ patch.object(hub.taskview, "run_steps", return_value=0), \
+ patch.object(hub.tui, "flash", lambda *a, **k: None), \
+ patch.object(hub.audiocpp_backend, "find_local_checkout",
+ return_value=None):
+ hub._Hub(None).run()
+ # Selecting a server toggles it directly (no action sub-menu), then
+ # Esc steps back one screen at a time to the server list and main.
self.assertEqual(
titles,
["tts-audiobook-generator", "Start / Stop a server",
- "qwen-tts server", "qwen-clone (stopped)",
- "qwen-tts server", "Start / Stop a server",
- "tts-audiobook-generator"])
+ "Start / Stop a server", "tts-audiobook-generator"])
def test_esc_on_main_menu_quits(self):
titles = self._drive([tui.Wizard.BACK], [], [])
diff --git a/app/tests/test_taskview.py b/app/tests/test_taskview.py
index d1bc658..5d97902 100644
--- a/app/tests/test_taskview.py
+++ b/app/tests/test_taskview.py
@@ -212,6 +212,66 @@ class RenderTests(_FakeTui, unittest.TestCase):
self.assertIn("Progress", text)
+class NoWaitTests(_FakeTui, unittest.TestCase):
+ """wait_on_finish=False: the view returns to the caller on finish."""
+
+ def make_view(self, steps=(), width=80, height=24, wait_on_finish=False):
+ screen = FakeScreen(width=width, height=height)
+ with patch.object(taskview.TaskView, "_worker_main", lambda self: None):
+ view = taskview.TaskView(screen, "Setup", list(steps),
+ clock=lambda: 1000.0,
+ wait_on_finish=wait_on_finish)
+ return view, screen
+
+ def _strings(self, screen):
+ return " ".join(text for _, _, text, _ in screen.strings)
+
+ def _run_view(self, wait_on_finish):
+ """Run() with a synchronous worker that posts a successful finish."""
+ screen = FakeScreen(width=80, height=24)
+ step = _step("one")
+
+ def fake_worker_main(self):
+ self._queue.put({"kind": "step_start", "index": 0,
+ "title": "one"})
+ self._queue.put({"kind": "step_done", "index": 0, "rc": 0})
+ self._queue.put({"kind": "finish", "phase": "done", "rc": 0})
+
+ with patch.object(taskview.TaskView, "_worker_main",
+ fake_worker_main):
+ view = taskview.TaskView(screen, "Setup", [step],
+ clock=lambda: 1000.0,
+ wait_on_finish=wait_on_finish)
+ # Replace the daemon thread with a synchronous runner so the
+ # events are queued before run() enters its loop (deterministic).
+ view._worker = _SyncWorker(view._worker_main)
+ return view.run()
+
+ def test_returns_immediately_when_not_waiting(self):
+ # No scripted keys: run() must return on finish without asking for one.
+ self.assertEqual(self._run_view(wait_on_finish=False), 0)
+
+ def test_done_footer_omits_key_hint_when_not_waiting(self):
+ view, screen = self.make_view(steps=[_step("one")])
+ view.handle_event({"kind": "step_start", "index": 0, "title": "one"})
+ view.handle_event({"kind": "step_done", "index": 0, "rc": 0})
+ view.handle_event({"kind": "finish", "phase": "done", "rc": 0})
+ view.render()
+ text = self._strings(screen)
+ self.assertIn("completed", text)
+ self.assertNotIn("press any key", text)
+
+
+class _SyncWorker:
+ """A stand-in for threading.Thread that runs the target synchronously."""
+
+ def __init__(self, fn):
+ self._fn = fn
+
+ def start(self):
+ self._fn()
+
+
class LabelTests(unittest.TestCase):
def test_fmt_bytes(self):
self.assertEqual(taskview._fmt_bytes(512), "512B")
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
index 5b5cb3c..030b668 100644
--- a/app/tests/test_tui.py
+++ b/app/tests/test_tui.py
@@ -50,6 +50,7 @@ class FakeCurses:
ACS_LRCORNER = "lr"
ACS_VLINE = "v"
ACS_HLINE = "h"
+ ACS_RARROW = "ra"
class error(Exception):
pass
@@ -254,6 +255,33 @@ class MenuTests(TuiTestCase):
if drawn == "Build audio.cpp server")
self.assertEqual(label_attr, tui._THEME["body"])
+ def test_selected_row_has_arrow_in_the_margin(self):
+ # The highlighted option gets an ACS_RARROW in the blank margin
+ # left of its text; unselected rows draw none.
+ screen = FakeScreen(keys=[10])
+ tui.menu(screen, "Pick", self.OPTIONS)
+ x0, _ = self.dialog_box(screen)
+ arrow_x = x0 + 1 + tui.Frame.LIST_MARGIN - 1
+ arrows = [(y, x) for y, x, ch, _ in screen.chars
+ if ch == FakeCurses.ACS_RARROW]
+ y_first = next(y for y, _, text, _ in screen.strings
+ if text == "first option")
+ self.assertEqual(arrows, [(y_first, arrow_x)])
+
+ def test_arrow_follows_the_cursor(self):
+ # Down moves the arrow onto the second option on the redraw.
+ screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, 10])
+ tui.menu(screen, "Pick", self.OPTIONS)
+ x0, _ = self.dialog_box(screen)
+ arrow_x = x0 + 1 + tui.Frame.LIST_MARGIN - 1
+ arrows = [y for y, x, ch, _ in screen.chars
+ if ch == FakeCurses.ACS_RARROW and x == arrow_x]
+ y_first = next(y for y, _, text, _ in screen.strings
+ if text == "first option")
+ y_second = next(y for y, _, text, _ in screen.strings
+ if text == "second option")
+ self.assertEqual(arrows, [y_first, y_second])
+
class MenuTableTests(TuiTestCase):
"""The optional status table: aligned columns and colored statuses."""
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 285a1cf..29edf9b 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -25,6 +25,7 @@ import io
import json
import re
import shutil
+import sys
import urllib.parse
from datetime import datetime
from pathlib import Path
@@ -366,59 +367,137 @@ class _Hub:
# -- servers --------------------------------------------------------
def screen_server(self):
+ """One flat menu of every installed backend's servers.
+
+ A status table above the menu shows each server's live state —
+ "running" (green) or "stopped" (red) — and selecting an entry
+ toggles it directly (starts a stopped server, stops a running one)
+ without an extra action menu. The state lives in the table, not on
+ the entries, because the menu's selection bar would cover inline
+ colors. A backend with a single server is labelled by its name; a
+ multi-server backend (qwen: CustomVoice + Base) gets one entry per
+ server, suffixed with the server name.
+ """
statuses = detect_all()
candidates = [st for st in statuses if st.installed]
if not candidates:
tui.flash(self.stdscr, "No backend is installed yet — use "
"'Configure backends' first.")
return tui.Wizard.BACK
- options = [(st.label, st.key) for st in candidates]
- key = tui.menu(self.stdscr, "Start / Stop a server", options,
- back_value=tui.Wizard.BACK,
- table_title="Backend status",
- table_rows=_status_rows(statuses),
- notice_lines=_notice_lines())
- if key is tui.Wizard.BACK:
- return tui.Wizard.BACK
- status = next((s for s in statuses if s.key == key), None)
- if status is None:
- return tui.Wizard.BACK
- specs = status.servers
- if not specs:
- tui.flash(self.stdscr, f"{status.label} has no server "
- "configured. Run 'Configure backends' first.")
- return tui.Wizard.BACK
- if len(specs) == 1:
- return functools.partial(self._server_action, specs[0])
- return functools.partial(self._server_spec, status, specs)
-
- def _server_spec(self, status, specs):
- options = [(f"{s.name} ({'running' if common.server_running(s.url) else 'stopped'})",
- s.name) for s in specs]
- name = tui.menu(self.stdscr, f"{status.label} server", options,
- back_value=tui.Wizard.BACK)
- if name is tui.Wizard.BACK:
+ options = []
+ rows = []
+ for st in candidates:
+ specs = st.servers
+ for spec in specs:
+ running = common.server_running(spec.url)
+ label = st.label if len(specs) == 1 \
+ else f"{st.label} — {spec.name}"
+ options.append((label, spec))
+ rows.append((label,
+ "running" if running else "stopped",
+ "ok" if running else "err", "body"))
+ if not options:
+ tui.flash(self.stdscr, "No backend server is configured yet — "
+ "use 'Configure backends' first.")
return tui.Wizard.BACK
- spec = next((s for s in specs if s.name == name), None)
- if spec is None:
+ spec = tui.menu(self.stdscr, "Start / Stop a server", options,
+ back_value=tui.Wizard.BACK,
+ help_lines=["Select a server to start or stop it."],
+ table_title="Server status",
+ table_rows=rows,
+ notice_lines=_notice_lines())
+ if spec is tui.Wizard.BACK:
return tui.Wizard.BACK
- return functools.partial(self._server_action, spec)
+ return functools.partial(self._server_toggle, spec)
- def _server_action(self, spec):
+ def _server_toggle(self, spec):
+ """Start SPEC's server when stopped, stop it when running.
+
+ Runs inside the task view (no console drop); the server module's
+ plain-console output is tee'd to a log file under ``app/logs`` so
+ nothing is lost, and on failure a flash points the user at that
+ file. Returns BACK so the stack lands back on the server list,
+ which re-reads each server's live state.
+ """
running = common.server_running(spec.url)
- action = tui.menu(
- self.stdscr,
- f"{spec.name} ({'running' if running else 'stopped'})",
- [("Start", "start"), ("Stop", "stop")],
- back_value=tui.Wizard.BACK)
- if action is tui.Wizard.BACK:
- return tui.Wizard.BACK
- with tui.suspend(self.stdscr):
+ action = "stop" if running else "start"
+ step, log_path = _server_action_step(spec, action)
+ taskview.run_steps(self.stdscr, f"{action.capitalize()} "
+ f"{spec.name} server", [step],
+ wait_on_finish=False)
+ # Re-check the server instead of trusting the step's exit code
+ # (cancel and failure both come back non-zero): did the toggle take?
+ now_running = common.server_running(spec.url)
+ if action == "start" and not now_running:
+ tui.flash(self.stdscr, f"Could not start the {spec.name} "
+ f"server. See the log: {log_path}", "err")
+ elif action == "stop" and now_running:
+ tui.flash(self.stdscr, f"Could not stop the {spec.name} "
+ f"server. See the log: {log_path}", "err")
+ return tui.Wizard.BACK
+
+
+class _TeeWriter:
+ """A file-like that mirrors writes to a log file and an inner stream.
+
+ Used to capture the server module's plain-console output (the task view
+ already redirects stdout to its line-writer) into a persistent log file
+ under ``servers.LOG_DIR`` without losing the on-screen log tail.
+ """
+
+ def __init__(self, logf, inner):
+ self._logf = logf
+ self._inner = inner
+
+ def write(self, text):
+ if not text:
+ return 0
+ try:
+ self._logf.write(text)
+ except OSError:
+ pass
+ try:
+ self._inner.write(text)
+ except OSError:
+ pass
+ return len(text)
+
+ def flush(self):
+ try:
+ self._logf.flush()
+ except OSError:
+ pass
+ try:
+ self._inner.flush()
+ except OSError:
+ pass
+
+
+def _server_action_step(spec, action: str):
+ """Build a task step that starts/stops SPEC's server, logged to a file.
+
+ ACTION is "start" or "stop". The step runs inside the task view (no
+ console drop): the server module's output is tee'd to
+ ``<servers.LOG_DIR>/<name>-<action>.log`` and to the view's log tail.
+ Returns ``(TaskStep, log_path)`` so the caller can point the user at the
+ file on failure.
+ """
+ log_path = servers.LOG_DIR / f"{spec.name}-{action}.log"
+ title = (f"Start {spec.name} server" if action == "start"
+ else f"Stop {spec.name} server")
+
+ def work(emit, cancel):
+ servers.LOG_DIR.mkdir(parents=True, exist_ok=True)
+ inner = sys.stdout # the task view's line-writer, when run in TUI
+ with log_path.open("w", encoding="utf-8") as logf, \
+ contextlib.redirect_stdout(_TeeWriter(logf, inner)):
if action == "start":
- servers.start(spec)
+ ok = servers.start(spec, cancel=cancel)
else:
- servers.stop(spec.name)
- return tui.Wizard.BACK
+ ok = servers.stop(spec.name)
+ return 0 if ok else 1
+
+ return taskview.TaskStep(title, work), log_path
def _installable(info, by_key: dict) -> bool:
diff --git a/app/ui/taskview.py b/app/ui/taskview.py
index 7f8134b..ea72a86 100644
--- a/app/ui/taskview.py
+++ b/app/ui/taskview.py
@@ -143,13 +143,17 @@ def _lane_step_mark(current: Optional[int],
return "[ ]", "dim"
-def run_steps(scr, title: str, steps: List[TaskStep]) -> int:
+def run_steps(scr, title: str, steps: List[TaskStep],
+ wait_on_finish: bool = True) -> int:
"""Run STEPS in order inside the curses screen; return the first bad rc.
Returns 0 when every step succeeded, otherwise the first non-zero exit
- code (a cancelled run returns a non-zero code too).
+ code (a cancelled run returns a non-zero code too). With WAIT_ON_FINISH
+ False the view returns to the caller as soon as the run reaches a
+ terminal phase instead of waiting for a key press (used by the hub's
+ start/stop actions, which land straight back on the menu).
"""
- view = TaskView(scr, title, steps)
+ view = TaskView(scr, title, steps, wait_on_finish=wait_on_finish)
return view.run()
@@ -192,12 +196,14 @@ class TaskView:
"""Draws and drives one list of setup steps; see the module docstring."""
def __init__(self, scr, title: str, steps: List[TaskStep],
- clock: Callable[[], float] = time.time):
+ clock: Callable[[], float] = time.time,
+ wait_on_finish: bool = True):
import curses
self.curses = curses
self.scr = scr
self.title = title
self.steps = steps
+ self.wait_on_finish = wait_on_finish
self.theme = tui._ensure_theme(curses)
self._clock = clock
# -- state -----------------------------------------------------
@@ -330,6 +336,8 @@ class TaskView:
while True:
self._drain()
self.render()
+ if self.phase in _TERMINAL and not self.wait_on_finish:
+ return self._result_rc()
key = self._get_key()
if key is None:
continue
@@ -458,14 +466,15 @@ class TaskView:
break
# -- footer ----------------------------------------------------
+ suffix = "" if not self.wait_on_finish else " — press any key to return"
if self.phase == "done":
- footer = "completed — press any key to return"
+ footer = "completed" + suffix
kind = "ok"
elif self.phase == "cancelled":
- footer = "cancelled — press any key to return"
+ footer = "cancelled" + suffix
kind = "warn"
elif self.phase == "error":
- footer = "finished with errors — press any key to return"
+ footer = "finished with errors" + suffix
kind = "err"
elif self.cancelling:
footer = "cancelling..."
diff --git a/app/ui/tui.py b/app/ui/tui.py
index 0d47863..34c9a3e 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -449,6 +449,9 @@ class Frame:
selected = logical == self.cursor and row["selectable"]
if selected:
_addstr(scr, y, inner_x, " " * inner_w, theme["bar"])
+ if row["align"] == "left":
+ _addch(scr, y, inner_x + self.LIST_MARGIN - 1,
+ self.curses.ACS_RARROW, theme["bar"])
if row["segments"] is not None:
self._draw_segments_row(y, row, inner_x, inner_w, selected)
else: