aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--app/tests/test_tui.py67
-rw-r--r--app/ui/tui.py151
3 files changed, 141 insertions, 79 deletions
diff --git a/README.md b/README.md
index 88b5342..698688e 100644
--- a/README.md
+++ b/README.md
@@ -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))