diff options
| author | historia <historiavg@proton.me> | 2026-08-26 18:39:33 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-26 18:39:33 -0400 |
| commit | df95e7034df683c38fde67890430ab4c2abfa4ba (patch) | |
| tree | f46d3eb393e605e82a4d058a2290061b77403649 /app | |
| parent | 544486a374cd5cae7acce1302648d8dad079db48 (diff) | |
| download | tts-audiobook-generator-df95e7034df683c38fde67890430ab4c2abfa4ba.tar.gz | |
feat: in-place toggle field, updated transcription tui to use new field
Diffstat (limited to 'app')
| -rw-r--r-- | app/backends/audiocpp/wizard.py | 99 | ||||
| -rwxr-xr-x | app/backends/faster.py | 82 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 128 | ||||
| -rw-r--r-- | app/tests/test_backends_faster.py | 94 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 52 | ||||
| -rw-r--r-- | app/ui/tui.py | 35 |
6 files changed, 261 insertions, 229 deletions
diff --git a/app/backends/audiocpp/wizard.py b/app/backends/audiocpp/wizard.py index 831ae3d..bfc0eb7 100644 --- a/app/backends/audiocpp/wizard.py +++ b/app/backends/audiocpp/wizard.py @@ -143,33 +143,14 @@ def _write_and_advise(audiocpp_dir: Path, wav_dir: Optional[Path], f"{'entry' if count == 1 else 'entries'}.") -def _transcription_choices(wav_files: list, existing: Dict[str, str], - prompt_exists: bool) -> Tuple[list, str]: - """Shape the transcription question for the setup form. - - Returns ``(choices, default_mode)`` where MODE is ``"all"`` - (re-transcribe everything), ``"missing"`` (only .wavs without an - existing transcript) or ``"keep"`` (reuse prompt_text untouched). - Plain choice pairs the combined config form can show on one row. - """ - if not prompt_exists: - return [("Re-transcribe all", "all")], "all" - missing = [wav for wav in wav_files - if not existing.get(wav.stem, "").strip()] - if not missing: - return ([("Keep the existing transcripts", "keep"), - ("Re-transcribe all", "all")], "keep") - return ([("Only transcribe new voices", "missing"), - ("Re-transcribe all", "all")], "missing") - - def _plan_from_mode(mode: str, wav_files: list, existing: Dict[str, str]) -> dict: """Build the transcription PLAN for the chosen form MODE. The plan dict is what ``voices._transcribe`` consumes: "missing" - carries the .wavs lacking a transcript plus the existing mapping; - "all"/"keep" name the mode and reuse the mapping read while asking. + carries the .wavs lacking a transcript plus the existing mapping, + "all" re-transcribes everything; both reuse the mapping read while + applying the form. """ if mode == "missing": missing = [wav for wav in wav_files @@ -365,18 +346,6 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser return next((f["value"] for f in fields_list if f.get("key") == key), default) - def _transcription_state(wav_dir): - """(wav_files, existing transcripts, prompt_text exists) or None.""" - if wav_dir is None: - return None - wav_files = find_wav_files(Path(wav_dir)) - if not wav_files: - return None - prompt_path = Path(wav_dir) / PROMPT_TEXT_FILENAME - prompt_exists = bool(prompt_path.exists()) and not args.force - existing = read_prompt_text(prompt_path) if prompt_exists else {} - return wav_files, existing, prompt_exists - def _apply_form(result: dict) -> dict: """Fold the form's answers into the settings and finalize.""" # Backend/build: the interactive combination. A backend whose @@ -400,15 +369,18 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser # Transcription plan (transcription itself runs in the tail). s["plan"] = None if s["include_clone"]: - state = _transcription_state(s["wav_dir"]) - if state is not None: - wav_files, existing, prompt_exists = state - choices, default_mode = _transcription_choices( - wav_files, existing, prompt_exists) - mode = result.get("transcription") - if mode not in [candidate for _label, candidate in choices]: - mode = default_mode - s["plan"] = _plan_from_mode(mode, wav_files, existing) + wav_files = find_wav_files(Path(s["wav_dir"])) \ + if s["wav_dir"] is not None else [] + prompt_path = Path(s["wav_dir"]) / PROMPT_TEXT_FILENAME \ + if s["wav_dir"] is not None else None + existing = {} + if prompt_path is not None and prompt_path.exists() \ + and not args.force: + existing = read_prompt_text(prompt_path) + mode = result.get("transcription") + if mode not in ("missing", "all"): + mode = "missing" + s["plan"] = _plan_from_mode(mode, wav_files, existing) s["download"] = bool(result.get("download")) and ( _models.download_applicable(s["audiocpp_dir"], @@ -499,41 +471,16 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser } fields.append(wav_field) - def state_of(fs): - return _transcription_state(_field_val(fs, "wav_dir")) - - initial_state = state_of([wav_field]) - initial_default = _transcription_choices(*initial_state)[1] \ - if initial_state is not None else "missing" - - def transcription_choices(fs): - state = state_of(fs) - if state is None: - return [("Re-transcribe all", "all")] - return _transcription_choices(*state)[0] - - def transcription_visible(fs) -> bool: - return state_of(fs) is not None - - def reset_transcription(fs_list) -> None: - # The directory changed: snap the stale choice to a valid one. - field = next((f for f in fs_list - if f.get("key") == "transcription"), None) - if field is not None: - modes = [mode for _label, mode in transcription_choices( - fs_list)] - if field["value"] not in modes: - state = state_of(fs_list) - field["value"] = _transcription_choices(*state)[1] \ - if state is not None else "missing" - + # Voice transcript handling: a plain in-place toggle, always + # offered whenever any clone-capable model is hosted (no + # dependency on what the picked directory currently holds). fields.append({ "key": "transcription", "label": "Voice transcripts", - "kind": "choice", "value": initial_default, - "choices": transcription_choices, - "visible": transcription_visible, + "kind": "toggle", "value": "missing", + "choices": [("Transcribe new voices", "missing"), + ("Re-transcribe all voices", "all")], + "visible": lambda fs: bool(s["include_clone"]), }) - wav_field["on_change"] = reset_transcription if _models.download_applicable(s["audiocpp_dir"], s["model_entries"]): fields.append({ @@ -575,7 +522,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser result = tui.form( stdscr, "Configure audio.cpp", fields, buttons=("Continue", "Cancel"), - start_on_buttons=False, back_value=tui.Wizard.BACK) + start_on_buttons=True, back_value=tui.Wizard.BACK) if result is tui.Wizard.BACK: return tui.Wizard.BACK return _apply_form(result) diff --git a/app/backends/faster.py b/app/backends/faster.py index 60ddddf..6cfa53e 100755 --- a/app/backends/faster.py +++ b/app/backends/faster.py @@ -117,36 +117,16 @@ def load_voices(path: Path) -> dict: return data -def _decide_faster_transcription(wav_files: list, existing_voices: dict - ) -> tuple: - """Shape the transcription question for the setup form. - - Returns ``(choices, default_mode)``: CHOICES is a list of - ``(label, mode)`` pairs where MODE is ``"missing"`` (only the new - voices), ``"all"`` (re-transcribe everything) or ``"keep"`` - (reuse voices.json untouched). With new .wavs present transcribing - only those is offered first (and is the default); otherwise — and - always, per the modify design — re-transcribing everything stays - available, but keeping the existing file is the default. - """ - existing = dict(existing_voices) - new_wavs = [wav for wav in wav_files if wav.stem not in existing] - if new_wavs: - return ([("Only transcribe new voices", "missing"), - ("Re-transcribe all", "all")], "missing") - return ([("Keep the existing voices.json", "keep"), - ("Re-transcribe all", "all")], "keep") - - def _write_voices_json(output_path: Path, wav_dir: Path, language: str, - whisper_model: str, plan: Optional[dict]) -> Optional[dict]: + whisper_model: str, + plan: Optional[dict]) -> Optional[dict]: """Transcribe the wav dir and write voices.json; return the voices dict. - PLAN (built by ``_decide_faster_transcription`` in the wizard, or an - "all" plan for a fresh/flag run) decides whether every voice is - re-transcribed ("all"), only the new ones ("missing" — merged into the - existing entries), or nothing changes ("keep" — the existing file is - left untouched and returned as-is). None (cancelled) writes nothing. + PLAN (from the wizard's transcription toggle, or an "all" plan for a + fresh/flag run) decides whether every voice is re-transcribed + ("all"), only the new ones ("missing" — merged into the existing + entries), or nothing changes ("keep" — the existing file is left + untouched and returned as-is). None (cancelled) writes nothing. """ if plan is None: return None @@ -242,49 +222,14 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: ] modifying = bool(existing_voices) and not args.force if modifying: - # Modify flow: offer keep/new-only/all when the picked directory - # holds .wavs. Recomputed live so switching directories updates it. - - def current_dir(fields_list): - value = next(f["value"] for f in fields_list - if f.get("key") == "wav_dir") - return Path(value) if value else wav_start - - choices_cache: dict = {} - - def transcription_field() -> dict: - wav_files = find_wav_files(current_dir(fields)) - if choices_cache.get("dir") != wav_files: - choices, default = _decide_faster_transcription( - wav_files, existing_voices) - choices_cache.clear() - choices_cache.update({"dir": wav_files, - "choices": choices, - "default": default}) - return choices_cache - - def transcription_choices(_fields_list): - return list(transcription_field()["choices"]) - - def reset_transcription(fields_list) -> None: - field = next(f for f in fields_list - if f.get("key") == "transcription") - modes = [mode for _label, mode - in transcription_field()["choices"]] - if field["value"] not in modes: - field["value"] = transcription_field()["default"] - + # Modify flow: a plain in-place toggle, always offered. fields.append({ "key": "transcription", "label": "Transcription", - "kind": "choice", - "value": transcription_field()["default"], - "choices": transcription_choices, - "visible": lambda fs: bool(find_wav_files(current_dir(fs))), + "kind": "toggle", "value": "missing", + "choices": [("Transcribe new voices", "missing"), + ("Re-transcribe all voices", "all")], "note": "An existing voices.json was found.", }) - # Changing the directory refreshes the transcription offer; - # tui.form calls the field's `on_change` with the field list. - fields[0]["on_change"] = reset_transcription result = tui.form( stdscr, "Set up faster-qwen3-tts", fields, @@ -299,7 +244,10 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: if not modifying: plan = {"mode": "all", "missing": [], "existing": {}} elif find_wav_files(wav_dir): - plan = _plan_for(result["transcription"], wav_dir, existing_voices) + mode = result.get("transcription") + if mode not in ("missing", "all"): + mode = "missing" + plan = _plan_for(mode, wav_dir, existing_voices) else: # Directory without .wavs on a modify run: keep the existing file. plan = {"mode": "keep", "missing": [], diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 37745f3..d364c06 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -792,31 +792,109 @@ class InstallModelsTests(unittest.TestCase): self.checkout, [{"path": "models/higgs"}])) -class TranscriptionChoicesTests(unittest.TestCase): - """_transcription_choices: the renamed voice-transcripts options.""" - - def test_fresh_directory_offers_the_renamed_all(self): - choices, default = make_server.wizard._transcription_choices( - [], {}, prompt_exists=False) - self.assertEqual(default, "all") - self.assertEqual(choices, [("Re-transcribe all", "all")]) - - def test_existing_transcripts_offer_new_only_and_all(self): - wavs = [Path("/x/narrator.wav"), Path("/x/new.wav")] - choices, default = make_server.wizard._transcription_choices( - wavs, {"narrator": "old transcript"}, prompt_exists=True) - self.assertEqual(default, "missing") - self.assertEqual([label for label, _mode in choices], - ["Only transcribe new voices", "Re-transcribe all"]) - - def test_complete_transcripts_offer_keep_and_all(self): - wavs = [Path("/x/narrator.wav")] - choices, default = make_server.wizard._transcription_choices( - wavs, {"narrator": "old transcript"}, prompt_exists=True) - self.assertEqual(default, "keep") - self.assertEqual([label for label, _mode in choices], - ["Keep the existing transcripts", - "Re-transcribe all"]) +class ConfigFormTranscriptionToggleTests(unittest.TestCase): + """The combined form's Voice transcripts row: one fixed two-way toggle. + + The row is always visible whenever a clone-capable family is hosted + (it must not hide itself just because the picked wav directory has no + .wavs yet), and the plan it produces follows the toggled mode. + """ + + def _checkout(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + return _make_checkout(Path(tmp.name)) + + def _empty_dir(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + return Path(tmp.name) + + def _run(self, checkout, voices_dir, picked_family=None, override=None): + """Drive _wizard on CHECKOUT; return (form call capture, settings).""" + catalog = make_server.catalog.load_model_catalog(checkout) + family = picked_family or "qwen3_tts" + index = next(i for i, entry in enumerate(catalog) + if entry["family"] == family) + target = catalog[index]["packages"][0]["target_directory"] + captured = {} + + def fake_form(stdscr, title, fields, **kwargs): + captured.update(kwargs) + captured["fields"] = fields + result = {f["key"]: f["value"] for f in fields} + if override: + result.update(override) + return result + + with patch.object(make_server.build, "find_local_checkout", + return_value=checkout), \ + patch.object(tui, "checkbox_tree", + return_value=[(index, target)]), \ + patch.object(tui, "form", side_effect=fake_form), \ + patch.object(make_server.wizard, "VOICES_DIR", voices_dir): + settings = make_server.wizard._wizard( + None, make_server.wizard.build_parser().parse_args([]), + make_server.wizard.build_parser()) + return captured, settings + + def test_row_is_a_fixed_toggle_defaulting_to_new_voices(self): + checkout = self._checkout() + captured, _settings = self._run(checkout, self._empty_dir()) + by_key = {f["key"]: f for f in captured["fields"]} + row = by_key["transcription"] + self.assertEqual(row["kind"], "toggle") + self.assertEqual(row["value"], "missing") + self.assertEqual( + row["choices"], + [("Transcribe new voices", "missing"), + ("Re-transcribe all voices", "all")]) + + def test_row_always_visible_when_clone_capable(self): + # Regression: the row used to hide itself until the wav directory + # contained .wavs; a clone-capable pick must always offer it. + checkout = self._checkout() + voices = self._empty_dir() + captured, _settings = self._run(checkout, voices) + by_key = {f["key"]: f for f in captured["fields"]} + self.assertTrue(by_key["transcription"]["visible"](captured["fields"])) + self.assertEqual(_settings["plan"]["mode"], "missing") + self.assertEqual(_settings["plan"]["missing"], []) + + def test_row_hidden_without_a_clone_capable_pick(self): + checkout = self._checkout() + captured, _settings = self._run(checkout, self._empty_dir(), + picked_family="supertonic") + by_key = {f["key"]: f for f in captured["fields"]} + self.assertFalse(by_key["transcription"]["visible"](captured["fields"])) + self.assertIsNone(_settings["plan"]) + + def test_form_opens_on_the_continue_button(self): + checkout = self._checkout() + captured, _settings = self._run(checkout, self._empty_dir()) + self.assertTrue(captured.get("start_on_buttons")) + + def test_new_voices_plan_carries_only_untranscribed_wavs(self): + checkout = self._checkout() + voices = self._empty_dir() + (voices / "extra.wav").write_bytes(b"x") + (voices / "narrator.wav").write_bytes(b"x") + common.write_prompt_text(voices, {"narrator": "Old words."}) + _captured, settings = self._run(checkout, voices) + self.assertEqual(settings["plan"]["mode"], "missing") + self.assertEqual([w.name for w in settings["plan"]["missing"]], + ["extra.wav"]) + self.assertEqual(settings["plan"]["existing"], + {"narrator": "Old words."}) + + def test_toggled_all_retranscribes_everything(self): + checkout = self._checkout() + voices = self._empty_dir() + (voices / "narrator.wav").write_bytes(b"x") + common.write_prompt_text(voices, {"narrator": "Old words."}) + _captured, settings = self._run(checkout, voices, + override={"transcription": "all"}) + self.assertEqual(settings["plan"]["mode"], "all") class TranscribeWavDirTests(unittest.TestCase): diff --git a/app/tests/test_backends_faster.py b/app/tests/test_backends_faster.py index c03f031..a093fbe 100644 --- a/app/tests/test_backends_faster.py +++ b/app/tests/test_backends_faster.py @@ -110,8 +110,8 @@ class LoadVoicesTests(unittest.TestCase): self.assertEqual(make_voices.load_voices(self.path), {}) -class DecideFasterTranscriptionTests(unittest.TestCase): - """_decide_faster_transcription: the re-transcribe plan questions.""" +class PlanForTests(unittest.TestCase): + """_plan_for: execution plans for the wizard's transcription toggle.""" def setUp(self): self._tmp = tempfile.TemporaryDirectory() @@ -124,59 +124,18 @@ class DecideFasterTranscriptionTests(unittest.TestCase): def tearDown(self): self._tmp.cleanup() - def test_new_voices_default_to_missing_mode(self): - choices, default = make_voices._decide_faster_transcription( - [self.narrator, self.new_voice], - {"narrator": {"ref_text": "old"}}) - self.assertEqual(default, "missing") - modes = [mode for _label, mode in choices] - self.assertIn("missing", modes) - self.assertIn("all", modes) - missing = [w for w in (self.narrator, self.new_voice) - if w.stem not in {"narrator"}] + def test_missing_plan_carries_only_unlisted_wavs(self): plan = make_voices._plan_for("missing", self.folder, {"narrator": {"ref_text": "old"}}) self.assertEqual(plan["mode"], "missing") - self.assertEqual([w.name for w in plan["missing"]], - [w.name for w in missing]) - - def test_declining_new_voices_transcribes_all(self): - choices, default = make_voices._decide_faster_transcription( - [self.narrator, self.new_voice], - {"narrator": {"ref_text": "old"}}) - # Re-transcribing everything stays available alongside new-only. - modes = [mode for _label, mode in choices] - self.assertIn("all", modes) - plan = make_voices._plan_for("all", self.folder, - {"narrator": {"ref_text": "old"}}) - self.assertEqual(plan["mode"], "all") - - def test_no_new_voices_offers_retranscribe_default_keep(self): - choices, default = make_voices._decide_faster_transcription( - [self.narrator], {"narrator": {"ref_text": "old"}}) - self.assertEqual(default, "keep") - modes = [mode for _label, mode in choices] - self.assertEqual(modes, ["keep", "all"]) + self.assertEqual([w.name for w in plan["missing"]], ["new.wav"]) + self.assertEqual(plan["existing"], {"narrator": {"ref_text": "old"}}) - def test_no_new_voices_can_retranscribe_all(self): - _choices, _default = make_voices._decide_faster_transcription( - [self.narrator], {"narrator": {"ref_text": "old"}}) + def test_all_plan_names_the_mode(self): plan = make_voices._plan_for("all", self.folder, {"narrator": {"ref_text": "old"}}) self.assertEqual(plan["mode"], "all") - - def test_choice_labels_are_the_renamed_ones(self): - # The option names shown for the Voice-transcripts choice. - with_new, _ = make_voices._decide_faster_transcription( - [self.narrator, self.new_voice], - {"narrator": {"ref_text": "old"}}) - self.assertEqual([label for label, _mode in with_new], - ["Only transcribe new voices", "Re-transcribe all"]) - without_new, _ = make_voices._decide_faster_transcription( - [self.narrator], {"narrator": {"ref_text": "old"}}) - self.assertEqual([label for label, _mode in without_new], - ["Keep the existing voices.json", - "Re-transcribe all"]) + self.assertEqual(plan["missing"], []) class MainTests(unittest.TestCase): @@ -333,7 +292,7 @@ class WizardFormTests(unittest.TestCase): # no keep/new-only choice exists. self.assertEqual(settings["plan"]["mode"], "all") - def test_modify_run_offers_transcription_modes(self): + def test_modify_run_offers_transcription_toggle(self): folder = self._wavs() (folder / "new.wav").write_bytes(b"x") # a voice not in voices.json output = folder / "voices.json" @@ -347,11 +306,14 @@ class WizardFormTests(unittest.TestCase): captured["keys"] = [f["key"] for f in fields] by_key = {f["key"]: f for f in fields} self.assertIn("transcription", by_key) - modes = [mode for _label, mode in by_key[ - "transcription"]["choices"](fields)] - # New .wavs exist, so both transcribing only those and - # re-transcribing everything are offered. - self.assertEqual(modes, ["missing", "all"]) + # A plain in-place toggle (no popup): fixed choices, new-only. + row = by_key["transcription"] + self.assertEqual(row["kind"], "toggle") + self.assertEqual(row["value"], "missing") + self.assertEqual( + row["choices"], + [("Transcribe new voices", "missing"), + ("Re-transcribe all voices", "all")]) result = {f["key"]: f["value"] for f in fields} result["transcription"] = "missing" return result @@ -372,6 +334,30 @@ class WizardFormTests(unittest.TestCase): ["new.wav"]) self.assertEqual(settings["wav_dir"], folder) + def test_modify_run_toggled_all_retranscribes_everything(self): + folder = self._wavs() + (folder / "new.wav").write_bytes(b"x") + output = folder / "voices.json" + existing = {"narrator": { + "ref_audio": str(folder / "narrator.wav"), + "ref_text": "old transcript", "language": "English"}} + output.write_text(json.dumps(existing), encoding="utf-8") + + def fake_form(stdscr, title, fields, **kwargs): + result = {f["key"]: f["value"] for f in fields} + result["transcription"] = "all" + return result + + with patch.object(make_voices, "_is_installed", return_value=True), \ + patch.object(make_voices, "_is_cloned", return_value=True), \ + patch.object(make_voices.tui, "form", + side_effect=fake_form): + settings = make_voices._wizard( + None, self._args("--output", str(output), + "--skip-install", "--skip-clone")) + self.assertIsNotNone(settings) + self.assertEqual(settings["plan"]["mode"], "all") + def test_cancel_aborts(self): with patch.object(make_voices, "_is_installed", return_value=True), \ patch.object(make_voices, "_is_cloned", return_value=True), \ diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index b839a71..7821ebb 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -684,6 +684,58 @@ class FormTests(TuiTestCase): result = tui.form(screen, "Settings", fields) self.assertEqual(result, {"combine": True}) + def test_toggle_field_cycles_with_enter(self): + # Enter steps the toggle to its next choice in place (no menu + # opens), Tab -> button, Enter saves. + fields = [{"key": "transcripts", "label": "Transcripts", + "kind": "toggle", "value": "missing", + "choices": [("New voices", "missing"), + ("All voices", "all")]}] + screen = FakeScreen(keys=[10, 9, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"transcripts": "all"}) + + def test_toggle_field_cycles_with_space(self): + fields = [{"key": "transcripts", "label": "Transcripts", + "kind": "toggle", "value": "missing", + "choices": [("New voices", "missing"), + ("All voices", "all")]}] + screen = FakeScreen(keys=[ord(" "), 9, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"transcripts": "all"}) + + def test_toggle_field_last_choice_wraps_to_the_first(self): + fields = [{"key": "transcripts", "label": "Transcripts", + "kind": "toggle", "value": "all", + "choices": [("New voices", "missing"), + ("All voices", "all")]}] + screen = FakeScreen(keys=[10, 9, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"transcripts": "missing"}) + + def test_toggle_field_renders_the_matching_label(self): + fields = [{"key": "transcripts", "label": "Transcripts", + "kind": "toggle", "value": "missing", + "choices": [("New voices", "missing"), + ("All voices", "all")]}] + screen = FakeScreen(keys=[9, 10]) # straight to Save + tui.form(screen, "Settings", fields) + texts = [text for _, _, text, _ in screen.strings] + self.assertTrue(any("New voices" in text for text in texts)) + self.assertFalse(any("All voices" in text for text in texts)) + + def test_toggle_field_fires_on_change(self): + calls = [] + fields = [{"key": "transcripts", "label": "Transcripts", + "kind": "toggle", "value": "missing", + "choices": [("New voices", "missing"), + ("All voices", "all")], + "on_change": lambda fs: calls.append(1)}] + screen = FakeScreen(keys=[10, 9, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(calls, [1]) + self.assertEqual(result, {"transcripts": "all"}) + def test_hidden_field_keeps_value_and_is_not_drawn(self): fields = self._fields() fields.append({"key": "hidden", "label": "Secret", "kind": "text", diff --git a/app/ui/tui.py b/app/ui/tui.py index d91274b..e2118eb 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -883,6 +883,10 @@ def form(scr, title: str, fields: Sequence[dict], "validate": lambda s: None if s.isdigit() else "digits only"} {"key": "combine", "label": "Combine chapters", "kind": "bool", "value": False} + {"key": "transcripts", "label": "Voice transcripts", + "kind": "toggle", "value": "missing", + "choices": [("Transcribe new voices", "missing"), + ("Re-transcribe all voices", "all")]} {"key": "voices", "label": "Voices directory", "kind": "dir", "value": Path("./voices")} @@ -891,7 +895,9 @@ def form(scr, title: str, fields: Sequence[dict], ``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; ``dir`` opens the DOS-style + toggles in place on Enter or Space; ``toggle`` shows the label whose + VALUE is selected and cycles through its ``(label, value)`` CHOICES + in place on Enter or Space; ``dir`` opens the DOS-style directory browser (browse_directory) on Enter — its VALUE is a Path (or str path, used as the browse start), an empty value starts at the working directory, and backing out of the browser keeps the old @@ -949,12 +955,28 @@ def form(scr, title: str, fields: Sequence[dict], if callback is not None: callback(fields) + def activate_inline(field: dict) -> None: + """Enter/Space on an in-place field: flip a bool, step a toggle.""" + if field["kind"] == "bool": + field["value"] = not bool(field["value"]) + else: # toggle: cycle through the choice values + values = [value for _label, value in field.get("choices") or []] + if values: + index = values.index(field["value"]) \ + if field["value"] in values else -1 + field["value"] = values[(index + 1) % len(values)] + run_on_change(field) + def display_value(field: dict) -> str: if field.get("kind") == "bool": return "Yes" if field["value"] else "No" if field.get("kind") == "dir": value = field["value"] return str(value) if value is not None else "" + if field.get("kind") == "toggle": + for label, value in field.get("choices") or []: + if value == field["value"]: + return label return str(field["value"]) while True: @@ -1030,14 +1052,13 @@ def form(scr, title: str, fields: Sequence[dict], 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 == ord(" ") and shown[cursor].get("kind") in \ + ("bool", "toggle"): + activate_inline(shown[cursor]) elif key in (10, 13): field = shown[cursor] - if field.get("kind") == "bool": - field["value"] = not bool(field["value"]) - run_on_change(field) + if field.get("kind") in ("bool", "toggle"): + activate_inline(field) elif field.get("kind") == "choice": choices = field.get("choices") or [] if callable(choices): |
