aboutsummaryrefslogtreecommitdiff
path: root/app/ui/hub.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 23:49:34 -0400
committerhistoria <historiavg@proton.me>2026-08-24 23:49:34 -0400
commitfe4b2b9eb7fb8aac81f65630720c9079d0a3121a (patch)
tree9c5e5f56d0b931d25e6580f10d09453505eddc2f /app/ui/hub.py
parentf4b1de303704e13818259d5057d176cd841b6ed8 (diff)
downloadtts-audiobook-generator-fe4b2b9eb7fb8aac81f65630720c9079d0a3121a.tar.gz
feat: user-friendly menu gating, clearer install/configure path for backends
Diffstat (limited to 'app/ui/hub.py')
-rw-r--r--app/ui/hub.py165
1 files changed, 122 insertions, 43 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index d71fa4c..247dd49 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -10,8 +10,10 @@ The entire hub runs in one curses session, driven by a single ``tui.Wizard``
stack of screens (the ``_Hub`` class below). Every menu/action is a screen
that returns the next screen, ``Wizard.BACK`` (Esc/q) to pop one screen, or
None to quit. Backend setup wizards and the conversion run view run as
-opaque leaf screens on this same session (console tails under
-``tui.suspend``); a leaf screen finishes by returning ``Wizard.BACK``, so
+opaque leaf screens on this same session (the wizards' long setup tails and
+model downloads run inside the ``ui.taskview`` task view, and only the quick
+uninstall/start/stop actions use ``tui.suspend``); a leaf screen finishes by
+returning ``Wizard.BACK``, so
the stack lands back on the menu that launched it. Esc therefore steps back
exactly one screen everywhere — on the main menu (an empty stack) it quits.
'q' mirrors Esc on every screen that has no typed text.
@@ -56,7 +58,7 @@ from converter.tts import (
BACKEND_QWEN,
normalize_language,
)
-from ui import runview, tui
+from ui import runview, taskview, tui
_CANCEL = object() # sentinel: a convert preflight confirm backed out
@@ -74,6 +76,12 @@ def run() -> int:
return 0
except KeyboardInterrupt:
return 130
+ finally:
+ # The curses session is over and the terminal is restored: surface
+ # anything setup steps queued for the console (e.g. a failed audio.cpp
+ # build's copy-pastable command and build log path).
+ for notice in common.drain_post_tui_notices():
+ print(notice)
return 0
@@ -134,11 +142,15 @@ class _Hub:
def screen_configure(self):
"""One flat menu of backend setup/configure/cleanup actions.
- Options are populated from the detected statuses: install (any
- uninstalled backend), configure each installed backend,
- download/delete audio.cpp models (when a server.json references
- models on/off disk), and uninstall. Selecting one pushes the next
- screen; Esc pops back to the main menu.
+ The audio.cpp "next step" — build its server (when a checkout has
+ no binary) or download its missing models (only once built, so
+ build > configure > download — Build and Download never appear
+ together) — heads the menu with a yellow ``[recommended]`` tag,
+ separated from the rest by a blank line. The remaining options are
+ populated from the detected statuses: configure each installed
+ backend, install (backends with nothing on disk), and uninstall.
+ Selecting one pushes the next screen; Esc pops back to the main
+ menu.
"""
while True:
statuses = detect_all()
@@ -146,25 +158,42 @@ class _Hub:
installed = [info for info in REGISTRY
if by_key.get(info.key) is not None
and by_key[info.key].installed]
- options = [(f"Configure {info.label}", ("configure", info.key))
- for info in installed]
- if any(info.key not in by_key or not by_key[info.key].installed
- for info in REGISTRY):
- options.append(("Install Backend", "install"))
audiocpp_status = by_key.get("audiocpp")
missing = []
- if audiocpp_status is not None and audiocpp_status.installed:
+ needs_build = False
+ if audiocpp_status is not None:
checkout = audiocpp_backend.find_local_checkout()
- server_json = checkout / "server.json" if checkout else None
- if server_json is not None and server_json.exists():
- missing = audiocpp_backend.missing_model_entries(
- server_json)
- if missing:
+ if checkout is not None:
+ built = audiocpp_backend.find_audiocpp_server_bin(
+ checkout) is not None
+ if not built:
+ needs_build = True
+ server_json = checkout / "server.json"
+ # Models can only be downloaded once the server binary
+ # exists (build > configure > download), so Build and
+ # Download never appear together.
+ if built and audiocpp_status.configured \
+ and server_json.exists():
+ missing = audiocpp_backend.missing_model_entries(
+ server_json)
+
+ options = []
+ if needs_build:
+ options.append(("Build audio.cpp server", "build_audiocpp",
+ ("[recommended]", "warn")))
+ elif missing:
options.append(("Download Missing Models (audio.cpp)",
- "download_models"))
-
- if installed:
+ "download_models",
+ ("[recommended]", "warn")))
+ if needs_build or missing:
+ options.append(tui.MENU_SEPARATOR)
+
+ options += [(f"Configure {info.label}", ("configure", info.key))
+ for info in installed]
+ if any(_installable(info, by_key) for info in REGISTRY):
+ options.append(("Install Backend", "install"))
+ if any(_uninstallable(info, by_key) for info in REGISTRY):
options.append(("Uninstall Backend", "uninstall"))
choice = tui.menu(
@@ -183,6 +212,9 @@ class _Hub:
if choice == "download_models":
_download_models_action(self.stdscr)
continue # an inline action: re-show this same menu
+ if choice == "build_audiocpp":
+ audiocpp_backend.build_screen(self.stdscr)
+ continue # an inline action: re-show this same menu
_kind, key = choice
info = get(key)
if info is None:
@@ -223,20 +255,22 @@ class _Hub:
def _pick_backend(self, installed_only: bool):
"""Pick a backend for the Install/Uninstall actions.
- With INSTALLED_ONLY False every backend is listed (the install
- list); with it True only the currently-installed ones are (the
- uninstall list). Returns a registry entry, or None to go back.
+ With INSTALLED_ONLY False every backend with nothing on disk yet is
+ listed (the install list — audio.cpp only without a checkout, since
+ a downloaded-but-unbuilt checkout is past install); with it True the
+ ones with something on disk to remove are (the uninstall list —
+ including a downloaded-but-unbuilt audio.cpp checkout, which
+ ``uninstall`` deletes whole). Returns a registry entry, or None to
+ go back.
"""
statuses = detect_all()
by_key = {st.key: st for st in statuses}
if installed_only:
candidates = [info for info in REGISTRY
- if by_key.get(info.key) is not None
- and by_key[info.key].installed]
+ if _uninstallable(info, by_key)]
else:
candidates = [info for info in REGISTRY
- if by_key.get(info.key) is None
- or not by_key[info.key].installed]
+ if _installable(info, by_key)]
if not candidates:
tui.flash(self.stdscr, "No backends to list here.")
return None
@@ -384,15 +418,45 @@ class _Hub:
return tui.Wizard.BACK
+def _installable(info, by_key: dict) -> bool:
+ """True when INFO has nothing on disk yet — an install-entry candidate.
+
+ audio.cpp is installable only without a checkout: a downloaded-but-unbuilt
+ checkout is already past the install step (its next action is the hub's
+ Build entry), so listing it under "Install Backend" would duplicate that
+ and suggest re-running setup from scratch. The other backends are
+ installable while not installed.
+ """
+ if info.key == "audiocpp":
+ return audiocpp_backend.find_local_checkout() is None
+ status = by_key.get(info.key)
+ return status is None or not status.installed
+
+
+def _uninstallable(info, by_key: dict) -> bool:
+ """True when INFO has something on disk that uninstall removes.
+
+ audio.cpp's ``installed`` flag means *built*, so a downloaded-but-unbuilt
+ checkout would otherwise miss the Uninstall menu — but its checkout
+ (binary, models, server.json) lives on disk and ``uninstall()`` removes
+ it, so it counts too. The other backends' ``installed`` already covers
+ everything their uninstaller touches.
+ """
+ if info.key == "audiocpp":
+ return audiocpp_backend.find_local_checkout() is not None
+ status = by_key.get(info.key)
+ return status is not None and status.installed
+
+
def _download_models_action(stdscr) -> None:
"""Run the "Download Missing Models (audio.cpp)" action inside the TUI.
- Computes the missing models; when they map to install commands it
- suspends curses to stream the downloads, then flashes a result — instead
- of silently returning to the main menu. When the checkout/server.json is
- missing, nothing is missing, or the models do not map to an install
- command, it flashes an explanatory notice (the latter explaining how to
- install each model by hand).
+ Computes the missing models; when they map to install commands it runs
+ the downloads in the task view (with real byte progress and cancellation)
+ and flashes a result — instead of dropping to the console. When the
+ checkout/server.json is missing, nothing is missing, or the models do not
+ map to an install command, it flashes an explanatory notice (the latter
+ explaining how to install each model by hand).
"""
checkout = audiocpp_backend.find_local_checkout()
if checkout is None:
@@ -415,10 +479,16 @@ def _download_models_action(stdscr) -> None:
tui.flash(stdscr, audiocpp_backend.hand_install_guidance(
checkout, missing), "err")
return
- with tui.suspend(stdscr):
- audiocpp_backend.install_models(checkout, guidance)
- tui.flash(stdscr, "Model download finished. See the output above for "
- "any warnings.", "ok")
+
+ def run(emit, cancel):
+ audiocpp_backend.install_models(checkout, guidance,
+ emit=emit, cancel=cancel)
+ return 0
+
+ taskview.run_steps(stdscr, "Download models",
+ [taskview.TaskStep("Download missing models", run)])
+ tui.flash(stdscr, "Model download finished. Any warnings were shown in "
+ "the log.", "ok")
def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]:
@@ -428,10 +498,13 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]:
server this tool started (``status.managed``) — or remotely — a server
found by probing its remote URL (``status.remote``); the text names
which, e.g. "running [local]", "running [remote]", or
- "running [local, remote]". Otherwise 'installed' (orange/warn) when the
- backend is present on disk, or 'unavailable' (red/err); a backend that is
- neither installed nor running is unusable, so its name is dimmed
- (NAME_KIND). A multi-model backend (qwen) also names which models
+ "running [local, remote]". Otherwise a backend set up only part-way
+ (``status.partial``) shows that label verbatim (amber), e.g. audio.cpp's
+ "downloaded (not built)" or "built (not configured)"; 'installed'
+ (orange/warn) when the backend is present on disk; or 'unavailable'
+ (red/err). A backend that is neither installed nor running is unusable,
+ so its name is dimmed (NAME_KIND). A multi-model backend (qwen) also
+ names which models
answered in parentheses, e.g. "running [local, remote] (Base,
CustomVoice)". CURSES has no true orange, so the theme's yellow 'warn' is
used; it renders amber/orange on most terminals.
@@ -448,6 +521,12 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]:
if status.running_models:
text += " (" + ", ".join(status.running_models) + ")"
return (text, "ok", "body")
+ if status is not None and status.partial:
+ # Part-way set up (audio.cpp: "downloaded (not built)" /
+ # "built (not configured)"): amber text, name dimmed while the
+ # backend is still unusable.
+ name_kind = "dim" if not status.installed else "body"
+ return (status.partial, "warn", name_kind)
if status is not None and status.installed:
if status.models_missing and not status.running:
return ("installed (models missing)", "warn", "body")