From 83d0b7a5b2f669a1d3a9a0bea9c74b00e0b4c07b Mon Sep 17 00:00:00 2001 From: historia Date: Fri, 28 Aug 2026 19:01:16 -0400 Subject: fix: no .wav directory error formatting --- app/tests/test_tui.py | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++ app/ui/hub.py | 2 +- app/ui/tui.py | 33 +++++++++++++++++++++++++------- 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index 4c8b431..72465c1 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -1355,6 +1355,58 @@ class FlashTests(TuiTestCase): self.assertIsNone(frame.status) +class FrameStatusTests(TuiTestCase): + """Frame status: a multi-line status stacks above the footer row. + + An embedded "\\n" must never spill onto the footer's row (the + controls line) — the dialog grows and the block is drawn one line + per row, ending just above the footer. + """ + + MESSAGE = "first line\nsecond line\n" # trailing blank separator line + + def _draw(self): + frame = tui.Frame(self.screen, "Generate Speech", + "Up/Down = move Enter = edit") + frame.mark("Model: qwen-tts", selectable=True) + frame.buttons = (["Generate!", "Cancel"], 0) + frame.status = (self.MESSAGE, "err") + frame.draw() + + def _y_of(self, text, exact=True): + matches = self.screen.strings if exact else \ + [(y, x, t, a) for y, x, t, a in self.screen.strings + if t.strip() == text] + return next(y for y, _, t, _ in matches + if (t == text if exact else True)) + + def test_status_lines_stack_above_the_footer_row(self): + self._draw() + first_y = self._y_of("first line", exact=False) + second_y = self._y_of("second line", exact=False) + footer_y = self._y_of("Up/Down = move Enter = edit") + self.assertEqual(second_y, first_y + 1) + # The trailing "\n" separates the error from the controls: the + # footer sits one blank row below the last status line. + self.assertEqual(footer_y, second_y + 2) + self.assert_inside_border(self.screen) + + def test_buttons_clear_the_status_block(self): + self._draw() + buttons_y = self._y_of("[ Generate! ]") + first_y = self._y_of("first line", exact=False) + self.assertLess(buttons_y, first_y) + self.assert_inside_border(self.screen) + + def test_single_line_status_sits_directly_above_the_footer(self): + frame = tui.Frame(self.screen, "Title", "footer") + frame.status = ("only line", "err") + frame.draw() + status_y = self._y_of("only line", exact=False) + footer_y = self._y_of("footer") + self.assertEqual(footer_y, status_y + 1) + + class TextViewerTests(TuiTestCase): """tui.text_viewer: a scrollable read-only dialog; Esc/q/Enter closes.""" diff --git a/app/ui/hub.py b/app/ui/hub.py index 0471711..74b582f 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -1197,7 +1197,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, if local: return ("No .wav files available to clone\n" "Go to Configure Backends → Configure audio.cpp and " - "set a voice clone directory.") + "set a voice clone directory.\n") return ("No .wav files available to clone — the audio.cpp server " f"at {url} hosts none. Configure its voice-clone .wav " "directory on that machine.") diff --git a/app/ui/tui.py b/app/ui/tui.py index 60e2dda..19764ab 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -403,13 +403,27 @@ class Frame: + 2 * row["indent"] return len(row["text"]) + 2 * row["indent"] + def _status_extra(self) -> int: + """Rows the status block needs beyond its single bottom row. + + A multi-line status stacks upward from the row above the footer; + the dialog must grow (and the buttons row must shift up) so the + block clears the buttons and the body rows. + """ + if not self.status: + return 0 + return max(0, len(self.status[0].split("\n")) - 1) + def _measure(self, width: int) -> int: """Dialog width: widest row plus frame, capped to the screen.""" longest = max(len(self.title) + 4, len(self.footer) + 4, 40) for row in self.rows: longest = max(longest, self._row_width(row) + 4) if self.status: - longest = max(longest, len(self.status[0]) + 6) + # Measure per line: a multi-line status must not widen the + # dialog to the combined length of its lines. + longest = max(longest, max(len(line) for line + in self.status[0].split("\n")) + 6) if self.buttons: labels, _ = self.buttons longest = max(longest, @@ -463,7 +477,8 @@ class Frame: # Borders, title, status and footer are fixed chrome; a titled # frame also reserves a blank line below its title, and buttons # take their own row above the status. - chrome = 6 + (1 if self.title else 0) + (1 if self.buttons else 0) + chrome = 6 + (1 if self.title else 0) + (1 if self.buttons else 0) \ + + self._status_extra() dialog_h = min(max(self.MIN_HEIGHT, len(flat) + chrome), height) visible = max(1, dialog_h - chrome) self.page_size = max(1, visible) @@ -605,7 +620,7 @@ class Frame: rendered = [f"[ {label} ]" for label in labels] total = sum(len(r) for r in rendered) + 3 * (len(rendered) - 1) x = inner_x + max(0, (inner_w - total) // 2) - y = y0 + dialog_h - 4 + y = y0 + dialog_h - 4 - self._status_extra() for index, text in enumerate(rendered): if index: x += 3 @@ -623,10 +638,14 @@ class Frame: if self.status: text, kind = self.status attr = theme.get(kind, theme["body"]) - text = _fit(f" {text} ", inner_w) - _addstr(scr, y0 + dialog_h - 3, - inner_x + max(0, (inner_w - len(text)) // 2), - text, attr) + # The status is a block of lines stacking upward from the + # row above the footer (an embedded "\n" never spills onto + # the footer's row); each line is fitted on its own. + for offset, line in enumerate(reversed(text.split("\n"))): + line = _fit(f" {line} ", inner_w) + _addstr(scr, y0 + dialog_h - 3 - offset, + inner_x + max(0, (inner_w - len(line)) // 2), + line, attr) footer = _fit(self.footer, inner_w) _addstr(scr, y0 + dialog_h - 2, inner_x + max(0, (inner_w - len(footer)) // 2), -- cgit v1.2.3