diff options
Diffstat (limited to 'app/ui')
| -rw-r--r-- | app/ui/hub.py | 174 | ||||
| -rw-r--r-- | app/ui/tui.py | 29 |
2 files changed, 149 insertions, 54 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py index 95ac06a..2ac1551 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -3,7 +3,8 @@ 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, set up a new backend, or configure an existing one. +one of them, or install/configure/remove a backend via the "Configure +backends" menu. Each backend's setup wizard runs in its own curses session, so the hub collects a "command" inside its own wrapper, returns to the plain terminal, and then dispatches — no nested curses sessions. @@ -70,14 +71,14 @@ def run() -> int: kind = command[0] if kind == "quit": return 0 - if kind == "setup": + if kind in ("install", "configure"): info = get(command[1]) if info is not None: info.setup_tui() - elif kind == "configure": + elif kind == "uninstall": info = get(command[1]) - if info is not None and command[2] < len(info.configure_actions): - info.configure_actions[command[2]].run() + if info is not None: + info.uninstall() elif kind == "convert": _dispatch_conversion(command[1], command[2]) elif kind == "server": @@ -88,14 +89,13 @@ def _hub_menu(stdscr) -> Optional[tuple]: """Show the main menu; return a command tuple, or None to quit.""" while True: statuses = detect_all() - options = [("Set up a backend", "setup")] + 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, ("Convert books", "convert")) if any(st.installed for st in statuses): - options.append(("Configure a backend", "configure")) options.append(("Start/Stop Backend Servers", "server")) options.append(("Settings", "settings")) options.append(("Quit", "quit")) @@ -109,12 +109,8 @@ def _hub_menu(stdscr) -> Optional[tuple]: cmd = _convert_menu(stdscr, statuses) if cmd is not None: return cmd - elif choice == "setup": - cmd = _setup_menu(stdscr, statuses) - if cmd is not None: - return cmd - elif choice == "configure": - cmd = _configure_menu(stdscr, statuses) + elif choice == "configure_backends": + cmd = _configure_backends_menu(stdscr, statuses) if cmd is not None: return cmd elif choice == "server": @@ -125,48 +121,130 @@ def _hub_menu(stdscr) -> Optional[tuple]: _settings_menu(stdscr) -def _setup_menu(stdscr, statuses) -> Optional[tuple]: - """Pick a backend to set up. Returns ("setup", key) or None to go back.""" - options = [(info.label, info.key) for info in REGISTRY] - choice = tui.menu(stdscr, "Set up a backend", options, - back_value=_GO_BACK, - help_lines=["Clone/build/install a backend so you can " - "convert with it."], - table_title="Backend status", - table_rows=_status_rows(statuses), - notice_lines=_notice_lines()) - if choice is _GO_BACK or choice is None: - return None - return ("setup", choice) - +def _configure_backends_menu(stdscr, statuses) -> Optional[tuple]: + """One flat menu of backend setup/configure/cleanup actions. -def _configure_menu(stdscr, statuses) -> Optional[tuple]: - """Pick an installed backend and one of its configure actions.""" + Replaces the old "Set up a backend" + "Configure a backend" pair with a + single screen whose 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. Each option returns a command tuple that + ``run`` dispatches after the curses session ends. + """ by_key = {st.key: st for st in statuses} installed = [info for info in REGISTRY if by_key.get(info.key) is not None and by_key[info.key].installed] - if not installed: - tui.flash(stdscr, "No backend is installed yet — use 'Set up a " - "backend' first.") + options: list = [] + if any(info.key not in by_key or not by_key[info.key].installed + for info in REGISTRY): + options.append(("Install Backend", "install")) + for info in installed: + options.append((f"Configure {info.label}", ("configure", info.key))) + + audiocpp_status = by_key.get("audiocpp") + missing = [] + if audiocpp_status is not None and audiocpp_status.installed: + 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: + options.append(("Download Missing Models (audio.cpp)", + "download_models")) + + if installed: + options.append(("Uninstall Backend", "uninstall")) + + choice = tui.menu( + stdscr, "Configure backends", options, + back_value=_GO_BACK, + help_lines=["Install, configure, or remove a TTS backend."], + table_title="Backend status", + table_rows=_status_rows(statuses), + notice_lines=_notice_lines()) + if choice is _GO_BACK or choice is None: return None - options = [(info.label, info.key) for info in installed] - key = tui.menu(stdscr, "Configure a backend", options, + if choice == "install": + return _pick_backend_menu(stdscr, statuses, "Install Backend", + installed_only=False) + if choice == "uninstall": + return _pick_backend_menu(stdscr, statuses, "Uninstall Backend", + installed_only=True) + if choice == "download_models": + _download_models_action(stdscr) + return None + kind, key = choice + return (kind, key) + + +def _pick_backend_menu(stdscr, statuses, title: str, + installed_only: bool) -> Optional[tuple]: + """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 ``(action, key)`` where action is "install" or "uninstall". + """ + 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] + else: + candidates = [info for info in REGISTRY + if by_key.get(info.key) is None + or not by_key[info.key].installed] + if not candidates: + tui.flash(stdscr, "No backends to list here.") + return None + options = [(info.label, info.key) for info in candidates] + key = tui.menu(stdscr, title, options, back_value=_GO_BACK, table_title="Backend status", table_rows=_status_rows(statuses), notice_lines=_notice_lines()) if key is _GO_BACK or key is None: return None - info = get(key) - actions = info.configure_actions - choice = tui.menu( - stdscr, f"Configure {info.label}", - [(action.label, index) for index, action in enumerate(actions)], - back_value=_GO_BACK) - if choice is _GO_BACK or choice is None: - return None - return ("configure", key, choice) + action = "uninstall" if installed_only else "install" + return (action, key) + + +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). + """ + checkout = audiocpp_backend.find_local_checkout() + if checkout is None: + tui.flash(stdscr, "No audio.cpp checkout found — install audio.cpp " + "first.", "err") + return + server_json = checkout / "server.json" + if not server_json.exists(): + tui.flash(stdscr, "No audio.cpp server.json found — configure " + "audio.cpp first.", "err") + return + missing = audiocpp_backend.missing_model_entries(server_json) + if not missing: + tui.flash(stdscr, "Every configured audio.cpp model is already " + "downloaded.", "ok") + return + guidance = audiocpp_backend.missing_model_install_guidance( + checkout, missing) + if not guidance: + 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 _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: @@ -244,7 +322,7 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: st, True)) if not entries: tui.flash(stdscr, "No backend is ready to convert with yet — use " - "'Set up a backend' first.") + "'Configure backends' first.") return None builders = {} for key, _label, st, remote in entries: @@ -439,7 +517,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: 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 " - "'Set up a backend' first.") + "'Configure backends' first.") return None try: data = json.loads(server_json.read_text(encoding="utf-8")) @@ -1108,8 +1186,8 @@ def _server_menu(stdscr, statuses) -> Optional[tuple]: # file), so listing it would dead-end. candidates = [st for st in statuses if st.installed] if not candidates: - tui.flash(stdscr, "No backend is installed yet — use 'Set up a " - "backend' first.") + tui.flash(stdscr, "No backend is installed yet — use " + "'Configure backends' first.") return None options = [(st.label, st.key) for st in candidates] key = tui.menu(stdscr, "Start / Stop a server", options, @@ -1130,7 +1208,7 @@ def _server_actions(stdscr, status) -> Optional[tuple]: specs = status.servers if not specs: tui.flash(stdscr, f"{status.label} has no server configured. " - "Run 'Set up a backend' first.") + "Run 'Configure backends' first.") return None if len(specs) == 1: spec = specs[0] diff --git a/app/ui/tui.py b/app/ui/tui.py index 83ec43d..95130cf 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -1113,7 +1113,8 @@ def browse_directory(scr, title: str, def checkbox_tree(scr, title: str, families: List[dict], footer: Optional[str] = None, expand_all: bool = False, - back_value: object = None) -> List[Tuple[int, str]]: + back_value: object = None, + checked: Optional[set] = None) -> List[Tuple[int, str]]: """Pick model families and packages from an expandable tree. FAMILIES is a list of dicts (one per family) shaped like:: @@ -1134,9 +1135,12 @@ def checkbox_tree(scr, title: str, families: List[dict], cursor. Enter returns the flat list of (family_index, option_key) pairs for every checked option, in tree order; at least one checked option is required. Nothing is checked by default, and with - EXPAND_ALL every family starts expanded. A "[recommended]" tag is - shown only when a - family has more than one option — a single option needs no tag. + EXPAND_ALL every family starts expanded. CHECKED (a set of + (family_index, option_key) pairs) pre-checks those options instead, + expanding every family that holds a checked option and placing the + cursor on the first such family — the "modify an existing config" + entry point. A "[recommended]" tag is shown only when a family has + more than one option — a single option needs no tag. Family and option rows are left-justified like a DOS list. Esc (or 'q') aborts the wizard unless BACK_VALUE is given (not None), in which case either key returns it so the caller can fall back a @@ -1148,9 +1152,14 @@ def checkbox_tree(scr, title: str, families: List[dict], "Enter = accept Esc = cancel") frame = Frame(scr, title, footer) expanded = {index for index in range(len(families))} if expand_all else set() - checked = set() # (family_index, option_key) + checked = set(checked or ()) # (family_index, option_key) - expanded.add(0) + if checked: + for index, _option_key in checked: + expanded.add(index) + expanded.add(0) + else: + expanded.add(0) def family_checked(index: int) -> bool: return any(pair[0] == index for pair in checked) @@ -1170,9 +1179,17 @@ def checkbox_tree(scr, title: str, families: List[dict], nodes.append(("option", index, option["key"])) return nodes + first_checked = min((index for index, _option_key in checked), + default=None) cursor = 0 while True: nodes = visible_nodes() + if first_checked is not None: + for position, node in enumerate(nodes): + if node[0] == "family" and node[1] == first_checked: + cursor = position + break + first_checked = None cursor = max(0, min(cursor, len(nodes) - 1)) frame.rows = [] for node in nodes: |
