#!/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' behaves exactly like Esc: it goes back when a back/cancel value is set, otherwise it aborts the wizard. 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.""" class Wizard: """Drive a stack of screen closures with "Esc goes back one screen". Each screen is a zero-argument callable that shows exactly one interactive screen and returns a navigation result: Wizard.BACK the user pressed Esc/q; go back one screen a callable advance to that screen (it is the next screen) None abort the whole wizard any value finish the wizard and return that value (the settings) ``run(first_screen)`` returns the final value, or None when the user pressed Esc on the first screen (or a screen returned None). Only screens that actually render are pushed onto the stack, so Esc always lands on the previous real screen; a step whose value is already known (a flag, or a condition that does not apply) is folded into the screen that precedes it and never appears on the stack, so it cannot be backed into. """ BACK = object() def __init__(self): self._stack = [] def run(self, first_screen) -> Optional[object]: screen = first_screen while True: nxt = screen() if nxt is Wizard.BACK: if not self._stack: return None screen = self._stack.pop() continue if nxt is None: return None if not callable(nxt): return nxt self._stack.append(screen) screen = nxt @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). The notice itself is the dialog's only content (no "Notice" heading); Esc dismisses it (it does not abort). A timed-out getch (-1; the screen may still be in a redraw cadence) is ignored so the notice really waits for a key. """ frame = Frame(scr, "", "Press any key to continue Esc = back") frame.mark(text, frame.theme.get(kind, frame.theme["body"])) frame.cursor = None frame.draw() while True: try: key = scr.getch() except KeyboardInterrupt: raise WizardCancelled() from None if key == 3: # Ctrl-C still aborts raise WizardCancelled() if key != -1: return # On screens without typed text, Esc and 'q' mean the same thing: go # back when a back/cancel value is set, abort otherwise ('q' is an # ordinary character inside text editors). _CANCEL_KEYS = (27, ord("q")) # A menu() option marker: a bare MENU_SEPARATOR in the options list # renders a blank, non-selectable divider row between option groups. MENU_SEPARATOR = object() # --------------------------------------------------------------------------- # 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_BOLD, "check": curses.A_BOLD, "accent": curses.A_BOLD, } if curses.has_colors(): try: curses.start_color() black = curses.COLOR_BLACK gray = 236 if getattr(curses, "COLORS", 0) >= 256 else 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), "btn_off": (curses.COLOR_WHITE, gray), "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"] 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. """ # 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) 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 if self.title: 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 + (3 if self.title else 2) + (line - self.scroll) selected = logical == self.cursor and row["selectable"] if selected: _addstr(scr, y, inner_x, " " * inner_w, theme["bar"]) if row["align"] == "left": _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) 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() while True: try: key = self.scr.getch() if key == 3: # Ctrl-C still aborts raise WizardCancelled() except KeyboardInterrupt: raise WizardCancelled() from None if key != -1: break 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 def confirm_yn_cancel(scr, question: str) -> str: """Ask QUESTION with Yes / No / Cancel buttons; return the answer. Like ``confirm``, but with a third Cancel button and a string result: "yes", "no", or "cancel". Tab or the arrow keys cycle all three buttons (wrapping around), Enter activates the highlighted one (Yes starts highlighted), y/n answer directly, and Esc (or 'q') counts as Cancel. """ frame = Frame(scr, question, "Tab/arrows = switch Enter = confirm y/n " "Esc = cancel") index = 0 while True: frame.rows = [] frame.cursor = None frame.buttons = (["Yes", "No", "Cancel"], index) frame.draw() curses = frame.curses key = frame.get_key(cancel_keys=()) if key in _CANCEL_KEYS: return "cancel" if key in (9, curses.KEY_RIGHT, curses.KEY_DOWN, ord("l")): index = (index + 1) % 3 elif key in (curses.KEY_BTAB, curses.KEY_LEFT, curses.KEY_UP, ord("h")): index = (index - 1) % 3 elif key in (ord("y"), ord("Y")): return "yes" elif key in (ord("n"), ord("N")): return "no" elif key in (10, 13): return ("yes", "no", "cancel")[index] # --------------------------------------------------------------------------- # 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, notice_lines: Optional[Sequence[Tuple[str, str]]] = None): """Show OPTIONS as (label, value) pairs; return the chosen value. Each option is ``(label, value)``, optionally ``(label, value, suffix)`` where SUFFIX is ``(text, kind)`` rendered in the theme color KIND after the label (e.g. a yellow ``[recommended]`` tag). A bare ``MENU_SEPARATOR`` in the list renders a blank, non-selectable divider row, which the cursor skips over. 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. NOTICE_LINES render above the table (and after HELP_LINES): each entry is (text, kind) where KIND is a theme key, so the hub can warn in red (e.g. "Warning: ffmpeg not installed!") without polluting the status table. Esc (or 'q') aborts the wizard unless BACK_VALUE is given (not None), in which case either key returns it so the caller can fall back a screen. """ if not options: raise ValueError("menu() needs at least one option") entries = [opt for opt in options if opt is not MENU_SEPARATOR] if not entries: raise ValueError("menu() needs at least one selectable option") frame = Frame(scr, title, "Up/Down = move Enter = select Esc = cancel") cursor = max(0, min(default_index, len(entries) - 1)) while True: frame.rows = [] for line in help_lines or []: frame.mark(line, frame.theme["dim"]) if help_lines: frame.mark("") if notice_lines: for text, kind in notice_lines: frame.mark(text, frame.theme.get(kind, frame.theme["body"]), align="left") 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("") cursor_rows = [] for opt in options: if opt is MENU_SEPARATOR: frame.mark("", selectable=False, align="left") continue label = opt[0] if len(opt) > 2: suffix_text, suffix_kind = opt[2] frame.mark_segments( [(label, frame.theme["body"]), (" " + suffix_text, frame.theme.get(suffix_kind, frame.theme["body"]))], selectable=True, align="left") else: frame.mark(label, selectable=True, align="left") cursor_rows.append(len(frame.rows) - 1) frame.cursor = cursor_rows[cursor] frame.draw() key = frame.get_key(cancel_keys=()) if key in _CANCEL_KEYS and back_value is not None: return back_value if key in _CANCEL_KEYS: raise WizardCancelled() moved = frame.motion(key, cursor, len(entries), wrap=True) if moved is not None: cursor = moved elif key in (10, 13): return entries[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 # 'q' is an ordinary character in a text editor; only Esc cancels. 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: multi-field settings form with accept/cancel buttons # --------------------------------------------------------------------------- def form(scr, title: str, fields: Sequence[dict], back_value: object = None, help_lines: Optional[Sequence[str]] = None, buttons: Sequence[str] = ("Save", "Cancel"), start_on_buttons: bool = False) -> Optional[dict]: """Edit several labeled fields on one screen, then accept or cancel. FIELDS is a list of dicts, one per row, shaped like:: {"key": "audio_format", "label": "Audio format", "kind": "choice", "value": "m4b", "choices": ["mp3", "m4b", "ogg", "flac"]} {"key": "chunk_size", "label": "Chunk size", "kind": "text", "value": "250", "validate": lambda s: None if s.isdigit() else "digits only"} {"key": "combine", "label": "Combine chapters", "kind": "bool", "value": False} {"key": "transcripts", "label": "Voice transcripts", "kind": "toggle", "value": "missing", "choices": [("Transcribe new voices", "missing"), ("Re-transcribe all voices", "all")]} {"key": "voices", "label": "Voices directory", "kind": "dir", "value": Path("./voices")} Fields render as a two-column table: each label is padded to the widest label so every value starts in the same column. KINDS: ``choice`` opens a single choice menu (its ``choices`` may be a callable of the field list, resolved when the menu opens); ``text`` opens a line editor (reusing its VALIDATE); ``bool`` shows Yes/No and toggles in place on Enter or Space; ``toggle`` shows the label whose VALUE is selected and cycles through its ``(label, value)`` CHOICES in place on Enter or Space; ``dir`` opens the DOS-style directory browser (browse_directory) on Enter — its VALUE is a Path (or str path, used as the browse start), an empty value starts at the working directory, and backing out of the browser keeps the old value. Accepting a directory also moves focus to the first button, so Enter right after picking continues to the next screen. A field may set ``visible`` to a bool or a callable of the field list; hidden fields are not drawn, are skipped by the cursor, and keep their value across hide/show. A field may set ``on_change`` to a callable of the field list, invoked whenever its value changes so dependent fields (choices, visibility, defaults) can be recomputed. A choice field whose resolved list is empty cannot be opened: Enter is a no-op, or flashes the field's optional ``on_empty_choices`` message (string or callable of the field list) — an explanation the submit-time ``validate`` can echo when an empty pick must be refused. An optional ``note`` string on a field renders as a dim, non-selectable line in a blank-line frame above that field's row — a section divider with a short explanation. Up/Down (or k/j) move the cursor; Enter edits or toggles the highlighted field. Tab, Left/Right, j or k at the ends of the list move focus to the BUTTONS — Down from the last field and Up from the first field both land on the first button (the fields wrap onto the buttons); from the buttons, Down/j/Tab wrap back to the first field and Up/k/BTAB to the last, Left/Right switch the buttons. Enter on the first button validates every visible field that has a ``validate`` (the first failure flashes in red and re-focuses that row) and returns ``{key: value}``, Enter on the second button returns BACK_VALUE. Esc (or 'q') returns BACK_VALUE / aborts as in menu(). Values are edited in place in the FIELDS dicts, so Cancel simply discards them. BUTTONS customizes the two button labels (default "Save"/"Cancel"); START_ON_BUTTONS puts the initial focus on the first button so Enter accepts immediately. """ if not fields: raise ValueError("form() needs at least one field") frame = Frame(scr, title, "Up/Down = move Enter = edit Tab/arrows = buttons " "Esc = cancel") cursor = 0 on_buttons = start_on_buttons btn_index = 0 edit_cancel = object() # sentinel: backed out of a field editor label_w = max(len(field["label"]) for field in fields) def shown_fields() -> List[dict]: result: List[dict] = [] for field in fields: visible = field.get("visible", True) if callable(visible): visible = visible(fields) if visible: result.append(field) return result def run_on_change(field: dict) -> None: callback = field.get("on_change") if callback is not None: callback(fields) def activate_inline(field: dict) -> None: """Enter/Space on an in-place field: flip a bool, step a toggle.""" if field["kind"] == "bool": field["value"] = not bool(field["value"]) else: # toggle: cycle through the choice values values = [value for _label, value in field.get("choices") or []] if values: index = values.index(field["value"]) \ if field["value"] in values else -1 field["value"] = values[(index + 1) % len(values)] run_on_change(field) def display_value(field: dict) -> str: if field.get("kind") == "bool": return "Yes" if field["value"] else "No" if field.get("kind") == "dir": value = field["value"] return str(value) if value is not None else "" if field.get("kind") == "toggle": for label, value in field.get("choices") or []: if value == field["value"]: return label return str(field["value"]) while True: shown = shown_fields() cursor = max(0, min(cursor, len(shown) - 1)) if shown else 0 frame.rows = [] for line in help_lines or []: frame.mark(line, frame.theme["dim"]) if help_lines: frame.mark("") field_rows: List[int] = [] # visible field index -> row index for field in shown: if field.get("note"): frame.mark("") frame.mark(field["note"], frame.theme["dim"], align="left") frame.mark("") field_rows.append(len(frame.rows)) name = f"{field['label']}:".ljust(label_w + 1) frame.mark_segments( [(name, frame.theme["body"]), (" " + display_value(field), frame.theme["input"])], selectable=True, align="left") frame.cursor = None if on_buttons else field_rows[cursor] frame.buttons = (list(buttons), btn_index if on_buttons else None) frame.draw() curses = frame.curses key = frame.get_key(cancel_keys=()) if key in _CANCEL_KEYS and back_value is not None: return back_value if key in _CANCEL_KEYS: raise WizardCancelled() if on_buttons: if key in (curses.KEY_UP, ord("k"), curses.KEY_BTAB): # Wrap up through the buttons onto the last field. on_buttons = False cursor = len(shown) - 1 if shown else 0 elif key in (curses.KEY_DOWN, ord("j"), 9): # Wrap down through the buttons back to the first field. on_buttons = False cursor = 0 elif key in (curses.KEY_LEFT, curses.KEY_RIGHT, ord("h"), ord("l")): btn_index = 1 - btn_index elif key in (10, 13): if btn_index == 0: # accept (Save / Generate!) for index, field in enumerate(shown): validate = field.get("validate") if validate is not None: error = validate(field["value"]) if error is not None: on_buttons = False cursor = index frame.flash(error, "err") break else: return {field["key"]: field["value"] for field in fields} else: # Cancel return back_value else: if key in (curses.KEY_DOWN, ord("j")) \ and cursor == len(shown) - 1: on_buttons = True btn_index = 0 # first button elif key in (curses.KEY_UP, ord("k")) and cursor == 0: on_buttons = True btn_index = 0 # first button (wraps around from the top) else: moved = frame.motion(key, cursor, len(shown), wrap=True) if moved is not None: cursor = moved elif key in (9, curses.KEY_BTAB, curses.KEY_LEFT, curses.KEY_RIGHT, ord("h"), ord("l")): on_buttons = True btn_index = 0 elif key == ord(" ") and shown[cursor].get("kind") in \ ("bool", "toggle"): activate_inline(shown[cursor]) elif key in (10, 13): field = shown[cursor] if field.get("kind") in ("bool", "toggle"): activate_inline(field) elif field.get("kind") == "choice": choices = field.get("choices") or [] if callable(choices): choices = choices(fields) choices = list(choices) if not choices: # A dynamic choice list can legitimately come # back empty (e.g. an audio.cpp model whose # server hosts no clone voices). menu() would # raise; explain instead when the field says # how to fill the list. message = field.get("on_empty_choices") if callable(message): message = message(fields) if message: frame.flash(str(message), "err") else: if isinstance(choices[0], (tuple, list)) \ and len(choices[0]) == 2: pairs = [(label, value) for label, value in choices] else: pairs = [(c, c) for c in choices] values = [value for _, value in pairs] default = values.index(field["value"]) \ if field["value"] in values else 0 chosen = menu(scr, field["label"], pairs, default_index=default, back_value=edit_cancel) if chosen is not edit_cancel: field["value"] = chosen run_on_change(field) elif field.get("kind") == "dir": start = field["value"] start = Path(start) if start else Path.cwd() picked = browse_directory( scr, field["label"], start=start, validate=field.get("validate"), back_value=edit_cancel) if picked is not edit_cancel: field["value"] = picked run_on_change(field) # Picking a directory is a completed choice: # hand focus straight to the accept button so # Enter continues, with no extra Tab hunting. on_buttons = True btn_index = 0 else: edited = line_edit(scr, field["label"], field["value"], validate=field.get("validate"), back_value=edit_cancel) if edited is not edit_cancel: field["value"] = edited run_on_change(field) # --------------------------------------------------------------------------- # 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, 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. Esc (or 'q') aborts the wizard unless BACK_VALUE is given (not None), in which case either key 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 in _CANCEL_KEYS 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: current = entries[sel - offset] 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, checked: Optional[set] = None, start_on_buttons: bool = False) -> 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 or Enter on a family row checks its recommended option (or clears every option when one is already checked); Space or Enter on an option row toggles that option. Right/l expands or collapses the family under the cursor, Left/h collapses it. Tab (or Up/Down at the ends of the list) moves the focus to the Confirm/Back buttons: Enter on Confirm returns the flat list of (family_index, option_key) pairs for every checked option, in tree order, and requires at least one checked option; Enter on Back returns BACK_VALUE. Nothing is checked by default and every family starts collapsed; with EXPAND_ALL every family starts expanded. CHECKED (a set of (family_index, option_key) pairs) pre-checks those options instead, expanding every family that holds a checked option and placing the cursor on the first such family — the "modify an existing config" entry point. START_ON_BUTTONS puts the initial focus on Confirm, so Enter accepts the tree as it stands (the seeded modify selection) immediately. 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 either key 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 Enter/Space = check " "Left/Right = expand Tab = buttons Esc = cancel") frame = Frame(scr, title, footer) expanded = {index for index in range(len(families))} if expand_all else set() checked = set(checked or ()) # (family_index, option_key) for index, _option_key in checked: expanded.add(index) 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 first_checked = min((index for index, _option_key in checked), default=None) cursor = 0 on_buttons = start_on_buttons btn_index = 0 while True: nodes = visible_nodes() if first_checked is not None: for position, node in enumerate(nodes): if node[0] == "family" and node[1] == first_checked: cursor = position break first_checked = None 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 = None if on_buttons else cursor frame.buttons = (["Confirm", "Back"], btn_index if on_buttons else None) if on_buttons: frame.status = None else: node = nodes[cursor] frame.status = (families[node[1]].get("detail", ""), "info") frame.draw() curses = frame.curses key = frame.get_key(cancel_keys=()) if key in _CANCEL_KEYS and back_value is not None: return back_value if key in _CANCEL_KEYS: raise WizardCancelled() if on_buttons: if key in (curses.KEY_LEFT, curses.KEY_RIGHT, ord("h"), ord("l")): btn_index = 1 - btn_index elif key in (curses.KEY_UP, ord("k"), curses.KEY_BTAB): on_buttons = False cursor = len(nodes) - 1 if nodes else 0 elif key in (curses.KEY_DOWN, ord("j"), 9): on_buttons = False cursor = 0 elif key in (10, 13): if btn_index == 0: # Confirm selection = accept() if selection: return selection frame.flash("Check at least one model package", "err") else: # Back return back_value else: node = nodes[cursor] if key in (curses.KEY_DOWN, ord("j")) \ and cursor == len(nodes) - 1: on_buttons = True btn_index = 0 elif key in (curses.KEY_UP, ord("k")) and cursor == 0: on_buttons = True btn_index = 0 elif key in (9, curses.KEY_BTAB): on_buttons = True btn_index = 0 else: moved = frame.motion(key, cursor, len(nodes), wrap=True) if moved is not None: cursor = moved elif key in (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 in (curses.KEY_LEFT, ord("h")) \ and node[0] == "family": expanded.discard(node[1]) elif key in (10, 13, 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))