aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/tests/test_hub.py28
-rw-r--r--app/tests/test_tui.py114
-rw-r--r--app/ui/hub.py54
-rw-r--r--app/ui/tui.py167
4 files changed, 275 insertions, 88 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 798c27f..fd22a4e 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -344,21 +344,37 @@ class HubMenuTests(unittest.TestCase):
result = hub._Hub(screen).run()
self.assertIsNone(result)
self.assertEqual(len(calls), 1)
- title, lines, kwargs = calls[0]
+ title, items, kwargs = calls[0]
self.assertEqual(title, "Help")
self.assertIs(kwargs.get("back_value"), tui.Wizard.BACK)
- text = "\n".join(lines)
+ # Flatten the viewer rows: (segments, indent) pairs join their
+ # texts; plain strings (the blank lines) pass through.
+ text = "\n".join(
+ "".join(part for part, _ in item[0])
+ if isinstance(item, tuple) else item
+ for item in items)
self.assertIn("1. Put your ebooks (epub, txt, or pdf) here:", text)
- self.assertIn(str(hub.BOOKS_FOLDER), text)
self.assertIn("2. Put any .wavs of voices to clone here:", text)
- self.assertIn(str(hub.common.VOICES_DIR), text)
- self.assertIn("Install Backend and install audio.cpp.", text)
+ self.assertIn("3. If no backend is installed, go to Configure "
+ "Backends > Install Backend and install audio.cpp.",
+ text)
+ self.assertIn("4. Select TTS models to install. If you're unsure, "
+ "try these qwen3-tts models:", text)
self.assertIn("qwen3_tts_1_7b_base_q8_0", text)
self.assertIn("qwen3_tts_1_7b_customvoice_q8_0", text)
self.assertIn("5. Go to Generate Audiobooks.", text)
+ # The "no need to manually start/stop" sentence sits on its own
+ # indented line below the step-5 paragraph.
+ self.assertIn("stop it.\nThere is no need to manually "
+ "start/stop servers.", text)
self.assertIn("6. Generated audiobooks (m4b, mp3, etc.) will "
"output here:", text)
- self.assertIn(str(hub.AUDIOBOOKS_FOLDER), text)
+ # The three folder paths are their own indented, white-bold
+ # ("input") rows; the numbered steps start at the margin.
+ self.assertIn(([(str(hub.BOOKS_FOLDER), "input")], 1), items)
+ self.assertIn(([(str(hub.common.VOICES_DIR), "input")], 1), items)
+ self.assertIn(([(str(hub.AUDIOBOOKS_FOLDER), "input")], 1), items)
+ self.assertEqual(items[0][1], 0)
class SubmenuStatusTableTests(unittest.TestCase):
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
index 916f434..e12da76 100644
--- a/app/tests/test_tui.py
+++ b/app/tests/test_tui.py
@@ -1288,6 +1288,16 @@ class TextViewerTests(TuiTestCase):
LINES = ["first line", "second line", "third line"]
+ def _last_frame(self, screen, title=" Help "):
+ """Texts of the final draw after the title (strings accumulate)."""
+ titles = [i for i, entry in enumerate(screen.strings)
+ if entry[2] == title]
+ return [entry[2] for entry in screen.strings[titles[-1] + 1:]]
+
+ def _top_line(self, drawn):
+ """First body-row text of a frame's drawn strings."""
+ return next(text for text in drawn if not text.startswith(" lines"))
+
def test_lines_render_and_enter_closes(self):
marker = object()
screen = FakeScreen(keys=[10])
@@ -1324,45 +1334,99 @@ class TextViewerTests(TuiTestCase):
tui.text_viewer(screen, "Help", self.LINES)
def test_no_cursor_bar_is_drawn(self):
- # Read-only: no row is selectable, so even with the cursor on a
- # line the cyan selection bar never paints.
- screen = FakeScreen(keys=[FakeCurses.KEY_END, ord("q")])
+ # Read-only: no row is selectable, so the cyan selection bar
+ # never paints, however far the text is scrolled.
+ screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ ord("q")])
tui.text_viewer(screen, "Help", self.LINES, back_value=object())
bars = [s for s in screen.strings
if s[3] == tui._THEME["bar"] and not s[2].strip()]
self.assertEqual(bars, [])
- def test_end_scrolls_the_last_line_into_view(self):
- # Content taller than the terminal: End shows the last line and
- # the frame's scroll indicator appears in the border.
+ def test_down_scrolls_one_line_per_keypress(self):
+ # The text itself scrolls immediately: two Downs put the third
+ # physical line at the top of the viewport, and the border
+ # reports which lines are visible.
lines = [f"line {i}" for i in range(40)]
- screen = FakeScreen(keys=[FakeCurses.KEY_END, ord("q")])
+ screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ ord("q")])
tui.text_viewer(screen, "Help", lines, back_value=object())
- drawn = [text for _, _, text, _ in screen.strings]
- self.assertIn("line 39", drawn)
- self.assertTrue(any("/40" in text for text in drawn))
+ drawn = self._last_frame(screen)
+ self.assertIn(" lines 3-19 of 40 ", drawn)
+ self.assertEqual(self._top_line(drawn), "line 2")
- def test_home_returns_to_the_top(self):
+ def test_up_stops_at_the_top(self):
lines = [f"line {i}" for i in range(40)]
- screen = FakeScreen(keys=[FakeCurses.KEY_END, FakeCurses.KEY_HOME,
+ screen = FakeScreen(keys=[FakeCurses.KEY_UP, FakeCurses.KEY_UP,
ord("q")])
tui.text_viewer(screen, "Help", lines, back_value=object())
- drawn = [text for _, _, text, _ in screen.strings]
- self.assertIn("line 0", drawn)
+ drawn = self._last_frame(screen)
+ self.assertIn(" lines 1-17 of 40 ", drawn)
+ self.assertEqual(self._top_line(drawn), "line 0")
- def test_page_down_moves_a_full_page(self):
- # PageDown from the top lands one page (17 visible rows) down:
- # the first line scrolls off, the next one is at the top.
+ def test_down_stops_at_the_bottom(self):
+ # 40 lines with 17 visible: scroll clamps at 23, so the last
+ # line stays on screen and the range ends at 40.
lines = [f"line {i}" for i in range(40)]
- screen = FakeScreen(keys=[FakeCurses.KEY_NPAGE, ord("q")])
+ keys = [FakeCurses.KEY_DOWN] * 30 + [ord("q")]
+ screen = FakeScreen(keys=keys)
tui.text_viewer(screen, "Help", lines, back_value=object())
- # Only the final frame counts: strings accumulate across redraws.
- titles = [i for i, entry in enumerate(screen.strings)
- if entry[2] == " Help "]
- drawn = [entry[2] for entry in screen.strings[titles[-1]:]]
- self.assertIn("line 1", drawn)
- self.assertNotIn("line 0", drawn)
- self.assertIn(" 2/40 ", drawn)
+ drawn = self._last_frame(screen)
+ self.assertIn(" lines 24-40 of 40 ", drawn)
+ self.assertEqual(self._top_line(drawn), "line 23")
+ self.assertIn("line 39", drawn)
+
+ def test_j_and_k_scroll_too(self):
+ lines = [f"line {i}" for i in range(40)]
+ # j, j (down twice), k (back up once): one line below the top.
+ screen = FakeScreen(keys=[ord("j"), ord("j"), ord("k"), ord("q")])
+ tui.text_viewer(screen, "Help", lines, back_value=object())
+ self.assertEqual(self._top_line(self._last_frame(screen)), "line 1")
+
+ def test_segments_indent_and_color(self):
+ # Numbered rows start at the margin with their number in the
+ # title color; indent=1 rows sit two columns further in.
+ content = [([("1. ", "title"), ("Step one", None)], 0),
+ ([("continuation", None)], 1),
+ ""]
+ screen = FakeScreen(keys=[10])
+ tui.text_viewer(screen, "Help", content, back_value=object())
+ x0, _ = self.dialog_box(screen)
+ margin = x0 + 1 + tui.Frame.LIST_MARGIN
+ number = next(entry for entry in screen.strings
+ if entry[2] == "1.")
+ self.assertEqual(number[1], margin)
+ self.assertEqual(number[3], tui._THEME["title"])
+ step = next(entry for entry in screen.strings
+ if entry[2] == "Step")
+ self.assertEqual(step[1], margin + 3)
+ cont = next(entry for entry in screen.strings
+ if entry[2] == "continuation")
+ self.assertEqual(cont[1], margin + 2)
+
+ def test_segments_wrap_when_wider_than_the_dialog(self):
+ # wrap=True segments rows word-wrap like text rows (colors
+ # kept), never truncating mid-word at the border.
+ content = [([("word " * 25, None)], 1)] # 125 columns of text
+ screen = FakeScreen(keys=[10])
+ tui.text_viewer(screen, "Help", content, back_value=object())
+ self.assert_inside_border(screen)
+ ys = {y for y, _, text, _ in screen.strings if "word" in text}
+ self.assertGreaterEqual(len(ys), 2)
+
+ def test_segments_truncate_by_default(self):
+ # Without wrap=True a segments row keeps its old behavior —
+ # one physical line truncated with '~' — so menus, forms and
+ # trees (wrap-less callers) never change layout.
+ frame = tui.Frame(self.screen, "T", "footer")
+ frame.mark_segments([("w" * 100, frame.theme["body"])],
+ selectable=True, align="left")
+ frame.cursor = 0
+ frame.draw()
+ drawn = [text for _, _, text, _ in self.screen.strings
+ if text.startswith("w")]
+ self.assertEqual(len(drawn), 1)
+ self.assertTrue(drawn[0].endswith("~"))
class WizardTests(unittest.TestCase):
diff --git a/app/ui/hub.py b/app/ui/hub.py
index fd2d371..25baf37 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -832,32 +832,50 @@ def _notice_lines() -> Optional[list]:
def _help_lines() -> list:
- """The Help screen's quick-start text (folder paths resolved live)."""
+ """The Help screen's quick-start text (folder paths resolved live).
+
+ Items are text_viewer rows: "" (a blank line) or a (segments,
+ indent) pair — SEGMENTS are (text, kind) with KIND a theme key
+ (None = body). Numbered steps start at the margin; every other
+ line is indented two spaces so it reads as part of its step.
+ """
return [
- "1. Put your ebooks (epub, txt, or pdf) here:",
- str(BOOKS_FOLDER),
+ ([("1. ", "title"),
+ ("Put your ebooks (epub, txt, or pdf) here:", None)], 0),
+ ([(str(BOOKS_FOLDER), "input")], 1),
"",
- "2. Put any .wavs of voices to clone here:",
- str(common.VOICES_DIR),
+ ([("2. ", "title"),
+ ("Put any .wavs of voices to clone here:", None)], 0),
+ ([(str(common.VOICES_DIR), "input")], 1),
"",
- "3. If no backend is installed, go to Configure Backends > "
- "Install Backend and install audio.cpp.",
+ ([("3. ", "title"),
+ ("If no backend is installed, go to ", None),
+ ("Configure Backends", "accent"), (" > ", None),
+ ("Install Backend", "accent"),
+ (" and install audio.cpp.", None)], 0),
"",
- "4. Select TTS models to install. If you're unsure, try these "
- "qwen3-tts models:",
+ ([("4. ", "title"),
+ ("Select TTS models to install. If you're unsure, try "
+ "these qwen3-tts models:", None)], 0),
"",
- "Voice cloning: qwen3_tts_1_7b_base_q8_0",
- "Built-in-voice: qwen3_tts_1_7b_customvoice_q8_0",
+ ([("Voice cloning:", None), (" ", None),
+ ("qwen3_tts_1_7b_base_q8_0", "ok")], 1),
+ ([("Built-in-voice:", None), (" ", None),
+ ("qwen3_tts_1_7b_customvoice_q8_0", "ok")], 1),
"",
- "It will take a while to build audio.cpp and download the model "
- "files.",
+ ([("It will take a while to build audio.cpp and download "
+ "the model files.", None)], 1),
"",
- "5. Go to Generate Audiobooks. It will automatically start the "
- "necessary server, generate the books, and stop it. There is no "
- "need to manually start/stop servers.",
+ ([("5. ", "title"), ("Go to ", None),
+ ("Generate Audiobooks", "accent"),
+ (". It will automatically start the necessary server, "
+ "generate the books, and stop it.", None)], 0),
+ ([("There is no need to manually start/stop servers.", None)], 1),
"",
- "6. Generated audiobooks (m4b, mp3, etc.) will output here:",
- str(AUDIOBOOKS_FOLDER),
+ ([("6. ", "title"),
+ ("Generated audiobooks (m4b, mp3, etc.) will output here:",
+ None)], 0),
+ ([(str(AUDIOBOOKS_FOLDER), "input")], 1),
]
diff --git a/app/ui/tui.py b/app/ui/tui.py
index 933626f..1997415 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -10,7 +10,8 @@ left-justified for readability; the black background matches the
terminal default, so the full-screen repaints curses performs while
resizing a dialog never flash. One screen per decision: a directory
browser, an expandable checkbox tree, a single-line text editor, a
-single-choice menu, and a yes/no confirm. There is no framework —
+single-choice menu, a yes/no confirm, and a scrollable text viewer.
+There is no framework —
every widget is a function that runs its own key loop on a curses
window and returns the chosen value.
@@ -34,9 +35,10 @@ bold/reverse/dim.
import contextlib
import os
+import re
import textwrap
from pathlib import Path
-from typing import Callable, List, Optional, Sequence, Tuple
+from typing import Callable, List, Optional, Sequence, Tuple, Union
# Make Esc register quickly instead of pausing for an escape sequence.
os.environ.setdefault("ESCDELAY", "25")
@@ -262,6 +264,37 @@ def _fit(text: str, width: int) -> str:
return text[: max(0, width - 1)] + "~"
+def _wrap_segments(segments: Sequence[Tuple[str, int]], width: int
+ ) -> List[List[Tuple[str, int]]]:
+ """Word-wrap (text, attr) segments into lines of at most WIDTH columns.
+
+ Whitespace runs are kept as their own tokens (so the aligned double
+ space in "Voice cloning: name" survives), a line never breaks at a
+ whitespace token (the break replaces it), and a word wider than
+ WIDTH stays on a line of its own (the draw truncates it). Returns
+ at least one line — an empty SEGMENTS yields one empty line.
+ """
+ tokens: List[Tuple[str, int]] = []
+ for text, attr in segments:
+ for piece in re.split(r"(\s+)", text):
+ if piece:
+ tokens.append((piece, attr))
+ lines: List[List[Tuple[str, int]]] = []
+ current: List[Tuple[str, int]] = []
+ used = 0
+ for text, attr in tokens:
+ if current and used + len(text) > width:
+ lines.append(current)
+ current, used = [], 0
+ if text.isspace():
+ continue # the break eats the whitespace it happens at
+ current.append((text, attr))
+ used += len(text)
+ if current:
+ lines.append(current)
+ return lines or [[]]
+
+
class Frame:
"""A dialog centered on the black desktop, DOS style.
@@ -293,6 +326,11 @@ class Frame:
self.buttons: Optional[Tuple[Sequence[str], int]] = None
self.scroll = 0
self.page_size = 1
+ # Optional scroll-indicator formatter: called as
+ # scroll_label(scroll, total_lines, visible) whenever the frame
+ # draws its border while the content overflows. None keeps the
+ # compact default " x/y " (used by menus, trees, the browser).
+ self.scroll_label = None
try:
curses.curs_set(0)
except curses.error:
@@ -320,11 +358,17 @@ class Frame:
def mark_segments(self, segments: Sequence[Tuple[str, int]],
indent: int = 0, selectable: bool = False,
- align: str = "center") -> None:
- """Append a row of (text, attr) segments (truncated, not wrapped)."""
+ align: str = "center", wrap: bool = False) -> None:
+ """Append a row of (text, attr) segments (truncated, not wrapped).
+
+ With WRAP the row word-wraps to the dialog width like a text
+ row instead of being truncated — each wrapped piece keeps its
+ segments' colors.
+ """
self.rows.append({"text": None, "segments": list(segments),
"attr": 0, "indent": indent,
- "selectable": selectable, "align": align})
+ "selectable": selectable, "align": align,
+ "wrap": wrap})
def selectable(self) -> List[int]:
"""Logical indices of the selectable rows, in order."""
@@ -353,12 +397,30 @@ class Frame:
sum(len(label) + 6 for label in labels) + 4)
return min(longest + 4, width - 2)
- def _flatten(self, usable: int) -> List[Tuple[int, dict, Optional[str]]]:
- """Wrap text rows into physical (logical index, row, piece) lines."""
- flat: List[Tuple[int, dict, Optional[str]]] = []
+ def _flatten(self, usable: int
+ ) -> List[Tuple[int, dict,
+ Union[str, List[Tuple[str, int]], None]]]:
+ """Wrap rows into physical (logical index, row, piece) lines.
+
+ A piece is the wrapped text of a text row, the wrapped segment
+ list of a wrap=True segments row, or None (an unwrapped
+ segments row draws row["segments"] itself).
+ """
+ flat: List[Tuple[int, dict,
+ Union[str, List[Tuple[str, int]], None]]] = []
for index, row in enumerate(self.rows):
if row["segments"] is not None:
- flat.append((index, row, None))
+ if row.get("wrap"):
+ wrap_width = usable
+ if row["align"] == "left":
+ # Leave room for the list margin, the indent
+ # and the right border, like the text rows.
+ wrap_width = usable - 1 - 2 * row["indent"]
+ for piece in _wrap_segments(row["segments"],
+ max(10, wrap_width)):
+ flat.append((index, row, piece))
+ else:
+ flat.append((index, row, None))
continue
wrap_width = usable
if row["align"] == "left":
@@ -371,7 +433,8 @@ class Frame:
return flat
def _geometry(self, height: int, width: int, dialog_w: int,
- flat: List[Tuple[int, dict, Optional[str]]]
+ flat: List[Tuple[int, dict,
+ Union[str, List[Tuple[str, int]], None]]]
) -> Tuple[int, int, int, int]:
"""Place the dialog and scroll the cursor row into view.
@@ -442,12 +505,17 @@ class Frame:
_addstr(scr, y0 + 1, inner_x + max(0, (inner_w - len(title)) // 2),
title, theme["title"])
if total_lines > visible:
- indicator = f" {self.scroll + 1}/{total_lines} "
+ if self.scroll_label is not None:
+ indicator = self.scroll_label(self.scroll, total_lines,
+ visible)
+ else:
+ indicator = f" {self.scroll + 1}/{total_lines} "
_addstr(scr, y0, max(x0 + 1, x0 + dialog_w - 1 - len(indicator)),
indicator, theme["dim"])
def _draw_rows(self, y0: int, x0: int, dialog_w: int,
- flat: List[Tuple[int, dict, Optional[str]]],
+ flat: List[Tuple[int, dict,
+ Union[str, List[Tuple[str, int]], None]]],
visible: int) -> None:
theme = self.theme
scr = self.scr
@@ -463,15 +531,20 @@ class Frame:
_addch(scr, y, inner_x + self.LIST_MARGIN - 2,
self.curses.ACS_RARROW, theme["bar"])
if row["segments"] is not None:
- self._draw_segments_row(y, row, inner_x, inner_w, selected)
+ self._draw_segments_row(y, row, piece, inner_x, inner_w,
+ selected)
else:
self._draw_text_row(y, row, piece, inner_x, inner_w,
selected)
- def _draw_segments_row(self, y: int, row: dict, inner_x: int,
- inner_w: int, selected: bool) -> None:
+ def _draw_segments_row(self, y: int, row: dict,
+ piece: Union[str, List[Tuple[str, int]], None],
+ inner_x: int, inner_w: int, selected: bool) -> None:
scr, theme = self.scr, self.theme
- total = sum(len(text) for text, _ in row["segments"])
+ # A wrapped row draws only its piece; an unwrapped one (piece is
+ # None) draws all of row["segments"] (truncated at the border).
+ segments = piece if piece is not None else row["segments"]
+ total = sum(len(text) for text, _ in segments)
if row["align"] == "left":
x = inner_x + self.LIST_MARGIN + 2 * row["indent"]
else:
@@ -479,7 +552,7 @@ class Frame:
+ 2 * row["indent"]
# Never paint over the right border column.
room = max(0, inner_x + inner_w - 1 - x)
- for text, attr in row["segments"]:
+ for text, attr in segments:
text = _fit(text, room)
if not text:
break
@@ -1504,37 +1577,53 @@ def checkbox_tree(scr, title: str, families: List[dict],
# Widget: scrollable text viewer
# ---------------------------------------------------------------------------
-def text_viewer(scr, title: str, lines: Sequence[str],
+def text_viewer(scr, title: str, lines: Sequence,
back_value: object = None) -> object:
- """Show LINES as a read-only dialog; Esc/q/Enter closes it.
-
- A scrollable pop-up for longer explanatory text (the hub's Help
- screen). Rows wrap like body rows and stay centered; none is
- selectable, so no cursor bar is drawn — but the (logical) cursor
- still scrolls the view into place: Up/Down (or k/j) move one line,
- Home/End jump to the top/bottom, PageUp/PageDown page, and the
- frame's scroll indicator (``x/y`` in the border) appears whenever
- the text overflows the dialog. Closing returns BACK_VALUE when it
- is given (not None), so the caller can fall back a screen; without
- one, Esc/q raise WizardCancelled as in menu().
+ """Show LINES as a left-justified, scrollable read-only dialog.
+
+ A pop-up for longer explanatory text (the hub's Help screen). Each
+ LINES item is either a plain string (rendered as a body row at the
+ list margin; "" renders a blank row) or a ``(segments, indent)``
+ pair — SEGMENTS are (text, kind) with KIND a theme key (None =
+ body) — word-wrapped to the dialog width. Rows with INDENT 1 sit
+ two columns further in than INDENT 0, so continuation lines read
+ as part of their numbered step.
+
+ No row is selectable (no cursor bar). Instead Up/Down (or j/k)
+ scroll the text itself, one line per keypress, clamped to the
+ content; while the text overflows the dialog, the border shows
+ which lines are visible, e.g. " lines 3-19 of 40 ". Enter, Esc (or
+ 'q') closes: BACK_VALUE is returned when given (not None), so the
+ caller can fall back a screen; without one, Esc/q raise
+ WizardCancelled as in menu().
"""
- frame = Frame(scr, title,
- "Up/Down = scroll PgUp/PgDn = page Enter/Esc = close")
- for line in lines:
- frame.mark(line)
- cursor = 0
+ frame = Frame(scr, title, "Up/Down = scroll Enter/Esc = close")
+ frame.scroll_label = lambda scroll, total, visible: \
+ f" lines {scroll + 1}-{min(scroll + visible, total)} of {total} "
+ for item in lines:
+ if isinstance(item, tuple):
+ segments, indent = item
+ frame.mark_segments(
+ [(text, frame.theme.get(kind, frame.theme["body"]))
+ for text, kind in segments],
+ indent=indent, align="left", wrap=True)
+ else:
+ frame.mark(item, align="left")
+ frame.cursor = None
while True:
- cursor = max(0, min(cursor, len(frame.rows) - 1))
- frame.cursor = cursor if frame.rows else None
frame.draw()
key = frame.get_key(cancel_keys=())
if key in _CANCEL_KEYS:
if back_value is not None:
return back_value
raise WizardCancelled()
- moved = frame.motion(key, cursor, len(frame.rows))
- if moved is not None:
- cursor = moved
+ curses = frame.curses
+ if key in (curses.KEY_UP, ord("k")):
+ frame.scroll = max(0, frame.scroll - 1)
+ elif key in (curses.KEY_DOWN, ord("j")):
+ # Past the end this self-clamps: _geometry clamps scroll to
+ # the content height on every draw.
+ frame.scroll += 1
elif key in (10, 13):
if back_value is not None:
return back_value