From 2a9a78dd1caec0811ed2640b28375890ee96e4cc Mon Sep 17 00:00:00 2001 From: historia Date: Thu, 27 Aug 2026 17:46:02 -0400 Subject: feat: help menu in tui --- app/ui/hub.py | 95 ++++++++++++++++++++++++++++++++++++++++++++--------------- app/ui/tui.py | 51 ++++++++++++++++++++++++++++---- 2 files changed, 117 insertions(+), 29 deletions(-) (limited to 'app/ui') 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() -- cgit v1.2.3