aboutsummaryrefslogtreecommitdiff
path: root/app/ui
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py95
-rw-r--r--app/ui/tui.py51
2 files changed, 117 insertions, 29 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index a57ab6c..fd2d371 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -4,7 +4,7 @@
The hub is the single entry point for the whole workflow: it detects which
backends are already set up and offers to convert the input directory with
one of them, or install/configure/remove a backend via the "Configure
-backends" menu.
+Backends" menu.
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
@@ -46,8 +46,10 @@ from backends import probe as backend_probe
from backends import qwen as qwen_backend
from converter import config
from converter.converter import (
+ AUDIOBOOKS_FOLDER,
AUDIO_FORMATS,
AudiobookConverter,
+ BOOKS_FOLDER,
LOGS_FOLDER,
voice_mode_for,
)
@@ -117,20 +119,21 @@ class _Hub:
def screen_main(self):
statuses = detect_all()
- options = [("Configure backends", "configure_backends")]
+ options = [("Configure Backends", "configure_backends")]
# Converting works against an external (remote) server too, but
# configuring one and starting/stopping its servers need it on
# this machine.
if any(st.installed or st.running for st in statuses):
- options.insert(0, ("Generate audiobooks", "convert"))
+ options.insert(0, ("Generate Audiobooks", "convert"))
if any(st.installed for st in statuses):
options.append(("Start/Stop Backend Servers", "server"))
options.append(("Settings", "settings"))
+ options.append(("Help", "help"))
options.append(("Quit", "quit"))
choice = tui.menu(
self.stdscr, "tts-audiobook-generator", options,
back_value=tui.Wizard.BACK,
- table_title="Backend status", table_rows=_status_rows(statuses),
+ table_title="Backend Status", table_rows=_status_rows(statuses),
notice_lines=_notice_lines())
if choice is tui.Wizard.BACK or choice == "quit":
return None
@@ -140,6 +143,8 @@ class _Hub:
return self.screen_configure
if choice == "server":
return self.screen_server
+ if choice == "help":
+ return self.screen_help
return self.screen_settings
# -- configure / install / uninstall --------------------------------
@@ -191,7 +196,7 @@ class _Hub:
options = []
if needs_build:
- options.append(("Build audio.cpp server", "build_audiocpp",
+ options.append(("Build audio.cpp Server", "build_audiocpp",
("[recommended]", "warn")))
elif missing:
options.append(("Download Missing Models (audio.cpp)",
@@ -205,16 +210,16 @@ class _Hub:
if any(_installable(info, by_key) for info in REGISTRY):
options.append(("Install Backend", "install"))
if any(_updatable(info, by_key) for info in REGISTRY):
- options.append(("Update backends", "update"))
+ options.append(("Update Backends", "update"))
if any(_uninstallable(info, by_key) for info in REGISTRY):
options.append(("Uninstall Backend", "uninstall"))
choice = tui.menu(
- self.stdscr, "Configure backends", options,
+ self.stdscr, "Configure Backends", options,
back_value=tui.Wizard.BACK,
help_lines=["Install, update, configure, or remove a TTS "
"backend."],
- table_title="Backend status",
+ table_title="Backend Status",
table_rows=_status_rows(statuses),
notice_lines=_notice_lines())
if choice is tui.Wizard.BACK:
@@ -357,7 +362,7 @@ class _Hub:
title = "Uninstall Backend" if installed_only else "Install Backend"
key = tui.menu(self.stdscr, title, options,
back_value=tui.Wizard.BACK,
- table_title="Backend status",
+ table_title="Backend Status",
table_rows=_status_rows(statuses),
notice_lines=_notice_lines())
if key is tui.Wizard.BACK:
@@ -377,7 +382,7 @@ class _Hub:
return tui.Wizard.BACK
fields, builders, statuses = prepared
while True:
- result = tui.form(self.stdscr, "Generate audiobooks", fields,
+ result = tui.form(self.stdscr, "Generate Audiobooks", fields,
buttons=("Generate!", "Cancel"),
start_on_buttons=True,
back_value=tui.Wizard.BACK)
@@ -474,6 +479,18 @@ class _Hub:
tui.flash(self.stdscr, str(exc), "err")
return tui.Wizard.BACK
+ # -- help ------------------------------------------------------------
+
+ def screen_help(self):
+ """Show the quick-start Help text in a scrollable dialog.
+
+ A leaf screen: the viewer closes on Esc/q/Enter (its back_value),
+ so the stack pops back to the menu that opened it.
+ """
+ tui.text_viewer(self.stdscr, "Help", _help_lines(),
+ back_value=tui.Wizard.BACK)
+ return tui.Wizard.BACK
+
# -- servers --------------------------------------------------------
def screen_server(self):
@@ -491,7 +508,7 @@ class _Hub:
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.")
+ "'Configure Backends' first.")
return tui.Wizard.BACK
options = []
rows = []
@@ -507,12 +524,12 @@ class _Hub:
"ok" if running else "err", "body"))
if not options:
tui.flash(self.stdscr, "No backend server is configured yet — "
- "use 'Configure backends' first.")
+ "use 'Configure Backends' first.")
return tui.Wizard.BACK
- spec = tui.menu(self.stdscr, "Start / Stop a server", options,
+ spec = tui.menu(self.stdscr, "Start / Stop A Server", options,
back_value=tui.Wizard.BACK,
help_lines=["Start/stop local servers manually."],
- table_title="Server status",
+ table_title="Server Status",
table_rows=rows,
notice_lines=_notice_lines())
if spec is tui.Wizard.BACK:
@@ -617,7 +634,7 @@ def _configurable(info) -> bool:
model weight installs there) is always configurable; other backends
count via their non-trivial setup wizard. Bare qwen — whose wizard asks
no questions: ports live in Settings, the speaker is chosen per run on
- Generate audiobooks — would only ever flash "already installed", so it
+ Generate Audiobooks — would only ever flash "already installed", so it
stays excluded until it ships a dedicated screen.
"""
return info.configure_screen is not None or info.key != "qwen"
@@ -654,7 +671,7 @@ def _uninstallable(info, by_key: dict) -> bool:
def _updatable(info, by_key: dict) -> bool:
- """True when the "Update backends" action has something to do for INFO.
+ """True when the "Update Backends" action has something to do for INFO.
The same on-disk predicate as _uninstallable — update acts on exactly
what uninstall removes (the pip package / the checkout) — plus the
@@ -715,7 +732,7 @@ def _download_models_action(stdscr) -> None:
def _update_backends_action(stdscr) -> None:
- """Run the "Update backends" action inside the TUI.
+ """Run the "Update Backends" action inside the TUI.
One task-view step per installed backend that implements update, in
registry order; each update stops its managed server first (best-
@@ -740,17 +757,17 @@ def _update_backends_action(stdscr) -> None:
steps = [taskview.TaskStep(f"Update {info.label}", make_work(info))
for info in targets]
- rc = taskview.run_steps(stdscr, "Update backends", steps)
+ rc = taskview.run_steps(stdscr, "Update Backends", steps)
if rc == 0:
tui.flash(stdscr, "Every backend is up to date (or just "
"updated).", "ok")
elif rc == 130:
- tui.flash(stdscr, "Update cancelled — re-run 'Update backends' "
+ tui.flash(stdscr, "Update cancelled — re-run 'Update Backends' "
"any time.", "warn")
else:
tui.flash(stdscr, "Some updates did not complete (failed or "
"cancelled) — see the log above. Re-run 'Update "
- "backends' to retry.", "err")
+ "Backends' to retry.", "err")
def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]:
@@ -814,6 +831,36 @@ def _notice_lines() -> Optional[list]:
return None
+def _help_lines() -> list:
+ """The Help screen's quick-start text (folder paths resolved live)."""
+ return [
+ "1. Put your ebooks (epub, txt, or pdf) here:",
+ str(BOOKS_FOLDER),
+ "",
+ "2. Put any .wavs of voices to clone here:",
+ str(common.VOICES_DIR),
+ "",
+ "3. If no backend is installed, go to Configure Backends > "
+ "Install Backend and install audio.cpp.",
+ "",
+ "4. Select TTS models to install. If you're unsure, try these "
+ "qwen3-tts models:",
+ "",
+ "Voice cloning: qwen3_tts_1_7b_base_q8_0",
+ "Built-in-voice: qwen3_tts_1_7b_customvoice_q8_0",
+ "",
+ "It will take a while to build audio.cpp and download the model "
+ "files.",
+ "",
+ "5. Go to Generate Audiobooks. It will automatically start the "
+ "necessary server, generate the books, and stop it. There is no "
+ "need to manually start/stop servers.",
+ "",
+ "6. Generated audiobooks (m4b, mp3, etc.) will output here:",
+ str(AUDIOBOOKS_FOLDER),
+ ]
+
+
def _convert_form(stdscr) -> Optional[tuple]:
"""Build the Convert-books form (fields + builders), or None to go back.
@@ -843,7 +890,7 @@ def _convert_form(stdscr) -> Optional[tuple]:
st, True))
if not entries:
tui.flash(stdscr, "No backend is ready to convert with yet — use "
- "'Configure backends' first.")
+ "'Configure Backends' first.")
return None
builders = {}
# A backend can appear twice (managed + "[remote]"), so the remote
@@ -969,7 +1016,7 @@ def _field_value(fields, key: str, default=None):
# Dim hint shown while editing a Language field (Settings and Generate
-# audiobooks): which languages a model accepts varies by backend/model.
+# Audiobooks): which languages a model accepts varies by backend/model.
_LANGUAGE_EDIT_HINT = ["Check model documentation for supported languages."]
@@ -1056,7 +1103,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
server_json = checkout / "server.json" if checkout else None
if not (server_json and server_json.exists()):
tui.flash(stdscr, "No audio.cpp server.json found — run "
- "'Configure backends' first.")
+ "'Configure Backends' first.")
return None
try:
data = json.loads(server_json.read_text(encoding="utf-8"))
@@ -1178,7 +1225,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
"""Why a clone-capable entry has no selectable voices."""
if local:
return ("No .wav files available to clone — run Configure "
- "backends → audio.cpp and add voices to its "
+ "Backends → audio.cpp and add voices to its "
"voice-clone .wav directory.")
return ("No .wav files available to clone — the audio.cpp server "
f"at {url} hosts none. Configure its voice-clone .wav "
diff --git a/app/ui/tui.py b/app/ui/tui.py
index a647444..933626f 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -1492,9 +1492,50 @@ def checkbox_tree(scr, title: str, families: List[dict],
if options:
checked.add((index, options[0]["key"]))
expanded.add(index)
+ else:
+ _, index, option_key = node
+ if (index, option_key) in checked:
+ checked.discard((index, option_key))
else:
- _, index, option_key = node
- if (index, option_key) in checked:
- checked.discard((index, option_key))
- else:
- checked.add((index, option_key))
+ checked.add((index, option_key))
+
+
+# ---------------------------------------------------------------------------
+# Widget: scrollable text viewer
+# ---------------------------------------------------------------------------
+
+def text_viewer(scr, title: str, lines: Sequence[str],
+ back_value: object = None) -> object:
+ """Show LINES as a read-only dialog; Esc/q/Enter closes it.
+
+ A scrollable pop-up for longer explanatory text (the hub's Help
+ screen). Rows wrap like body rows and stay centered; none is
+ selectable, so no cursor bar is drawn — but the (logical) cursor
+ still scrolls the view into place: Up/Down (or k/j) move one line,
+ Home/End jump to the top/bottom, PageUp/PageDown page, and the
+ frame's scroll indicator (``x/y`` in the border) appears whenever
+ the text overflows the dialog. Closing returns BACK_VALUE when it
+ is given (not None), so the caller can fall back a screen; without
+ one, Esc/q raise WizardCancelled as in menu().
+ """
+ frame = Frame(scr, title,
+ "Up/Down = scroll PgUp/PgDn = page Enter/Esc = close")
+ for line in lines:
+ frame.mark(line)
+ cursor = 0
+ while True:
+ cursor = max(0, min(cursor, len(frame.rows) - 1))
+ frame.cursor = cursor if frame.rows else None
+ frame.draw()
+ key = frame.get_key(cancel_keys=())
+ if key in _CANCEL_KEYS:
+ if back_value is not None:
+ return back_value
+ raise WizardCancelled()
+ moved = frame.motion(key, cursor, len(frame.rows))
+ if moved is not None:
+ cursor = moved
+ elif key in (10, 13):
+ if back_value is not None:
+ return back_value
+ raise WizardCancelled()