diff options
| author | historia <historiavg@proton.me> | 2026-08-24 04:07:06 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 04:07:06 -0400 |
| commit | 9fb2434dcc8a1ed7bd085b453859d473de64448a (patch) | |
| tree | 8a5ebe4db5defe6c42a7e8adf84f9b7f6ce26db5 /app | |
| parent | 73b466fbc054e80b50e318b49643aee8a03c784b (diff) | |
| download | tts-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.py | 6 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 63 | ||||
| -rw-r--r-- | app/ui/hub.py | 4 | ||||
| -rw-r--r-- | app/ui/tui.py | 48 |
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: |
