aboutsummaryrefslogtreecommitdiff
path: root/app/ui
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py174
-rw-r--r--app/ui/tui.py29
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: