aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 04:07:06 -0400
committerhistoria <historiavg@proton.me>2026-08-24 04:07:06 -0400
commit9fb2434dcc8a1ed7bd085b453859d473de64448a (patch)
tree8a5ebe4db5defe6c42a7e8adf84f9b7f6ce26db5 /app
parent73b466fbc054e80b50e318b49643aee8a03c784b (diff)
downloadtts-audiobook-generator-9fb2434dcc8a1ed7bd085b453859d473de64448a.tar.gz
fix: menu tui up/down, port explanation in menu
Diffstat (limited to 'app')
-rw-r--r--app/tests/test_hub.py6
-rw-r--r--app/tests/test_tui.py63
-rw-r--r--app/ui/hub.py4
-rw-r--r--app/ui/tui.py48
4 files changed, 98 insertions, 23 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 4b394e8..0e7c2bd 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -492,6 +492,12 @@ class SettingsTests(unittest.TestCase):
self.assertEqual(kinds["audio_format"], "choice")
self.assertEqual(kinds["audio_bitrate"], "text")
self.assertEqual(kinds["audiocpp_port"], "text")
+ # The ports section note hangs off the first port field so it
+ # renders between the output settings and the ports.
+ notes = {f["key"]: f.get("note") for f in captured["fields"]}
+ self.assertTrue(notes["qwen_custom_port"])
+ self.assertIsNone(notes["audio_format"])
+ self.assertIsNone(notes["audiocpp_port"])
self.assertEqual(applied, [{"audio_format": "ogg",
"audio_bitrate": "192k",
"language": "English",
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
index 5862a54..38f4fa9 100644
--- a/app/tests/test_tui.py
+++ b/app/tests/test_tui.py
@@ -416,13 +416,32 @@ class FormTests(TuiTestCase):
result = tui.form(screen, "Settings", self._fields())
self.assertEqual(result, {"fmt": "m4b", "chunk": "250"})
- def test_up_on_first_field_moves_to_cancel(self):
- marker = object()
- # Up from the first field steps onto the Cancel button, Enter.
+ def test_up_on_first_field_wraps_to_save(self):
+ # Up from the first field wraps onto the Save button; Enter saves.
screen = FakeScreen(keys=[FakeCurses.KEY_UP, 10])
- result = tui.form(screen, "Settings", self._fields(),
- back_value=marker)
- self.assertIs(result, marker)
+ result = tui.form(screen, "Settings", self._fields())
+ self.assertEqual(result, {"fmt": "m4b", "chunk": "250"})
+
+ def test_k_on_first_field_wraps_to_save(self):
+ screen = FakeScreen(keys=[ord("k"), 10])
+ result = tui.form(screen, "Settings", self._fields())
+ self.assertEqual(result, {"fmt": "m4b", "chunk": "250"})
+
+ def test_k_on_buttons_returns_to_fields(self):
+ # Down to the last field, Down onto Save, k back into the fields;
+ # editing then proves focus left the button row.
+ screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ ord("k"), 10, ord("x"), 10, 9, 10])
+ result = tui.form(screen, "Settings", self._fields())
+ self.assertEqual(result, {"fmt": "m4b", "chunk": "250x"})
+
+ def test_j_on_cancel_returns_to_fields(self):
+ # Up wraps to Save, Left to Cancel, j back into the fields; the
+ # choice editor opening proves focus left the button row.
+ screen = FakeScreen(keys=[FakeCurses.KEY_UP, FakeCurses.KEY_LEFT,
+ ord("j"), 10, 10, 9, 10])
+ result = tui.form(screen, "Settings", self._fields())
+ self.assertEqual(result, {"fmt": "m4b", "chunk": "250"})
def test_up_down_on_buttons_returns_to_fields(self):
# Down (last field -> Save), Up returns to the last field, Enter
@@ -484,6 +503,38 @@ class FormTests(TuiTestCase):
with self.assertRaises(ValueError):
tui.form(self.screen, "Settings", [])
+ def test_field_note_renders_and_save(self):
+ fields = self._fields()
+ fields[1]["note"] = "A short section note"
+ screen = FakeScreen(keys=[9, 10])
+ result = tui.form(screen, "Settings", fields)
+ self.assertEqual(result, {"fmt": "m4b", "chunk": "250"})
+ self.assert_inside_border(screen)
+
+ def test_highlight_skips_note_rows_to_the_field(self):
+ fields = self._fields()
+ fields[1]["note"] = "A short section note"
+ snapshots = []
+ original_draw = tui.Frame.draw
+
+ def spy(frame):
+ original_draw(frame)
+ snapshots.append((frame.cursor,
+ [row["text"] if row["segments"] is None
+ else "".join(text for text, _ in row["segments"])
+ for row in frame.rows]))
+
+ # Down moves from the first field to the one after the note block.
+ screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, 9, 10])
+ with patch.object(tui.Frame, "draw", spy):
+ result = tui.form(screen, "Settings", fields)
+ self.assertEqual(result, {"fmt": "m4b", "chunk": "250"})
+ cursor, texts = snapshots[1]
+ # The cursor must sit on the Chunk field's own row — not on one
+ # of the blank/note rows inserted above it (the old base+index
+ # math highlighted the blank line).
+ self.assertEqual(texts[cursor], "Chunk: 250")
+
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 0e227da..c3efce6 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -403,7 +403,9 @@ def _settings_menu(stdscr) -> None:
{"key": "qwen_custom_port", "label": "qwen-tts CustomVoice port",
"kind": "text",
"value": str(_port_from_url(config.QWEN_API_URL, 7860)),
- "validate": _validate_port},
+ "validate": _validate_port,
+ "note": "Ports apply to servers this tool starts and detecting "
+ "local servers"},
{"key": "qwen_clone_port", "label": "qwen-tts Base (clone) port",
"kind": "text",
"value": str(_port_from_url(config.CLONE_API_URL, 7861)),
diff --git a/app/ui/tui.py b/app/ui/tui.py
index 12c7d4f..7b6c740 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -755,16 +755,22 @@ def form(scr, title: str, fields: Sequence[dict],
"kind": "text", "value": "250",
"validate": lambda s: None if s.isdigit() else "digits only"}
- Each field renders as a left-justified ``Label: value`` row. 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 or Up/Down move focus to
- the Save/Cancel buttons — Up from the first field and Down from the
- last field step straight onto them; 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.
+ 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.
"""
if not fields:
raise ValueError("form() needs at least one field")
@@ -775,17 +781,26 @@ def form(scr, title: str, fields: Sequence[dict],
on_buttons = False
btn_index = 0
edit_cancel = object() # sentinel: backed out of a field editor
+ label_w = max(len(field["label"]) for field in fields)
while True:
frame.rows = []
for line in help_lines or []:
frame.mark(line, frame.theme["dim"])
if help_lines:
frame.mark("")
- base = len(frame.rows)
+ field_rows: List[int] = [] # field index -> row index
for field in fields:
- frame.mark(f"{field['label']}: {field['value']}",
- selectable=True, align="left")
- frame.cursor = None if on_buttons else base + cursor
+ if field.get("note"):
+ frame.mark("")
+ frame.mark(field["note"], frame.theme["dim"], align="left")
+ frame.mark("")
+ field_rows.append(len(frame.rows))
+ name = f"{field['label']}:".ljust(label_w + 1)
+ frame.mark_segments(
+ [(name, frame.theme["body"]),
+ (" " + field["value"], 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.draw()
curses = frame.curses
@@ -795,7 +810,8 @@ 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):
+ if key in (9, curses.KEY_BTAB, curses.KEY_UP, curses.KEY_DOWN,
+ ord("j"), ord("k")):
on_buttons = False
elif key in (curses.KEY_LEFT, curses.KEY_RIGHT,
ord("h"), ord("l")):
@@ -823,7 +839,7 @@ def form(scr, title: str, fields: Sequence[dict],
btn_index = 0 # Save
elif key in (curses.KEY_UP, ord("k")) and cursor == 0:
on_buttons = True
- btn_index = 1 # Cancel
+ btn_index = 0 # Save (wraps around from the top)
else:
moved = frame.motion(key, cursor, len(fields), wrap=True)
if moved is not None: