aboutsummaryrefslogtreecommitdiff
path: root/app/ui/tui.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui/tui.py')
-rw-r--r--app/ui/tui.py167
1 files changed, 128 insertions, 39 deletions
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