diff options
Diffstat (limited to 'tools/tui.py')
| -rw-r--r-- | tools/tui.py | 843 |
1 files changed, 648 insertions, 195 deletions
diff --git a/tools/tui.py b/tools/tui.py index e1eda05..906aec5 100644 --- a/tools/tui.py +++ b/tools/tui.py @@ -1,22 +1,38 @@ #!/usr/bin/env python3 -"""Minimal curses TUI widgets for the interactive tools. - -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. +"""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 - Esc abort the whole wizard (raises WizardCancelled) + 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; inside text editors it is an ordinary character. +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 @@ -28,6 +44,82 @@ 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 # --------------------------------------------------------------------------- @@ -40,6 +132,23 @@ def _addstr(scr, y: int, x: int, text: str, attr: int = 0) -> None: 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: @@ -50,75 +159,306 @@ def _fit(text: str, width: int) -> str: class Frame: - """A screen frame: title, scrolling body rows, message and footer. - - Widgets append styled body rows via mark(), call draw() after every - state change, and read keys through get_key()/edit_line(). + """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.message = "" # transient status line - self.message_attr = None # None -> bold reverse video - self.rows: List[dict] = [] # {text, attr, indent} + 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.cursor = 0 # highlighted row index + 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). - def mark(self, text: str, attr: int = 0, indent: int = 0) -> None: - self.rows.append({"text": text, "attr": attr, "indent": indent}) + 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: - curses = self.curses scr = self.scr scr.erase() height, width = scr.getmaxyx() - if height < 6 or width < 20: - _addstr(scr, 0, 0, _fit("Terminal too small", width - 1), - curses.A_BOLD) + 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 - top = 2 - visible = height - 3 - top - if visible < 1: - visible = 1 - # Keep the cursor inside the viewport. - if self.cursor < self.scroll: - self.scroll = self.cursor - elif self.cursor >= self.scroll + visible: - self.scroll = self.cursor - visible + 1 - if self.scroll + visible > len(self.rows): - self.scroll = max(0, len(self.rows) - visible) - scrolling = len(self.rows) > visible - indicator = f" {self.cursor + 1}/{len(self.rows)} " if scrolling else "" - title_width = width - 1 - (len(indicator) if indicator else 0) - _addstr(scr, 0, 0, _fit(self.title, title_width), - curses.A_BOLD | curses.A_UNDERLINE) - for index in range(self.scroll, - min(len(self.rows), self.scroll + visible)): - row = self.rows[index] - line = " " * row["indent"] + row["text"] - attr = row["attr"] - if index == self.cursor: - attr |= curses.A_REVERSE - _addstr(scr, top + index - self.scroll, 0, - _fit(line, width - 1), attr) - if indicator: - _addstr(scr, 0, max(0, width - len(indicator)), indicator, - curses.A_DIM) - if self.message: - attr = self.message_attr - if attr is None: - attr = curses.A_BOLD | curses.A_REVERSE - _addstr(scr, height - 2, 0, _fit(self.message, width - 1), attr) - _addstr(scr, height - 1, 0, _fit(self.footer, width - 1), curses.A_DIM) + 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: @@ -131,31 +471,38 @@ class Frame: raise WizardCancelled() return key - def edit_line(self, start: str, prompt: str = "" - ) -> Optional[str]: - """Run an inline editor on the message line. + 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 = start + text = "" while True: - height, width = self.scr.getmaxyx() - self.message = "" + self.status = (f"{prompt}{text}_", "input") self.draw() - room = max(1, width - 2 - len(prompt)) - shown = text if len(text) < room else ">" + text[-(room - 2):] - _addstr(self.scr, height - 2, 0, - _fit(f"{prompt}{shown}_", width - 1), curses.A_BOLD) - self.scr.refresh() try: key = self.scr.getch() except KeyboardInterrupt: raise WizardCancelled() from None if key == 27: return None - if key in (10, 13): # Enter + 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] @@ -164,63 +511,89 @@ class Frame: # --------------------------------------------------------------------------- -# Widget: yes/no confirm +# Widget: yes/no confirm with buttons # --------------------------------------------------------------------------- def confirm(scr, question: str, default: bool = False, - body: Optional[Sequence[str]] = None) -> bool: - """Ask a yes/no QUESTION; Enter takes DEFAULT, Esc aborts. - - BODY lines are shown above the question (a summary, for example). + 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, - "y = yes n = no Enter = default Esc = cancel") - cancel = (27, ord("q")) + "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) - if body: - frame.mark("") - hint = "[Y/n]" if default else "[y/N]" - frame.mark(f"{question} {hint}") - frame.cursor = len(frame.rows) - 1 + frame.cursor = None + frame.buttons = (["Yes", "No"], index) frame.draw() - key = frame.get_key(cancel) - if key in (ord("y"), ord("Y")): + 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 - if key in (ord("n"), ord("N")): + elif key in (ord("n"), ord("N")): return False - if key in (10, 13): - return default + elif key in (10, 13): + return index == 0 # --------------------------------------------------------------------------- # Widget: single-choice menu # --------------------------------------------------------------------------- -def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0): +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. + 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") - cancel = (27, ord("q")) 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) - frame.cursor = cursor + frame.mark(label, selectable=True, align="left") + frame.cursor = base + cursor frame.draw() - curses = frame.curses - key = frame.get_key(cancel) - if key in (curses.KEY_UP, ord("k")): - cursor = (cursor - 1) % len(options) - elif key in (curses.KEY_DOWN, ord("j")): - cursor = (cursor + 1) % len(options) + 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] @@ -230,38 +603,49 @@ def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0): # --------------------------------------------------------------------------- def line_edit(scr, title: str, default: str, - validate: Optional[Callable[[str], Optional[str]]] = None - ) -> 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. - VALIDATE receives the entered string and returns an error message or - None; Enter on an invalid value shows the message and keeps editing. - Esc aborts the wizard ('q' is an ordinary character here). + 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 = "" + 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.cursor = 1 - frame.message = error + 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() # Esc only; 'q' must stay typeable + 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 - error = f"{error} (edit, then Enter)" 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) @@ -282,72 +666,144 @@ def _list_dirs(path: Path) -> List[Path]: def browse_directory(scr, title: str, validate: Optional[Callable[[Path], Optional[str]]] = None, - start: Optional[Path] = 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; Enter accepts the directory being listed. - - Right (or l) descends into the highlighted entry, Left/Backspace/u - goes to the parent, and e edits the path directly. VALIDATE receives - the listed directory and returns an error message or None; Enter on - an invalid directory is refused with that message. Esc aborts the - wizard. + """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 Right = open Left = parent e = edit " - "path Enter = choose this directory Esc = cancel") + footer = ("Up/Down = move Enter = open/use Left = parent " + "e = type path Esc = cancel") frame = Frame(scr, title, footer) - cancel = (27, ord("q")) current = Path(start) if start is not None else Path.cwd() try: current = current.resolve() except OSError: current = Path.cwd() - cursor = 0 + sel = 0 + highlight: Optional[Path] = None def validation_error() -> Optional[str]: if validate is None: return None - return validate(current) + 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) - cursor = max(0, min(cursor, max(0, len(entries) - 1))) + has_parent = current.parent != current + offset = 1 + (1 if has_parent else 0) frame.rows = [] - frame.mark(f"Directory: {current}", frame.curses.A_BOLD) - error = validation_error() - if error is None: - frame.mark(" This directory is a valid choice. Press Enter.", - frame.curses.A_DIM) - else: - frame.mark(f" {error}", frame.curses.A_BOLD) + 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("") - if not entries: - frame.mark(" (no subdirectories)") + 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}/") - header = 3 # directory line, validity line, blank separator - frame.cursor = header + (cursor if entries else 0) - frame.message = "" + 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) - if key in (curses.KEY_UP, ord("k")): - cursor = max(0, cursor - 1) - elif key in (curses.KEY_DOWN, ord("j")): - if entries: - cursor = min(len(entries) - 1, cursor + 1) - elif key in (curses.KEY_RIGHT, ord("l")): - if entries: - current = entries[cursor] - cursor = 0 + 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): - parent = current.parent - if parent != current: - current = parent - cursor = 0 + if has_parent: + highlight = current + current = current.parent elif key == ord("e"): - result = frame.edit_line("", prompt="path: ") - if result is not None: + result = frame.edit_status(prompt="path: ") + if result: candidate = Path(os.path.expanduser(result)) if not candidate.is_absolute(): candidate = current / candidate @@ -357,19 +813,9 @@ def browse_directory(scr, title: str, pass if candidate.is_dir(): current = candidate - cursor = 0 + sel = 0 else: - frame.message = f"Not a directory: {candidate}" - frame.draw() - frame.get_key(cancel) - frame.get_key(cancel) - elif key in (10, 13): # Enter: accept the listed directory - error = validation_error() - if error is None: - return current - frame.message = f"{error} (keep browsing)" - frame.draw() - frame.get_key(cancel) + frame.flash(f"Not a directory: {candidate}", "err") # --------------------------------------------------------------------------- @@ -378,7 +824,8 @@ def browse_directory(scr, title: str, def checkbox_tree(scr, title: str, families: List[dict], footer: Optional[str] = None, - expand_all: bool = False) -> List[Tuple[int, str]]: + 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:: @@ -398,27 +845,23 @@ def checkbox_tree(scr, title: str, families: List[dict], 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. The first family's recommended option starts - checked (the prompt flow's default), and with EXPAND_ALL every - family starts expanded. + 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) - cancel = (27, ord("q")) expanded = {index for index in range(len(families))} if expand_all else set() checked = set() # (family_index, option_key) - if families: - expanded.add(0) - first = families[0]["options"] - for option in first: - if option.get("recommended"): - checked.add((0, option["key"])) - break - else: - if first: - checked.add((0, first[0]["key"])) + expanded.add(0) def family_checked(index: int) -> bool: return any(pair[0] == index for pair in checked) @@ -447,29 +890,43 @@ def checkbox_tree(scr, title: str, families: List[dict], if node[0] == "family": index = node[1] family = families[index] - mark = "x" if family_checked(index) else " " + on = family_checked(index) + mark = "x" if on else " " arrow = "-" if index in expanded else "+" - attr = frame.curses.A_BOLD if family_checked(index) else 0 - frame.mark(f"[{mark}] {arrow} {family['label']}", attr) + 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 " " - note = " [recommended]" if option.get("recommended") else "" - frame.mark(f" [{mark}] {option['label']}{note}") + 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.message = families[node[1]].get("detail", "") - frame.message_attr = frame.curses.A_DIM + frame.status = (families[node[1]].get("detail", ""), "info") frame.draw() curses = frame.curses - key = frame.get_key(cancel) - if key in (curses.KEY_UP, ord("k")): - cursor = (cursor - 1) % len(nodes) - elif key in (curses.KEY_DOWN, ord("j")): - cursor = (cursor + 1) % len(nodes) + 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: @@ -504,8 +961,4 @@ def checkbox_tree(scr, title: str, families: List[dict], selection = accept() if selection: return selection - frame.message = "Check at least one model package (Space)" - frame.message_attr = None - frame.draw() - frame.get_key(cancel) - frame.message_attr = frame.curses.A_DIM + frame.flash("Check at least one model package (Space)", "err") |
