diff options
| author | historia <historiavg@proton.me> | 2026-08-23 23:48:25 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-23 23:48:25 -0400 |
| commit | 5bfbdcb5765fd4eb57d13c67169bb3c2706ead75 (patch) | |
| tree | a07a27976f56a449e8c33641161553aa0989f5c2 /tools/tui.py | |
| parent | 07f7b351f2956b6c92761877c9a4314bcede3b6e (diff) | |
| download | tts-audiobook-generator-5bfbdcb5765fd4eb57d13c67169bb3c2706ead75.tar.gz | |
feat: audiobook.py tui: convert, modify, or install backends
Diffstat (limited to 'tools/tui.py')
| -rw-r--r-- | tools/tui.py | 964 |
1 files changed, 0 insertions, 964 deletions
diff --git a/tools/tui.py b/tools/tui.py deleted file mode 100644 index 906aec5..0000000 --- a/tools/tui.py +++ /dev/null @@ -1,964 +0,0 @@ -#!/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 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.""" - - -# 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): - """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. - 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("") - 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") |
