aboutsummaryrefslogtreecommitdiff
path: root/ui/tui.py
diff options
context:
space:
mode:
Diffstat (limited to 'ui/tui.py')
-rw-r--r--ui/tui.py1039
1 files changed, 1039 insertions, 0 deletions
diff --git a/ui/tui.py b/ui/tui.py
new file mode 100644
index 0000000..c73fb09
--- /dev/null
+++ b/ui/tui.py
@@ -0,0 +1,1039 @@
+#!/usr/bin/env python3
+"""Colorful DOS-style curses TUI widgets for the interactive tools.
+
+Every screen is a dialog centered on a black desktop, like an old DOS
+TUI: a yellow title, colored status messages (green/yellow/red), a
+bright cyan cursor bar, and Yes/No buttons you switch with Tab for
+every yes/no question. Instructions and prompts are centered while
+lists (directory contents, menu options, checkbox trees) are
+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 —
+every widget is a function that runs its own key loop on a curses
+window and returns the chosen value.
+
+Common key bindings:
+
+ Up/Down (or k/j) move the cursor
+ Enter accept (the highlighted button or row)
+ Tab or Left/Right switch Yes/No buttons (confirmations)
+ Esc abort the whole wizard (raises WizardCancelled);
+ a widget passed back_value returns that sentinel
+ instead, so the caller can fall back a screen
+ (confirm() historically names this cancel_value)
+
+On screens without typed text (menus, confirm, tree, browser) 'q' also
+aborts — even when a back_value is set, so Esc means "back" while 'q'
+still means "quit". Inside text editors 'q' is an ordinary character.
+When the terminal has no color support the theme degrades to
+bold/reverse/dim.
+"""
+
+import contextlib
+import os
+import textwrap
+from pathlib import Path
+from typing import Callable, List, Optional, Sequence, Tuple
+
+# Make Esc register quickly instead of pausing for an escape sequence.
+os.environ.setdefault("ESCDELAY", "25")
+
+
+class WizardCancelled(Exception):
+ """Raised when the user presses Esc to abort the wizard."""
+
+
+@contextlib.contextmanager
+def suspend(scr):
+ """Temporarily leave curses to run plain-console code.
+
+ Long-running steps that stream output to the terminal (cloning a
+ repository, building, pip-installing, transcribing) cannot share the
+ curses screen, so the wizard suspends curses for the duration of the
+ step and repaints the current screen afterward. ``scr`` is the curses
+ window returned to the wrapper callback.
+ """
+ import curses
+ try:
+ curses.endwin()
+ except curses.error:
+ pass
+ try:
+ yield
+ finally:
+ try:
+ scr.redrawwin()
+ scr.refresh()
+ except Exception:
+ pass
+ try:
+ curses.curs_set(0)
+ except curses.error:
+ pass
+
+
+def flash(scr, text: str, kind: str = "warn") -> None:
+ """Show a one-line notice until any key is pressed, then return.
+
+ Used by the hub for "not set up yet"-style messages. KIND is a theme
+ key (warn/err/ok/info). Esc dismisses the notice (it does not abort).
+ """
+ frame = Frame(scr, "Notice", "Press any key to continue Esc = back")
+ frame.mark(text, frame.theme.get(kind, frame.theme["body"]))
+ frame.cursor = None
+ frame.draw()
+ try:
+ key = scr.getch()
+ except KeyboardInterrupt:
+ raise WizardCancelled() from None
+ if key == 3: # Ctrl-C still aborts
+ raise WizardCancelled()
+
+
+# Esc and 'q' both abort on screens without typed text ('q' is an
+# ordinary character inside text editors).
+_CANCEL_KEYS = (27, ord("q"))
+
+
+# ---------------------------------------------------------------------------
+# Theme
+# ---------------------------------------------------------------------------
+
+_THEME: dict = {}
+
+
+def _ensure_theme(curses) -> dict:
+ """Build (once) the attribute table for the classic DOS look.
+
+ White text on a black desktop, a cyan border, yellow titles and
+ warnings, green success/check marks, red errors, a black-on-cyan
+ cursor bar and a black-on-green selected button. Black matches the
+ terminal's default background, so the clear-screen repaints curses
+ performs when a dialog changes size never flash. Without colors,
+ everything falls back to bold/reverse/dim attributes.
+ """
+ if _THEME:
+ return _THEME
+ theme = {
+ "desktop": 0,
+ "border": curses.A_BOLD,
+ "title": curses.A_BOLD,
+ "body": 0,
+ "dim": curses.A_DIM,
+ "ok": curses.A_BOLD,
+ "warn": curses.A_BOLD,
+ "err": curses.A_BOLD | curses.A_REVERSE,
+ "info": curses.A_DIM,
+ "input": curses.A_BOLD,
+ "bar": curses.A_REVERSE,
+ "btn_on": curses.A_REVERSE | curses.A_BOLD,
+ "btn_off": curses.A_DIM,
+ "check": curses.A_BOLD,
+ "accent": curses.A_BOLD,
+ }
+ if curses.has_colors():
+ try:
+ curses.start_color()
+ black = curses.COLOR_BLACK
+ pairs = {
+ "desktop": (curses.COLOR_WHITE, black),
+ "border": (curses.COLOR_CYAN, black),
+ "title": (curses.COLOR_YELLOW, black),
+ "ok": (curses.COLOR_GREEN, black),
+ "warn": (curses.COLOR_YELLOW, black),
+ "err": (curses.COLOR_RED, black),
+ "info": (curses.COLOR_WHITE, black),
+ "input": (curses.COLOR_WHITE, black),
+ "bar": (curses.COLOR_BLACK, curses.COLOR_CYAN),
+ "btn_on": (curses.COLOR_BLACK, curses.COLOR_GREEN),
+ "check": (curses.COLOR_GREEN, black),
+ "accent": (curses.COLOR_CYAN, black),
+ }
+ for number, (name, (fg, bg)) in enumerate(pairs.items(), 1):
+ curses.init_pair(number, fg, bg)
+ theme[name] = curses.color_pair(number)
+ theme["dim"] = curses.A_DIM | theme["desktop"]
+ theme["body"] = theme["desktop"]
+ theme["btn_off"] = curses.A_DIM | theme["desktop"]
+ for name in ("title", "ok", "warn", "err", "check", "accent",
+ "input"):
+ theme[name] |= curses.A_BOLD
+ theme["info"] = curses.A_DIM | theme["info"]
+ except curses.error:
+ pass
+ _THEME.clear()
+ _THEME.update(theme)
+ return _THEME
+
+
+# ---------------------------------------------------------------------------
+# Shared drawing helpers
+# ---------------------------------------------------------------------------
+
+def _addstr(scr, y: int, x: int, text: str, attr: int = 0) -> None:
+ """addstr that ignores out-of-bounds and terminal-capability errors."""
+ try:
+ scr.addstr(y, x, text, attr)
+ except Exception:
+ pass
+
+
+def _addch(scr, y: int, x: int, ch, attr: int = 0) -> None:
+ """addch that ignores out-of-bounds and terminal-capability errors."""
+ try:
+ scr.addch(y, x, ch, attr)
+ except Exception:
+ pass
+
+
+def _hline(scr, y: int, x: int, n: int, attr: int = 0) -> None:
+ """hline of ACS_HLINE that ignores terminal-capability errors."""
+ import curses
+ try:
+ scr.hline(y, x, curses.ACS_HLINE, n, attr)
+ except Exception:
+ pass
+
+
+def _fit(text: str, width: int) -> str:
+ """Truncate TEXT to WIDTH columns, appending '~' when cut."""
+ if width < 1:
+ return ""
+ if len(text) <= width:
+ return text
+ return text[: max(0, width - 1)] + "~"
+
+
+class Frame:
+ """A dialog centered on the black desktop, DOS style.
+
+ Widgets append logical rows with mark()/mark_segments() and call
+ draw() after every state change. Rows are centered by default;
+ list rows pass align="left" to start at a fixed margin from the
+ left border. Rows that are not selectable (help text, the current
+ directory, blank lines) are skipped by the cursor. The selected
+ row is drawn as a full-width bright bar. Below the rows sit the
+ optional Yes/No buttons, a colored one-line status, and a dim
+ footer.
+ """
+
+ MIN_HEIGHT = 8
+ MIN_WIDTH = 30
+ # Columns between the left border and align="left" rows.
+ LIST_MARGIN = 2
+
+ def __init__(self, scr, title: str, footer: str):
+ import curses
+ self.curses = curses
+ self.scr = scr
+ self.title = title
+ self.footer = footer
+ self.theme = _ensure_theme(curses)
+ self.rows: List[dict] = []
+ self.cursor: Optional[int] = None # logical row index
+ self.status: Optional[Tuple[str, str]] = None # (text, kind)
+ self.buttons: Optional[Tuple[Sequence[str], int]] = None
+ self.scroll = 0
+ self.page_size = 1
+ try:
+ curses.curs_set(0)
+ except curses.error:
+ pass
+ try:
+ scr.bkgd(" ", self.theme["desktop"])
+ except curses.error:
+ pass
+
+ # -- content ---------------------------------------------------------
+
+ def mark(self, text: str, attr: Optional[int] = None, indent: int = 0,
+ selectable: bool = False, align: str = "center") -> None:
+ """Append a body row (wrapped when longer than the box).
+
+ ALIGN is "center" (the default, for instructions and prompts)
+ or "left" (for lists), which starts the row at a fixed margin
+ from the left border.
+ """
+ if attr is None:
+ attr = self.theme["body"]
+ self.rows.append({"text": text, "segments": None, "attr": attr,
+ "indent": indent, "selectable": selectable,
+ "align": align})
+
+ 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)."""
+ self.rows.append({"text": None, "segments": list(segments),
+ "attr": 0, "indent": indent,
+ "selectable": selectable, "align": align})
+
+ def selectable(self) -> List[int]:
+ """Logical indices of the selectable rows, in order."""
+ return [index for index, row in enumerate(self.rows)
+ if row["selectable"]]
+
+ # -- drawing ---------------------------------------------------------
+
+ def _row_width(self, row: dict) -> int:
+ """Logical width of a row, including its indent."""
+ if row["segments"] is not None:
+ return sum(len(text) for text, _ in row["segments"]) \
+ + 2 * row["indent"]
+ return len(row["text"]) + 2 * row["indent"]
+
+ 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)
+ if self.buttons:
+ labels, _ = self.buttons
+ longest = max(longest,
+ 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]]] = []
+ for index, row in enumerate(self.rows):
+ if row["segments"] is not None:
+ flat.append((index, row, None))
+ continue
+ wrap_width = usable
+ if row["align"] == "left":
+ # Leave room for the list margin, the indent and the
+ # right border so a wrapped line is never re-truncated.
+ wrap_width = usable - 1 - 2 * row["indent"]
+ pieces = textwrap.wrap(row["text"], max(10, wrap_width)) or [""]
+ for piece in pieces:
+ flat.append((index, row, piece))
+ return flat
+
+ def _geometry(self, height: int, width: int, dialog_w: int,
+ flat: List[Tuple[int, dict, Optional[str]]]
+ ) -> Tuple[int, int, int, int]:
+ """Place the dialog and scroll the cursor row into view.
+
+ Returns (y0, x0, dialog_h, visible); also refreshes
+ self.scroll and self.page_size.
+ """
+ chrome = 7 if self.buttons else 6 # title/gap/status/footer/borders
+ dialog_h = min(max(self.MIN_HEIGHT, len(flat) + chrome), height)
+ visible = max(1, dialog_h - chrome)
+ self.page_size = max(1, visible)
+ if self.cursor is not None:
+ positions = [i for i, (logical, _, _) in enumerate(flat)
+ if logical == self.cursor]
+ if positions:
+ first, last = positions[0], positions[-1]
+ if first < self.scroll:
+ self.scroll = first
+ elif last >= self.scroll + visible:
+ self.scroll = last - visible + 1
+ self.scroll = max(0, min(self.scroll, max(0, len(flat) - visible)))
+ y0 = max(0, (height - dialog_h) // 2)
+ x0 = max(0, (width - dialog_w) // 2)
+ return y0, x0, dialog_h, visible
+
+ def draw(self) -> None:
+ scr = self.scr
+ scr.erase()
+ height, width = scr.getmaxyx()
+ if height < self.MIN_HEIGHT or width < self.MIN_WIDTH:
+ msg = "Terminal too small"
+ _addstr(scr, height // 2, max(0, (width - len(msg)) // 2),
+ msg, self.curses.A_BOLD)
+ scr.refresh()
+ return
+ dialog_w = self._measure(width)
+ flat = self._flatten(dialog_w - 4)
+ y0, x0, dialog_h, visible = self._geometry(height, width,
+ dialog_w, flat)
+ self._draw_frame(y0, x0, dialog_h, dialog_w, len(flat), visible)
+ self._draw_rows(y0, x0, dialog_w, flat, visible)
+ self._draw_buttons(y0, x0, dialog_h, dialog_w)
+ self._draw_status_footer(y0, x0, dialog_h, dialog_w)
+ scr.refresh()
+
+ def _draw_frame(self, y0: int, x0: int, dialog_h: int, dialog_w: int,
+ total_lines: int, visible: int) -> None:
+ curses, theme = self.curses, self.theme
+ scr = self.scr
+ border = theme["border"]
+ _addch(scr, y0, x0, curses.ACS_ULCORNER, border)
+ _addch(scr, y0, x0 + dialog_w - 1, curses.ACS_URCORNER, border)
+ _addch(scr, y0 + dialog_h - 1, x0, curses.ACS_LLCORNER, border)
+ _addch(scr, y0 + dialog_h - 1, x0 + dialog_w - 1,
+ curses.ACS_LRCORNER, border)
+ _hline(scr, y0, x0 + 1, dialog_w - 2, border)
+ _hline(scr, y0 + dialog_h - 1, x0 + 1, dialog_w - 2, border)
+ for y in range(y0 + 1, y0 + dialog_h - 1):
+ _addch(scr, y, x0, curses.ACS_VLINE, border)
+ _addch(scr, y, x0 + dialog_w - 1, curses.ACS_VLINE, border)
+
+ inner_x = x0 + 1
+ inner_w = dialog_w - 2
+ title = _fit(f" {self.title} ", inner_w)
+ _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} "
+ _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]]],
+ visible: int) -> None:
+ theme = self.theme
+ scr = self.scr
+ inner_x = x0 + 1
+ inner_w = dialog_w - 2
+ for line in range(self.scroll, min(len(flat), self.scroll + visible)):
+ logical, row, piece = flat[line]
+ y = y0 + 2 + (line - self.scroll)
+ selected = logical == self.cursor and row["selectable"]
+ if selected:
+ _addstr(scr, y, inner_x, " " * inner_w, theme["bar"])
+ if row["segments"] is not None:
+ self._draw_segments_row(y, row, 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:
+ scr, theme = self.scr, self.theme
+ total = sum(len(text) for text, _ in row["segments"])
+ if row["align"] == "left":
+ x = inner_x + self.LIST_MARGIN + 2 * row["indent"]
+ else:
+ x = inner_x + max(0, (inner_w - total) // 2) \
+ + 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"]:
+ text = _fit(text, room)
+ if not text:
+ break
+ _addstr(scr, y, x, text, theme["bar"] if selected else attr)
+ x += len(text)
+ room -= len(text)
+
+ def _draw_text_row(self, y: int, row: dict, piece: Optional[str],
+ inner_x: int, inner_w: int, selected: bool) -> None:
+ scr, theme = self.scr, self.theme
+ text = " " * row["indent"] + piece
+ if row["align"] == "left":
+ x = inner_x + self.LIST_MARGIN
+ limit = inner_w - 1 - self.LIST_MARGIN - 2 * row["indent"]
+ else:
+ x = inner_x + max(0, (inner_w - len(text)) // 2)
+ limit = inner_w
+ text = _fit(text, limit)
+ attr = theme["bar"] if selected else row["attr"]
+ _addstr(scr, y, x, text, attr)
+
+ def _draw_buttons(self, y0: int, x0: int, dialog_h: int,
+ dialog_w: int) -> None:
+ if not self.buttons:
+ return
+ theme = self.theme
+ scr = self.scr
+ inner_x = x0 + 1
+ inner_w = dialog_w - 2
+ labels, selected = self.buttons
+ 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
+ for index, text in enumerate(rendered):
+ if index:
+ x += 3
+ _addstr(scr, y, x, text,
+ theme["btn_on"] if index == selected
+ else theme["btn_off"])
+ x += len(text)
+
+ def _draw_status_footer(self, y0: int, x0: int, dialog_h: int,
+ dialog_w: int) -> None:
+ theme = self.theme
+ scr = self.scr
+ inner_x = x0 + 1
+ inner_w = dialog_w - 2
+ 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)
+ footer = _fit(self.footer, inner_w)
+ _addstr(scr, y0 + dialog_h - 2,
+ inner_x + max(0, (inner_w - len(footer)) // 2),
+ footer, theme["dim"])
+
+ # -- key helpers ------------------------------------------------------
+
+ def motion(self, key: int, cursor: int, count: int,
+ wrap: bool = False) -> Optional[int]:
+ """New cursor index for a motion KEY, or None when it moves nothing.
+
+ Up/Down (or k/j) move one row, wrapping around at the ends when
+ WRAP is set (menus and trees) and clamping otherwise (the
+ browser); Home/End jump to the first/last row; PageUp/PageDown
+ move self.page_size rows. COUNT is the number of rows.
+ """
+ curses = self.curses
+ if key in (curses.KEY_UP, ord("k")):
+ if wrap and cursor <= 0:
+ return count - 1
+ return max(0, cursor - 1)
+ if key in (curses.KEY_DOWN, ord("j")):
+ if wrap and cursor >= count - 1:
+ return 0
+ return min(count - 1, cursor + 1)
+ if key == curses.KEY_HOME:
+ return 0
+ if key == curses.KEY_END:
+ return count - 1
+ if key == curses.KEY_PPAGE:
+ return max(0, cursor - self.page_size)
+ if key == curses.KEY_NPAGE:
+ return min(count - 1, cursor + self.page_size)
+ return None
+
+ def get_key(self, cancel_keys: Sequence[int] = (27,)) -> int:
+ """Read one key; cancel keys and Ctrl-C raise WizardCancelled."""
+ try:
+ key = self.scr.getch()
+ except KeyboardInterrupt:
+ raise WizardCancelled() from None
+ if key == 3: # Ctrl-C
+ raise WizardCancelled()
+ if key in cancel_keys:
+ raise WizardCancelled()
+ return key
+
+ def flash(self, text: str, kind: str = "err") -> None:
+ """Show TEXT on the status line until any key is pressed."""
+ self.status = (text, kind)
+ self.draw()
+ try:
+ key = self.scr.getch()
+ if key == 3: # Ctrl-C still aborts
+ raise WizardCancelled()
+ except KeyboardInterrupt:
+ raise WizardCancelled() from None
+ self.status = None
+
+ def edit_status(self, prompt: str = "") -> Optional[str]:
+ """Edit a line of text on the status line.
+
+ Returns the edited string on Enter, or None when the user backs
+ out with Esc (the caller decides what that means).
+ """
+ curses = self.curses
+ text = ""
+ while True:
+ self.status = (f"{prompt}{text}_", "input")
+ self.draw()
+ try:
+ key = self.scr.getch()
+ except KeyboardInterrupt:
+ raise WizardCancelled() from None
+ if key == 27:
+ return None
+ if key == 3: # Ctrl-C
+ raise WizardCancelled()
+ if key in (10, 13):
+ return text
+ if key in (curses.KEY_BACKSPACE, 8, 127):
+ text = text[:-1]
+ elif 32 <= key < 127:
+ text += chr(key)
+
+
+# ---------------------------------------------------------------------------
+# Widget: yes/no confirm with buttons
+# ---------------------------------------------------------------------------
+
+def confirm(scr, question: str, default: bool = False,
+ body: Optional[Sequence[str]] = None,
+ cancel_value: object = None):
+ """Ask a yes/no QUESTION with centered Yes/No buttons.
+
+ The QUESTION is the dialog title (shown exactly once); optional
+ BODY lines sit centered above the buttons. Tab or the arrow keys
+ switch the buttons, Enter activates the highlighted one (the
+ DEFAULT button starts highlighted, drawn bright against the dim
+ other one), and y/n answer directly. Esc (or 'q') aborts the
+ wizard — unless CANCEL_VALUE is given (not None), in which case it
+ is returned instead, so the caller can fall back to a previous
+ screen rather than aborting the whole wizard.
+ """
+ frame = Frame(scr, question,
+ "Tab/arrows = switch Enter = confirm y/n Esc = cancel")
+ index = 0 if default else 1
+ while True:
+ frame.rows = []
+ for line in body or []:
+ frame.mark(line)
+ frame.cursor = None
+ frame.buttons = (["Yes", "No"], index)
+ frame.draw()
+ curses = frame.curses
+ key = frame.get_key(cancel_keys=())
+ if key in _CANCEL_KEYS:
+ if cancel_value is not None:
+ return cancel_value
+ raise WizardCancelled()
+ if key in (9, curses.KEY_LEFT, curses.KEY_RIGHT, curses.KEY_UP,
+ curses.KEY_DOWN, curses.KEY_BTAB, ord("h"), ord("l")):
+ index = 1 - index
+ elif key in (ord("y"), ord("Y")):
+ return True
+ elif key in (ord("n"), ord("N")):
+ return False
+ elif key in (10, 13):
+ return index == 0
+
+
+# ---------------------------------------------------------------------------
+# Widget: single-choice menu
+# ---------------------------------------------------------------------------
+
+def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0,
+ help_lines: Optional[Sequence[str]] = None,
+ back_value: object = None,
+ table_title: Optional[str] = None,
+ table_rows: Optional[Sequence[tuple]] = None):
+ """Show OPTIONS as (label, value) pairs; return the chosen value.
+
+ The cursor starts on DEFAULT_INDEX; Enter returns the highlighted
+ option's value. Options are left-justified like a DOS list;
+ HELP_LINES are dim, centered explanatory lines shown above them.
+
+ TABLE_TITLE + TABLE_ROWS render an aligned two-column table above the
+ options: each row is (name, status, kind) where KIND is a theme key
+ ("ok"/"warn"/"err"/"info"/...), optionally followed by NAME_KIND, a
+ theme key for the name column ("dim" to fade an unusable entry;
+ "body" — the default — otherwise). The name column is padded to the
+ widest name so every status starts at the same column — a monospace
+ grid. The title is dim and left-aligned with the rows. Used by the
+ hub to show each backend's state (unavailable / installed / running)
+ in matching columns with color.
+
+ Esc (or 'q') aborts the wizard unless BACK_VALUE is given (not None),
+ in which case Esc returns it so the caller can fall back a screen.
+ """
+ if not options:
+ raise ValueError("menu() needs at least one option")
+ frame = Frame(scr, title,
+ "Up/Down = move Enter = select Esc = cancel")
+ cursor = max(0, min(default_index, len(options) - 1))
+ while True:
+ frame.rows = []
+ for line in help_lines or []:
+ frame.mark(line, frame.theme["dim"])
+ if help_lines:
+ frame.mark("")
+ if table_rows:
+ if table_title:
+ frame.mark(table_title, frame.theme["dim"], align="left")
+ name_w = max(len(row[0]) for row in table_rows)
+ for row in table_rows:
+ name, status, kind = row[0], row[1], row[2]
+ name_kind = row[3] if len(row) > 3 else "body"
+ frame.mark_segments(
+ [(name.ljust(name_w),
+ frame.theme.get(name_kind, frame.theme["body"])),
+ (" " + status,
+ frame.theme.get(kind, frame.theme["body"]))],
+ align="left")
+ frame.mark("")
+ base = len(frame.rows)
+ for label, _ in options:
+ frame.mark(label, selectable=True, align="left")
+ frame.cursor = base + cursor
+ frame.draw()
+ key = frame.get_key(cancel_keys=())
+ if key == 27 and back_value is not None:
+ return back_value
+ if key in _CANCEL_KEYS:
+ raise WizardCancelled()
+ moved = frame.motion(key, cursor, len(options), wrap=True)
+ if moved is not None:
+ cursor = moved
+ elif key in (10, 13):
+ return options[cursor][1]
+
+
+# ---------------------------------------------------------------------------
+# Widget: single-line text editor
+# ---------------------------------------------------------------------------
+
+def line_edit(scr, title: str, default: str,
+ validate: Optional[Callable[[str], Optional[str]]] = None,
+ help_lines: Optional[Sequence[str]] = None,
+ back_value: object = None) -> str:
+ """Edit one line of text, pre-filled with DEFAULT; Enter accepts.
+
+ HELP_LINES are dim explanatory lines shown above the input.
+ VALIDATE receives the entered string and returns an error message
+ or None; Enter on an invalid value shows the message in red and
+ keeps editing. Esc aborts the wizard ('q' is an ordinary
+ character here) unless BACK_VALUE is given (not None), in which case
+ Esc returns it so the caller can fall back a screen.
+ """
+ frame = Frame(scr, title,
+ "type to edit Backspace = erase Enter = accept "
+ "Esc = cancel")
+ text = default
+ error = None
+ while True:
+ frame.rows = []
+ for line in help_lines or []:
+ frame.mark(line, frame.theme["dim"])
+ frame.mark("")
+ frame.mark(f"{text}_", frame.theme["input"])
+ frame.cursor = None
+ frame.status = (error, "err") if error else None
+ frame.draw()
+ curses = frame.curses
+ key = frame.get_key(cancel_keys=()) # handle Esc manually below
+ if key == 27 and back_value is not None:
+ return back_value
+ if key == 27:
+ raise WizardCancelled()
+ if key in (10, 13):
+ if validate is None:
+ return text
+ error = validate(text)
+ if error is None:
+ return text
+ continue
+ if key in (curses.KEY_BACKSPACE, 8, 127):
+ text = text[:-1]
+ elif key == 21: # Ctrl-U: clear the line
+ text = ""
+ elif 32 <= key < 127:
+ text += chr(key)
+
+
+# ---------------------------------------------------------------------------
+# Widget: directory browser
+# ---------------------------------------------------------------------------
+
+def _list_dirs(path: Path) -> List[Path]:
+ """Return the subdirectories of PATH, sorted, dot-dirs excluded."""
+ try:
+ entries = [child for child in path.iterdir()
+ if child.is_dir() and not child.name.startswith(".")]
+ except OSError:
+ return []
+ return sorted(entries, key=lambda child: child.name.lower())
+
+
+def browse_directory(scr, title: str,
+ validate: Optional[Callable[[Path], Optional[str]]] = None,
+ start: Optional[Path] = None,
+ info: Optional[Callable[[Path],
+ Optional[Tuple[str, str]]]] = None,
+ preview: Optional[Callable[[Path],
+ Optional[Tuple[str, str]]]] = None,
+ help_lines: Optional[Sequence[str]] = None,
+ auto_select: Optional[Callable[
+ [Path], Optional[Path]]] = None,
+ back_value: object = None
+ ) -> Path:
+ """Pick a directory DOS-browser style.
+
+ The listing starts with a bright '[ Use this directory ]' row (the
+ cursor starts there; Enter accepts the directory being listed), a
+ dim '..' for the parent, and one row per subdirectory. List rows
+ are left-justified; instructions and the current path stay
+ centered. Enter or Right on a highlighted subdirectory opens it,
+ Left/Backspace goes to the parent, 'e' types a path directly, and
+ Home/End/PageUp/PageDown navigate long listings. Coming back out
+ of a directory highlights the directory you came from.
+
+ VALIDATE receives the listed directory and returns an error message
+ or None; Enter on an invalid directory is refused with that message.
+ INFO(directory) returns a (text, kind) status shown under the
+ listed directory's path — kind is "ok" (green), "warn" (yellow),
+ "err" (red), "info" (dim) or "input". PREVIEW(directory) returns
+ one for the highlighted subdirectory, shown on the status line.
+ AUTO_SELECT receives a highlighted subdirectory when the user
+ opens it (Enter, Right or 'l') and may return a Path to accept
+ immediately — as if '[ Use this directory ]' had been pressed on
+ it — instead of descending; returning None keeps browsing. This
+ lets a subdirectory that already looks like the target (e.g. an
+ 'audio.cpp' checkout containing 'model_specs/') be picked in one
+ keystroke. Esc (or 'q') aborts the wizard unless BACK_VALUE is given
+ (not None), in which case Esc returns it so the caller can fall back
+ a screen.
+ """
+ footer = ("Up/Down = move Enter = open/use Left = parent "
+ "e = type path Esc = cancel")
+ frame = Frame(scr, title, footer)
+ current = Path(start) if start is not None else Path.cwd()
+ try:
+ current = current.resolve()
+ except OSError:
+ current = Path.cwd()
+ sel = 0
+ highlight: Optional[Path] = None
+
+ def validation_error() -> Optional[str]:
+ if validate is None:
+ return None
+ try:
+ return validate(current)
+ except OSError:
+ return "Cannot read this directory"
+
+ def call(callback, path: Path) -> Optional[Tuple[str, str]]:
+ if callback is None:
+ return None
+ try:
+ return callback(path)
+ except OSError:
+ return None
+
+ while True:
+ entries = _list_dirs(current)
+ has_parent = current.parent != current
+ offset = 1 + (1 if has_parent else 0)
+ frame.rows = []
+ for line in help_lines or []:
+ frame.mark(line, frame.theme["dim"])
+ frame.mark(f"Directory: {current}", frame.theme["accent"])
+ current_info = call(info, current)
+ if current_info:
+ frame.mark(current_info[0],
+ frame.theme.get(current_info[1], frame.theme["body"]))
+ frame.mark("")
+ frame.mark("[ Use this directory ]", frame.theme["ok"],
+ selectable=True, align="left")
+ if has_parent:
+ frame.mark("..", frame.theme["dim"], selectable=True,
+ align="left")
+ for entry in entries:
+ frame.mark(f"{entry.name}/", selectable=True, align="left")
+ selectable = frame.selectable()
+ if highlight is not None:
+ sel = 0
+ for index, entry in enumerate(entries):
+ if entry == highlight:
+ sel = offset + index
+ break
+ highlight = None
+ sel = max(0, min(sel, len(selectable) - 1))
+ frame.cursor = selectable[sel] if selectable else None
+
+ if sel == 0:
+ frame.status = ("Enter = use this directory", "info")
+ elif has_parent and sel == 1:
+ frame.status = ("Enter = open the parent directory", "info")
+ else:
+ entry = entries[sel - offset]
+ frame.status = call(preview, entry) \
+ or (f"Enter = open {entry.name}/", "info")
+ frame.draw()
+ curses = frame.curses
+ key = frame.get_key(cancel_keys=())
+ if key == 27 and back_value is not None:
+ return back_value
+ if key in _CANCEL_KEYS:
+ raise WizardCancelled()
+ moved = frame.motion(key, sel, len(selectable))
+ if moved is not None:
+ sel = moved
+ elif key in (10, 13, curses.KEY_RIGHT, ord("l")):
+ if sel == 0:
+ error = validation_error()
+ if error is None:
+ return current
+ frame.flash(f"{error} (keep browsing)", "err")
+ elif has_parent and sel == 1:
+ highlight = current
+ current = current.parent
+ else:
+ entry = entries[sel - offset]
+ if auto_select is not None:
+ picked = auto_select(entry)
+ if picked is not None:
+ return picked
+ current = entry
+ sel = 0
+ elif key in (curses.KEY_LEFT, ord("h"), ord("u"),
+ curses.KEY_BACKSPACE, 8, 127):
+ if has_parent:
+ highlight = current
+ current = current.parent
+ elif key == ord("e"):
+ result = frame.edit_status(prompt="path: ")
+ if result:
+ candidate = Path(os.path.expanduser(result))
+ if not candidate.is_absolute():
+ candidate = current / candidate
+ try:
+ candidate = candidate.resolve()
+ except OSError:
+ pass
+ if candidate.is_dir():
+ current = candidate
+ sel = 0
+ else:
+ frame.flash(f"Not a directory: {candidate}", "err")
+
+
+# ---------------------------------------------------------------------------
+# Widget: expandable checkbox tree
+# ---------------------------------------------------------------------------
+
+def checkbox_tree(scr, title: str, families: List[dict],
+ footer: Optional[str] = None,
+ expand_all: bool = False,
+ back_value: object = None) -> List[Tuple[int, str]]:
+ """Pick model families and packages from an expandable tree.
+
+ FAMILIES is a list of dicts (one per family) shaped like::
+
+ {
+ "label": "Qwen3-TTS (qwen3_tts)",
+ "detail": "tts, cloning, design",
+ "options": [
+ {"key": "Base-GGUF", "label": "base", "recommended": True},
+ {"key": "VoiceDesign-GGUF", "label": "voicedesign",
+ "recommended": False},
+ ],
+ }
+
+ Space on a family row checks its recommended option (or clears every
+ option when one is already checked); Space on an option row toggles
+ that option. Tab/Right expands or collapses the family under the
+ cursor. Enter returns the flat list of (family_index, option_key)
+ pairs for every checked option, in tree order; at least one checked
+ option is required. Nothing is checked by default, and with
+ EXPAND_ALL every family starts expanded. A "[recommended]" tag is
+ shown only when a
+ family has more than one option — a single option needs no tag.
+ Family and option rows are left-justified like a DOS list. Esc (or
+ 'q') aborts the wizard unless BACK_VALUE is given (not None), in
+ which case Esc returns it so the caller can fall back a screen.
+ """
+ if not families:
+ raise ValueError("checkbox_tree() needs at least one family")
+ footer = footer or ("Up/Down = move Tab/Right = expand Space = check "
+ "Enter = accept Esc = cancel")
+ frame = Frame(scr, title, footer)
+ expanded = {index for index in range(len(families))} if expand_all else set()
+ checked = set() # (family_index, option_key)
+
+ expanded.add(0)
+
+ def family_checked(index: int) -> bool:
+ return any(pair[0] == index for pair in checked)
+
+ def accept() -> List[Tuple[int, str]]:
+ return [(index, option["key"])
+ for index, family in enumerate(families)
+ for option in family["options"]
+ if (index, option["key"]) in checked]
+
+ def visible_nodes() -> List[tuple]:
+ nodes: List[tuple] = [] # ("family", i) or ("option", i, key)
+ for index, family in enumerate(families):
+ nodes.append(("family", index))
+ if index in expanded:
+ for option in family["options"]:
+ nodes.append(("option", index, option["key"]))
+ return nodes
+
+ cursor = 0
+ while True:
+ nodes = visible_nodes()
+ cursor = max(0, min(cursor, len(nodes) - 1))
+ frame.rows = []
+ for node in nodes:
+ if node[0] == "family":
+ index = node[1]
+ family = families[index]
+ on = family_checked(index)
+ mark = "x" if on else " "
+ arrow = "-" if index in expanded else "+"
+ frame.mark_segments(
+ [(f"[{mark}] ",
+ frame.theme["check"] if on else frame.theme["dim"]),
+ (f"{arrow} {family['label']}",
+ frame.theme["accent"] if on else frame.theme["body"])],
+ selectable=True, align="left")
+ else:
+ _, index, option_key = node
+ option = next(opt for opt in families[index]["options"]
+ if opt["key"] == option_key)
+ is_on = (index, option_key) in checked
+ mark = "x" if is_on else " "
+ segments = [(f"[{mark}] ",
+ frame.theme["check"] if is_on
+ else frame.theme["dim"]),
+ (option["label"], frame.theme["body"])]
+ if option.get("recommended") \
+ and len(families[index]["options"]) > 1:
+ segments.append((" [recommended]", frame.theme["warn"]))
+ frame.mark_segments(segments, indent=2, selectable=True,
+ align="left")
+ frame.cursor = cursor
+ node = nodes[cursor]
+ frame.status = (families[node[1]].get("detail", ""), "info")
+ frame.draw()
+ curses = frame.curses
+ key = frame.get_key(cancel_keys=())
+ if key == 27 and back_value is not None:
+ return back_value
+ if key in _CANCEL_KEYS:
+ raise WizardCancelled()
+ moved = frame.motion(key, cursor, len(nodes), wrap=True)
+ if moved is not None:
+ cursor = moved
+ elif key in (9, curses.KEY_RIGHT, ord("l")) and node[0] == "family":
+ index = node[1]
+ if index in expanded:
+ expanded.discard(index)
+ else:
+ expanded.add(index)
+ elif key == curses.KEY_LEFT and node[0] == "family":
+ expanded.discard(node[1])
+ elif key == ord(" "):
+ if node[0] == "family":
+ index = node[1]
+ options = families[index]["options"]
+ if family_checked(index):
+ for option in options:
+ checked.discard((index, option["key"]))
+ else:
+ for option in options:
+ if option.get("recommended"):
+ checked.add((index, option["key"]))
+ break
+ else:
+ if options:
+ checked.add((index, options[0]["key"]))
+ expanded.add(index)
+ else:
+ _, index, option_key = node
+ if (index, option_key) in checked:
+ checked.discard((index, option_key))
+ else:
+ checked.add((index, option_key))
+ elif key in (10, 13): # Enter: accept the checked selection
+ selection = accept()
+ if selection:
+ return selection
+ frame.flash("Check at least one model package (Space)", "err")