From 65c6f737f1545ef225768af897acd20f163a4fb4 Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 26 Aug 2026 20:43:05 -0400 Subject: fix: settings menu only prompts to save after change --- app/ui/hub.py | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- app/ui/tui.py | 14 +++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) (limited to 'app/ui') 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 -- cgit v1.2.3