diff options
Diffstat (limited to 'app/ui/tui.py')
| -rw-r--r-- | app/ui/tui.py | 167 |
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 |
