aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-28 19:01:16 -0400
committerhistoria <historiavg@proton.me>2026-08-28 19:01:16 -0400
commit83d0b7a5b2f669a1d3a9a0bea9c74b00e0b4c07b (patch)
tree23c6809c02c19a2652717ade04a4312bae36db06
parent03ed68bafec56ef8bb690440d796c1a742042907 (diff)
downloadtts-audiobook-generator-83d0b7a5b2f669a1d3a9a0bea9c74b00e0b4c07b.tar.gz
fix: no .wav directory error formatting
-rw-r--r--app/tests/test_tui.py52
-rw-r--r--app/ui/hub.py2
-rw-r--r--app/ui/tui.py33
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),