From 9dd4f9595be3b1d76a3a07dc3eca90cfaf8a3f97 Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 02:19:50 -0400 Subject: feat: settings menu in tui --- tests/test_hub.py | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++--- tests/test_tui.py | 76 ++++++++++++++++++++ ui/hub.py | 103 +++++++++++++++++++++++++++ ui/tui.py | 106 ++++++++++++++++++++++++++++ 4 files changed, 478 insertions(+), 11 deletions(-) diff --git a/tests/test_hub.py b/tests/test_hub.py index 21f795e..b3a17e8 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -71,14 +71,14 @@ class HubMenuTests(unittest.TestCase): return BackendStatus(key, label, installed=False, configured=False) def test_quit_returns_none_when_no_backend(self): - # No backends installed/running: menu is [Set up, Quit]. Quit is the - # 2nd option (Down once) then Enter. - screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, 10]) + # No backends installed/running: menu is [Set up, Settings, Quit]. + # Quit is the 3rd option (Down twice) then Enter. + screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10]) with patch.object(hub, "detect_all", return_value=[]): result = hub._hub_menu(screen) self.assertIsNone(result) - def test_menu_has_only_setup_and_quit_without_backends(self): + def test_menu_has_only_setup_settings_and_quit_without_backends(self): # Capture the options handed to tui.menu: with nothing installed or # running, Convert/Configure must be absent. captured = {} @@ -92,9 +92,10 @@ class HubMenuTests(unittest.TestCase): patch.object(hub, "detect_all", return_value=[]): hub._hub_menu(screen) labels = [label for label, _ in captured["options"]] - self.assertEqual(labels, ["Set up a backend...", "Quit"]) + self.assertEqual(labels, ["Set up a backend...", "Settings...", + "Quit"]) - def test_menu_has_all_five_when_one_installed(self): + def test_menu_has_all_six_when_one_installed(self): captured = {} def fake_menu(stdscr, title, options, **kwargs): @@ -112,7 +113,7 @@ class HubMenuTests(unittest.TestCase): self.assertEqual( labels, ["Convert books...", "Set up a backend...", - "Configure a backend...", "Server...", "Quit"]) + "Configure a backend...", "Server...", "Settings...", "Quit"]) # The status table is passed through, one row per backend. self.assertEqual(captured["rows"], [("qwen-tts", "installed", "warn", "body")]) @@ -138,7 +139,7 @@ class HubMenuTests(unittest.TestCase): [("audio.cpp", "unavailable", "err", "dim"), ("qwen-tts", "running", "ok", "body")]) - def test_menu_has_all_five_when_one_running_only(self): + def test_menu_has_all_six_when_one_running_only(self): # Running but not installed (an external server) still unlocks the # Convert/Configure/Server entries. captured = {} @@ -157,7 +158,7 @@ class HubMenuTests(unittest.TestCase): self.assertEqual( labels, ["Convert books...", "Set up a backend...", - "Configure a backend...", "Server...", "Quit"]) + "Configure a backend...", "Server...", "Settings...", "Quit"]) def test_convert_with_no_available_backend_offers_setup(self): # One installed-but-not-ready backend → Convert is offered. The @@ -182,11 +183,12 @@ class HubMenuTests(unittest.TestCase): with patch.object(hub, "detect_all", return_value=statuses), \ patch.object(hub, "REGISTRY", infos): # Convert(Enter), setup-entry(Enter), Esc on setup menu, - # back at main menu -> Down x4 -> Enter (Quit). + # back at main menu -> Down x5 -> Enter (Quit; Settings sits + # just before it). screen = FakeScreen(keys=[10, 10, 27, FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, - 10]) + FakeCurses.KEY_DOWN, 10]) result = hub._hub_menu(screen) self.assertIsNone(result) @@ -316,5 +318,185 @@ class AddAutostartTests(unittest.TestCase): self.assertNotIn("autostart", cmd[2]) +class SettingsTests(unittest.TestCase): + """Settings menu: field collection, validation, config.py writing.""" + + def test_write_config_preserves_comments_and_other_lines(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "config.py" + path.write_text( + "# Default output options\n" + 'AUDIO_FORMAT = "m4b"\n' + 'AUDIO_BITRATE = "128k"\n' + 'LANGUAGE = "English"\n' + "\n" + "CHUNK_SIZE = 250 # words per request\n", + encoding="utf-8") + with patch.object(hub.config, "__file__", str(path)): + hub._write_config({"AUDIO_FORMAT": "mp3", + "AUDIO_BITRATE": "192k", + "LANGUAGE": "Japanese", + "CHUNK_SIZE": 300}) + text = path.read_text(encoding="utf-8") + self.assertEqual( + text, + "# Default output options\n" + 'AUDIO_FORMAT = "mp3"\n' + 'AUDIO_BITRATE = "192k"\n' + 'LANGUAGE = "Japanese"\n' + "\n" + "CHUNK_SIZE = 300 # words per request\n") + + def test_write_config_missing_key_raises(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "config.py" + path.write_text("X = 1\n", encoding="utf-8") + with patch.object(hub.config, "__file__", str(path)): + with self.assertRaises(ValueError): + hub._write_config({"AUDIO_FORMAT": "mp3"}) + + def test_apply_settings_writes_and_reloads_in_memory(self): + written = {} + + def fake_write(updates): + written.update(updates) + + original = {name: getattr(hub.config, name) for name in + ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", + "CHUNK_SIZE")} + self.addCleanup(lambda: [setattr(hub.config, name, value) + for name, value in original.items()]) + values = {"audio_format": "ogg", "audio_bitrate": " 192k ", + "language": "en", "chunk_size": "300"} + with patch.object(hub, "_write_config", fake_write): + hub._apply_settings(values) + # Values are trimmed and language normalized to a display name. + self.assertEqual(written, {"AUDIO_FORMAT": "ogg", + "AUDIO_BITRATE": "192k", + "LANGUAGE": "English", + "CHUNK_SIZE": 300}) + # In-memory config is reloaded so this session sees the change. + self.assertEqual(hub.config.AUDIO_FORMAT, "ogg") + self.assertEqual(hub.config.AUDIO_BITRATE, "192k") + self.assertEqual(hub.config.LANGUAGE, "English") + self.assertEqual(hub.config.CHUNK_SIZE, 300) + + def test_apply_settings_rejects_bad_values(self): + original = {name: getattr(hub.config, name) for name in + ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", + "CHUNK_SIZE")} + self.addCleanup(lambda: [setattr(hub.config, name, value) + for name, value in original.items()]) + with patch.object(hub, "_write_config") as mk_write: + with self.assertRaises(ValueError): + hub._apply_settings({"audio_format": "m4b", + "audio_bitrate": "128k", + "language": "Klingon", + "chunk_size": "250"}) + with self.assertRaises(ValueError): + hub._apply_settings({"audio_format": "m4b", + "audio_bitrate": "128k", + "language": "English", + "chunk_size": "0"}) + mk_write.assert_not_called() + + def test_field_validators(self): + self.assertIsNone(hub._validate_bitrate("128k")) + self.assertIsNotNone(hub._validate_bitrate(" ")) + self.assertIsNone(hub._validate_language("English")) + self.assertIsNone(hub._validate_language("en")) + self.assertIsNotNone(hub._validate_language("Klingon")) + self.assertIsNone(hub._validate_chunk_size("250")) + self.assertIsNotNone(hub._validate_chunk_size("abc")) + self.assertIsNotNone(hub._validate_chunk_size("0")) + + def test_settings_menu_builds_form_and_saves(self): + captured = {} + + def fake_form(stdscr, title, fields, back_value=None): + captured["fields"] = fields + return {"audio_format": "ogg", "audio_bitrate": "192k", + "language": "English", "chunk_size": "300"} + + applied = [] + + def fake_apply(values): + applied.append(values) + + def fake_flash(stdscr, text, kind="warn"): + captured["flash"] = (text, kind) + + with patch.object(hub.tui, "form", fake_form), \ + patch.object(hub, "_apply_settings", fake_apply), \ + patch.object(hub.tui, "flash", fake_flash): + hub._settings_menu(None) + self.assertEqual([f["key"] for f in captured["fields"]], + ["audio_format", "audio_bitrate", "language", + "chunk_size"]) + kinds = {f["key"]: f["kind"] for f in captured["fields"]} + self.assertEqual(kinds["audio_format"], "choice") + self.assertEqual(kinds["audio_bitrate"], "text") + self.assertEqual(applied, [{"audio_format": "ogg", + "audio_bitrate": "192k", + "language": "English", + "chunk_size": "300"}]) + self.assertEqual(captured["flash"], ("Settings saved.", "ok")) + + def test_settings_menu_cancel_does_not_apply(self): + def fake_form(stdscr, title, fields, back_value=None): + return back_value # user pressed Cancel + + applied = [] + + def fake_apply(values): + applied.append(values) + + with patch.object(hub.tui, "form", fake_form), \ + patch.object(hub, "_apply_settings", fake_apply): + hub._settings_menu(None) + self.assertEqual(applied, []) + + def test_settings_menu_writes_config_end_to_end(self): + import tempfile + tui._THEME.clear() + self.addCleanup(tui._THEME.clear) + curses = FakeCurses() + patcher = patch.dict("sys.modules", {"curses": curses}) + patcher.start() + self.addCleanup(patcher.stop) + + original = {name: getattr(hub.config, name) for name in + ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", + "CHUNK_SIZE")} + self.addCleanup(lambda: [setattr(hub.config, name, value) + for name, value in original.items()]) + + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "config.py" + path.write_text( + "# Default output options\n" + 'AUDIO_FORMAT = "m4b"\n' + 'AUDIO_BITRATE = "128k"\n' + 'LANGUAGE = "English"\n' + "\n" + "CHUNK_SIZE = 250\n", + encoding="utf-8") + with patch.object(hub.config, "__file__", str(path)): + # Down to Chunk size, Enter -> editor, Ctrl-U + '300', + # Enter; Tab -> Save, Enter; a key dismisses the flash. + screen = FakeScreen(keys=[ + FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, + FakeCurses.KEY_DOWN, 10, 21, ord("3"), ord("0"), + ord("0"), 10, 9, 10, 10]) + hub._settings_menu(screen) + text = path.read_text(encoding="utf-8") + self.assertIn('AUDIO_FORMAT = "m4b"', text) + self.assertIn("CHUNK_SIZE = 300", text) + # The running session also picked up the change in-memory. + self.assertEqual(hub.config.CHUNK_SIZE, 300) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_tui.py b/tests/test_tui.py index 58d8273..c121d55 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -365,6 +365,82 @@ class LineEditTests(TuiTestCase): self.assertEqual(value, "teq") +class FormTests(TuiTestCase): + def _fields(self): + # Fresh dicts each call: form() edits field values in place, and a + # shared class attribute would leak edits between tests. + return [ + {"key": "fmt", "label": "Format", "kind": "choice", + "value": "m4b", "choices": ["mp3", "m4b", "ogg"]}, + {"key": "chunk", "label": "Chunk", "kind": "text", + "value": "250"}, + ] + + def test_save_returns_current_values(self): + screen = FakeScreen(keys=[9, 10]) # Tab -> Save, Enter + result = tui.form(screen, "Settings", self._fields()) + self.assertEqual(result, {"fmt": "m4b", "chunk": "250"}) + + def test_cancel_returns_back_value(self): + marker = object() + # Tab -> buttons, Left -> Cancel, Enter. + screen = FakeScreen(keys=[9, FakeCurses.KEY_LEFT, 10]) + result = tui.form(screen, "Settings", self._fields(), + back_value=marker) + self.assertIs(result, marker) + + def test_esc_returns_back_value(self): + marker = object() + screen = FakeScreen(keys=[27]) + self.assertIs( + tui.form(screen, "Settings", self._fields(), back_value=marker), + marker) + + def test_q_still_aborts_with_back_value(self): + marker = object() + screen = FakeScreen(keys=[ord("q")]) + with self.assertRaises(tui.WizardCancelled): + tui.form(screen, "Settings", self._fields(), back_value=marker) + + def test_choice_field_picks_another_value(self): + # Enter opens the choice menu on 'm4b' (index 1); Down moves to + # 'ogg', Enter accepts; Tab -> Save, Enter. + screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, 10, 9, 10]) + result = tui.form(screen, "Settings", self._fields()) + self.assertEqual(result, {"fmt": "ogg", "chunk": "250"}) + + def test_text_field_edits_then_saves(self): + # Down to the text row, Enter opens the editor, type 'x', Enter, + # then Tab -> Save, Enter. + screen = FakeScreen( + keys=[FakeCurses.KEY_DOWN, 10, ord("x"), 10, 9, 10]) + result = tui.form(screen, "Settings", self._fields()) + self.assertEqual(result, {"fmt": "m4b", "chunk": "250x"}) + + def test_validation_error_refuses_save_then_recovers(self): + fields = [ + {"key": "chunk", "label": "Chunk", "kind": "text", "value": "bad", + "validate": lambda s: None if s.isdigit() else "digits only"}, + {"key": "fmt", "label": "Format", "kind": "text", "value": "x"}, + ] + # Tab->Save(Enter) fails, the flash consumes the next key; Enter + # reopens the editor, Ctrl-U clears 'bad', type '120', Enter; then + # Tab->Save(Enter). + keys = [9, 10, 10, 10, 21, ord("1"), ord("2"), ord("0"), 10, 9, 10] + screen = FakeScreen(keys=keys) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"chunk": "120", "fmt": "x"}) + + def test_rows_left_justified_inside_the_border(self): + screen = FakeScreen(keys=[9, 10]) + tui.form(screen, "Settings", self._fields()) + self.assert_inside_border(screen) + + def test_empty_fields_rejected(self): + with self.assertRaises(ValueError): + tui.form(self.screen, "Settings", []) + + def _accept_audio_cpp(entry: Path): """auto_select callback that accepts an 'audio.cpp' checkout root.""" if entry.name == "audio.cpp" and (entry / "model_specs").is_dir(): diff --git a/ui/hub.py b/ui/hub.py index f3e2b7e..b2a2b08 100644 --- a/ui/hub.py +++ b/ui/hub.py @@ -13,6 +13,7 @@ main menu. """ import json +import re from pathlib import Path from typing import Optional, Tuple @@ -35,6 +36,7 @@ from converter.tts import ( BACKEND_AUDIOCPP, BACKEND_FASTER, BACKEND_QWEN, + normalize_language, ) from ui import tui @@ -79,6 +81,7 @@ def _hub_menu(stdscr) -> Optional[tuple]: options.insert(0, ("Convert books...", "convert")) options.append(("Configure a backend...", "configure")) options.append(("Server...", "server")) + options.append(("Settings...", "settings")) options.append(("Quit", "quit")) rows = [(st.label, *_status_mark(st)) for st in statuses] choice = tui.menu( @@ -102,6 +105,8 @@ def _hub_menu(stdscr) -> Optional[tuple]: cmd = _server_menu(stdscr, statuses) if cmd is not None: return cmd + elif choice == "settings": + _settings_menu(stdscr) def _setup_menu(stdscr, statuses) -> Optional[tuple]: @@ -373,6 +378,104 @@ def _common_options(stdscr) -> Optional[dict]: } +# --------------------------------------------------------------------------- +# Settings menu (global output options -> converter/config.py) +# --------------------------------------------------------------------------- + +def _settings_menu(stdscr) -> None: + """Edit the global output settings; Save writes them back to config.py.""" + fields = [ + {"key": "audio_format", "label": "Audio format", "kind": "choice", + "value": config.AUDIO_FORMAT, "choices": list(AUDIO_FORMATS)}, + {"key": "audio_bitrate", "label": "Audio bitrate", "kind": "text", + "value": config.AUDIO_BITRATE, + "validate": _validate_bitrate}, + {"key": "language", "label": "Language", "kind": "text", + "value": config.LANGUAGE, "validate": _validate_language}, + {"key": "chunk_size", "label": "Chunk size (words)", "kind": "text", + "value": str(config.CHUNK_SIZE), "validate": _validate_chunk_size}, + ] + result = tui.form(stdscr, "Settings", fields, back_value=_GO_BACK) + if result is None or result is _GO_BACK: + return + try: + _apply_settings(result) + except ValueError as exc: + tui.flash(stdscr, str(exc), "err") + return + tui.flash(stdscr, "Settings saved.", "ok") + + +def _validate_bitrate(value: str) -> Optional[str]: + """Error message for a blank audio bitrate, or None to accept it.""" + if value.strip(): + return None + return "Audio bitrate must not be empty" + + +def _validate_language(value: str) -> Optional[str]: + """Error message for an unrecognized LANGUAGE, or None to accept it.""" + try: + normalize_language(value) + return None + except ValueError as exc: + return str(exc) + + +def _validate_chunk_size(value: str) -> Optional[str]: + """Error message for an invalid CHUNK_SIZE, or None to accept it.""" + try: + number = int(value.strip()) + except ValueError: + return "Enter a whole number of words, e.g. 250" + if number < 1: + return "Chunk size must be at least 1" + return None + + +def _apply_settings(values: dict) -> None: + """Write VALUES to converter/config.py and reload them in-memory.""" + chunk_size = int(values["chunk_size"].strip()) + if chunk_size < 1: + raise ValueError("Chunk size must be at least 1") + bitrate = values["audio_bitrate"].strip() + if not bitrate: + raise ValueError("Audio bitrate must not be empty") + if values["audio_format"] not in AUDIO_FORMATS: + raise ValueError(f"Unsupported audio format: {values['audio_format']}") + updates = { + "AUDIO_FORMAT": values["audio_format"], + "AUDIO_BITRATE": bitrate, + "LANGUAGE": normalize_language(values["language"]), + "CHUNK_SIZE": chunk_size, + } + _write_config(updates) + for name, value in updates.items(): + setattr(config, name, value) + + +def _write_config(updates: dict) -> None: + """Rewrite the ``NAME = value`` lines for UPDATES in converter/config.py. + + Only the value of each named assignment changes: the indentation, the + quotes (double, matching the file's style) and any trailing comment on + the line are preserved. Every other line is left untouched. + """ + path = Path(config.__file__).resolve() + text = path.read_text(encoding="utf-8") + for name, value in updates.items(): + rendered = str(value) if isinstance(value, int) else f'"{value}"' + pattern = re.compile( + rf"^(\s*{re.escape(name)}\s*=\s*)(\S*)(\s*(#.*))?$", + re.MULTILINE) + text, count = pattern.subn( + lambda m, rendered=rendered: + f"{m.group(1)}{rendered}{m.group(3) or ''}", text) + if count != 1: + raise ValueError(f"Could not find {name} in {path}") + path.write_text(text, encoding="utf-8") + + def _run_conversion(backend: str, kwargs: dict) -> None: """Run a conversion in the plain console (after the TUI returns). diff --git a/ui/tui.py b/ui/tui.py index c73fb09..9047f36 100644 --- a/ui/tui.py +++ b/ui/tui.py @@ -725,6 +725,112 @@ def line_edit(scr, title: str, default: str, text += chr(key) +# --------------------------------------------------------------------------- +# Widget: multi-field settings form with Save/Cancel buttons +# --------------------------------------------------------------------------- + +def form(scr, title: str, fields: Sequence[dict], + back_value: object = None, + help_lines: Optional[Sequence[str]] = None) -> Optional[dict]: + """Edit several labeled fields on one screen, then Save or Cancel. + + FIELDS is a list of dicts, one per row, shaped like:: + + {"key": "audio_format", "label": "Audio format", + "kind": "choice", "value": "m4b", + "choices": ["mp3", "m4b", "ogg", "flac"]} + {"key": "chunk_size", "label": "Chunk size", + "kind": "text", "value": "250", + "validate": lambda s: None if s.isdigit() else "digits only"} + + Each field renders as a left-justified ``Label: value`` row. Up/Down + (or k/j) move the cursor; Enter on a ``choice`` row opens a single + choice menu, Enter on a ``text`` row opens a line editor (reusing its + VALIDATE for that one field). Tab or the arrow keys move focus to the + Save/Cancel buttons; Enter on Save validates every text field (the + first failure flashes in red and re-focuses that row) and returns + ``{key: value}``, Enter on Cancel returns BACK_VALUE. Esc (or 'q') + returns BACK_VALUE / aborts as in menu(). Values are edited in place + in the FIELDS dicts, so Cancel simply discards them. + """ + if not fields: + raise ValueError("form() needs at least one field") + frame = Frame(scr, title, + "Up/Down = move Enter = edit Tab = Save/Cancel " + "Esc = cancel") + cursor = 0 + on_buttons = False + btn_index = 0 + edit_cancel = object() # sentinel: backed out of a field editor + while True: + frame.rows = [] + for line in help_lines or []: + frame.mark(line, frame.theme["dim"]) + if help_lines: + frame.mark("") + base = len(frame.rows) + for field in fields: + frame.mark(f"{field['label']}: {field['value']}", + selectable=True, align="left") + frame.cursor = None if on_buttons else base + cursor + frame.buttons = (["Save", "Cancel"], btn_index if on_buttons else None) + frame.draw() + curses = frame.curses + key = frame.get_key(cancel_keys=()) + if key == 27 and back_value is not None: + return back_value + if key in _CANCEL_KEYS: + raise WizardCancelled() + if on_buttons: + if key in (9, curses.KEY_BTAB, curses.KEY_UP, curses.KEY_DOWN): + on_buttons = False + elif key in (curses.KEY_LEFT, curses.KEY_RIGHT, + ord("h"), ord("l")): + btn_index = 1 - btn_index + elif key in (10, 13): + if btn_index == 0: # Save + for index, field in enumerate(fields): + validate = field.get("validate") + if field.get("kind") == "text" and validate: + error = validate(field["value"]) + if error is not None: + on_buttons = False + cursor = index + frame.flash(error, "err") + break + else: + return {field["key"]: field["value"] + for field in fields} + else: # Cancel + return back_value + else: + moved = frame.motion(key, cursor, len(fields), wrap=True) + if moved is not None: + cursor = moved + elif key in (9, curses.KEY_BTAB, curses.KEY_LEFT, + curses.KEY_RIGHT, ord("h"), ord("l")): + on_buttons = True + btn_index = 0 + elif key in (10, 13): + field = fields[cursor] + if field.get("kind") == "choice": + choices = list(field.get("choices") or []) + default = choices.index(field["value"]) \ + if field["value"] in choices else 0 + chosen = menu(scr, field["label"], + [(c, c) for c in choices], + default_index=default, + back_value=edit_cancel) + if chosen is not edit_cancel: + field["value"] = chosen + else: + edited = line_edit(scr, field["label"], field["value"], + validate=field.get("validate"), + back_value=edit_cancel) + if edited is not edit_cancel: + field["value"] = edited + + # --------------------------------------------------------------------------- # Widget: directory browser # --------------------------------------------------------------------------- -- cgit v1.2.3