aboutsummaryrefslogtreecommitdiff
path: root/app/ui
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 20:43:05 -0400
committerhistoria <historiavg@proton.me>2026-08-26 20:43:05 -0400
commit65c6f737f1545ef225768af897acd20f163a4fb4 (patch)
tree9c0974944191e50b9caa90591dfcb7f97c659770 /app/ui
parent975bd960fd07e75799b8e3adc4c0033046b34792 (diff)
downloadtts-audiobook-generator-65c6f737f1545ef225768af897acd20f163a4fb4.tar.gz
fix: settings menu only prompts to save after change
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py69
-rw-r--r--app/ui/tui.py14
2 files changed, 79 insertions, 4 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index d9d291b..0fd1b4d 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -414,6 +414,7 @@ class _Hub:
def screen_settings(self):
fields = _settings_fields()
+ original = {field["key"]: field["value"] for field in fields}
while True:
result = tui.form(self.stdscr, "Settings", fields,
back_value=tui.Wizard.BACK)
@@ -425,7 +426,10 @@ class _Hub:
tui.flash(self.stdscr, str(exc), "err")
return tui.Wizard.BACK
# q/Esc (or the Cancel button) left the form without saving:
- # ask whether the edits should be kept before discarding them.
+ # with no edits there is nothing to keep, so go straight back;
+ # otherwise ask whether the edits should be preserved.
+ if not _settings_changed(fields, original):
+ return tui.Wizard.BACK
answer = tui.confirm_yn_cancel(self.stdscr, "Save settings?")
if answer == "cancel":
continue # back into the form, edits intact
@@ -1022,6 +1026,17 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
if data.get("voice_dir") else []
voice_cache: dict = {} # model id -> voices (local: shared list)
+ # Per-family request-option support comes from this machine's audio.cpp
+ # checkout model_specs, best effort for both entries: the server's HTTP
+ # API does not report it. A "[remote]" entry is classified from the same
+ # local specs when a family matches; with no checkout every family counts
+ # as unknown and the Request options field stays hidden.
+ specs_checkout = checkout if local \
+ else audiocpp_backend.find_local_checkout()
+ option_families = (
+ audiocpp_backend.request_options_families(specs_checkout)
+ if specs_checkout is not None else {})
+
def voices_for(model_id: str) -> list:
if local:
return local_voices
@@ -1119,6 +1134,31 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
entry.get("id") or "")
return f"{entry.get('id') or '':<{id_width}} ({capability})"
+ def entry_supports_options(fs) -> bool:
+ """True when the selected entry's family defines request options.
+
+ Resolved strictly from this machine's model_specs: a family the
+ specs prove unable to read options, or cannot classify at all,
+ keeps the field hidden (unknown support is treated as no).
+ """
+ family = model_entry(fs).get("family") or ""
+ return audiocpp_backend.supports_request_options(
+ option_families, family) is True
+
+ # Edit-dialog help lines: short, and identical for every capability
+ # (design-model validation already explains its own requirement).
+ INSTRUCTIONS_HELP = [
+ "TTS style instructions. Supported by some clone models. Example:",
+ '"Speak in a calm, soothing, and happy tone."',
+ ]
+ # Edit-dialog help for the Request options field — at most 2 lines;
+ # unsupported keys are ignored server-side, so nothing else needs
+ # spelling out here.
+ OPTIONS_HELP = [
+ "KEY=VALUE items, comma/space separated; unsupported keys ignored.",
+ "Examples: emotion=neutral, speed=1.1, temperature=0.8",
+ ]
+
fields = [
{"key": prefix + "model_id", "label": "Model", "kind": "choice",
"value": default_model,
@@ -1142,13 +1182,17 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
# that read instructions, the voice itself (instruction-voice mode).
{"key": prefix + "instructions", "label": "Instructions", "kind": "text",
"value": config.AUDIOCPP_INSTRUCTIONS,
+ "help": INSTRUCTIONS_HELP,
"validate": lambda value: None
if (model_capability(fields) != AUDIOCPP_VOICE_DESIGN or str(value).strip())
else "Describe the voice, e.g. 'A warm female narrator'"},
- # Free-form per-model controls (--option KEY=VALUE on the CLI),
- # e.g. "emotion=neutral, speed=1.1".
+ # Free-form per-model controls (--option KEY=VALUE on the CLI).
+ # Shown only for families whose audio.cpp spec declares request
+ # options; unknown-support families keep it hidden.
{"key": prefix + "request_options", "label": "Request options",
"kind": "text", "value": "",
+ "visible": entry_supports_options,
+ "help": OPTIONS_HELP,
"validate": _validate_request_options},
]
@@ -1314,6 +1358,25 @@ def _faster_fields(stdscr, api_url: Optional[str] = None,
# Settings menu (global output options -> app/converter/config.py)
# ---------------------------------------------------------------------------
+def _settings_changed(fields: list, original: dict) -> bool:
+ """True when any field's current value differs from its ORIGINAL.
+
+ Text values compare whitespace-stripped (the form's editor and
+ _apply_settings trim them anyway), so retyping a setting with stray
+ spaces does not count as a change.
+ """
+ for field in fields:
+ value = field["value"]
+ base = original[field["key"]]
+ if isinstance(value, str) and isinstance(base, str):
+ changed = value.strip() != base.strip()
+ else:
+ changed = value != base
+ if changed:
+ return True
+ return False
+
+
def _settings_fields() -> list:
"""The global output-settings field list (Save writes to config.py)."""
return [
diff --git a/app/ui/tui.py b/app/ui/tui.py
index f1c5419..319c123 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -920,7 +920,11 @@ def form(scr, title: str, fields: Sequence[dict],
An optional ``note`` string on a field renders as a dim,
non-selectable line in a blank-line frame above that field's row — a
- section divider with a short explanation. Up/Down (or k/j) move the
+ section divider with a short explanation. An optional ``help`` list
+ of strings is shown as dim lines inside the field's edit dialog
+ (line editor / choice menu / directory browser title screens),
+ letting a field explain itself at edit time; like ``label`` it may
+ be a callable of the field list. Up/Down (or k/j) move the
cursor; Enter edits or toggles the highlighted field. Tab, Left/Right,
j or k at the ends of the list move focus to the BUTTONS — Down from
the last field and Up from the first field both land on the first
@@ -952,6 +956,13 @@ def form(scr, title: str, fields: Sequence[dict],
label = field["label"]
return str(label(fields)) if callable(label) else str(label)
+ def field_help(field: dict) -> Optional[List[str]]:
+ """Resolve FIELD's optional help lines (static or computed)."""
+ help_lines = field.get("help")
+ if callable(help_lines):
+ help_lines = help_lines(fields)
+ return list(help_lines) if help_lines else None
+
def shown_fields() -> List[dict]:
result: List[dict] = []
for field in fields:
@@ -1126,6 +1137,7 @@ def form(scr, title: str, fields: Sequence[dict],
edited = line_edit(scr, field_label(field),
field["value"],
validate=field.get("validate"),
+ help_lines=field_help(field),
back_value=edit_cancel)
if edited is not edit_cancel:
field["value"] = edited