diff options
| author | historia <historiavg@proton.me> | 2026-08-24 14:49:17 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 14:49:17 -0400 |
| commit | 7ee1d4bb63c12982ec4900ec870ad96baba4b22b (patch) | |
| tree | 3f4261201b1bf05546c7c9241f7f3bcb4ea42b5e | |
| parent | e7a3d65f68659d17f37b79e8bfefea19d7ac0648 (diff) | |
| download | tts-audiobook-generator-7ee1d4bb63c12982ec4900ec870ad96baba4b22b.tar.gz | |
feat: combine wizard menus into a single generate options menu
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 239 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 144 | ||||
| -rw-r--r-- | app/ui/hub.py | 388 | ||||
| -rw-r--r-- | app/ui/tui.py | 143 |
5 files changed, 632 insertions, 284 deletions
@@ -48,7 +48,7 @@ python audiobook.py A full-screen TUI opens and shows each backend's status in a table — **unavailable** (red, name dimmed: not installed and no server running), **installed** (orange), or **running** (green, when an external server is already accepting connections on its configured port). From the menu you can: -- **Convert books…** — process the `input/` directory with a ready/running backend (for a backend set up here it reads its `server.json` / `voices.json` so you pick the model and voice from menus; with only a running external server it queries the server itself instead — audio.cpp lists its models and voices over HTTP, faster asks you to type a voice name). If the server isn't running you're offered to start it automatically; after the conversion you're asked whether to stop it, or +- **Convert books…** — pick a ready/running backend, then set everything on one screen: model, voice (or speaker / clone .wav for qwen), instructions, output format, speed, whether to combine all chapters into one file, and debug mode (for a backend set up here it reads its `server.json` / `voices.json`; with only a running external server it queries the server itself instead — audio.cpp lists its models and voices over HTTP, faster asks you to type a voice name). Focus starts on **Generate!**, so Enter accepts the defaults. The "combine chapters" option is hidden for `m4b`, which is always one file. If the server isn't running it's started automatically; after the conversion you're asked whether to stop it, or - **Set up a backend…** — clone, build, and configure a backend end-to-end (audio.cpp, qwen, faster), or - **Configure a backend…** — regenerate its config (a new `server.json`, rebuild `voices.json`, change ports/speaker), or - **Server…** — manually start or stop a configured backend's server (the hub spawns it in the managed venv and polls until it accepts connections). diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 1b7630e..d244144 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -17,13 +17,15 @@ from ui import hub, tui class _ScriptedTUI: """Stand-in for the tui widget module: answers each menu/line_edit/ - confirm call from a scripted answer list and records every prompt.""" + confirm/form call from a scripted answer list and records every prompt.""" def __init__(self): self.script = [] self.prompts = [] self.options_seen = [] self.flashes = [] + self.form_script = [] # values dicts / None / sentinel for tui.form + self.forms_seen = [] # (title, fields, kwargs) for tui.form def _next(self, prompt, options=None): self.prompts.append(prompt) @@ -41,6 +43,10 @@ class _ScriptedTUI: def confirm(self, stdscr, question, **kwargs): return self._next(question) + def form(self, stdscr, title, fields, **kwargs): + self.forms_seen.append((title, fields, kwargs)) + return self.form_script.pop(0) + def flash(self, stdscr, text, kind="warn"): self.flashes.append(text) @@ -418,19 +424,30 @@ class SubmenuStatusTableTests(unittest.TestCase): class ConvertFlowTests(unittest.TestCase): - """_convert_audiocpp / _convert_faster: local-config menus vs. live - queries against a running remote server.""" + """_convert_audiocpp / _convert_qwen / _convert_faster: each backend + collects its settings on a single form (local config or live remote + queries).""" def setUp(self): self.tui = _ScriptedTUI() - for name in ("menu", "line_edit", "confirm", "flash"): + for name in ("menu", "line_edit", "confirm", "form", "flash"): patcher = patch.object(hub.tui, name, getattr(self.tui, name)) patcher.start() self.addCleanup(patcher.stop) - def _answer_common_options(self): - # Output format, speed, single-file, debug. - self.tui.script += ["m4b", "1.5", False, False] + def _form_values(self, **overrides): + """A fully-populated form result, with sensible defaults.""" + values = {"output_format": "m4b", "speed": "1.0", + "single_file": False, "debug": False} + values.update(overrides) + return values + + def _answer_form(self, **overrides): + self.tui.form_script.append(self._form_values(**overrides)) + + def _field(self, key, form_index=-1): + _, fields, _ = self.tui.forms_seen[form_index] + return next(f for f in fields if f["key"] == key) # ------------------------------------------------------------------ # audio.cpp: remote server (no local checkout / server.json) @@ -450,90 +467,121 @@ class ConvertFlowTests(unittest.TestCase): patcher.start() self.addCleanup(patcher.stop) - def test_audiocpp_remote_queries_live_models_and_voices(self): + def test_audiocpp_remote_builds_one_form(self): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self.tui.script += ["higgs", "narrator", ""] - self._answer_common_options() - cmd = hub._convert_audiocpp(None, []) + self._answer_form(model_id="higgs", voice="narrator", + instructions="", speed="1.5") + cmd = hub._convert_audiocpp(None) self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], hub.BACKEND_AUDIOCPP) kwargs = cmd[2] self.assertEqual(kwargs["model_id"], "higgs") self.assertEqual(kwargs["voice"], "narrator") self.assertIsNone(kwargs["instructions"]) - # The model menu was fed from the live query. - self.assertEqual(self.tui.options_seen[0], + self.assertEqual(kwargs["output_format"], "m4b") + self.assertEqual(kwargs["speed"], 1.5) + self.assertFalse(kwargs["single_file"]) + self.assertFalse(kwargs["debug"]) + # One form, not a cascade of menus/editors. + self.assertEqual(len(self.tui.forms_seen), 1) + title, fields, form_kwargs = self.tui.forms_seen[0] + self.assertEqual(title, "Convert with audio.cpp") + self.assertEqual([f["key"] for f in fields], + ["model_id", "voice", "instructions", + "output_format", "speed", "single_file", "debug"]) + self.assertEqual(form_kwargs["buttons"], ("Generate!", "Cancel")) + self.assertTrue(form_kwargs["start_on_buttons"]) + # The model menu was fed from the live query (label, id). + self.assertEqual(fields[0]["choices"], [("higgs (higgs_audio_tts, tts)", "higgs")]) - def test_audiocpp_remote_qwen3_tts_offers_builtin_speaker_first(self): + def test_audiocpp_qwen3_tts_voice_choices_lead_with_builtin_speaker(self): self._patch_remote( [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self.tui.script += ["qwen", None, ""] - self._answer_common_options() - cmd = hub._convert_audiocpp(None, []) + self._answer_form(model_id="qwen", voice="(built-in speaker)", + instructions="") + cmd = hub._convert_audiocpp(None) + # The sentinel maps to "no voice" (built-in speaker). self.assertIsNone(cmd[2]["voice"]) - self.assertEqual(self.tui.options_seen[1], - [("(built-in speaker)", None), ("narrator", "narrator")]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("voice") + choices = voice_field["choices"](fields) + self.assertEqual(choices, + [("(built-in speaker)", "(built-in speaker)"), + ("narrator", "narrator")]) def test_audiocpp_remote_missing_family_treated_as_qwen3_tts(self): # Legacy servers omit family/task; the converter defaults them to - # qwen3_tts/tts and so must the menus (voice optional). + # qwen3_tts/tts and so must the form (voice optional). self._patch_remote([{"id": "legacy", "family": "", "task": ""}], voices=[]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - # Empty server voices: no Voice menu, built-in speaker implied. - self.tui.script += ["legacy", ""] - self._answer_common_options() - cmd = hub._convert_audiocpp(None, []) + self._answer_form(model_id="legacy", + voice="(built-in speaker)", instructions="") + cmd = hub._convert_audiocpp(None) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) - def test_audiocpp_remote_vdes_needs_instructions_not_voice(self): + def test_audiocpp_vdes_hides_voice_and_requires_instructions(self): self._patch_remote( [{"id": "design", "family": "qwen3_tts", "task": "vdes"}]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self.tui.script += ["design", "A warm British narrator"] - self._answer_common_options() - cmd = hub._convert_audiocpp(None, []) + self._answer_form(model_id="design", voice=None, + instructions="A warm British narrator") + cmd = hub._convert_audiocpp(None) self.assertIsNone(cmd[2]["voice"]) self.assertEqual(cmd[2]["instructions"], "A warm British narrator") - # No voice prompt happened at all. - self.assertNotIn("Voice", [p for p in self.tui.prompts]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("voice") + self.assertFalse(voice_field["visible"](fields)) + instr = self._field("instructions") + self.assertIsNotNone(instr["validate"]("")) + self.assertIsNone(instr["validate"]("describe me")) + + def test_audiocpp_required_voice_validates(self): + # A non-qwen3_tts family needs a --voice; a blank value refuses. + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(model_id="higgs", voice="narrator", + instructions="") + hub._convert_audiocpp(None) + voice_field = self._field("voice") + self.assertIsNotNone(voice_field["validate"]("")) + self.assertIsNone(voice_field["validate"]("narrator")) def test_audiocpp_remote_unreachable_models_flash_and_abort(self): self._patch_remote(None) # endpoint did not answer valid JSON - cmd = hub._convert_audiocpp(None, []) + cmd = hub._convert_audiocpp(None) self.assertIsNone(cmd) self.assertIn("Could not list models", self.tui.flashes[0]) def test_audiocpp_remote_empty_models_flash_and_abort(self): self._patch_remote([]) - cmd = hub._convert_audiocpp(None, []) + cmd = hub._convert_audiocpp(None) self.assertIsNone(cmd) self.assertIn("hosts no model entries", self.tui.flashes[0]) - def test_audiocpp_remote_no_server_voices_for_clone_model_aborts(self): + def test_audiocpp_remote_no_server_voices_leaves_voice_blank(self): + # No voices listed for a required-voice model: the form still opens + # with an empty Voice field (Generate-time validation reports it). self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=[]) - self.tui.script += ["higgs"] - cmd = hub._convert_audiocpp(None, []) - self.assertIsNone(cmd) - self.assertIn("lists none", self.tui.flashes[0]) - - def test_audiocpp_remote_failed_voices_query_aborts(self): - self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], - voices=None) - self.tui.script += ["higgs"] - cmd = hub._convert_audiocpp(None, []) - self.assertIsNone(cmd) - self.assertIn("Could not list voices", self.tui.flashes[0]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(model_id="higgs", voice="", instructions="") + cmd = hub._convert_audiocpp(None) + self.assertIsNotNone(cmd) + self.assertIsNone(cmd[2]["voice"]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("voice") + self.assertEqual(voice_field["choices"](fields), []) # ------------------------------------------------------------------ # audio.cpp: local managed setup keeps reading its server.json @@ -559,29 +607,87 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.audiocpp_backend, "fetch_server_models", must_not_query), \ patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self.tui.script += ["qwen", "Narrator", ""] - self._answer_common_options() - cmd = hub._convert_audiocpp(None, []) + self._answer_form(model_id="qwen", voice="Narrator", + instructions="") + cmd = hub._convert_audiocpp(None) self.assertEqual(queried, []) self.assertIsNotNone(cmd) self.assertEqual(cmd[2]["model_id"], "qwen") self.assertEqual(cmd[2]["voice"], "Narrator") # ------------------------------------------------------------------ + # common fields: output format, speed, single-file, debug + # ------------------------------------------------------------------ + + def test_common_fields_hide_combine_for_m4b(self): + fields = hub._common_fields() + fmt = next(f for f in fields if f["key"] == "output_format") + single = next(f for f in fields if f["key"] == "single_file") + fmt["value"] = "m4b" + self.assertFalse(single["visible"](fields)) + fmt["value"] = "mp3" + self.assertTrue(single["visible"](fields)) + + # ------------------------------------------------------------------ + # qwen: speaker or clone + # ------------------------------------------------------------------ + + def test_qwen_builds_speaker_and_clone_form(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", + ["Vivian", "Serena"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"): + self._answer_form(mode="custom", speaker="Serena", clone="") + with patch.object(hub.common, "update_config_value") as mk_update: + cmd = hub._convert_qwen(None) + speaker_in_memory = hub.config.SPEAKER + self.assertEqual(cmd[0], "convert") + self.assertEqual(cmd[1], hub.BACKEND_QWEN) + self.assertIsNone(cmd[2]["clone"]) + # The speaker choice is persisted for future runs too. + mk_update.assert_called_once_with("SPEAKER", "Serena") + self.assertEqual(speaker_in_memory, "Serena") + fields = self.tui.forms_seen[0][1] + self.assertEqual([f["key"] for f in fields], + ["mode", "speaker", "clone", "output_format", + "speed", "single_file", "debug"]) + mode_field = self._field("mode") + self.assertEqual(mode_field["choices"], + [("Built-in speaker", "custom"), + ("Clone from a .wav file", "clone")]) + speaker_field = self._field("speaker") + clone_field = self._field("clone") + # Speaker shows in custom mode; the .wav path shows in clone mode. + self.assertTrue(speaker_field["visible"](fields)) + self.assertFalse(clone_field["visible"](fields)) + mode_field["value"] = "clone" + self.assertFalse(speaker_field["visible"](fields)) + self.assertTrue(clone_field["visible"](fields)) + + def test_qwen_clone_mode_passes_path_and_keeps_speaker(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"): + self._answer_form(mode="clone", speaker="Vivian", + clone="/tmp/ref.wav") + with patch.object(hub.common, "update_config_value") as mk_update: + cmd = hub._convert_qwen(None) + self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") + # Clone mode does not touch the global speaker. + mk_update.assert_not_called() + + # ------------------------------------------------------------------ # faster: remote server (no local voices.json) # ------------------------------------------------------------------ - def test_faster_remote_prompts_for_a_voice_name(self): + def test_faster_remote_uses_a_text_voice_field(self): with tempfile.TemporaryDirectory() as td: with patch.object(hub.faster_backend, "_checkout", return_value=Path(td)): - self.tui.script += ["obama"] - self._answer_common_options() + self._answer_form(voice="obama") cmd = hub._convert_faster(None) self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], "faster") self.assertEqual(cmd[2]["voice"], "obama") - self.assertIn("Server-side voice", self.tui.prompts[0]) + self.assertEqual(self._field("voice")["kind"], "text") def test_faster_local_still_lists_voices_json(self): with tempfile.TemporaryDirectory() as td: @@ -590,12 +696,13 @@ class ConvertFlowTests(unittest.TestCase): json.dumps({"default": {}, "obama": {}}), encoding="utf-8") with patch.object(hub.faster_backend, "_checkout", return_value=checkout): - self.tui.script += ["obama"] - self._answer_common_options() + self._answer_form(voice="obama") cmd = hub._convert_faster(None) self.assertEqual(cmd[2]["voice"], "obama") - # The voice came from a menu over voices.json, not a text field. - self.assertIn("Select the voice to clone", self.tui.prompts[0]) + voice_field = self._field("voice") + self.assertEqual(voice_field["kind"], "choice") + self.assertEqual(voice_field["choices"], + [("default", "default"), ("obama", "obama")]) class SelectSpecTests(unittest.TestCase): @@ -690,15 +797,7 @@ class RunConversionTests(unittest.TestCase): class AddAutostartTests(unittest.TestCase): - """_add_autostart: offers to start the server when it isn't running.""" - - def setUp(self): - tui._THEME.clear() - self.curses = FakeCurses() - self._patcher = patch.dict("sys.modules", {"curses": self.curses}) - self._patcher.start() - self.addCleanup(self._patcher.stop) - self.addCleanup(tui._THEME.clear) + """_add_autostart: always starts the server when it isn't running.""" def _status(self): spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"]) @@ -706,20 +805,18 @@ class AddAutostartTests(unittest.TestCase): configured=True, running=False, servers=[spec]) - def test_sets_autostart_when_user_confirms(self): - screen = FakeScreen(keys=[10]) # Enter = Yes + def test_sets_autostart_when_not_running(self): cmd = ("convert", "qwen", {"clone": None}) with patch.object(hub, "detect_all", return_value=[self._status()]), \ patch("backends.common.server_running", return_value=False): - hub._add_autostart(screen, cmd, [self._status()]) + hub._add_autostart(cmd, [self._status()]) self.assertEqual(cmd[2]["autostart"], "qwen-custom") def test_no_autostart_when_server_already_running(self): - screen = FakeScreen(keys=[10]) cmd = ("convert", "qwen", {"clone": None}) with patch.object(hub, "detect_all", return_value=[self._status()]), \ patch("backends.common.server_running", return_value=True): - hub._add_autostart(screen, cmd, [self._status()]) + hub._add_autostart(cmd, [self._status()]) self.assertNotIn("autostart", cmd[2]) diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index d121c9a..ce408af 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -542,6 +542,150 @@ class FormTests(TuiTestCase): # math highlighted the blank line). self.assertEqual(texts[cursor], "Chunk: 250") + def test_custom_button_labels_and_start_on_buttons(self): + # buttons= overrides the labels; start_on_buttons=True means Enter + # alone (with no navigation) accepts the defaults. + screen = FakeScreen(keys=[10]) + result = tui.form(screen, "Convert", self._fields(), + buttons=("Generate!", "Cancel"), + start_on_buttons=True) + self.assertEqual(result, {"fmt": "m4b", "chunk": "250"}) + texts = [text for _, _, text, _ in screen.strings] + self.assertIn("[ Generate! ]", texts) + self.assertIn("[ Cancel ]", texts) + self.assertNotIn("[ Save ]", texts) + + def test_bool_field_toggles_with_enter(self): + fields = [{"key": "combine", "label": "Combine", "kind": "bool", + "value": False}] + # Enter toggles the bool to True, Tab -> button, Enter saves. + screen = FakeScreen(keys=[10, 9, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"combine": True}) + + def test_bool_field_toggles_with_space(self): + fields = [{"key": "combine", "label": "Combine", "kind": "bool", + "value": False}] + screen = FakeScreen(keys=[ord(" "), 9, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"combine": True}) + + def test_hidden_field_keeps_value_and_is_not_drawn(self): + fields = self._fields() + fields.append({"key": "hidden", "label": "Secret", "kind": "text", + "value": "kept", "visible": False}) + screen = FakeScreen(keys=[9, 10]) + result = tui.form(screen, "Settings", fields) + # The hidden field's value is preserved and returned. + self.assertEqual(result, {"fmt": "m4b", "chunk": "250", + "hidden": "kept"}) + texts = [text for _, _, text, _ in screen.strings] + self.assertNotIn("Secret:", texts) + + def test_visible_callable_hides_row_after_change(self): + fields = [ + {"key": "fmt", "label": "Format", "kind": "choice", + "value": "mp3", "choices": ["mp3", "m4b"]}, + {"key": "combine", "label": "Combine", "kind": "bool", + "value": False, + "visible": lambda fs: next( + f["value"] for f in fs if f["key"] == "fmt") != "m4b"}, + ] + snapshots = [] + original_draw = tui.Frame.draw + + def spy(frame): + original_draw(frame) + snapshots.append([row["text"] if row["segments"] is None + else "".join(t for t, _ in row["segments"]) + for row in frame.rows]) + + # Enter on Format, Down to m4b, Enter; Tab -> button, Enter. + screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, 10, 9, 10]) + with patch.object(tui.Frame, "draw", spy): + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"fmt": "m4b", "combine": False}) + self.assertTrue(any("Combine:" in row for row in snapshots[0])) + self.assertFalse(any("Combine:" in row for row in snapshots[-1])) + + def test_callable_choices_resolve_at_open(self): + fields = [{"key": "fmt", "label": "Format", "kind": "choice", + "value": "a", "choices": lambda fs: ["a", "b", "c"]}] + screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, 10, 9, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"fmt": "b"}) + + def test_on_change_fires_after_value_change(self): + calls = [] + fields = [{"key": "fmt", "label": "Format", "kind": "choice", + "value": "a", "choices": ["a", "b"], + "on_change": lambda fs: calls.append("changed")}] + screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, 10, 9, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"fmt": "b"}) + self.assertEqual(calls, ["changed"]) + + def test_choice_field_validation_refuses_save(self): + fields = [ + {"key": "fmt", "label": "Format", "kind": "choice", + "value": "bad", "choices": ["good", "bad"], + "validate": lambda v: None if v == "good" else "pick good"}, + {"key": "other", "label": "Other", "kind": "text", "value": "x"}, + ] + # Tab -> Save fails on fmt; the flash consumes the next key; then + # re-open fmt, Down to 'good', Enter; Tab -> Save, Enter. + keys = [9, 10, 10, 10, FakeCurses.KEY_DOWN, 10, 9, 10] + screen = FakeScreen(keys=keys) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"fmt": "good", "other": "x"}) + + def _two_text_fields(self): + return [{"key": "first", "label": "First", "kind": "text", + "value": "a"}, + {"key": "second", "label": "Second", "kind": "text", + "value": "b"}] + + def test_up_from_buttons_lands_on_last_field(self): + # Focus starts on Generate!; Up wraps up through it onto the last + # field (the list wraps: Up from the first button -> the bottom). + screen = FakeScreen(keys=[ + FakeCurses.KEY_UP, 10, ord("x"), 10, 9, 10]) + result = tui.form(screen, "Convert", self._two_text_fields(), + buttons=("Generate!", "Cancel"), + start_on_buttons=True) + self.assertEqual(result, {"first": "a", "second": "bx"}) + + def test_down_from_buttons_lands_on_first_field(self): + # Down wraps down through the buttons back onto the first field. + screen = FakeScreen(keys=[ + FakeCurses.KEY_DOWN, 10, ord("x"), 10, 9, 10]) + result = tui.form(screen, "Convert", self._two_text_fields(), + buttons=("Generate!", "Cancel"), + start_on_buttons=True) + self.assertEqual(result, {"first": "ax", "second": "b"}) + + def test_k_from_buttons_lands_on_last_field(self): + screen = FakeScreen(keys=[ord("k"), 10, ord("x"), 10, 9, 10]) + result = tui.form(screen, "Convert", self._two_text_fields(), + buttons=("Generate!", "Cancel"), + start_on_buttons=True) + self.assertEqual(result, {"first": "a", "second": "bx"}) + + def test_tab_from_buttons_lands_on_first_field(self): + screen = FakeScreen(keys=[9, 10, ord("x"), 10, 9, 10]) + result = tui.form(screen, "Convert", self._two_text_fields(), + buttons=("Generate!", "Cancel"), + start_on_buttons=True) + self.assertEqual(result, {"first": "ax", "second": "b"}) + + def test_btab_from_buttons_lands_on_last_field(self): + screen = FakeScreen(keys=[FakeCurses.KEY_BTAB, 10, ord("x"), 10, + 9, 10]) + result = tui.form(screen, "Convert", self._two_text_fields(), + buttons=("Generate!", "Cancel"), + start_on_buttons=True) + self.assertEqual(result, {"first": "a", "second": "bx"}) + def _accept_audio_cpp(entry: Path): """auto_select callback that accepts an 'audio.cpp' checkout root.""" diff --git a/app/ui/hub.py b/app/ui/hub.py index 0b599d0..b233d1b 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -31,6 +31,7 @@ from backends import ( ) from backends import audiocpp as audiocpp_backend from backends import faster as faster_backend +from backends import qwen as qwen_backend from converter import config from converter.converter import AUDIO_FORMATS from converter.tts import ( @@ -218,7 +219,7 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: if key is _GO_BACK or key is None: return None if key == BACKEND_AUDIOCPP: - cmd = _convert_audiocpp(stdscr, statuses) + cmd = _convert_audiocpp(stdscr) elif key == BACKEND_QWEN: cmd = _convert_qwen(stdscr) elif key == BACKEND_FASTER: @@ -227,17 +228,75 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: return None if cmd is None: return None - _add_autostart(stdscr, cmd, statuses) + _add_autostart(cmd, statuses) return cmd -def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]: - """Collect audio.cpp run settings for a managed or remote server. +# Sentinel value the audio.cpp Voice field uses for "no --voice" (the +# built-in CustomVoice speaker); mapped to None when the form returns. +_AUDIOCPP_BUILTIN_SPEAKER = "(built-in speaker)" - With a local checkout configured (its server.json), the menus are fed - from that file — the config of the server this tool manages. Without - one, the running server is external and nothing is known about it - locally, so its model and voice lists are queried live instead (the + +def _field_value(fields, key: str, default=None): + """Current value of the field named KEY, or DEFAULT when absent.""" + for field in fields: + if field.get("key") == key: + return field["value"] + return default + + +def _common_fields() -> list: + """Field dicts for output format, speed, single-file, and debug. + + The single-file field is hidden for m4b (always a single file with + embedded chapter markers), so its "visible" callable reads the live + output-format value from the field list. + """ + fmt_default = config.AUDIO_FORMAT \ + if config.AUDIO_FORMAT in AUDIO_FORMATS else AUDIO_FORMATS[0] + return [ + {"key": "output_format", "label": "Output format", "kind": "choice", + "value": fmt_default, "choices": list(AUDIO_FORMATS)}, + {"key": "speed", "label": "Speed", "kind": "text", "value": "1.0", + "validate": lambda s: None if (_is_float(s) and float(s) > 0) + else "Enter a positive number, e.g. 1.0"}, + {"key": "single_file", "label": "Combine all chapters", + "kind": "bool", "value": False, + "visible": lambda fs: _field_value(fs, "output_format") != "m4b"}, + {"key": "debug", "label": "Debug", "kind": "bool", "value": False}, + ] + + +def _common_kwargs(values: dict) -> dict: + """Map the common form fields to converter keyword arguments.""" + output_format = values["output_format"] + return { + "output_format": output_format, + "speed": float(values["speed"]), + "single_file": bool(values["single_file"]) + and output_format != "m4b", + "debug": bool(values["debug"]), + } + + +def _show_convert_form(stdscr, title: str, fields: list) -> Optional[dict]: + """Show the single conversion form (Generate!/Cancel, focus on + Generate!) and return its values, or None/_GO_BACK to go back.""" + result = tui.form(stdscr, title, fields, + buttons=("Generate!", "Cancel"), + start_on_buttons=True, back_value=_GO_BACK) + if result is None or result is _GO_BACK: + return None + return result + + +def _convert_audiocpp(stdscr) -> Optional[tuple]: + """Collect audio.cpp run settings on one form. + + With a local checkout configured (its server.json), the model list is + fed from that file — the config of the server this tool manages. + Without one, the running server is external and nothing is known about + it locally, so its models and voices are queried live instead (the same GET /v1/models and GET /v1/audio/voices endpoints the converter resolves at run time). """ @@ -273,128 +332,146 @@ def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]: return None data = {} - model_options = [(f"{m.get('id')} ({m.get('family') or '?'}, " - f"{m.get('task') or 'tts'})", m.get("id")) - for m in models] - model_id = tui.menu(stdscr, "Select the audio.cpp model to use", - model_options, back_value=_GO_BACK) - if model_id is _GO_BACK or model_id is None: - return None - entry = next((m for m in models if m.get("id") == model_id), {}) - family = entry.get("family") - task = entry.get("task", "tts") - if not local: - # Servers predating the family/task fields omit them; mirror the - # converter's defaults (_resolve_family/_resolve_task): unknown - # family means qwen3_tts, a missing task plain tts. - family = family or AUDIOCPP_FAMILY_QWEN3_TTS - task = task or "tts" - - def _voices() -> Optional[list]: - """Voice names for MODEL_ID, or None when a remote server's voice - list cannot be queried. Locally: the voice_dir's .wav stems; - remotely: GET /v1/audio/voices.""" + # Normalize each entry so the form logic sees a family/task always. + models = [dict(m) for m in models] + for entry in models: + entry["family"] = entry.get("family") or "" + entry["task"] = entry.get("task") or "tts" + if not local and not entry["family"]: + # Servers predating the family field omit it; mirror the + # converter's default: unknown family means qwen3_tts. + entry["family"] = AUDIOCPP_FAMILY_QWEN3_TTS + + local_voices = _list_voices(data.get("voice_dir")) \ + if data.get("voice_dir") else [] + voice_cache: dict = {} # model id -> voices (local: shared list) + + def voices_for(model_id: str) -> list: if local: - voice_dir = data.get("voice_dir") - return _list_voices(voice_dir) if voice_dir else [] - return audiocpp_backend.fetch_server_voices(url, model_id) - - # Voice: optional for qwen3_tts (built-in speaker), required otherwise. - voice = None - if task == "vdes": - # Voice design: no voice, instructions required. - pass - elif family == AUDIOCPP_FAMILY_QWEN3_TTS: - # Speaker mode available; voice optional. - voices = _voices() or [] - if voices: - opts = [("(built-in speaker)", None)] + [(v, v) for v in voices] - voice = tui.menu(stdscr, "Voice", opts, back_value=_GO_BACK) - if voice is _GO_BACK: - return None + return local_voices + if model_id not in voice_cache: + fetched = audiocpp_backend.fetch_server_voices(url, model_id) + voice_cache[model_id] = fetched or [] + return voice_cache[model_id] + + def model_entry(fields): + model_id = _field_value(fields, "model_id") + return next((m for m in models if m.get("id") == model_id), + models[0]) + + def model_task(fields) -> str: + return model_entry(fields).get("task", "tts") + + def model_family(fields) -> str: + return model_entry(fields).get("family") or "" + + def reset_voice(fields) -> None: + """Re-point the Voice field at the newly selected model's voice.""" + voice_field = next(f for f in fields if f.get("key") == "voice") + if model_task(fields) == "vdes": + voice_field["value"] = None + elif model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS: + voice_field["value"] = _AUDIOCPP_BUILTIN_SPEAKER else: - voice = None - else: - voices = _voices() - if voices is None: - tui.flash(stdscr, f"Could not list voices from the audio.cpp " - f"server at {url}.") - return None - if not voices: - if local: - tui.flash(stdscr, f"This model needs a --voice but voice_dir " - f"{data.get('voice_dir')} has no .wav voices. " - "Reconfigure audio.cpp or add voices.") - else: - tui.flash(stdscr, f"This model needs a --voice but the " - f"server at {url} lists none for '{model_id}'. " - "Configure voice presets or a voice_dir on the " - "server.") - return None - voice = tui.menu(stdscr, "Select the voice to clone", [(v, v) for v in voices], - back_value=_GO_BACK) - if voice is _GO_BACK or voice is None: - return None + voices = voices_for(_field_value(fields, "model_id")) + voice_field["value"] = voices[0] if voices else "" + + def voice_choices(fields) -> list: + if model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS: + return [(_AUDIOCPP_BUILTIN_SPEAKER, _AUDIOCPP_BUILTIN_SPEAKER)] \ + + [(v, v) for v in voices_for(_field_value(fields, + "model_id"))] + return [(v, v) for v in voices_for(_field_value(fields, "model_id"))] + + model_ids = [m.get("id") for m in models] + default_model = config.AUDIOCPP_MODEL_ID \ + if config.AUDIOCPP_MODEL_ID in model_ids else model_ids[0] + default_entry = next((m for m in models if m.get("id") == default_model), + models[0]) + initial_voice = _AUDIOCPP_BUILTIN_SPEAKER + if default_entry.get("task") == "vdes": + initial_voice = None + elif default_entry.get("family") != AUDIOCPP_FAMILY_QWEN3_TTS: + initial = voices_for(default_model) + initial_voice = initial[0] if initial else "" - # Instructions: required for vdes, optional otherwise. - instructions = None - if task == "vdes": - instructions = tui.line_edit( - stdscr, "Voice design instructions (required for this model)", - config.AUDIOCPP_INSTRUCTIONS, - validate=lambda s: None if s.strip() - else "Describe the voice, e.g. 'A warm female narrator'", - back_value=_GO_BACK) - if instructions is _GO_BACK: - return None - else: - instructions = tui.line_edit( - stdscr, "Style instructions (optional, blank for none)", - config.AUDIOCPP_INSTRUCTIONS, back_value=_GO_BACK) - if instructions is _GO_BACK: - return None - if not instructions.strip(): - instructions = None + fields = [ + {"key": "model_id", "label": "Model", "kind": "choice", + "value": default_model, + "choices": [(f"{m.get('id')} ({m.get('family') or '?'}, " + f"{m.get('task') or 'tts'})", m.get("id")) + for m in models], + "on_change": reset_voice}, + {"key": "voice", "label": "Voice", "kind": "choice", + "value": initial_voice, + "choices": lambda fs: voice_choices(fs), + "visible": lambda fs: model_task(fs) != "vdes", + "validate": lambda value: None + if (model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS or value) + else "This model needs a voice — pick one or switch models"}, + {"key": "instructions", "label": "Instructions", "kind": "text", + "value": config.AUDIOCPP_INSTRUCTIONS, + "validate": lambda value: None + if (model_task(fields) != "vdes" or str(value).strip()) + else "Describe the voice, e.g. 'A warm female narrator'"}, + ] + fields += _common_fields() - common_kw = _common_options(stdscr) - if common_kw is None: + result = _show_convert_form(stdscr, "Convert with audio.cpp", fields) + if result is None: return None + + model_id = result["model_id"] + voice = result["voice"] + if voice == _AUDIOCPP_BUILTIN_SPEAKER or not voice: + voice = None + entry = next((m for m in models if m.get("id") == model_id), {}) + if entry.get("task") == "vdes": + voice = None + instructions = (result["instructions"] or "").strip() or None return ("convert", BACKEND_AUDIOCPP, { "model_id": model_id, "voice": voice, "instructions": instructions, - **common_kw, + **_common_kwargs(result), }) def _convert_qwen(stdscr) -> Optional[tuple]: - """Collect qwen run settings: built-in speaker or clone a .wav.""" - mode = tui.menu( - stdscr, "qwen-tts mode", - [("Custom voice (built-in speaker)", "custom"), - ("Voice clone from a .wav file", "clone")], - back_value=_GO_BACK, - help_lines=[f"Speaker: {config.SPEAKER} (change it via Configure " - "qwen-tts)"]) - if mode is _GO_BACK or mode is None: - return None - clone = None - if mode == "clone": - clone = tui.line_edit( - stdscr, "Path to a reference .wav (10-15s is ideal)", - "", - validate=lambda s: None if (s and Path(s).is_file() + """Collect qwen run settings on one form: speaker or clone a .wav.""" + speakers = list(qwen_backend.QWEN_SPEAKERS) + default_speaker = config.SPEAKER if config.SPEAKER in speakers \ + else speakers[0] + fields = [ + {"key": "mode", "label": "Voice mode", "kind": "choice", + "value": "custom", + "choices": [("Built-in speaker", "custom"), + ("Clone from a .wav file", "clone")]}, + {"key": "speaker", "label": "Speaker", "kind": "choice", + "value": default_speaker, "choices": speakers, + "visible": lambda fs: _field_value(fs, "mode") == "custom"}, + {"key": "clone", "label": "Clone .wav path", "kind": "text", + "value": "", + "validate": lambda s: None if (s and Path(s).is_file() and s.lower().endswith(".wav")) - else "Enter the path to an existing .wav file", - back_value=_GO_BACK) - if clone is _GO_BACK: - return None - common_kw = _common_options(stdscr) - if common_kw is None: + else "Enter the path to an existing .wav file", + "visible": lambda fs: _field_value(fs, "mode") == "clone"}, + ] + fields += _common_fields() + result = _show_convert_form(stdscr, "Convert with qwen-tts", fields) + if result is None: return None - return ("convert", BACKEND_QWEN, {"clone": clone, **common_kw}) + clone = result["clone"].strip() if result["mode"] == "clone" else None + speaker = result["speaker"] + if result["mode"] == "custom" and speaker != config.SPEAKER: + # Persist the speaker choice for this and future runs (mirrors the + # qwen setup wizard), so the converter picks it up at request time. + common.update_config_value("SPEAKER", speaker) + config.SPEAKER = speaker + return ("convert", BACKEND_QWEN, {"clone": clone, + **_common_kwargs(result)}) def _convert_faster(stdscr) -> Optional[tuple]: - """Collect faster run settings: pick or type a voice name. + """Collect faster run settings on one form: pick or type a voice name. With a local checkout's voices.json the picker lists it (the config of the server this tool manages). Without one, the running server was @@ -416,60 +493,29 @@ def _convert_faster(stdscr) -> Optional[tuple]: return None if voices is None: # No local voices.json: prompt for a server-side voice name. - voice_text = tui.line_edit( - stdscr, "Server-side voice to clone with " - "(blank uses the server's first voice)", - config.FASTER_VOICE, - validate=lambda s: None if s.strip() else "Enter a voice name", - back_value=_GO_BACK) - if voice_text is _GO_BACK: - return None - voice = voice_text.strip() + fields = [ + {"key": "voice", "label": "Voice", "kind": "text", + "value": config.FASTER_VOICE, + "validate": lambda s: None if s.strip() else "Enter a voice name"}, + ] else: default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \ next(iter(voices)) - voice = tui.menu( - stdscr, "Select the voice to clone", - [(k, k) for k in voices], - default_index=list(voices).index(default), back_value=_GO_BACK) - if voice is _GO_BACK or voice is None: - return None - common_kw = _common_options(stdscr) - if common_kw is None: - return None - return ("convert", BACKEND_FASTER, {"voice": voice, **common_kw}) - - -def _common_options(stdscr) -> Optional[dict]: - """Collect output format, speed, single-file, debug.""" - fmt_options = [(f, f) for f in AUDIO_FORMATS] - fmt_default = AUDIO_FORMATS.index(config.AUDIO_FORMAT) \ - if config.AUDIO_FORMAT in AUDIO_FORMATS else 0 - output_format = tui.menu(stdscr, "Output format", fmt_options, - default_index=fmt_default, back_value=_GO_BACK) - if output_format is _GO_BACK or output_format is None: - return None - speed_text = tui.line_edit( - stdscr, "Playback speed (1.0 = normal)", "1.0", - validate=lambda s: None if (_is_float(s) and float(s) > 0) - else "Enter a positive number, e.g. 1.0", - back_value=_GO_BACK) - if speed_text is _GO_BACK: + fields = [ + {"key": "voice", "label": "Voice", "kind": "choice", + "value": default, "choices": [(k, k) for k in voices]}, + ] + fields += _common_fields() + result = _show_convert_form(stdscr, "Convert with faster-qwen3-tts", + fields) + if result is None: return None - single_file = tui.confirm(stdscr, "Combine all chapters into one file?", - default=False, cancel_value=_GO_BACK) - if single_file is _GO_BACK: - return None - debug = tui.confirm(stdscr, "Debug mode (dump per-chunk audio/text)?", - default=False, cancel_value=_GO_BACK) - if debug is _GO_BACK: - return None - return { - "output_format": output_format, - "speed": float(speed_text), - "single_file": single_file, - "debug": debug, - } + voice = result["voice"].strip() if isinstance(result["voice"], str) \ + else result["voice"] + return ("convert", BACKEND_FASTER, { + "voice": voice or None, + **_common_kwargs(result), + }) # --------------------------------------------------------------------------- @@ -696,11 +742,13 @@ def _maybe_stop_server(name: str) -> None: servers.stop(name) -def _add_autostart(stdscr, cmd: tuple, statuses) -> None: - """Offer to auto-start the conversion's target server when it isn't running. +def _add_autostart(cmd: tuple, statuses) -> None: + """Auto-start the conversion's target server when it isn't running. Records the chosen server spec name as ``kwargs['autostart']`` for - ``_run_conversion`` to act on. Mode-aware for qwen (custom vs clone). + ``_run_conversion`` to act on. The user already accepted the run on the + Generate! screen, so no start-server prompt is asked here — the server + is simply started. Mode-aware for qwen (custom vs clone). """ _, key, kwargs = cmd status = next((s for s in statuses if s.key == key), None) @@ -711,11 +759,7 @@ def _add_autostart(stdscr, cmd: tuple, statuses) -> None: return if common.server_running(spec.url): return - choice = tui.confirm(stdscr, f"The {status.label} server is not running. " - "Start it automatically?", default=True, - cancel_value=False) - if choice is True: - kwargs["autostart"] = spec.name + kwargs["autostart"] = spec.name def _select_spec(status, kwargs) -> Optional[ServerSpec]: diff --git a/app/ui/tui.py b/app/ui/tui.py index 6d768dd..83ec43d 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -744,13 +744,15 @@ def line_edit(scr, title: str, default: str, # --------------------------------------------------------------------------- -# Widget: multi-field settings form with Save/Cancel buttons +# Widget: multi-field settings form with accept/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. + help_lines: Optional[Sequence[str]] = None, + buttons: Sequence[str] = ("Save", "Cancel"), + start_on_buttons: bool = False) -> Optional[dict]: + """Edit several labeled fields on one screen, then accept or cancel. FIELDS is a list of dicts, one per row, shaped like:: @@ -760,42 +762,82 @@ def form(scr, title: str, fields: Sequence[dict], {"key": "chunk_size", "label": "Chunk size", "kind": "text", "value": "250", "validate": lambda s: None if s.isdigit() else "digits only"} + {"key": "combine", "label": "Combine chapters", + "kind": "bool", "value": False} Fields render as a two-column table: each label is padded to the - widest label so every value starts in the same column. 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 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, - Left/Right, j or k at the ends of the list move focus to the - Save/Cancel buttons — Down from the last field and Up from the first - field both land on Save (the fields wrap onto the buttons); on the - buttons, arrows/j/k/Tab return to the fields. 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. + widest label so every value starts in the same column. KINDS: + ``choice`` opens a single choice menu (its ``choices`` may be a + callable of the field list, resolved when the menu opens); ``text`` + opens a line editor (reusing its VALIDATE); ``bool`` shows Yes/No and + toggles in place on Enter or Space. + + A field may set ``visible`` to a bool or a callable of the field + list; hidden fields are not drawn, are skipped by the cursor, and + keep their value across hide/show. A field may set ``on_change`` to + a callable of the field list, invoked whenever its value changes so + dependent fields (choices, visibility, defaults) can be recomputed. + + 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 + 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 + button (the fields wrap onto the buttons); from the buttons, Down/j/Tab + wrap back to the first field and Up/k/BTAB to the last, Left/Right + switch the buttons. Enter on the first button validates every visible + field that has a ``validate`` (the first failure flashes in red and + re-focuses that row) and returns ``{key: value}``, Enter on the second + button 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. + + BUTTONS customizes the two button labels (default "Save"/"Cancel"); + START_ON_BUTTONS puts the initial focus on the first button so Enter + accepts immediately. """ if not fields: raise ValueError("form() needs at least one field") frame = Frame(scr, title, - "Up/Down = move Enter = edit Tab/arrows = Save/Cancel " + "Up/Down = move Enter = edit Tab/arrows = buttons " "Esc = cancel") cursor = 0 - on_buttons = False + on_buttons = start_on_buttons btn_index = 0 edit_cancel = object() # sentinel: backed out of a field editor label_w = max(len(field["label"]) for field in fields) + + def shown_fields() -> List[dict]: + result: List[dict] = [] + for field in fields: + visible = field.get("visible", True) + if callable(visible): + visible = visible(fields) + if visible: + result.append(field) + return result + + def run_on_change(field: dict) -> None: + callback = field.get("on_change") + if callback is not None: + callback(fields) + + def display_value(field: dict) -> str: + if field.get("kind") == "bool": + return "Yes" if field["value"] else "No" + return str(field["value"]) + while True: + shown = shown_fields() + cursor = max(0, min(cursor, len(shown) - 1)) if shown else 0 frame.rows = [] for line in help_lines or []: frame.mark(line, frame.theme["dim"]) if help_lines: frame.mark("") - field_rows: List[int] = [] # field index -> row index - for field in fields: + field_rows: List[int] = [] # visible field index -> row index + for field in shown: if field.get("note"): frame.mark("") frame.mark(field["note"], frame.theme["dim"], align="left") @@ -804,10 +846,10 @@ def form(scr, title: str, fields: Sequence[dict], name = f"{field['label']}:".ljust(label_w + 1) frame.mark_segments( [(name, frame.theme["body"]), - (" " + field["value"], frame.theme["input"])], + (" " + display_value(field), frame.theme["input"])], selectable=True, align="left") frame.cursor = None if on_buttons else field_rows[cursor] - frame.buttons = (["Save", "Cancel"], btn_index if on_buttons else None) + frame.buttons = (list(buttons), btn_index if on_buttons else None) frame.draw() curses = frame.curses key = frame.get_key(cancel_keys=()) @@ -816,17 +858,22 @@ def form(scr, title: str, fields: Sequence[dict], if key in _CANCEL_KEYS: raise WizardCancelled() if on_buttons: - if key in (9, curses.KEY_BTAB, curses.KEY_UP, curses.KEY_DOWN, - ord("j"), ord("k")): + if key in (curses.KEY_UP, ord("k"), curses.KEY_BTAB): + # Wrap up through the buttons onto the last field. + on_buttons = False + cursor = len(shown) - 1 if shown else 0 + elif key in (curses.KEY_DOWN, ord("j"), 9): + # Wrap down through the buttons back to the first field. on_buttons = False + cursor = 0 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): + if btn_index == 0: # accept (Save / Generate!) + for index, field in enumerate(shown): validate = field.get("validate") - if field.get("kind") == "text" and validate: + if validate is not None: error = validate(field["value"]) if error is not None: on_buttons = False @@ -840,32 +887,47 @@ def form(scr, title: str, fields: Sequence[dict], return back_value else: if key in (curses.KEY_DOWN, ord("j")) \ - and cursor == len(fields) - 1: + and cursor == len(shown) - 1: on_buttons = True - btn_index = 0 # Save + btn_index = 0 # first button elif key in (curses.KEY_UP, ord("k")) and cursor == 0: on_buttons = True - btn_index = 0 # Save (wraps around from the top) + btn_index = 0 # first button (wraps around from the top) else: - moved = frame.motion(key, cursor, len(fields), wrap=True) + moved = frame.motion(key, cursor, len(shown), 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 == ord(" ") and shown[cursor].get("kind") == "bool": + shown[cursor]["value"] = not bool(shown[cursor]["value"]) + run_on_change(shown[cursor]) 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], + field = shown[cursor] + if field.get("kind") == "bool": + field["value"] = not bool(field["value"]) + run_on_change(field) + elif field.get("kind") == "choice": + choices = field.get("choices") or [] + if callable(choices): + choices = choices(fields) + choices = list(choices) + if choices and isinstance(choices[0], (tuple, list)) \ + and len(choices[0]) == 2: + pairs = [(label, value) for label, value in choices] + else: + pairs = [(c, c) for c in choices] + values = [value for _, value in pairs] + default = values.index(field["value"]) \ + if field["value"] in values else 0 + chosen = menu(scr, field["label"], pairs, default_index=default, back_value=edit_cancel) if chosen is not edit_cancel: field["value"] = chosen + run_on_change(field) else: edited = line_edit(scr, field["label"], field["value"], @@ -873,6 +935,7 @@ def form(scr, title: str, fields: Sequence[dict], back_value=edit_cancel) if edited is not edit_cancel: field["value"] = edited + run_on_change(field) # --------------------------------------------------------------------------- |
