aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 18:39:33 -0400
committerhistoria <historiavg@proton.me>2026-08-26 18:39:33 -0400
commitdf95e7034df683c38fde67890430ab4c2abfa4ba (patch)
treef46d3eb393e605e82a4d058a2290061b77403649 /app
parent544486a374cd5cae7acce1302648d8dad079db48 (diff)
downloadtts-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.py99
-rwxr-xr-xapp/backends/faster.py82
-rw-r--r--app/tests/test_backends_audiocpp.py128
-rw-r--r--app/tests/test_backends_faster.py94
-rw-r--r--app/tests/test_tui.py52
-rw-r--r--app/ui/tui.py35
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):