aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--app/tests/test_hub.py239
-rw-r--r--app/tests/test_tui.py144
-rw-r--r--app/ui/hub.py388
-rw-r--r--app/ui/tui.py143
5 files changed, 632 insertions, 284 deletions
diff --git a/README.md b/README.md
index 56e5b85..5b8b370 100644
--- a/README.md
+++ b/README.md
@@ -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)
# ---------------------------------------------------------------------------