diff options
| author | historia <historiavg@proton.me> | 2026-08-24 21:10:43 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 21:10:43 -0400 |
| commit | f4b1de303704e13818259d5057d176cd841b6ed8 (patch) | |
| tree | 5c3c4e1495819434deddc223c1b4a670b3b7325e | |
| parent | 610d6828fa8300efa3f8df3d3bf8e3c00e24c1dc (diff) | |
| download | tts-audiobook-generator-f4b1de303704e13818259d5057d176cd841b6ed8.tar.gz | |
feat: add confirm/cancel buttons to checkbox tui
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 67 | ||||
| -rw-r--r-- | app/ui/tui.py | 151 |
3 files changed, 141 insertions, 79 deletions
@@ -47,7 +47,7 @@ python audiobook.py 4. When the TUI comes up, go to `Configure Backends > Install Backend`. Install `audio.cpp`, which supports numerous TTS models. It will automatically be cloned and built in the venv (this will take a while). -5. +5. Choose TTS models to install. `Qwen3-TTS` is a popular. Pick the `Base` model if you're cloning voices or `CustomVoice` for built-in TTS. ## CLI Options diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index d5cbb7e..7c9c800 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -744,16 +744,36 @@ class CheckboxTreeTests(TuiTestCase): "options": [{"key": "pkg-c", "label": "pkg-c", "recommended": True}]}, ] - def test_nothing_selected_by_default(self): - # Nothing is pre-checked: Enter alone flashes and waits, and a - # selection only happens after Space checks an option. The first - # Enter and the flash each consume a key. - screen = FakeScreen(keys=[10, 10, ord(" "), 10]) + def test_all_nodes_start_collapsed(self): + # Nothing is pre-checked and no family is expanded on the initial + # draw: only family rows are visible, each with a '+' collapse + # arrow, and no option rows are drawn. + screen = FakeScreen(keys=[27]) # Esc aborts after the first draw + with self.assertRaises(tui.WizardCancelled): + tui.checkbox_tree(screen, "Pick models", self.FAMILIES) + texts = [text for _, _, text, _ in screen.strings] + self.assertIn("+ Family one", texts) + self.assertIn("+ Family two", texts) + for label in ("pkg-a", "pkg-b", "pkg-c"): + self.assertNotIn(label, texts) + + def test_enter_checks_like_space(self): + # Enter on a family row checks its recommended option, exactly + # like Space; Tab then Confirm accepts the selection. + screen = FakeScreen(keys=[10, 9, 10]) + picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES) + self.assertEqual(picked, [(0, "pkg-a")]) + + def test_confirm_without_selection_flashes(self): + # Confirm with nothing checked flashes and stays; the user then + # checks a model and confirms for real. + keys = [9, 10, ord(" "), 9, 10, 9, 10] + screen = FakeScreen(keys=keys) picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES) self.assertEqual(picked, [(0, "pkg-a")]) def test_rows_left_justified_inside_the_border(self): - screen = FakeScreen(keys=[ord(" "), 10]) + screen = FakeScreen(keys=[ord(" "), 9, 10]) tui.checkbox_tree(screen, "Pick models", self.FAMILIES) x0, x_right = self.dialog_box(screen) margin = x0 + 1 + tui.Frame.LIST_MARGIN @@ -776,27 +796,27 @@ class CheckboxTreeTests(TuiTestCase): families = [{"label": "F", "detail": "tts", "options": [{"key": "long", "label": "x" * 60, "recommended": True}]}] - screen = FakeScreen(keys=[ord(" "), 10], width=120) + screen = FakeScreen(keys=[ord(" "), 9, 10], width=120) picked = tui.checkbox_tree(screen, "Pick", families) self.assertEqual(picked, [(0, "long")]) self.assert_inside_border(screen) - def test_space_checks_then_enter_accepts(self): - screen = FakeScreen(keys=[ord(" "), 10]) + def test_space_checks_then_confirm_accepts(self): + screen = FakeScreen(keys=[ord(" "), 9, 10]) picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES) self.assertEqual(picked, [(0, "pkg-a")]) - def test_prechecked_selection_accepted_directly(self): - # checked= seeds the tree (modify flow): Enter alone accepts the - # pre-checked option without any key presses in between. - screen = FakeScreen(keys=[10]) + def test_prechecked_selection_confirmed(self): + # checked= seeds the tree (modify flow): Tab then Confirm accepts + # the pre-checked options without any extra key presses. + screen = FakeScreen(keys=[9, 10]) picked = tui.checkbox_tree( screen, "Pick models", self.FAMILIES, checked={(0, "pkg-b"), (1, "pkg-c")}) self.assertEqual(picked, [(0, "pkg-b"), (1, "pkg-c")]) def test_prechecked_options_draw_as_checked(self): - screen = FakeScreen(keys=[10]) + screen = FakeScreen(keys=[9, 10]) tui.checkbox_tree(screen, "Pick models", self.FAMILIES, checked={(0, "pkg-b")}) texts = [text for _, _, text, _ in screen.strings] @@ -807,16 +827,25 @@ class CheckboxTreeTests(TuiTestCase): def test_prechecked_family_cursor_starts_on_it(self): # Only the second family is pre-checked, so the cursor starts on it: - # Space clears then re-checks that family (the cursor never moves). - # If the cursor were still on the first family, the two Spaces would - # check then clear family one and Enter would flash instead of - # accepting anything. - screen = FakeScreen(keys=[ord(" "), ord(" "), 10]) + # two Spaces clear then re-check that family (the cursor never + # moves). If the cursor were still on the first family, the two + # Spaces would check then clear family one and Confirm would flash + # instead of accepting anything. + screen = FakeScreen(keys=[ord(" "), ord(" "), 9, 10]) picked = tui.checkbox_tree( screen, "Pick models", self.FAMILIES, checked={(1, "pkg-c")}) self.assertEqual(picked, [(1, "pkg-c")]) + def test_back_button_returns_back_value(self): + marker = object() + # Tab -> buttons, Right -> Back, Enter. + screen = FakeScreen(keys=[9, FakeCurses.KEY_RIGHT, 10]) + self.assertIs( + tui.checkbox_tree(screen, "Pick models", self.FAMILIES, + back_value=marker), + marker) + def test_empty_families_rejected(self): with self.assertRaises(ValueError): tui.checkbox_tree(self.screen, "Pick", []) diff --git a/app/ui/tui.py b/app/ui/tui.py index f1abea1..0391998 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -1158,18 +1158,21 @@ def checkbox_tree(scr, title: str, families: List[dict], ], } - Space on a family row checks its recommended option (or clears every - option when one is already checked); Space on an option row toggles - that option. Tab/Right expands or collapses the family under the - cursor. Enter returns the flat list of (family_index, option_key) - pairs for every checked option, in tree order; at least one checked - option is required. Nothing is checked by default, and with - EXPAND_ALL every family starts expanded. CHECKED (a set of - (family_index, option_key) pairs) pre-checks those options instead, - expanding every family that holds a checked option and placing the - cursor on the first such family — the "modify an existing config" - entry point. A "[recommended]" tag is shown only when a family has - more than one option — a single option needs no tag. + Space or Enter on a family row checks its recommended option (or + clears every option when one is already checked); Space or Enter on + an option row toggles that option. Right/l expands or collapses the + family under the cursor, Left/h collapses it. Tab (or Up/Down at the + ends of the list) moves the focus to the Confirm/Back buttons: Enter + on Confirm returns the flat list of (family_index, option_key) pairs + for every checked option, in tree order, and requires at least one + checked option; Enter on Back returns BACK_VALUE. Nothing is checked + by default and every family starts collapsed; with EXPAND_ALL every + family starts expanded. CHECKED (a set of (family_index, option_key) + pairs) pre-checks those options instead, expanding every family that + holds a checked option and placing the cursor on the first such + family — the "modify an existing config" entry point. A + "[recommended]" tag is shown only when a family has more than one + option — a single option needs no tag. Family and option rows are left-justified like a DOS list. Esc (or 'q') aborts the wizard unless BACK_VALUE is given (not None), in which case either key returns it so the caller can fall back a @@ -1177,18 +1180,14 @@ def checkbox_tree(scr, title: str, families: List[dict], """ if not families: raise ValueError("checkbox_tree() needs at least one family") - footer = footer or ("Up/Down = move Tab/Right = expand Space = check " - "Enter = accept Esc = cancel") + footer = footer or ("Up/Down = move Enter/Space = check " + "Left/Right = expand Tab = buttons Esc = cancel") frame = Frame(scr, title, footer) expanded = {index for index in range(len(families))} if expand_all else set() checked = set(checked or ()) # (family_index, option_key) - if checked: - for index, _option_key in checked: - expanded.add(index) - expanded.add(0) - else: - expanded.add(0) + for index, _option_key in checked: + expanded.add(index) def family_checked(index: int) -> bool: return any(pair[0] == index for pair in checked) @@ -1211,6 +1210,8 @@ def checkbox_tree(scr, title: str, families: List[dict], first_checked = min((index for index, _option_key in checked), default=None) cursor = 0 + on_buttons = False + btn_index = 0 while True: nodes = visible_nodes() if first_checked is not None: @@ -1249,9 +1250,14 @@ def checkbox_tree(scr, title: str, families: List[dict], segments.append((" [recommended]", frame.theme["warn"])) frame.mark_segments(segments, indent=2, selectable=True, align="left") - frame.cursor = cursor - node = nodes[cursor] - frame.status = (families[node[1]].get("detail", ""), "info") + frame.cursor = None if on_buttons else cursor + frame.buttons = (["Confirm", "Back"], + btn_index if on_buttons else None) + if on_buttons: + frame.status = None + else: + node = nodes[cursor] + frame.status = (families[node[1]].get("detail", ""), "info") frame.draw() curses = frame.curses key = frame.get_key(cancel_keys=()) @@ -1259,41 +1265,68 @@ def checkbox_tree(scr, title: str, families: List[dict], return back_value if key in _CANCEL_KEYS: raise WizardCancelled() - moved = frame.motion(key, cursor, len(nodes), wrap=True) - if moved is not None: - cursor = moved - elif key in (9, curses.KEY_RIGHT, ord("l")) and node[0] == "family": - index = node[1] - if index in expanded: - expanded.discard(index) + if on_buttons: + if key in (curses.KEY_LEFT, curses.KEY_RIGHT, ord("h"), ord("l")): + btn_index = 1 - btn_index + elif key in (curses.KEY_UP, ord("k"), curses.KEY_BTAB): + on_buttons = False + cursor = len(nodes) - 1 if nodes else 0 + elif key in (curses.KEY_DOWN, ord("j"), 9): + on_buttons = False + cursor = 0 + elif key in (10, 13): + if btn_index == 0: # Confirm + selection = accept() + if selection: + return selection + frame.flash("Check at least one model package", "err") + else: # Back + return back_value + else: + node = nodes[cursor] + if key in (curses.KEY_DOWN, ord("j")) \ + and cursor == len(nodes) - 1: + on_buttons = True + btn_index = 0 + elif key in (curses.KEY_UP, ord("k")) and cursor == 0: + on_buttons = True + btn_index = 0 + elif key in (9, curses.KEY_BTAB): + on_buttons = True + btn_index = 0 else: - expanded.add(index) - elif key == curses.KEY_LEFT and node[0] == "family": - expanded.discard(node[1]) - elif key == ord(" "): - if node[0] == "family": - index = node[1] - options = families[index]["options"] - if family_checked(index): - for option in options: - checked.discard((index, option["key"])) - else: - for option in options: - if option.get("recommended"): - checked.add((index, option["key"])) - break + moved = frame.motion(key, cursor, len(nodes), wrap=True) + if moved is not None: + cursor = moved + elif key in (curses.KEY_RIGHT, ord("l")) \ + and node[0] == "family": + index = node[1] + if index in expanded: + expanded.discard(index) else: - if options: - checked.add((index, options[0]["key"])) - expanded.add(index) - else: - _, index, option_key = node - if (index, option_key) in checked: - checked.discard((index, option_key)) - else: - checked.add((index, option_key)) - elif key in (10, 13): # Enter: accept the checked selection - selection = accept() - if selection: - return selection - frame.flash("Check at least one model package (Space)", "err") + expanded.add(index) + elif key in (curses.KEY_LEFT, ord("h")) \ + and node[0] == "family": + expanded.discard(node[1]) + elif key in (10, 13, ord(" ")): + if node[0] == "family": + index = node[1] + options = families[index]["options"] + if family_checked(index): + for option in options: + checked.discard((index, option["key"])) + else: + for option in options: + if option.get("recommended"): + checked.add((index, option["key"])) + break + else: + if options: + checked.add((index, options[0]["key"])) + expanded.add(index) + else: + _, index, option_key = node + if (index, option_key) in checked: + checked.discard((index, option_key)) + else: + checked.add((index, option_key)) |
